From 6b7a05ab1c274b6b3143839d2ff7f0c940e073c2 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 10:04:31 -0700 Subject: [PATCH] Harvest pausable custom command MVP Harvested from PR #2732 by @aboimpinto. Parse pausable frontmatter for custom slash commands, add a narrow engine pause gate before tool execution, and preserve paused command state across separate messages until explicit resume, cancel, terminal completion, or a new command. Also centralize strict resume-message detection with negative coverage for deferred or negated phrases, and keep rollback/stash/worktree mutation behavior out of this slice. Co-authored-by: aboimpinto <1231687+aboimpinto@users.noreply.github.com> --- CHANGELOG.md | 4 + crates/tui/CHANGELOG.md | 4 + crates/tui/src/commands/user_commands.rs | 86 ++++++++++- crates/tui/src/core/engine.rs | 13 ++ crates/tui/src/core/engine/handle.rs | 18 +++ crates/tui/src/core/engine/turn_loop.rs | 8 + crates/tui/src/tui/app.rs | 9 ++ crates/tui/src/tui/composer_ui.rs | 8 + crates/tui/src/tui/ui.rs | 189 ++++++++++++++++++++++- crates/tui/src/tui/ui/tests.rs | 137 ++++++++++++++++ 10 files changed, 469 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90611c2b..b346d11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 TUI sidebar from the command line instead of relying on copy-hostile sidebar state during long transcript work (#2766, #2788). Thanks @mo-vic for the detailed report and @aboimpinto for the fix. +- Added a pausable custom slash-command MVP: commands with `pausable: true` + can pause before further tool execution, preserve the paused command while + separate messages are handled, and resume only on explicit continue/resume + wording. Harvested from #2732 with thanks to @aboimpinto. - Added Sofya (`provider = "sofya"`) as a search-tool backend with `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather than model-provider routing (#2790). Thanks @yusufgurdogan for the diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 90611c2b..b346d11a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 TUI sidebar from the command line instead of relying on copy-hostile sidebar state during long transcript work (#2766, #2788). Thanks @mo-vic for the detailed report and @aboimpinto for the fix. +- Added a pausable custom slash-command MVP: commands with `pausable: true` + can pause before further tool execution, preserve the paused command while + separate messages are handled, and resume only on explicit continue/resume + wording. Harvested from #2732 with thanks to @aboimpinto. - Added Sofya (`provider = "sofya"`) as a search-tool backend with `SOFYA_API_KEY` fallback, while keeping Sofya scoped to web search rather than model-provider routing (#2790). Thanks @yusufgurdogan for the diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 207fdc8f..8d847839 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -6,7 +6,7 @@ //! `/name`, the file contents are sent as a user message. //! //! Files may include optional YAML-like frontmatter between `---` markers. -//! Supported fields are `description`, `argument-hint`, and `allowed-tools`. +//! Supported fields are `description`, `argument-hint`, `allowed-tools`, and `pausable`. //! Frontmatter is stripped before the command body is sent to the model. //! //! ## Precedence @@ -206,6 +206,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { @@ -215,6 +218,9 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { app.active_allowed_tools = Some(parse_allowed_tools(value)); } + "pausable" => { + app.pausable = value.trim().eq_ignore_ascii_case("true"); + } _ => {} } } @@ -561,6 +567,84 @@ mod tests { ); } + #[test] + fn pausable_frontmatter_sets_app_state_without_worktree_mutation() { + use crate::config::Config; + + if std::process::Command::new("git") + .arg("--version") + .output() + .is_err() + { + return; + } + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + let init = std::process::Command::new("git") + .args(["-C", ws.to_str().unwrap(), "init"]) + .output() + .expect("git init"); + assert!( + init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + std::fs::write(ws.join("user-work.txt"), "untracked user work").unwrap(); + write_command( + &ws.join(".codewhale").join("commands"), + "pause-scan", + "---\ndescription: Scan repos\npausable: true\n---\nscan", + ); + + let mut app = App::new(test_options(ws.clone()), &Config::default()); + let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap(); + + assert!(app.pausable); + assert!(!app.paused); + assert!(app.paused_quarry.is_none()); + assert!(ws.join("user-work.txt").exists()); + let stash = std::process::Command::new("git") + .args(["-C", ws.to_str().unwrap(), "stash", "list"]) + .output() + .expect("git stash list"); + assert!( + stash.status.success(), + "git stash list failed: {}", + String::from_utf8_lossy(&stash.stderr) + ); + assert!( + String::from_utf8_lossy(&stash.stdout).trim().is_empty(), + "pausable dispatch must not create git stash entries" + ); + } + + #[test] + fn new_user_command_clears_stale_paused_state() { + use crate::config::Config; + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + let commands_dir = ws.join(".codewhale").join("commands"); + write_command( + &commands_dir, + "pause-scan", + "---\ndescription: Scan repos\npausable: true\n---\nscan", + ); + write_command(&commands_dir, "plain", "plain command"); + + let mut app = App::new(test_options(ws), &Config::default()); + let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap(); + app.paused = true; + app.paused_quarry = Some("Scan repos".to_string()); + + let _ = try_dispatch_user_command(&mut app, "/plain").unwrap(); + + assert!(!app.pausable); + assert!(!app.paused); + assert!(app.paused_quarry.is_none()); + } + #[test] fn review_regression_empty_allowed_tools_blocks_all_tools() { use crate::config::Config; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 96af3169..a890240d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -484,6 +484,8 @@ pub struct EngineHandle { tx_user_input: mpsc::Sender, /// Send steer input for an in-flight turn. tx_steer: mpsc::Sender, + /// Shared pause flag set by the TUI and read by the turn loop. + shared_paused: Arc>, } // `impl EngineHandle { ... }` moved to `engine/handle.rs` so the @@ -557,6 +559,8 @@ pub struct Engine { /// four TUI / command consumers; the cache turns N×O(messages) walks /// into a single recompute on a content change. token_estimate_cache: TokenEstimateCache, + /// Shared pause flag set by the TUI and read before tool execution. + shared_paused: Arc>, } // === Internal tool helpers === @@ -580,6 +584,10 @@ impl Engine { Ok(mut slot) => *slot = None, Err(poisoned) => *poisoned.into_inner() = None, } + match self.shared_paused.lock() { + Ok(mut paused) => *paused = false, + Err(poisoned) => *poisoned.into_inner() = false, + } } fn env_only_api_key_recovery_hint(api_config: &Config) -> Option { @@ -646,6 +654,7 @@ impl Engine { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let tool_exec_lock = Arc::new(RwLock::new(())); // Create clients for both providers @@ -808,6 +817,7 @@ impl Engine { sandbox_backend, current_mode: AppMode::Agent, token_estimate_cache: TokenEstimateCache::new(), + shared_paused: shared_paused.clone(), }; engine.rehydrate_latest_canonical_state(); @@ -819,6 +829,7 @@ impl Engine { tx_approval, tx_user_input, tx_steer, + shared_paused, }; (engine, handle) @@ -2791,6 +2802,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let handle = EngineHandle { tx_op, rx_event: Arc::new(RwLock::new(rx_event)), @@ -2799,6 +2811,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { tx_approval, tx_user_input, tx_steer, + shared_paused, }; MockEngineHandle { diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 1ed7e95d..4f8a4459 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -51,6 +51,24 @@ impl EngineHandle { } } + /// Pause or resume the current pausable command. + pub fn set_paused(&self, paused: bool) { + match self.shared_paused.lock() { + Ok(mut slot) => *slot = paused, + Err(poisoned) => *poisoned.into_inner() = paused, + } + } + + /// Check whether the engine pause gate is set. + #[cfg(test)] + #[must_use] + pub fn is_paused(&self) -> bool { + match self.shared_paused.lock() { + Ok(slot) => *slot, + Err(poisoned) => *poisoned.into_inner(), + } + } + /// Approve a pending tool call pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { self.tx_approval diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 892d0d21..6e7d0691 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1259,6 +1259,14 @@ impl Engine { } // Execute tools + if self.shared_paused.lock().is_ok_and(|paused| *paused) { + let _ = self + .tx_event + .send(Event::status("Request was Paused")) + .await; + return (TurnOutcomeStatus::Interrupted, None); + } + let tool_exec_lock = self.tool_exec_lock.clone(); let mcp_pool = if tool_uses .iter() diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 34b56aaf..319aa507 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1242,6 +1242,12 @@ pub struct App { /// Active tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. pub active_allowed_tools: Option>, + /// True when the active custom slash command opted into pause/resume. + pub pausable: bool, + /// True after Esc paused a pausable command and before it is resumed or cancelled. + pub paused: bool, + /// Saved custom-command objective while the command is paused. + pub paused_quarry: Option, pub history: Vec, pub history_version: u64, /// Per-cell revision counter, kept in lockstep with `history`. @@ -2065,6 +2071,9 @@ impl App { hunt: HuntState::default(), session: SessionState::default(), active_allowed_tools: None, + pausable: false, + paused: false, + paused_quarry: None, history: Vec::new(), history_version: 0, history_revisions: Vec::new(), diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 122f293a..fff29272 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -8,6 +8,7 @@ const COMPOSER_ARROW_SCROLL_LINES: usize = 3; pub(crate) enum EscapeAction { CloseSlashMenu, CancelRequest, + PauseCommand, DiscardQueuedDraft, ClearInput, Noop, @@ -18,6 +19,13 @@ pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeActi EscapeAction::CloseSlashMenu } else if app.queued_draft.is_some() { EscapeAction::DiscardQueuedDraft + } else if app.paused || app.paused_quarry.is_some() { + EscapeAction::CancelRequest + } else if app.pausable + && !app.paused + && (app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))) + { + EscapeAction::PauseCommand } else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) { EscapeAction::CancelRequest } else if !app.input.is_empty() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 23974140..6b636d5d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1697,6 +1697,10 @@ async fn run_event_loop( let was_locally_cancelled = app.suppress_stream_events_until_turn_complete; app.suppress_stream_events_until_turn_complete = false; app.active_allowed_tools = None; + if app.paused_quarry.is_none() { + app.pausable = false; + app.paused = false; + } if !matches!(status, crate::core::events::TurnOutcomeStatus::Completed) || draws_since_last_full_repaint >= PERIODIC_FULL_REPAINT_EVERY_N { @@ -3539,10 +3543,31 @@ async fn run_event_loop( } EscapeAction::CancelRequest => { app.backtrack.reset(); - engine_handle.cancel(); - mark_active_turn_cancelled_locally(app); - current_streaming_text.clear(); - app.status_message = Some("Request cancelled".to_string()); + if app.paused || app.paused_quarry.is_some() { + clear_paused_command_state(app, &engine_handle); + if app.is_loading + || matches!( + app.runtime_turn_status.as_deref(), + Some("in_progress") + ) + { + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + current_streaming_text.clear(); + } + app.active_allowed_tools = None; + app.hunt.quarry = None; + app.status_message = Some("Paused command cancelled".to_string()); + } else { + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + current_streaming_text.clear(); + app.status_message = Some("Request cancelled".to_string()); + } + } + EscapeAction::PauseCommand => { + app.backtrack.reset(); + pause_pausable_command(app, &engine_handle); } EscapeAction::DiscardQueuedDraft => { app.backtrack.reset(); @@ -4948,6 +4973,149 @@ fn queued_message_content_for_app( } } +fn paused_quarry_title(quarry: &str) -> &str { + quarry + .split(['\n', '\r']) + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .unwrap_or("the paused command") +} + +fn is_resume_message(message: &str) -> bool { + let words: Vec = message + .to_ascii_lowercase() + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(); + if words.is_empty() { + return false; + } + let text = words.join(" "); + let has_resume_verb = words + .iter() + .any(|word| matches!(word.as_str(), "continue" | "resume")); + if !has_resume_verb { + return false; + } + + let blockers = [ + "do not continue", + "do not resume", + "don t continue", + "don t resume", + "dont continue", + "dont resume", + "not continue", + "not resume", + "continue yet", + "resume yet", + "will continue", + "will resume", + "continue tomorrow", + "resume tomorrow", + "continue later", + "resume later", + ]; + if blockers.iter().any(|blocker| text.contains(blocker)) { + return false; + } + if matches!( + words.first().map(String::as_str), + Some("how" | "what" | "when" | "where" | "why") + ) { + return false; + } + + if words.len() == 1 { + return true; + } + + let context_words = [ + "please", "now", "paused", "pause", "command", "task", "work", "request", "goal", + "previous", "last", "same", "it", "that", "this", "go", "ahead", + ]; + if words + .iter() + .any(|word| context_words.contains(&word.as_str())) + { + return true; + } + + text.starts_with("can you continue") + || text.starts_with("can you resume") + || text.starts_with("could you continue") + || text.starts_with("could you resume") +} + +fn paused_command_note(title: &str, resume: bool) -> String { + let instruction = if resume { + "The user is resuming that paused command. Continue the paused command." + } else { + "The user is not resuming that paused command. Answer only the new message and do not continue the paused command." + }; + format!( + "\n\n\n\ +Paused custom slash command: {title}\n\ +{instruction}\n\ +" + ) +} + +fn prepare_paused_command_message( + app: &mut App, + engine_handle: &EngineHandle, + user_message: &str, +) -> Option { + if !app.paused && app.paused_quarry.is_none() { + engine_handle.set_paused(false); + return None; + } + + engine_handle.set_paused(false); + app.paused = false; + + let Some(quarry) = app + .paused_quarry + .clone() + .or_else(|| app.hunt.quarry.clone()) + else { + app.pausable = false; + return None; + }; + let title = paused_quarry_title(&quarry).to_string(); + if is_resume_message(user_message) { + app.hunt.quarry = Some(app.paused_quarry.take().unwrap_or(quarry)); + app.pausable = true; + Some(paused_command_note(&title, true)) + } else { + app.hunt.quarry = None; + Some(paused_command_note(&title, false)) + } +} + +fn pause_pausable_command(app: &mut App, engine_handle: &EngineHandle) { + app.paused_quarry = app + .paused_quarry + .clone() + .or_else(|| app.hunt.quarry.clone()); + app.hunt.quarry = None; + app.paused = true; + app.pausable = true; + engine_handle.set_paused(true); + app.status_message = Some( + "Request paused. Send `continue` or `resume` to continue, or Esc to cancel.".to_string(), + ); +} + +fn clear_paused_command_state(app: &mut App, engine_handle: &EngineHandle) { + app.pausable = false; + app.paused = false; + app.paused_quarry = None; + engine_handle.set_paused(false); +} + async fn dispatch_user_message( app: &mut App, config: &Config, @@ -4984,6 +5152,8 @@ async fn dispatch_user_message( } } + let paused_note = prepare_paused_command_message(app, engine_handle, &message.display); + // Set immediately to prevent double-dispatch before TurnStarted event arrives. let dispatch_started_at = Instant::now(); app.is_loading = true; @@ -5001,7 +5171,10 @@ async fn dispatch_user_message( &app.workspace, cwd.clone(), ); - let content = queued_message_content_for_app(app, &message, cwd); + let mut content = queued_message_content_for_app(app, &message, cwd); + if let Some(note) = paused_note.as_deref() { + content.push_str(note); + } let message_index = app.api_messages.len(); app.system_prompt = Some( prompts::system_prompt_for_mode_with_context_skills_and_session( @@ -6405,13 +6578,17 @@ async fn steer_user_message( engine_handle: &EngineHandle, message: QueuedMessage, ) -> Result<()> { + let paused_note = prepare_paused_command_message(app, engine_handle, &message.display); let cwd = std::env::current_dir().ok(); let references = crate::tui::file_mention::context_references_from_input( &message.display, &app.workspace, cwd.clone(), ); - let content = queued_message_content_for_app(app, &message, cwd); + let mut content = queued_message_content_for_app(app, &message, cwd); + if let Some(note) = paused_note.as_deref() { + content.push_str(note); + } let message_index = app.api_messages.len(); engine_handle.steer(content.clone()).await?; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4eaacdc8..f8f79ab8 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2611,6 +2611,121 @@ exit 2 ); } +#[test] +fn resume_message_helper_is_strict() { + for message in [ + "continue", + "resume", + "please continue", + "continue the paused command", + "can you resume the paused task", + "go ahead and resume", + ] { + assert!(is_resume_message(message), "expected resume: {message}"); + } + + for message in [ + "don't continue yet", + "do not resume yet", + "I will resume tomorrow", + "we can continue tomorrow", + "continue later", + "how do I resume a git cherry-pick?", + "please do not continue", + ] { + assert!( + !is_resume_message(message), + "expected not resume: {message}" + ); + } +} + +#[tokio::test] +async fn dispatch_non_resume_message_preserves_paused_command_state() { + let mut app = create_test_app(); + app.pausable = true; + app.paused = true; + app.paused_quarry = Some("Scan nested git repositories".to_string()); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + let mut engine = mock_engine_handle(); + engine.handle.set_paused(true); + let config = Config::default(); + + dispatch_user_message( + &mut app, + &config, + &engine.handle, + QueuedMessage::new("how are you?".to_string(), None), + ) + .await + .expect("dispatch user message"); + + assert!(!app.paused); + assert!(app.pausable); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories") + ); + assert!(app.hunt.quarry.is_none()); + assert!(!engine.handle.is_paused()); + match engine.rx_op.recv().await.expect("send message op") { + crate::core::ops::Op::SendMessage { + content, + goal_objective, + .. + } => { + assert!(goal_objective.is_none()); + assert!(content.contains("Paused custom slash command: Scan nested git repositories")); + assert!(content.contains("do not continue the paused command")); + } + other => panic!("expected SendMessage, got {other:?}"), + } +} + +#[tokio::test] +async fn dispatch_resume_message_restores_paused_command_goal() { + let mut app = create_test_app(); + app.pausable = true; + app.paused = true; + app.paused_quarry = Some("Scan nested git repositories".to_string()); + let mut engine = mock_engine_handle(); + engine.handle.set_paused(true); + let config = Config::default(); + + dispatch_user_message( + &mut app, + &config, + &engine.handle, + QueuedMessage::new("please continue the paused command".to_string(), None), + ) + .await + .expect("dispatch user message"); + + assert!(!app.paused); + assert!(app.pausable); + assert!(app.paused_quarry.is_none()); + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan nested git repositories") + ); + assert!(!engine.handle.is_paused()); + match engine.rx_op.recv().await.expect("send message op") { + crate::core::ops::Op::SendMessage { + content, + goal_objective, + .. + } => { + assert_eq!( + goal_objective.as_deref(), + Some("Scan nested git repositories") + ); + assert!(content.contains("Paused custom slash command: Scan nested git repositories")); + assert!(content.contains("Continue the paused command")); + } + other => panic!("expected SendMessage, got {other:?}"), + } +} + #[test] fn turn_liveness_watchdog_clears_stale_dispatch() { let mut app = create_test_app(); @@ -4246,6 +4361,28 @@ fn test_esc_priority_order_matches_cancel_stack() { assert_eq!(next_escape_action(&app, false), EscapeAction::Noop); } +#[test] +fn next_escape_action_pauses_then_cancels_pausable_command() { + let mut app = create_test_app(); + app.is_loading = true; + app.pausable = true; + app.paused = false; + + assert_eq!(next_escape_action(&app, false), EscapeAction::PauseCommand); + + app.paused = true; + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); + + app.is_loading = false; + app.paused = false; + app.pausable = true; + app.paused_quarry = Some("Scan repos".to_string()); + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); + + app.is_loading = true; + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); +} + #[test] fn visible_slash_menu_entries_respects_hide_flag() { let mut app = create_test_app();