diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 207fdc8f5e..073c404d40 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,25 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option todos.clear(), + Err(_) => { + tracing::warn!(target: "pausable", "todos lock contended or poisoned — state not cleared"); + } + } + match app.plan_state.try_lock() { + Ok(mut plan) => *plan = crate::tools::plan::PlanState::default(), + Err(_) => { + tracing::warn!(target: "pausable", "plan_state lock contended or poisoned — state not cleared"); + } + } + // Clear any previous pause state — new command, fresh start. + app.paused = false; + app.pausable = false; + app.paused_cancelled = false; + app.paused_at = None; + app.active_snapshot = None; for (key, value) in &metadata { match key.as_str() { "description" => { @@ -215,6 +234,45 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { app.active_allowed_tools = Some(parse_allowed_tools(value)); } + "pausable" if value.trim().eq_ignore_ascii_case("true") => { + // Snapshot workspace for potential rollback via git stash + if let Some(snap_id) = app.active_snapshot.take() + && let Ok(repo) = + crate::snapshot::repo::SnapshotRepo::open_or_init(&app.workspace) + { + let _ = repo.restore(&crate::snapshot::repo::SnapshotId(snap_id)); + } + let git_stash_cmd = std::process::Command::new("git") + .args([ + "-C", + &app.workspace.to_string_lossy(), + "stash", + "push", + "--include-untracked", + "-m", + "codewhale-pausable", + ]) + .output(); + if let Ok(output) = git_stash_cmd { + if output.status.success() { + tracing::debug!(target: "pausable", "created git stash snapshot"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!(target: "pausable", "git stash failed: {stderr}"); + } + } + app.pausable = true; + app.paused = false; + app.paused_cancelled = false; + } + "pausable" => { + // Explicitly set pausable: false + app.pausable = false; + app.paused = false; + app.paused_cancelled = false; + app.active_snapshot = None; + tracing::debug!(target: "pausable", "pausable explicitly set to false"); + } _ => {} } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 28ba47998f..e5ac51a8ef 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -439,6 +439,9 @@ pub struct EngineHandle { tx_user_input: mpsc::Sender, /// Send steer input for an in-flight turn. tx_steer: mpsc::Sender, + /// Shared paused flag — set by the UI, read by the turn loop. + /// Uses the same pattern as `cancel_token` to bypass the Op channel. + pub shared_paused: Arc>, } // `impl EngineHandle { ... }` moved to `engine/handle.rs` so the @@ -505,6 +508,9 @@ pub struct Engine { slop_ledger_gate_cache: Option<(Option, Option)>, /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. current_mode: AppMode, + /// Shared paused flag — set by the UI (via EngineHandle::set_paused), + /// read by the turn-loop pause gate. + shared_paused: Arc>, } // === Internal tool helpers === @@ -591,6 +597,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 @@ -752,6 +759,7 @@ impl Engine { workshop_vars, sandbox_backend, current_mode: AppMode::Agent, + shared_paused: shared_paused.clone(), }; engine.rehydrate_latest_canonical_state(); @@ -760,6 +768,7 @@ impl Engine { rx_event: Arc::new(RwLock::new(rx_event)), cancel_token: shared_cancel_token, cancel_reason, + shared_paused, tx_approval, tx_user_input, tx_steer, @@ -2606,11 +2615,13 @@ 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)), cancel_token: shared_cancel_token, cancel_reason, + shared_paused, tx_approval, tx_user_input, tx_steer, diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 1ed7e95d30..9341e9e3d4 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -110,4 +110,21 @@ impl EngineHandle { self.tx_steer.send(content.into()).await?; Ok(()) } + + /// Pause or resume the current command (for pausable commands). + /// Sets a shared flag that the turn loop reads before every tool call. + /// Uses the same side-channel pattern as `cancel()` — bypasses the Op + /// channel so it takes effect immediately, even during a running turn. + pub fn set_paused(&self, paused: bool) { + match self.shared_paused.lock() { + Ok(mut slot) => { + *slot = paused; + tracing::debug!(target: "pausable", paused, "EngineHandle::set_paused"); + } + Err(poisoned) => *poisoned.into_inner() = paused, + } + // Note: Op::SetPaused was removed — the shared flag is the single + // source of truth. Sending an Op would make the engine fire a status + // event that races with cancel/continue status from the UI. + } } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa0d..7fb123adeb 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1357,6 +1357,16 @@ impl Engine { ))); } + // Pause gate: when the command is paused, cancel the turn + // instead of returning a tool error. Returning a tool error + // causes the model to try alternative approaches; cancelling + // the turn cleanly stops all execution. + let is_paused = self.shared_paused.lock().is_ok_and(|g| *g); + tracing::debug!(target: "pausable", is_paused, tool_name, "pause gate check"); + if blocked_error.is_none() && is_paused { + self.cancel_token.cancel(); + } + if blocked_error.is_none() && let Some(hook_executor) = self.config.hook_executor.as_ref() && hook_executor.has_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index d3521d1987..74d326904b 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -269,6 +269,7 @@ impl WindowsJob { ) .map_err(windows_io_error)?; + #[allow(clippy::unnecessary_cast)] let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void); AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?; } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3eb494f163..4a34c9e40f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1174,6 +1174,18 @@ 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>, + /// Whether the current pausable command is paused (ESC once). + pub paused: bool, + /// Timestamp of the last pause (for ESC debounce). + pub paused_at: Option, + /// Whether the active command has `pausable: true` in frontmatter. + pub pausable: bool, + /// Whether the last pausable command was cancelled. + pub paused_cancelled: bool, + /// Snapshot ID for rollback of a pausable command. + pub active_snapshot: Option, + /// Saved hunt.quarry from before pause — restored on "continue". + pub paused_quarry: Option, pub history: Vec, pub history_version: u64, /// Per-cell revision counter, kept in lockstep with `history`. @@ -1985,6 +1997,12 @@ impl App { hunt: HuntState::default(), session: SessionState::default(), active_allowed_tools: None, + paused: false, + paused_at: None, + pausable: false, + paused_cancelled: false, + active_snapshot: None, + 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 708f4f97be..f119d883f3 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, @@ -16,8 +17,16 @@ pub(crate) enum EscapeAction { pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { if slash_menu_open { EscapeAction::CloseSlashMenu - } else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) { - EscapeAction::CancelRequest + } else if app.is_loading + || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + || app.pausable + || app.paused + { + if app.pausable && !app.paused { + EscapeAction::PauseCommand + } else { + EscapeAction::CancelRequest + } } else if app.queued_draft.is_some() && app.input.is_empty() { EscapeAction::DiscardQueuedDraft } else if !app.input.is_empty() { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fa1b26ce90..af06152d9b 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -179,6 +179,12 @@ pub(crate) struct SidebarWorkSummary { strategy_explanation: Option, strategy_steps: Vec, state_updating: bool, + /// Optional pause indicator text ("(Paused)" or "(Cancelled)"). + pause_indicator: Option, + /// True while app.paused is true (ESC pressed, tool calls blocked). + workflow_paused: bool, + /// True when app.paused_cancelled is true (ESC twice cancelling). + workflow_cancelled: bool, } impl SidebarWorkSummary { @@ -260,16 +266,36 @@ fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { }; Some(SidebarWorkSummary { - goal_objective: app.hunt.quarry.clone(), + goal_objective: if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }, goal_token_budget: app.hunt.token_budget, goal_completed: app.hunt.verdict == HuntVerdict::Hunted, goal_started_at: app.hunt.started_at, tokens_used: app.session.total_conversation_tokens, checklist_completion_pct, + pause_indicator: if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }, checklist_items, strategy_explanation, strategy_steps, state_updating: false, + workflow_paused: app.paused || app.paused_quarry.is_some(), + workflow_cancelled: app.paused_cancelled, }) })(); @@ -280,21 +306,61 @@ fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { if let Some(cached) = app.cached_work_summary.as_ref() { let mut summary = cached.clone(); - summary.goal_objective = app.hunt.quarry.clone(); + summary.goal_objective = if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }; summary.goal_token_budget = app.hunt.token_budget; summary.goal_completed = app.hunt.verdict == HuntVerdict::Hunted; summary.goal_started_at = app.hunt.started_at; summary.tokens_used = app.session.total_conversation_tokens; + summary.workflow_paused = app.paused || app.paused_quarry.is_some(); + summary.workflow_cancelled = app.paused_cancelled; + summary.pause_indicator = if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }; return summary; } SidebarWorkSummary { - goal_objective: app.hunt.quarry.clone(), + goal_objective: if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }, goal_token_budget: app.hunt.token_budget, goal_completed: app.hunt.verdict == HuntVerdict::Hunted, goal_started_at: app.hunt.started_at, tokens_used: app.session.total_conversation_tokens, state_updating: true, + workflow_paused: app.paused || app.paused_quarry.is_some(), + workflow_cancelled: app.paused_cancelled, + pause_indicator: if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }, ..SidebarWorkSummary::default() } } @@ -345,14 +411,30 @@ fn push_work_goal_lines( return; } - let icon = if summary.goal_completed { "✓" } else { "◆" }; + let icon = if summary.goal_completed { + "✓" + } else if summary.workflow_cancelled { + "✘" + } else if summary.workflow_paused { + "⏸" + } else { + "▶" + }; let status_style = if summary.goal_completed { Style::default() .fg(theme.success) .add_modifier(ratatui::style::Modifier::BOLD) + } else if summary.workflow_cancelled { + Style::default() + .fg(theme.error_fg) + .add_modifier(ratatui::style::Modifier::BOLD) + } else if summary.workflow_paused { + Style::default() + .fg(theme.accent_primary) + .add_modifier(ratatui::style::Modifier::BOLD) } else { Style::default() - .fg(theme.warning) + .fg(theme.status_working) .add_modifier(ratatui::style::Modifier::BOLD) }; @@ -3016,4 +3098,917 @@ mod tests { // Mouse outside content area (above) — row < content_area.y assert!((1u16) < section.content_area.y); } + + // ── Pause lifecycle tests ──────────────────────────────────────── + + #[test] + fn pause_indicator_none_when_not_paused_or_cancelled() { + let mut app = create_test_app(); + app.paused = false; + app.paused_cancelled = false; + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "expected no indicator when not paused/cancelled" + ); + } + + #[test] + fn pause_indicator_paused_when_paused() { + let mut app = create_test_app(); + app.paused = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Paused)")); + } + + #[test] + fn pause_indicator_pausing_when_loading() { + let mut app = create_test_app(); + app.paused = true; + app.is_loading = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Pausing)")); + } + + #[test] + fn pause_indicator_cancelled_when_cancelled() { + let mut app = create_test_app(); + app.paused_cancelled = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Cancelled)")); + } + + #[test] + fn goal_icon_play_when_running() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: None, + workflow_paused: false, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('▶'), + "expected play icon for running, got: {first}" + ); + } + + #[test] + fn goal_icon_check_when_completed() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: true, + pause_indicator: None, + workflow_paused: false, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('✓'), + "expected checkmark for completed, got: {first}" + ); + } + + #[test] + fn goal_icon_pause_when_paused() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: Some("(Paused)".to_string()), + workflow_paused: true, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('⏸'), + "expected pause icon for paused, got: {first}" + ); + } + + #[test] + fn goal_icon_cancelled_when_cancelled() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: Some("(Cancelled)".to_string()), + workflow_paused: false, + workflow_cancelled: true, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('✘'), + "expected cross icon for cancelled, got: {first}" + ); + } + + #[test] + fn cancel_status_not_overwritten_by_late_paused_event() { + // Simulate what the Event::Status handler in ui.rs does: + // when paused_cancelled is true, "Paused"/"Resumed" events are discarded. + let mut app = create_test_app(); + app.paused_cancelled = true; + app.status_message = Some("Request was Cancelled".to_string()); + + // Simulate the guard from EngineEvent::Status handler in ui.rs: + let late_message = "Request was Paused".to_string(); + if !(app.paused_cancelled + && (late_message == "Request was Paused" || late_message == "Request was Resumed")) + { + app.status_message = Some(late_message); + } + + assert_eq!( + app.status_message.as_deref(), + Some("Request was Cancelled"), + "guard must discard late Paused event when cancelled" + ); + } + + #[test] + fn pause_sets_pause_message_as_quarry() { + // When the user pauses a command, the quarry must be set to a pause + // message so the system prompt tells the model the command is on hold. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + + // Simulate PauseCommand handler saving + clearing the quarry: + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry must save the original goal" + ); + assert!( + app.hunt.quarry.is_none(), + "hunt.quarry must be None so goal system doesn't prompt model to resolve a pause goal" + ); + assert!(app.paused, "app.paused must be true"); + } + + #[test] + fn completed_command_clears_quarry() { + // When a command completes successfully, the verdict is set to Hunted + // but the quarry is kept so the WorkBench shows the goal with ✓. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + // Simulate successful completion (what the TurnCompleted handler does): + if app.hunt.quarry.is_some() { + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + // quarry is NOT cleared — sidebar shows completed goal + } + + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan repos"), + "quarry must persist after completion so WorkBench shows the goal" + ); + assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunted); + let summary = sidebar_work_summary(&mut app); + assert!(summary.goal_completed, "completed flag must be true"); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan repos"), + "WorkBench must show the completed goal text" + ); + } + + #[test] + fn cancel_clears_hunt_goal_so_model_does_not_resume_old_command() { + // Simulate a running pausable command with a description (hunt.quarry). + // When cancelled, the hunt goal must be cleared so the model does NOT + // see the old objective in the system prompt for the next turn. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.hunt.started_at = Some(std::time::Instant::now()); + app.active_allowed_tools = Some(vec!["exec_shell".to_string()]); + app.paused_cancelled = false; + app.paused = true; + app.pausable = true; + + // Simulate CancelRequest handler clearing on cancel: + // quarry is NOT cleared here — it persists so the WorkBench shows + // the cancelled goal. It is cleared in dispatch_user_message when + // the user sends a new message. + app.paused_cancelled = true; + app.paused = false; + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.active_allowed_tools = None; + + // Quarry is kept visible for WorkBench display + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan nested git repositories"), + "quarry must persist on cancel so WorkBench shows the cancelled goal" + ); + // Simulate dispatch_user_message clearing on next user message: + app.hunt.quarry = None; + assert!( + app.hunt.quarry.is_none(), + "quarry cleared in dispatch_user_message when user sends next message" + ); + assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting); + assert!( + app.active_allowed_tools.is_none(), + "tool restriction must be cleared on cancel" + ); + } + + #[test] + fn new_slash_command_after_cancel_clears_all_pause_state() { + // Simulate the full lifecycle: + // 1. Command running → cancel (paused_cancelled=true) + // 2. User types /git-scan → try_dispatch_user_command clears all state + // 3. New command should have NO trace of old pause state + let mut app = create_test_app(); + app.paused_cancelled = true; + app.paused = false; + app.pausable = false; + app.active_snapshot = Some("stash".to_string()); + app.hunt.quarry = Some("old scan".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + + // Simulate try_dispatch_user_command clearing: + app.paused_cancelled = false; + app.active_snapshot = None; + app.hunt.quarry = Some("new task".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.goal_objective.as_deref(), Some("new task")); + assert!( + summary.pause_indicator.is_none(), + "new command must have no pause indicator, got: {:?}", + summary.pause_indicator + ); + } + + // ── Full lifecycle workflow tests ─────────────────────────────── + + #[test] + fn lifecycle_pause_continue_complete() { + // 1. Start pausable command + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + app.is_loading = true; + + // → WorkBench should show "▶" play icon + let summary = sidebar_work_summary(&mut app); + assert!(!summary.goal_completed, "command is still running"); + assert!(summary.pause_indicator.is_none(), "not paused yet"); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "goal must show original description" + ); + + // 2. User presses ESC — pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + app.pausable = true; + app.is_loading = false; // past tool drain + + // → WorkBench should show "⏸ (Paused)" + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "must show paused after ESC" + ); + // quarry is cleared for system prompt, but paused_quarry keeps + // the goal visible in the WorkBench (with ⏸ icon). + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench must show the goal even when paused (from paused_quarry)" + ); + + // 3. User types "continue" — restore quarry, unpause + app.hunt.quarry = app.paused_quarry.take(); + app.paused = false; + app.pausable = false; + app.is_loading = true; + + // → WorkBench should show "▶" play icon with original goal + let summary = sidebar_work_summary(&mut app); + assert!(!summary.goal_completed, "continue -> still running"); + assert!( + summary.pause_indicator.is_none(), + "pause indicator must be gone after resume" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "original goal must be restored on continue" + ); + + // 4. Command completes successfully + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + app.hunt.quarry = None; + app.is_loading = false; + + // → WorkBench should show "✓" green checkmark + let summary = sidebar_work_summary(&mut app); + assert!(summary.goal_completed, "must be marked completed"); + assert!( + summary.pause_indicator.is_none(), + "no pause indicator when completed" + ); + assert!( + app.hunt.quarry.is_none(), + "quarry cleared so model is not prompted to continue" + ); + } + + #[test] + fn lifecycle_pause_cancel_new_command() { + // 1. Start pausable command + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. User presses ESC — pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + assert!(app.paused, "must be paused"); + assert!(app.paused_quarry.is_some(), "original goal must be saved"); + assert!(app.hunt.quarry.is_none(), "active goal must be cleared"); + + // 3. User presses ESC again — cancel (simulate CancelRequest handler) + // Restore quarry from paused_quarry so WorkBench shows the goal. + app.paused = false; + app.pausable = false; + app.paused_cancelled = true; + if let Some(saved) = app.paused_quarry.take() { + app.hunt.quarry = Some(saved); + } + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + // → WorkBench should show "✘ (Cancelled)" with the original goal + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Cancelled)"), + "must show cancelled indicator" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "workbench must show the original goal even when cancelled" + ); + assert!( + app.paused_quarry.is_none(), + "paused_quarry cleared on cancel" + ); + + // 4. User starts a fresh slash command + app.paused_cancelled = false; + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "new command must have no pause indicator" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Deploy to staging"), + "new command's goal must be shown" + ); + } + + #[test] + fn non_continue_while_paused_clears_system_prompt_goal() { + // When the user types something while paused (not "continue"/"resume"), + // the hunt.quarry must be None so the system prompt has no goal. + // This prevents the model from being prompted to continue the old command. + // The paused_quarry is preserved for WorkBench display. + let mut app = create_test_app(); + app.hunt.quarry = None; // set by PauseCommand handler + app.paused_quarry = Some("Scan repos".to_string()); + app.paused = true; + + // Simulate dispatch_user_message for a non-continue message: + app.paused = false; + app.paused_cancelled = false; + app.pausable = false; + // app.hunt.quarry stays None — NOT restored + + assert!( + app.hunt.quarry.is_none(), + "quarry must stay None for non-continue: system prompt has no goal" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan repos"), + "paused_quarry preserved for WorkBench" + ); + } + + /// Simulate what dispatch_user_message does when processing a message + /// while the app is paused. Mirrors the real flow in ui.rs so tests + /// catch regressions that state-only tests miss. + fn simulate_dispatch_non_continue(app: &mut App, message: &str) { + // Mirror submit_or_steer_message keyword detection: + // consume paused_quarry + restore hunt.quarry for "continue"/"resume" + { + let trimmed = message.trim().to_lowercase(); + if trimmed == "continue" + || trimmed == "resume" + || trimmed.starts_with("continue ") + || trimmed.starts_with("resume ") + || trimmed.contains(" continue ") + || trimmed.contains(" resume ") + || trimmed.ends_with(" continue") + || trimmed.ends_with(" resume") + { + if let Some(q) = app.paused_quarry.take() { + app.hunt.quarry = Some(q); + } + } + } + if app.paused || app.paused_quarry.is_some() { + // Only clear quarry if keyword interception didn't already + // restore it (e.g. for "continue"/"resume" messages). + if app.paused_quarry.is_some() { + app.hunt.quarry = None; + } + if app.paused { + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + app.is_loading = false; + } + } + } + + #[test] + fn dispatch_non_continue_preserves_workbench_and_clears_system_goal() { + // Real flow: start -> ESC (pause) -> "how are you?" + // Must: keep WorkBench visible AND clear system prompt goal. + let mut app = create_test_app(); + + // 1. Start pausable command + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. ESC -- pause (what PauseCommand handler does) + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // 3. Type "how are you?" -- through real dispatch simulation + simulate_dispatch_non_continue(&mut app, "how are you?"); + + // ASSERT: LLM-evaluation restores quarry into hunt.quarry. + // The LLM sees the paused command in the system prompt and a note + // in the message, and decides whether to continue or not. + // quarry stays None — no goal in system prompt (only the note in the message) + assert!( + app.hunt.quarry.is_none(), + "quarry must be None: system prompt has no active goal" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repos"), + "paused_quarry preserved for WorkBench display" + ); + + // ASSERT: LLM-evaluation cleared pause state, restored quarry + let summary = sidebar_work_summary(&mut app); + // WorkBench shows ⏸ with the paused goal (via paused_quarry fallback) + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "WorkBench must show Paused indicator (paused_quarry preserved)" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench must show the goal (restored from paused_quarry)" + ); + + // 4. Type "resume the paused command" -- same as any message now: + // quarry restored for LLM evaluation. Pause indicator cleared. + simulate_dispatch_non_continue(&mut app, "resume the paused command"); + + // ASSERT: keyword interception restored quarry + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan nested git repos"), + "'resume the paused command' restores quarry" + ); + // ASSERT: pause is cleared and paused_quarry consumed + assert!(!app.paused, "pause cleared after dispatch"); + assert!( + app.paused_quarry.is_none(), + "paused_quarry consumed on resume" + ); + // ASSERT: WorkBench shows the paused command (via paused_quarry fallback) + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "pause indicator cleared after resume: {:?}", + summary.pause_indicator + ); + // After "continue", paused_quarry is consumed and quarry is restored. + // The goal appears when the system prompt builds in the engine turn. + assert!( + summary.goal_objective.is_none() + || summary.goal_objective.as_deref() == Some("Scan nested git repos"), + "WorkBench: goal={:?} (None=correct after consume, Some=restored)", + summary.goal_objective + ); + } + + #[test] + fn dispatch_continue_restores_quarry_and_workbench() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Type "continue" -- through real dispatch simulation + simulate_dispatch_non_continue(&mut app, "continue"); + + // keyword detection restores quarry on "continue" + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan nested git repos"), + "'continue' restores quarry" + ); + assert!( + app.paused_quarry.is_none(), + "paused_quarry consumed on continue" + ); + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "pause indicator cleared after continue: {:?}", + summary.pause_indicator + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench shows goal via restored quarry" + ); + } + + #[test] + fn dispatch_resume_starts_with_restores_quarry() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Build deploy".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // "resume the paused command" should trigger starts_with("resume ") + simulate_dispatch_non_continue(&mut app, "resume the paused command"); + + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Build deploy"), + "'resume the paused command' restores quarry" + ); + } + + #[test] + fn dispatch_non_continue_after_resume_preserves_new_state() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // "resume" + simulate_dispatch_non_continue(&mut app, "resume"); + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan repos"), + "'resume' restores quarry" + ); + + // Now paused_quarry consumed (by keyword detection), paused is false. + // TurnStarted clears paused_cancelled and pausable. + app.paused_cancelled = false; + app.pausable = false; + + // User types "wait no" -- should be normal message, no pause interference + simulate_dispatch_non_continue(&mut app, "wait no"); + + // Quarry stays restored (unchanged by non-resume messages) + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan repos"), + "quarry stays restored after non-resume message" + ); + } + + #[test] + fn model_must_not_continue_paused_command_after_normal_message() { + // This test documents the EXPECTED behaviour: + // After pause -> "how are you?" -> model responds -> the model + // must NOT continue the paused scan command. + // + // Current reality: the model DOES continue because it sees the + // old request in conversation history. This test passes at the + // CODE level (our state transitions are correct), but the MODEL + // behaviour still violates this expectation. + // + // This is a CONTRACT test — if we later add conversation-trimming + // or system-prompt injection, this test must still hold. + let mut app = create_test_app(); + + // 1. Start pausable command with a clear goal + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. Pause (ESC) + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // 3. Type "how are you?" — non-continue message + simulate_dispatch_non_continue(&mut app, "how are you?"); + + // LLM-evaluation: quarry stays None (no goal in system prompt). + // The note appended to the message tells the LLM to evaluate whether + // the user wants to continue or not — no fragile keyword matching. + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None, note goes to message not system prompt" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry preserved for WorkBench display" + ); + + // WorkBench should show the paused command for user awareness + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repositories"), + "WorkBench must remind user of the paused command" + ); + + // 4. Now simulate what happens AFTER the model responds to "how are you?": + // The engine has finished processing and the turn completes. + // At this point, there should be NO mechanism that re-activates + // the paused command. The model should NOT pick it up. + // + // With LLM-evaluation: quarry stays None (no goal in system prompt). + // paused_quarry is preserved (WorkBench shows the paused command). + // The evaluation note appended to the message tells the LLM to + // NOT continue unless the user asks for it. + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None (no goal pressure on model)" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry preserved for WorkBench display" + ); + + // If this test fails, it means a code change RE-ACTIVATED the + // paused command's goal, causing the model to resume it unprompted. + } + + #[test] + fn non_continue_message_injects_cancellation_notice() { + // When a non-continue message is sent while paused, the message + // MUST include a cancellation notice so the model sees it in the + // conversation and does NOT continue the old command unprompted. + let mut msg = String::from("how are you?"); + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Simulate the exact non-continue branch from dispatch_user_message: + let paused_name = app + .paused_quarry + .as_deref() + .map(|q| q.split(['\n', '\r']).next().unwrap_or(q)) + .unwrap_or("the previous command"); + msg.push_str(&format!( + "\n\n---\n[The user paused: {paused_name}. Respond only to the new message above. Do NOT execute the paused command.]" + )); + app.paused = false; + app.hunt.quarry = None; + + assert!( + msg.contains("user paused"), + "FAIL: message must contain cancellation notice so model does not continue paused command" + ); + assert!( + msg.contains("Scan nested git repositories"), + "FAIL: notice must name the paused command so model knows what to avoid" + ); + } + + #[test] + fn workbench_shows_paused_after_dispatch_non_continue() { + // Direct sidebar state test using dispatch simulation: + // the WorkBench must show the paused command after a non-continue + // message is dispatched through the real flow. + let mut app = create_test_app(); + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + simulate_dispatch_non_continue(&mut app, "how are you?"); + + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Deploy to staging"), + "WorkBench must show the goal (restored from paused_quarry)" + ); + // LLM-evaluation: pause cleared, quarry restored. + // Pause indicator may be None or (Paused) depending on timing. + assert!( + summary.pause_indicator.is_none() + || summary.pause_indicator.as_deref() == Some("(Paused)"), + "WorkBench pause indicator: {:?} (expected None or Paused)", + summary.pause_indicator + ); + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None (note goes to message)" + ); + } + + #[test] + fn workbench_rendered_output_keeps_paused_after_non_continue() { + // Renders the actual work panel lines and checks the final output + // — catches regressions where summary is correct but the render + // path produces nothing (e.g. push_work_goal_lines exits early). + let mut app = create_test_app(); + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + simulate_dispatch_non_continue(&mut app, "how are you?"); + + let summary = sidebar_work_summary(&mut app); + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + + // If the goal_objective or pause_indicator is somehow lost in the + // render pipeline, lines will be empty or not contain the expected + // text. This catches the "WorkBench went blank" bug directly. + assert!( + !lines.is_empty(), + "FAIL: rendered lines are empty — WorkBench went blank" + ); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + // LLM-evaluation: paused_quarry preserved → workflow_paused=true → icon ⏸. + assert!( + first.contains('⏸'), + "FAIL: rendered line missing pause icon, got: {first}" + ); + assert!( + first.contains("Deploy to staging"), + "FAIL: rendered line missing goal text, got: {first}" + ); + } + + #[test] + fn resume_switches_icon_from_pause_to_play() { + // When the user resumes a paused command, the WorkBench icon must + // change from ⏸ (pause) to ▶ (play). The (Pausing)/(Paused) + // indicator must disappear because paused_quarry is consumed. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.pausable = true; + + // Pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Verify paused state shows pause icon + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Paused)")); + + // Resume: consume paused_quarry, restore hunt.quarry + app.hunt.quarry = app.paused_quarry.take(); + app.paused = false; + app.pausable = false; + + // Simulate TurnStarted (engine started processing) + app.is_loading = true; + + // The icon should now be ▶ (play), NOT ⏳/⏸ + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "FAIL: resume left pause_indicator={:?} — icon stuck on pause", + summary.pause_indicator + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan repos"), + "goal must be restored on resume" + ); + } + + #[test] + fn resume_interception_consumes_paused_quarry_from_any_path() { + // Verifies the interception logic now at the top of + // submit_or_steer_message (and in the simulation helper): + // when paused_quarry is set and the user types "resume the paused + // command", the quarry is consumed BEFORE deciding which dispatch + // path to take (Immediate vs Steer vs Queue). + let mut app = create_test_app(); + app.paused_quarry = Some("Scan nested git repos".to_string()); + app.paused = false; // unpaused state after "how are you?" + app.is_loading = true; // model still processing — would go Steer + app.hunt.quarry = None; + + // With LLM evaluation: no keyword interception. + // paused_quarry stays intact, hunt.quarry stays None. + assert!( + app.paused_quarry.is_some(), + "paused_quarry preserved for WorkBench display" + ); + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + + // After interception, even if command completes, the sidebar + // should show the resumed goal, not the pause icon + // (quarry is NOT cleared on completion — sidebar keeps the goal) + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + app.is_loading = false; + + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench should show completed goal" + ); + // goal_completed takes priority over pause_indicator for the icon + assert!( + summary.goal_completed, + "completed command must show checkmark (✓ overrides ⏸)" + ); + assert!( + summary.pause_indicator.is_some(), + "paused_quarry still set — indicator preserved (✓ icon)", + ); + } + + #[test] + fn resume_detected_in_middle_of_sentence() { + let mut app = create_test_app(); + app.paused_quarry = Some("Scan repos".to_string()); + app.hunt.quarry = None; + + simulate_dispatch_non_continue( + &mut app, + "can you please continue the paused slash command", + ); + + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan repos"), + "'can you please continue...' restores quarry" + ); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f040835b5d..9670038f13 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1623,6 +1623,12 @@ async fn run_event_loop( app.streaming_state.reset(); app.streaming_message_index = None; app.streaming_thinking_active_entry = None; + // Reset pause state for new turn. + // Note: pausable is NOT reset here — it is set by + // try_dispatch_user_command for pausable commands and + // persists until the user presses Esc or the turn ends. + app.paused = false; + app.paused_cancelled = false; let now = Instant::now(); app.turn_started_at = Some(now); app.turn_last_activity_at = Some(now); @@ -1713,6 +1719,21 @@ async fn run_event_loop( } crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), }); + // Mark goal as completed when turn finishes successfully + // so the WorkBench shows a green checkmark instead of + // the diamond icon. + if status == crate::core::events::TurnOutcomeStatus::Completed + && app.hunt.quarry.is_some() + { + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + } + + // Keep pause state visible after the turn ends so the + // WorkBench continues to show the pause indicator. + // Clear `pausable` so a fresh user message starts clean, + // but keep `paused`/`paused_cancelled` for the sidebar. + app.pausable = false; + if matches!( status, crate::core::events::TurnOutcomeStatus::Interrupted @@ -1949,7 +1970,16 @@ async fn run_event_loop( apply_engine_error_to_app(app, envelope); } EngineEvent::Status { message } => { - app.status_message = Some(message); + // Late engine status events (e.g. "Request was Paused" + // from a stale Op::SetPaused) must not overwrite a + // more recent cancellation status set by the UI. + if app.paused_cancelled + && (message == "Request was Paused" || message == "Request was Resumed") + { + // discard — cancel/resume status takes priority + } else { + app.status_message = Some(message); + } } EngineEvent::SessionUpdated { session_id, @@ -3445,10 +3475,54 @@ 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 { + // Cancelling while paused — stop the engine turn. + app.paused_cancelled = true; + app.paused = false; + // Restore the quarry from paused_quarry so the + // WorkBench shows the original goal (with ✘ icon). + // Cleared in dispatch_user_message when user types next. + app.hunt.quarry = app.paused_quarry.take(); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.active_allowed_tools = None; + engine_handle.set_paused(false); + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + current_streaming_text.clear(); + app.status_message = Some("Request was 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 => { + if app.paused { + // Already paused — resume + tracing::debug!(target: "pausable", "PauseCommand — resuming"); + app.hunt.quarry = app.paused_quarry.take(); + engine_handle.set_paused(false); + app.paused = false; + app.paused_at = None; + } else { + // First ESC — pause + tracing::debug!(target: "pausable", "PauseCommand — pausing"); + // Save the current goal so we can restore it on resume. + // Set a pause message so the system prompt tells + // the model the command is on hold instead of + // continuing the original request. + app.paused_quarry = app.hunt.quarry.clone(); + // Clear the quarry so the goal continuation + // system doesn't prompt the model to resolve + // a "pause" goal. The pause gate + system + // prompt are sufficient to prevent execution. + app.hunt.quarry = None; + engine_handle.set_paused(true); + app.paused = true; + app.paused_at = Some(std::time::Instant::now()); + app.paused_cancelled = false; + } } EscapeAction::DiscardQueuedDraft => { app.backtrack.reset(); @@ -4790,12 +4864,71 @@ fn queued_message_content_for_app( } } +/// Append an evaluation note to the message when there's a paused_quarry. +/// The LLM reads the note and decides whether to continue the paused command. +fn add_paused_evaluation_note(app: &mut App, message: &mut QueuedMessage) { + if app.paused_quarry.is_some() { + let name = app + .paused_quarry + .as_deref() + .and_then(|q| q.split(['\n', '\r']).next()) + .unwrap_or("the paused command"); + let note = format!( + "\n\n---\n[Note: The user previously paused: {name}. If they ask to \ + continue or resume it in their message above, do so. Otherwise, \ + ignore the paused command and respond only to the new message.]" + ); + message.display.push_str(¬e); + app.hunt.quarry = None; + } +} + async fn dispatch_user_message( app: &mut App, config: &Config, engine_handle: &EngineHandle, mut message: QueuedMessage, ) -> Result<()> { + // When paused or paused_quarry: restore the goal into the system prompt + // and append an evaluation note. The LLM decides whether to continue the + // paused command based on the user's message — no fragile keyword matching. + // The paused_quarry is always consumed here (either restored or discarded). + if app.paused || app.paused_quarry.is_some() { + if app.paused_quarry.is_some() { + add_paused_evaluation_note(app, &mut message); + } + if app.paused { + engine_handle.cancel(); + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + engine_handle.set_paused(false); + app.status_message = None; + } + // Fall through — message goes to engine as a normal user message. + } + + // Safety sync: if the app-level pause was cleared (e.g. by a new slash + // command in try_dispatch_user_command), make sure the engine flag is + // also cleared so the pause gate doesn't block the new command's tools. + // NOTE: check only app.paused — app.pausable may be true because the + // new command's frontmatter already set it, but the engine flag from + // the OLD paused command may still be hanging. + if !app.paused { + engine_handle.set_paused(false); + } + + // If we're in a cancelled state and the user is sending a new message, + // clear the quarry so the system prompt doesn't include the old goal. + // The quarry is kept visible in the WorkBench (via pause_indicator="(Cancelled)") + // until the user types, at which point it's naturally replaced by the new goal. + if app.paused_cancelled { + app.hunt.quarry = None; + app.paused_cancelled = false; + } + // #1364: run mutable `message_submit` hooks before dispatch. Hooks see the // user's display text and may replace or block it before file mentions, // skill wrapping, history, and model input are resolved. @@ -6219,8 +6352,21 @@ async fn execute_command_input( async fn steer_user_message( app: &mut App, engine_handle: &EngineHandle, - message: QueuedMessage, + mut message: QueuedMessage, ) -> Result<()> { + // Add a note for the Steer path (bypasses dispatch_user_message). + // Also clear pause state — the Steer path bypasses dispatch entirely. + if app.paused { + engine_handle.cancel(); + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + engine_handle.set_paused(false); + app.status_message = None; + } + add_paused_evaluation_note(app, &mut message); let cwd = std::env::current_dir().ok(); let references = crate::tui::file_mention::context_references_from_input( &message.display, @@ -6274,8 +6420,36 @@ async fn submit_or_steer_message( app: &mut App, config: &Config, engine_handle: &EngineHandle, - message: QueuedMessage, + mut message: QueuedMessage, ) -> Result<()> { + // UI-level interception: "continue"/"resume" consumes paused_quarry so + // the WorkBench icon changes from ⏸ to ▶. Also adds an evaluation note + // here because dispatch_user_message won't add one (paused_quarry consumed). + if app.paused_quarry.is_some() { + let trimmed = message.display.trim().to_lowercase(); + if trimmed == "continue" + || trimmed == "resume" + || trimmed.starts_with("continue ") + || trimmed.starts_with("resume ") + || trimmed.contains(" continue ") + || trimmed.contains(" resume ") + || trimmed.ends_with(" continue") + || trimmed.ends_with(" resume") + { + // Consume paused_quarry → workflow_paused becomes false → icon ▶ + // Restore to hunt.quarry → TurnCompleted can set Hunted → icon ✓ + if let Some(q) = app.paused_quarry.take() { + let name = q.split(['\n', '\r']).next().unwrap_or(&q).to_string(); + let note = format!( + "\n\n---\n[Note: The user previously paused: {name}. If they ask to \ + continue or resume it in their message above, do so. Otherwise, \ + ignore the paused command and respond only to the new message.]" + ); + message.display.push_str(¬e); + app.hunt.quarry = Some(q); + } + } + } match app.decide_submit_disposition() { SubmitDisposition::Immediate => { dispatch_user_message(app, config, engine_handle, message).await