Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 58 additions & 35 deletions src/commands/ralph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,17 +252,21 @@ async fn handle_session_outcome<W: Write>(
.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).
Expand All @@ -274,13 +278,16 @@ async fn handle_session_outcome<W: Write>(
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) {
Expand All @@ -298,12 +305,12 @@ async fn handle_session_outcome<W: Write>(
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 {
Expand All @@ -324,19 +331,29 @@ async fn handle_session_outcome<W: Write>(
}
}

/// What to do after waiting for user input at a pause point.
enum WaitResumeAction {
/// User provided text — resume with a new session.
Resume(Box<SessionRunner>, 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<W: Write>(
state: &mut SessionState,
session_config: &SessionConfig,
ctx: &mut Ctx<'_, W>,
) -> Result<Option<(SessionRunner, SessionState)>> {
) -> Result<WaitResumeAction> {
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,
Expand All @@ -345,16 +362,22 @@ async fn wait_input_and_resume<W: Write>(
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)]
Expand Down
2 changes: 1 addition & 1 deletion src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ async fn get_initial_runner<W: Write>(
state.session_id = Some(session_id);
Ok(Some(runner))
}
None => Ok(None),
Some(event_loop::WaitResult::Dismissed) | None => Ok(None),
}
}

Expand Down
20 changes: 13 additions & 7 deletions src/commands/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,13 +432,14 @@ async fn run_phase_with_wait<W: Write>(
.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,
Expand All @@ -447,12 +448,17 @@ async fn run_phase_with_wait<W: Write>(
&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(
Expand Down
11 changes: 9 additions & 2 deletions src/display/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/display/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ impl<W: Write> Renderer<W> {
} => 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();
Expand Down
54 changes: 51 additions & 3 deletions src/session/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ async fn handle_session_key_event<W: Write>(
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 {
Expand Down Expand Up @@ -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<String>` 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.
Expand All @@ -667,7 +678,7 @@ pub async fn wait_for_followup<W: Write>(
Ok(FollowUpAction::Sent)
}
Some(WaitResult::Interactive) => Ok(FollowUpAction::Interactive),
None => Ok(FollowUpAction::Exit),
Some(WaitResult::Dismissed) | None => Ok(FollowUpAction::Exit),
}
}

Expand All @@ -686,6 +697,7 @@ pub async fn wait_for_user_input<W: Write>(

/// 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<W: Write>(
input: &mut InputHandler,
renderer: &mut Renderer<W>,
Expand All @@ -694,6 +706,36 @@ pub async fn wait_for_interrupt_input<W: Write>(
session_id: &str,
base_config: &SessionConfig,
) -> Result<Option<String>> {
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<W: Write>(
input: &mut InputHandler,
renderer: &mut Renderer<W>,
io: &mut Io,
vcr: &VcrContext,
session_id: &str,
base_config: &SessionConfig,
) -> Result<Option<WaitInterruptResult>> {
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<W: Write>(
input: &mut InputHandler,
renderer: &mut Renderer<W>,
io: &mut Io,
vcr: &VcrContext,
session_id: &str,
base_config: &SessionConfig,
) -> Result<Option<WaitInterruptResult>> {
io.clear_event_channel();
vcr.call("idle", (), async |(): &()| Ok(())).await?;
let interactive_config = SessionConfig {
Expand All @@ -702,11 +744,14 @@ pub async fn wait_for_interrupt_input<W: Write>(
};
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),
}
}
Expand Down Expand Up @@ -759,6 +804,9 @@ async fn wait_for_text_input<W: Write>(
input.set_has_hint_line();
}
}
InputAction::Dismiss => {
return Ok(Some(WaitResult::Dismissed));
}
InputAction::Interrupt | InputAction::EndSession => {
return Ok(None);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/cases/fork/fork_basic/fork_basic.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
2 changes: 1 addition & 1 deletion tests/cases/fork/fork_buffered/fork_buffered.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
2 changes: 1 addition & 1 deletion tests/cases/fork/fork_single/fork_single.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Original file line number Diff line number Diff line change
Expand Up @@ -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...
2 changes: 1 addition & 1 deletion tests/cases/rendering/edit_tool/edit_tool.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
2 changes: 1 addition & 1 deletion tests/cases/rendering/grep_glob/grep_glob.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
2 changes: 1 addition & 1 deletion tests/cases/rendering/mcp_tool/mcp_tool.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
2 changes: 1 addition & 1 deletion tests/cases/rendering/tool_use/tool_use.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Loading