Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
2a58dc3
Phase 3: pausable command lifecycle (pause/resume/cancel)
aboimpinto Jun 3, 2026
3b49de0
fix: correct UiTheme field names in sidebar pause indicator
aboimpinto Jun 3, 2026
9813852
fix: keep pausable flag across TurnStarted, handle cancel-while-pause…
aboimpinto Jun 3, 2026
4b91ae1
fix: use shared Arc<StdMutex<bool>> for paused flag (bypasses Op chan…
aboimpinto Jun 3, 2026
878d981
fix: keep paused/paused_cancelled visible after turn ends, show 'Paus…
aboimpinto Jun 3, 2026
d3819b2
debug: add tracing::debug! at pause key points (set_paused, pause gat…
aboimpinto Jun 3, 2026
2625d71
fix: show (Pausing) in WorkBench during transition, let 'continue' me…
aboimpinto Jun 3, 2026
372a0d9
fix: any message while paused unpauses and sends, instead of blocking…
aboimpinto Jun 3, 2026
f79814e
fix: cancel old turn when unpausing with a new message to prevent com…
aboimpinto Jun 3, 2026
2b28bc8
fix: when paused and user types, cancel old turn; consume message so …
aboimpinto Jun 3, 2026
7f33a04
fix: pause gate cancels turn instead of returning tool error (prevent…
aboimpinto Jun 3, 2026
580e7d7
fix: mark hunt as Hunted on successful turn completion (green checkma…
aboimpinto Jun 3, 2026
2dd228f
feat: play icon ▶ while running (yellow), green checkmark ✓ on comple…
aboimpinto Jun 3, 2026
687c3a8
fix: cancel-while-paused now also calls engine_handle.cancel() — actu…
aboimpinto Jun 3, 2026
8692935
fix: merge pause indicator into the goal line (replaces icon instead …
aboimpinto Jun 3, 2026
e7439ef
fix: 'continue'/'resume' unpauses and lets message through (one keyst…
aboimpinto Jun 3, 2026
6420195
fix: clear todos and plan state when a new slash command starts (no s…
aboimpinto Jun 3, 2026
b5265e8
fix: second ESC while paused returns CancelRequest even after turn en…
aboimpinto Jun 3, 2026
5f39234
fix: cancel-while-paused clears flag directly (not via set_paused) to…
aboimpinto Jun 3, 2026
24e10ef
fix: new slash command clears pause state (app + engine flag); safety…
aboimpinto Jun 3, 2026
079b62e
fix: safety sync clears engine flag when app.paused is false, even if…
aboimpinto Jun 3, 2026
b9f4a42
test: add 8 pause lifecycle tests (indicator states, goal icons) + fi…
aboimpinto Jun 3, 2026
0d8981c
fix: standardised status messages — Request is Pausing / Request was …
aboimpinto Jun 3, 2026
6f3557c
fix: remove redundant Op::SetPaused try_send from set_paused() — avoi…
aboimpinto Jun 3, 2026
ca6845d
test: verify new slash command after cancel clears all pause state
aboimpinto Jun 3, 2026
c5871c9
fix: remove unused Op::SetPaused variant and handler (dead code warning)
aboimpinto Jun 3, 2026
d676a9c
fix: clear hunt.quarry on cancel so model doesn't resume old command …
aboimpinto Jun 3, 2026
1e7f988
fix: clear hunt.quarry on successful completion so model stops being …
aboimpinto Jun 3, 2026
4c04baa
feat: pause now lets messages through (no cancel); quarry saved/resto…
aboimpinto Jun 3, 2026
8905e25
fix: set pause message as quarry so model knows command is on hold in…
aboimpinto Jun 3, 2026
edcb4fb
test: add pause_sets_pause_message_as_quarry; strengthen pause instru…
aboimpinto Jun 3, 2026
9729951
fix: clear hunt.quarry on pause so goal system doesn't prompt model t…
aboimpinto Jun 3, 2026
c8b5ba4
test: add lifecycle workflow tests (pause->continue->complete, pause-…
aboimpinto Jun 3, 2026
6863211
fix: keep quarry on cancel so WorkBench shows ✘ with the original goa…
aboimpinto Jun 3, 2026
06f86f4
fix: display paused_quarry in WorkBench when hunt.quarry is None duri…
aboimpinto Jun 3, 2026
15f9206
fix: non-continue message while paused cancels old turn + clears hunt…
aboimpinto Jun 3, 2026
6ac0f90
fix: detect 'continue the scan' / 'resume the paused command' as resu…
aboimpinto Jun 3, 2026
ed524e3
fix: keep pause indicator visible while paused_quarry exists, even af…
aboimpinto Jun 3, 2026
3760c9c
test: non_continue_while_paused_clears_system_prompt_goal — proves hu…
aboimpinto Jun 3, 2026
38301f2
test: workbench_visible_through_pause_ask_resume — proves WorkBench p…
aboimpinto Jun 3, 2026
a93c311
fix: inject cancellation notice into message when non-continue sent w…
aboimpinto Jun 3, 2026
e2f19a9
test: resume_switches_icon_from_pause_to_play — verifies icon changes…
aboimpinto Jun 3, 2026
6b15315
debug: add tracing::debug! for pause state in sidebar_work_summary
aboimpinto Jun 3, 2026
1647862
fix: intercept resume in submit_or_steer_message (catches Steer path …
aboimpinto Jun 3, 2026
21e67df
chore: remove unused mut on message in submit_or_steer_message
aboimpinto Jun 3, 2026
54e8165
debug: add tracing at submit_or_steer interception point
aboimpinto Jun 3, 2026
f9f86e9
fix: add resume interception in steer_user_message (belt-and-suspenders)
aboimpinto Jun 3, 2026
e8b8482
fix: detect 'please continue' / 'resume the command' at any word posi…
aboimpinto Jun 3, 2026
aaaa684
cleanup: remove debug eprintln traces
aboimpinto Jun 3, 2026
e7695d5
cleanup: remove debug traces, run cargo fmt
aboimpinto Jun 4, 2026
655fd8e
fix: resolve all clippy warnings (collapsible_if, unnecessary_map_or,…
aboimpinto Jun 4, 2026
5de8254
fix: use set_paused in CancelRequest; use match on try_lock with warn…
aboimpinto Jun 4, 2026
922c1c3
feat: use LLM evaluation for resume detection (replace fragile keywor…
aboimpinto Jun 4, 2026
516f302
fix: LLM-evaluation approach — keep paused_quarry for WorkBench, rest…
aboimpinto Jun 4, 2026
11bf824
feat: remove keyword interception entirely — pure LLM evaluation for …
aboimpinto Jun 4, 2026
3c3ee94
feat: separate icon logic from pause_indicator, fix TurnCompleted ver…
aboimpinto Jun 4, 2026
f2a4179
refactor: extract add_paused_evaluation_note helper, remove duplicate…
aboimpinto Jun 4, 2026
0049780
fix: restore quarry.is_some() guard on TurnCompleted — prevents check…
aboimpinto Jun 4, 2026
b6e4e10
fix: workflow_paused includes paused_quarry — icon stays ⏸ after typi…
aboimpinto Jun 4, 2026
8c690a3
fix: add keyword detection back for icon/checkmark — LLM note still h…
aboimpinto Jun 4, 2026
365a6f7
chore: final cleanup before PR — format, centralize LLM eval note, ve…
aboimpinto Jun 4, 2026
4f67d63
fix: steer_user_message now clears pause state (engine flag + app.pau…
aboimpinto Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion crates/tui/src/commands/user_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -206,6 +206,25 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
app.hunt.verdict = HuntVerdict::Hunting;
app.hunt.token_budget = None;
app.active_allowed_tools = None;
// Clear todos and plan state from the previous command.
match app.todos.try_lock() {
Ok(mut todos) => 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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
for (key, value) in &metadata {
match key.as_str() {
"description" => {
Expand All @@ -215,6 +234,45 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
"allowed-tools" => {
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}");
}
}
Comment thread
aboimpinto marked this conversation as resolved.
app.pausable = true;
app.paused = false;
app.paused_cancelled = false;
}
Comment thread
aboimpinto marked this conversation as resolved.
"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");
}
_ => {}
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,9 @@ pub struct EngineHandle {
tx_user_input: mpsc::Sender<UserInputDecision>,
/// Send steer input for an in-flight turn.
tx_steer: mpsc::Sender<String>,
/// 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<StdMutex<bool>>,
}

// `impl EngineHandle { ... }` moved to `engine/handle.rs` so the
Expand Down Expand Up @@ -505,6 +508,9 @@ pub struct Engine {
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
/// 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<StdMutex<bool>>,
}

// === Internal tool helpers ===
Expand Down Expand Up @@ -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<StdMutex<Option<CancelReason>>> = 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
Expand Down Expand Up @@ -752,6 +759,7 @@ impl Engine {
workshop_vars,
sandbox_backend,
current_mode: AppMode::Agent,
shared_paused: shared_paused.clone(),
};
engine.rehydrate_latest_canonical_state();

Expand All @@ -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,
Expand Down Expand Up @@ -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<StdMutex<Option<CancelReason>>> = 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,
Expand Down
17 changes: 17 additions & 0 deletions crates/tui/src/core/engine/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
10 changes: 10 additions & 0 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
}
Expand Down
18 changes: 18 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
/// Whether the current pausable command is paused (ESC once).
pub paused: bool,
/// Timestamp of the last pause (for ESC debounce).
pub paused_at: Option<std::time::Instant>,
/// 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<String>,
/// Saved hunt.quarry from before pause — restored on "continue".
pub paused_quarry: Option<String>,
pub history: Vec<HistoryCell>,
pub history_version: u64,
/// Per-cell revision counter, kept in lockstep with `history`.
Expand Down Expand Up @@ -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(),
Expand Down
13 changes: 11 additions & 2 deletions crates/tui/src/tui/composer_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const COMPOSER_ARROW_SCROLL_LINES: usize = 3;
pub(crate) enum EscapeAction {
CloseSlashMenu,
CancelRequest,
PauseCommand,
DiscardQueuedDraft,
ClearInput,
Noop,
Expand All @@ -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() {
Expand Down
Loading
Loading