diff --git a/src/commands/ralph.rs b/src/commands/ralph.rs index 5155cdd..aae6d8e 100644 --- a/src/commands/ralph.rs +++ b/src/commands/ralph.rs @@ -252,17 +252,21 @@ async fn handle_session_outcome( .write_raw(&format!(" Total cost: ${:.2}\r\n", iter.total_cost)); // User pressed Ctrl+W — wait for input before continuing. + // Escape dismisses the wait and falls through to tag processing. if state.wait_requested { state.wait_requested = false; ctx.renderer.write_raw("\x07"); ctx.renderer.write_raw("\r\n[waiting for user input]\r\n"); - let Some((runner, new_state)) = - wait_input_and_resume(state, session_config, ctx).await? - else { - return Ok(LoopAction::Exit); - }; - iter.iteration_cost = 0.0; - return Ok(LoopAction::Resume(Box::new(runner), new_state)); + match wait_input_and_resume(state, session_config, ctx).await? { + WaitResumeAction::Resume(runner, new_state) => { + iter.iteration_cost = 0.0; + return Ok(LoopAction::Resume(runner, new_state)); + } + WaitResumeAction::Dismissed => { + // Fall through to tag processing below. + } + WaitResumeAction::Exit => return Ok(LoopAction::Exit), + } } // Check for wait-for-user before break tag (user input takes precedence). @@ -274,13 +278,16 @@ async fn handle_session_outcome( ctx.renderer.write_raw("\x07"); ctx.renderer .write_raw(&format!("\r\nWaiting for user: {reason}\r\n")); - let Some((runner, new_state)) = - wait_input_and_resume(state, session_config, ctx).await? - else { - return Ok(LoopAction::Exit); - }; - iter.iteration_cost = 0.0; - return Ok(LoopAction::Resume(Box::new(runner), new_state)); + match wait_input_and_resume(state, session_config, ctx).await? { + WaitResumeAction::Resume(runner, new_state) => { + iter.iteration_cost = 0.0; + return Ok(LoopAction::Resume(runner, new_state)); + } + WaitResumeAction::Dismissed => { + // Fall through to break tag check below. + } + WaitResumeAction::Exit => return Ok(LoopAction::Exit), + } } if let Some(reason) = config.scan_break(&result_text) { @@ -298,12 +305,12 @@ async fn handle_session_outcome( state.wait_requested = false; iter.iteration_cost += state.total_cost_usd; ctx.renderer.render_interrupted(); - let Some((runner, new_state)) = - wait_input_and_resume(state, session_config, ctx).await? - else { - return Ok(LoopAction::Exit); - }; - Ok(LoopAction::Resume(Box::new(runner), new_state)) + match wait_input_and_resume(state, session_config, ctx).await? { + WaitResumeAction::Resume(runner, new_state) => { + Ok(LoopAction::Resume(runner, new_state)) + } + WaitResumeAction::Dismissed | WaitResumeAction::Exit => Ok(LoopAction::Exit), + } } SessionOutcome::Reload { .. } => { let Some(session_id) = state.session_id.take() else { @@ -324,19 +331,29 @@ async fn handle_session_outcome( } } +/// What to do after waiting for user input at a pause point. +enum WaitResumeAction { + /// User provided text — resume with a new session. + Resume(Box, SessionState), + /// User dismissed the wait (Escape) — proceed without resuming. + Dismissed, + /// User exited (Ctrl+C / Ctrl+D). + Exit, +} + /// Wait for user input and spawn a resumed session. /// -/// Takes the `session_id` from `state`; returns `None` if no session ID is -/// available or the user exited without providing input. +/// Takes the `session_id` from `state`. On `Dismissed`, the session ID is +/// restored so the caller can fall through to further processing. async fn wait_input_and_resume( state: &mut SessionState, session_config: &SessionConfig, ctx: &mut Ctx<'_, W>, -) -> Result> { +) -> Result { let Some(session_id) = state.session_id.take() else { - return Ok(None); + return Ok(WaitResumeAction::Exit); }; - let Some(text) = event_loop::wait_for_interrupt_input( + match event_loop::wait_for_dismissable_input( ctx.input, ctx.renderer, ctx.io, @@ -345,16 +362,22 @@ async fn wait_input_and_resume( session_config, ) .await? - else { - return Ok(None); - }; - let resume_config = session_config.resume_with(text, session_id.clone()); - let runner = event_loop::spawn_session(resume_config, ctx.io, ctx.vcr).await?; - let new_state = SessionState { - session_id: Some(session_id), - ..Default::default() - }; - Ok(Some((runner, new_state))) + { + Some(event_loop::WaitInterruptResult::Text(text)) => { + let resume_config = session_config.resume_with(text, session_id.clone()); + let runner = event_loop::spawn_session(resume_config, ctx.io, ctx.vcr).await?; + let new_state = SessionState { + session_id: Some(session_id), + ..Default::default() + }; + Ok(WaitResumeAction::Resume(Box::new(runner), new_state)) + } + Some(event_loop::WaitInterruptResult::Dismissed) => { + state.session_id = Some(session_id); + Ok(WaitResumeAction::Dismissed) + } + None => Ok(WaitResumeAction::Exit), + } } #[cfg(test)] diff --git a/src/commands/run.rs b/src/commands/run.rs index 3482381..05ff0f6 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -238,7 +238,7 @@ async fn get_initial_runner( state.session_id = Some(session_id); Ok(Some(runner)) } - None => Ok(None), + Some(event_loop::WaitResult::Dismissed) | None => Ok(None), } } diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 815ccd3..06752da 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -432,13 +432,14 @@ async fn run_phase_with_wait( .write_raw(&format!(" Total cost: ${:.2}\r\n", ctx.total_cost)); // User pressed Ctrl+W — wait for input before parsing/following transition. + // Escape dismisses the wait and falls through to transition parsing. if wait_requested { ctx.renderer.write_raw("\x07"); ctx.renderer.write_raw("\r\n[waiting for user input]\r\n"); let sid = session_id .as_deref() .context("no session ID for wait resume")?; - let Some(user_text) = event_loop::wait_for_interrupt_input( + match event_loop::wait_for_dismissable_input( ctx.input, ctx.renderer, ctx.io, @@ -447,12 +448,17 @@ async fn run_phase_with_wait( &base_config, ) .await? - else { - return Ok(None); - }; - phase_prompt = user_text; - phase_resume = session_id; - continue; + { + Some(event_loop::WaitInterruptResult::Text(user_text)) => { + phase_prompt = user_text; + phase_resume = session_id; + continue; + } + Some(event_loop::WaitInterruptResult::Dismissed) => { + // Fall through to parse transitions from the last result. + } + None => return Ok(None), + } } let Some(transition) = parse_transition_with_retry( diff --git a/src/display/input.rs b/src/display/input.rs index a0d2772..53216c1 100644 --- a/src/display/input.rs +++ b/src/display/input.rs @@ -18,8 +18,10 @@ pub enum InputAction { Submit(String, InputMode), /// User wants to view a message (e.g. ":3", ":2/1", ":Bash", ":Edit[-1]"). ViewMessage(String), - /// User cancelled input (Escape). + /// User cancelled input (Escape with text in buffer). Cancel, + /// User dismissed the prompt (Escape on empty buffer). + Dismiss, /// User pressed Ctrl-C. Interrupt, /// User pressed Ctrl-D. @@ -334,9 +336,14 @@ impl InputHandler { KeyCode::Enter => self.handle_enter(event, out), KeyCode::Esc => { + let was_empty = self.buffer.is_empty(); self.deactivate(); self.clear_input_lines(out); - InputAction::Cancel + if was_empty { + InputAction::Dismiss + } else { + InputAction::Cancel + } } _ => InputAction::None, diff --git a/src/display/renderer.rs b/src/display/renderer.rs index ef6df59..ba4164c 100644 --- a/src/display/renderer.rs +++ b/src/display/renderer.rs @@ -252,7 +252,7 @@ impl Renderer { } => return, HintContext::Prompt { is_first_message: false, - } => "Enter follow up · :N view message · Ctrl+O interactive", + } => "Enter follow up · :N view message · Ctrl+O interactive · Esc skip", }; queue!(self.out, Print(theme::dim().apply(help)), Print("\r\n")).ok(); self.out.flush().ok(); diff --git a/src/session/event_loop.rs b/src/session/event_loop.rs index 4a9cb5d..18c65b2 100644 --- a/src/session/event_loop.rs +++ b/src/session/event_loop.rs @@ -420,7 +420,7 @@ async fn handle_session_key_event( InputAction::EndSession => { runner.close_input(); } - InputAction::Cancel => { + InputAction::Cancel | InputAction::Dismiss => { let flush = flush_event_buffer(locals, state, renderer); send_tag_warning(locals, runner, vcr).await?; if let FlushResult::Completed(ref result_text) = flush { @@ -646,6 +646,17 @@ pub enum WaitResult { Text(String), /// User wants to drop into the native Claude TUI. Interactive, + /// User dismissed the wait prompt (Escape on empty buffer). + Dismissed, +} + +/// Result from `wait_for_dismissable_input` — like `Option` but +/// distinguishes "user dismissed the wait" from "user exited entirely". +pub enum WaitInterruptResult { + /// User submitted text. + Text(String), + /// User dismissed the wait (Escape on empty buffer). + Dismissed, } /// Show a prompt and wait for user to type a follow-up or exit. @@ -667,7 +678,7 @@ pub async fn wait_for_followup( Ok(FollowUpAction::Sent) } Some(WaitResult::Interactive) => Ok(FollowUpAction::Interactive), - None => Ok(FollowUpAction::Exit), + Some(WaitResult::Dismissed) | None => Ok(FollowUpAction::Exit), } } @@ -686,6 +697,7 @@ pub async fn wait_for_user_input( /// Wait for user input from the interrupted state, handling Ctrl+O to open /// an interactive session. Returns the resume text, or None to exit. +/// Escape-dismiss is treated as exit (returns None). pub async fn wait_for_interrupt_input( input: &mut InputHandler, renderer: &mut Renderer, @@ -694,6 +706,36 @@ pub async fn wait_for_interrupt_input( session_id: &str, base_config: &SessionConfig, ) -> Result> { + match wait_for_input_inner(input, renderer, io, vcr, session_id, base_config).await? { + Some(WaitInterruptResult::Text(text)) => Ok(Some(text)), + Some(WaitInterruptResult::Dismissed) | None => Ok(None), + } +} + +/// Like `wait_for_interrupt_input`, but distinguishes "user dismissed the +/// wait" (Escape on empty buffer) from "user exited" (Ctrl+C / Ctrl+D). +/// Used for Ctrl+W waits where dismiss means "proceed without input." +pub async fn wait_for_dismissable_input( + input: &mut InputHandler, + renderer: &mut Renderer, + io: &mut Io, + vcr: &VcrContext, + session_id: &str, + base_config: &SessionConfig, +) -> Result> { + wait_for_input_inner(input, renderer, io, vcr, session_id, base_config).await +} + +/// Shared implementation for `wait_for_interrupt_input` and +/// `wait_for_dismissable_input`. +async fn wait_for_input_inner( + input: &mut InputHandler, + renderer: &mut Renderer, + io: &mut Io, + vcr: &VcrContext, + session_id: &str, + base_config: &SessionConfig, +) -> Result> { io.clear_event_channel(); vcr.call("idle", (), async |(): &()| Ok(())).await?; let interactive_config = SessionConfig { @@ -702,11 +744,14 @@ pub async fn wait_for_interrupt_input( }; loop { match wait_for_text_input(input, renderer, false, io, vcr).await? { - Some(WaitResult::Text(text)) => return Ok(Some(text)), + Some(WaitResult::Text(text)) => return Ok(Some(WaitInterruptResult::Text(text))), Some(WaitResult::Interactive) => { open_interactive_session(&interactive_config, io, vcr)?; renderer.render_returned_from_interactive(); } + Some(WaitResult::Dismissed) => { + return Ok(Some(WaitInterruptResult::Dismissed)); + } None => return Ok(None), } } @@ -759,6 +804,9 @@ async fn wait_for_text_input( input.set_has_hint_line(); } } + InputAction::Dismiss => { + return Ok(Some(WaitResult::Dismissed)); + } InputAction::Interrupt | InputAction::EndSession => { return Ok(None); } diff --git a/tests/cases/fork/fork_basic/fork_basic.snap b/tests/cases/fork/fork_basic/fork_basic.snap index 4f19d0d..d9f71c0 100644 --- a/tests/cases/fork/fork_basic/fork_basic.snap +++ b/tests/cases/fork/fork_basic/fork_basic.snap @@ -23,5 +23,5 @@ Perfect! Both files have been created: Done $0.01 · 1.4s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/fork/fork_buffered/fork_buffered.snap b/tests/cases/fork/fork_buffered/fork_buffered.snap index cbcf644..ec85323 100644 --- a/tests/cases/fork/fork_buffered/fork_buffered.snap +++ b/tests/cases/fork/fork_buffered/fork_buffered.snap @@ -29,5 +29,5 @@ I'm not sure what you'd like to do next. What can I help you with? Done $0.01 · 2.5s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/fork/fork_single/fork_single.snap b/tests/cases/fork/fork_single/fork_single.snap index f38a1ad..20b45dd 100644 --- a/tests/cases/fork/fork_single/fork_single.snap +++ b/tests/cases/fork/fork_single/fork_single.snap @@ -26,5 +26,5 @@ Done! The `greeting.txt` file has been created in the forked subtask with the co Done $0.01 · 4.1s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/orchestration/priority_dispatch/priority_dispatch.snap b/tests/cases/orchestration/priority_dispatch/priority_dispatch.snap index fb2c270..e185da0 100644 --- a/tests/cases/orchestration/priority_dispatch/priority_dispatch.snap +++ b/tests/cases/orchestration/priority_dispatch/priority_dispatch.snap @@ -401,6 +401,6 @@ Done $0.02 · 10.4s · 4 turns (:N to view) Total cost: $0.27  Waiting for user: Need permission to read brief.md and board.md to sync work and pick a task -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > Removing worktree... diff --git a/tests/cases/rendering/edit_tool/edit_tool.snap b/tests/cases/rendering/edit_tool/edit_tool.snap index 9862d55..11e6cf8 100644 --- a/tests/cases/rendering/edit_tool/edit_tool.snap +++ b/tests/cases/rendering/edit_tool/edit_tool.snap @@ -14,5 +14,5 @@ Done! I've changed the greeting from 'hello' to 'goodbye' and added a farewell l Done $0.01 · 6.7s · 3 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/rendering/grep_glob/grep_glob.snap b/tests/cases/rendering/grep_glob/grep_glob.snap index 54f77e5..48843bc 100644 --- a/tests/cases/rendering/grep_glob/grep_glob.snap +++ b/tests/cases/rendering/grep_glob/grep_glob.snap @@ -25,5 +25,5 @@ The other Rust file (`src/lib.rs`) only contains a helper function `add()` and d Done $0.04 · 8.6s · 5 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/rendering/mcp_tool/mcp_tool.snap b/tests/cases/rendering/mcp_tool/mcp_tool.snap index bf489ea..4d4cda2 100644 --- a/tests/cases/rendering/mcp_tool/mcp_tool.snap +++ b/tests/cases/rendering/mcp_tool/mcp_tool.snap @@ -21,5 +21,5 @@ The documentation is now available for reference. Let me know if you'd like me t Done $0.01 · 5.8s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/rendering/tool_use/tool_use.snap b/tests/cases/rendering/tool_use/tool_use.snap index da5f3c5..54a70b5 100644 --- a/tests/cases/rendering/tool_use/tool_use.snap +++ b/tests/cases/rendering/tool_use/tool_use.snap @@ -16,5 +16,5 @@ Would you like me to explore what's inside any of these directories? Done $0.01 · 4.4s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/rendering/write_single_line/write_single_line.snap b/tests/cases/rendering/write_single_line/write_single_line.snap index c76dbb1..13d132c 100644 --- a/tests/cases/rendering/write_single_line/write_single_line.snap +++ b/tests/cases/rendering/write_single_line/write_single_line.snap @@ -12,5 +12,5 @@ Done! I've created the file `hello.txt` with the content "Hello, world!". Done $0.01 · 4.8s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/error_handling/error_handling.snap b/tests/cases/session/error_handling/error_handling.snap index b70fcee..b457a3e 100644 --- a/tests/cases/session/error_handling/error_handling.snap +++ b/tests/cases/session/error_handling/error_handling.snap @@ -27,5 +27,5 @@ Done! The issue was that the test script was exiting with status code 1 (failure Done $0.03 · 14.3s · 8 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/interrupt_resume/interrupt_resume.snap b/tests/cases/session/interrupt_resume/interrupt_resume.snap index b8719d6..a5b076b 100644 --- a/tests/cases/session/interrupt_resume/interrupt_resume.snap +++ b/tests/cases/session/interrupt_resume/interrupt_resume.snap @@ -9,7 +9,7 @@ Session f8027909-8121-4350-a53e-1e6c37b9ae45 (claude-haiku-4-5-20251001) [2] ▶ Read /private/var/folders/21/3gpj27c974j5vc436plct78w0000gn/T/coven-vc... [interrupted — Ctrl+O to open interactive] -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > Continue where you left off --- @@ -20,5 +20,5 @@ The file **hello.txt** says: **"Hello from the test file!"** Done $0.01 · 3.5s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/multi_turn/multi_turn.snap b/tests/cases/session/multi_turn/multi_turn.snap index 45a8e40..3025f40 100644 --- a/tests/cases/session/multi_turn/multi_turn.snap +++ b/tests/cases/session/multi_turn/multi_turn.snap @@ -25,7 +25,7 @@ Is there something specific about Rust you'd like to explore or discuss? Done $0.01 · 4.0s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > How does ownership work? [2] Thinking... Rust has three core ownership rules: @@ -62,5 +62,5 @@ Does this make sense, or would you like me to dig into a specific aspect like wh Done $0.01 · 3.7s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/reload_basic/reload_basic.snap b/tests/cases/session/reload_basic/reload_basic.snap index c1e180e..a360a20 100644 --- a/tests/cases/session/reload_basic/reload_basic.snap +++ b/tests/cases/session/reload_basic/reload_basic.snap @@ -22,5 +22,5 @@ So to answer your test: I can see from the conversation that the secret number y Done $0.01 · 8.3s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/show_thinking/show_thinking.snap b/tests/cases/session/show_thinking/show_thinking.snap index a373674..8250054 100644 --- a/tests/cases/session/show_thinking/show_thinking.snap +++ b/tests/cases/session/show_thinking/show_thinking.snap @@ -22,5 +22,5 @@ Done! I've created `hello.py` with a "Hi, world!" greeting. Done $0.01 · 4.9s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/simple_qa/simple_qa.snap b/tests/cases/session/simple_qa/simple_qa.snap index 11d4d51..bcb10c4 100644 --- a/tests/cases/session/simple_qa/simple_qa.snap +++ b/tests/cases/session/simple_qa/simple_qa.snap @@ -10,5 +10,5 @@ Session f4aa2c84-16cb-4763-a7d4-9a116115983d (claude-haiku-4-5-20251001) Done $0.00 · 1.6s · 1 turn (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/session/steering/steering.snap b/tests/cases/session/steering/steering.snap index 0215114..647d75c 100644 --- a/tests/cases/session/steering/steering.snap +++ b/tests/cases/session/steering/steering.snap @@ -26,5 +26,5 @@ What's the best approach for what you're looking for? Done $0.01 · 6.8s · 3 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/subagent/parallel_subagent/parallel_subagent.snap b/tests/cases/subagent/parallel_subagent/parallel_subagent.snap index edf06a5..e770ac8 100644 --- a/tests/cases/subagent/parallel_subagent/parallel_subagent.snap +++ b/tests/cases/subagent/parallel_subagent/parallel_subagent.snap @@ -23,5 +23,5 @@ Both files are minimal, as expected for a test project setup. Done $0.05 · 8.5s · 3 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/subagent/subagent/subagent.snap b/tests/cases/subagent/subagent/subagent.snap index 4ecd70f..8c8ef92 100644 --- a/tests/cases/subagent/subagent/subagent.snap +++ b/tests/cases/subagent/subagent/subagent.snap @@ -13,5 +13,5 @@ The subagent has summarized the README.md: **This is a minimal test project crea Done $0.02 · 5.5s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip > diff --git a/tests/cases/subagent/subagent_error/subagent_error.snap b/tests/cases/subagent/subagent_error/subagent_error.snap index 4abcc84..2c09a68 100644 --- a/tests/cases/subagent/subagent_error/subagent_error.snap +++ b/tests/cases/subagent/subagent_error/subagent_error.snap @@ -19,5 +19,5 @@ Both Read calls were executed simultaneously in a single function_calls block. Done $0.02 · 7.7s · 2 turns (:N to view) -Enter follow up · :N view message · Ctrl+O interactive +Enter follow up · :N view message · Ctrl+O interactive · Esc skip >