feat(cli): press Esc to interrupt a running turn

While a turn runs the main thread is blocked in the conversation loop,
so nothing reads the keyboard. Add an EscapeInterruptMonitor that, for
the duration of a turn on an interactive terminal, switches stdin to a
minimal non-canonical mode (echo/line-buffering off; ISIG and output
processing untouched, unlike full raw mode) and polls for a lone ESC
byte. Escape sequences such as arrow keys are recognized and swallowed.
On Esc it trips the TurnInterruptSignal: the in-flight request is
aborted and control returns to the REPL prompt with the session intact.

Permission prompts read whole lines from stdin mid-turn, so stdin
ownership is coordinated through a StdinPromptGate: while a prompt holds
a lease the listener restores canonical mode and stops consuming bytes.

Also:
- spinner shows '(esc to interrupt)' and the banner/help mention Esc
- WorkerStatus/WorkerEventKind gain an 'interrupted' lifecycle state
- non-terminal stdin and non-unix platforms skip the listener; Ctrl+C
  interruption still works there

Closes the Esc-to-interrupt feature request (#3196).
This commit is contained in:
amazd 2026-06-10 14:05:14 -04:00
parent 3892450fa1
commit b0fad35dfb
5 changed files with 356 additions and 12 deletions

1
rust/Cargo.lock generated
View File

@ -2247,6 +2247,7 @@ dependencies = [
"crossterm",
"log",
"mock-anthropic-service",
"nix",
"plugins",
"pulldown-cmark",
"runtime",

View File

@ -34,6 +34,9 @@ pub enum WorkerStatus {
ToolPermissionRequired,
ReadyForPrompt,
Running,
/// The user stopped the turn mid-run (e.g. Esc or Ctrl+C); the worker
/// is back at the prompt with the session intact.
Interrupted,
Finished,
Failed,
}
@ -46,6 +49,7 @@ impl std::fmt::Display for WorkerStatus {
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
Self::Running => write!(f, "running"),
Self::Interrupted => write!(f, "interrupted"),
Self::Finished => write!(f, "finished"),
Self::Failed => write!(f, "failed"),
}
@ -82,6 +86,7 @@ pub enum WorkerEventKind {
PromptMisdelivery,
PromptReplayArmed,
Running,
Interrupted,
Restarted,
Finished,
Failed,
@ -1461,6 +1466,23 @@ mod tests {
use std::fs;
use std::process::Command;
#[test]
fn interrupted_status_serializes_and_displays_as_snake_case() {
assert_eq!(WorkerStatus::Interrupted.to_string(), "interrupted");
assert_eq!(
serde_json::to_string(&WorkerStatus::Interrupted).expect("should serialize"),
"\"interrupted\""
);
assert_eq!(
serde_json::from_str::<WorkerStatus>("\"interrupted\"").expect("should deserialize"),
WorkerStatus::Interrupted
);
assert_eq!(
serde_json::to_string(&WorkerEventKind::Interrupted).expect("should serialize"),
"\"interrupted\""
);
}
#[test]
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
let registry = WorkerRegistry::new();

View File

@ -24,6 +24,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" }
log = "0.4"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29", features = ["term", "poll"] }
[lints]
workspace = true

View File

@ -0,0 +1,280 @@
//! Esc-key interruption for running turns.
//!
//! While a turn runs, the main thread is blocked inside the conversation
//! loop, so rustyline is not reading the keyboard. This module spawns a
//! listener thread that switches stdin to a minimal non-canonical mode
//! (line buffering and echo off; signal handling and output processing
//! stay enabled, unlike full raw mode) and watches for a lone Escape
//! byte. When one arrives it trips the shared [`TurnInterruptSignal`],
//! which the conversation loop and the streaming API client poll to wind
//! the turn down gracefully.
//!
//! Permission prompts read whole lines from stdin mid-turn, so stdin
//! ownership is coordinated through [`StdinPromptGate`]: while a prompt
//! holds a lease, the listener restores canonical mode and stops
//! consuming bytes.
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread::JoinHandle;
use runtime::TurnInterruptSignal;
/// Terminal-mode bookkeeping guarded by the gate mutex. `saved` holds the
/// canonical-mode termios while non-canonical mode is active.
#[derive(Default)]
struct TtyMode {
#[cfg(unix)]
saved: Option<nix::sys::termios::Termios>,
}
#[derive(Default)]
struct GateState {
/// True while a permission prompt owns stdin.
busy: AtomicBool,
/// Tells the listener thread to exit at the end of the turn.
stop: AtomicBool,
/// Serializes stdin reads and terminal-mode flips.
mode: Mutex<TtyMode>,
}
/// Classification of one chunk of bytes read from the terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EscClassification {
/// A bare ESC byte: the user pressed the Escape key.
LoneEscape,
/// ESC followed by more bytes: the prefix of a terminal escape
/// sequence such as an arrow or function key.
EscapeSequence,
/// Anything else (regular type-ahead, control characters, ...).
Other,
}
#[cfg_attr(not(unix), allow(dead_code))]
fn classify_escape_chunk(bytes: &[u8]) -> EscClassification {
match bytes {
[0x1b] => EscClassification::LoneEscape,
[0x1b, ..] => EscClassification::EscapeSequence,
_ => EscClassification::Other,
}
}
/// Hands exclusive stdin ownership to interactive prompts while the
/// Esc listener is running.
#[derive(Clone)]
pub struct StdinPromptGate {
shared: Arc<GateState>,
}
impl StdinPromptGate {
/// Takes stdin away from the Esc listener for the lifetime of the
/// returned lease: the terminal returns to canonical mode so a
/// line-based prompt (e.g. the permission approval prompt) behaves
/// normally. Listening resumes when the lease is dropped.
pub fn lease(&self) -> StdinPromptLease<'_> {
self.shared.busy.store(true, Ordering::SeqCst);
// Wait for the listener's current poll cycle to release the
// terminal, then restore canonical mode for line input.
#[allow(unused_mut)]
let mut mode = self
.shared
.mode
.lock()
.unwrap_or_else(PoisonError::into_inner);
#[cfg(unix)]
tty::restore(&mut mode);
drop(mode);
StdinPromptLease {
shared: &self.shared,
}
}
}
/// RAII lease returned by [`StdinPromptGate::lease`].
pub struct StdinPromptLease<'a> {
shared: &'a GateState,
}
impl Drop for StdinPromptLease<'_> {
fn drop(&mut self) {
self.shared.busy.store(false, Ordering::SeqCst);
}
}
/// Background listener that maps a lone Esc keypress to
/// [`TurnInterruptSignal::interrupt`].
pub struct EscapeInterruptMonitor {
shared: Arc<GateState>,
join_handle: Option<JoinHandle<()>>,
}
impl EscapeInterruptMonitor {
/// Spawns the listener for the duration of one turn. Returns `None`
/// when stdin is not an interactive terminal or the platform does not
/// support terminal-mode switching; Ctrl+C interruption still works
/// in those cases.
pub fn spawn(signal: TurnInterruptSignal) -> Option<(Self, StdinPromptGate)> {
if !std::io::stdin().is_terminal() {
return None;
}
#[cfg(not(unix))]
{
let _ = signal;
None
}
#[cfg(unix)]
{
let shared = Arc::new(GateState::default());
let thread_shared = Arc::clone(&shared);
let join_handle = std::thread::spawn(move || listener_loop(&thread_shared, &signal));
let gate = StdinPromptGate {
shared: Arc::clone(&shared),
};
Some((
Self {
shared,
join_handle: Some(join_handle),
},
gate,
))
}
}
/// Stops the listener and restores the terminal before control
/// returns to the line editor.
pub fn stop(mut self) {
self.shared.stop.store(true, Ordering::SeqCst);
if let Some(join_handle) = self.join_handle.take() {
let _ = join_handle.join();
}
}
}
#[cfg(unix)]
fn listener_loop(shared: &GateState, signal: &TurnInterruptSignal) {
let mut buffer = [0u8; 64];
while !shared.stop.load(Ordering::SeqCst) {
if shared.busy.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(25));
continue;
}
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
if shared.busy.load(Ordering::SeqCst) || shared.stop.load(Ordering::SeqCst) {
continue;
}
if !tty::enable_noncanonical(&mut mode) {
// Terminal refused the mode switch; Esc support is
// unavailable for this turn but Ctrl+C still works.
return;
}
if !tty::wait_readable(100) {
continue;
}
let count = tty::read_pending(&mut buffer);
if classify_escape_chunk(&buffer[..count]) == EscClassification::LoneEscape {
// A slow terminal may split an escape sequence across reads;
// only treat ESC as the Esc key when nothing follows it.
if tty::wait_readable(50) {
let _ = tty::read_pending(&mut buffer);
continue;
}
signal.interrupt();
}
// Other bytes are intentionally swallowed: type-ahead is not
// preserved while a turn is running.
}
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
tty::restore(&mut mode);
}
#[cfg(unix)]
mod tty {
use std::os::fd::{AsFd, AsRawFd};
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use nix::sys::termios::{self, LocalFlags, SetArg, SpecialCharacterIndices};
use super::TtyMode;
/// Disables line buffering and echo while leaving signal generation
/// (ISIG) and output post-processing (OPOST) untouched, so Ctrl+C and
/// streamed output keep working. No-op when already active.
pub(super) fn enable_noncanonical(mode: &mut TtyMode) -> bool {
if mode.saved.is_some() {
return true;
}
let stdin = std::io::stdin();
let Ok(saved) = termios::tcgetattr(&stdin) else {
return false;
};
let mut noncanonical = saved.clone();
noncanonical.local_flags &= !(LocalFlags::ICANON | LocalFlags::ECHO);
noncanonical.control_chars[SpecialCharacterIndices::VMIN as usize] = 0;
noncanonical.control_chars[SpecialCharacterIndices::VTIME as usize] = 0;
if termios::tcsetattr(&stdin, SetArg::TCSANOW, &noncanonical).is_err() {
return false;
}
mode.saved = Some(saved);
true
}
pub(super) fn restore(mode: &mut TtyMode) {
if let Some(saved) = mode.saved.take() {
let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &saved);
}
}
pub(super) fn wait_readable(timeout_ms: u16) -> bool {
let stdin = std::io::stdin();
let mut fds = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
matches!(poll(&mut fds, PollTimeout::from(timeout_ms)), Ok(ready) if ready > 0)
&& fds[0]
.revents()
.is_some_and(|revents| revents.contains(PollFlags::POLLIN))
}
pub(super) fn read_pending(buffer: &mut [u8]) -> usize {
nix::unistd::read(std::io::stdin().as_raw_fd(), buffer).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::{classify_escape_chunk, EscClassification};
#[test]
fn lone_escape_byte_is_the_esc_key() {
assert_eq!(
classify_escape_chunk(&[0x1b]),
EscClassification::LoneEscape
);
}
#[test]
fn escape_followed_by_more_bytes_is_a_sequence() {
// Up arrow: ESC [ A
assert_eq!(
classify_escape_chunk(&[0x1b, 0x5b, 0x41]),
EscClassification::EscapeSequence
);
// Alt+f style: ESC f
assert_eq!(
classify_escape_chunk(&[0x1b, b'f']),
EscClassification::EscapeSequence
);
}
#[test]
fn regular_bytes_are_ignored() {
assert_eq!(classify_escape_chunk(b"hello"), EscClassification::Other);
assert_eq!(classify_escape_chunk(&[]), EscClassification::Other);
assert_eq!(classify_escape_chunk(&[0x03]), EscClassification::Other);
}
}

View File

@ -16,6 +16,7 @@
)]
mod init;
mod input;
mod interrupt;
mod render;
mod setup_wizard;
@ -7717,7 +7718,7 @@ impl LiveCli {
\x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\
\x1b[2mAuto-save\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline · \x1b[2mEsc\x1b[0m interrupts a running turn",
self.model,
self.permission_mode.as_str(),
git_branch,
@ -7742,7 +7743,10 @@ impl LiveCli {
fn prepare_turn_runtime(
&self,
emit_output: bool,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
) -> Result<
(BuiltRuntime, HookAbortMonitor, runtime::TurnInterruptSignal),
Box<dyn std::error::Error>,
> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let turn_interrupt_signal = runtime::TurnInterruptSignal::new();
let runtime = build_runtime(
@ -7758,9 +7762,10 @@ impl LiveCli {
)?
.with_hook_abort_signal(hook_abort_signal.clone())
.with_turn_interrupt_signal(turn_interrupt_signal.clone());
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal, turn_interrupt_signal);
let hook_abort_monitor =
HookAbortMonitor::spawn(hook_abort_signal, turn_interrupt_signal.clone());
Ok((runtime, hook_abort_monitor))
Ok((runtime, hook_abort_monitor, turn_interrupt_signal))
}
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
@ -7770,16 +7775,31 @@ impl LiveCli {
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let (mut runtime, hook_abort_monitor, turn_interrupt_signal) =
self.prepare_turn_runtime(true)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let mut escape_monitor = None;
if let Some((monitor, stdin_gate)) =
interrupt::EscapeInterruptMonitor::spawn(turn_interrupt_signal)
{
permission_prompter = permission_prompter.with_stdin_gate(stdin_gate);
escape_monitor = Some(monitor);
}
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
if escape_monitor.is_some() {
"🦀 Thinking... (esc to interrupt)"
} else {
"🦀 Thinking..."
},
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
if let Some(monitor) = escape_monitor {
monitor.stop();
}
hook_abort_monitor.stop();
match result {
Ok(summary) => {
@ -7920,7 +7940,7 @@ impl LiveCli {
*self.runtime.session_mut() = result.compacted_session.clone();
// Build a new runtime with the compacted session and retry
let (mut new_runtime, hook_abort_monitor) =
let (mut new_runtime, hook_abort_monitor, _turn_interrupt_signal) =
self.prepare_turn_runtime(true)?;
drop(hook_abort_monitor);
@ -8010,7 +8030,8 @@ impl LiveCli {
}
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@ -8023,7 +8044,8 @@ impl LiveCli {
}
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@ -8048,7 +8070,8 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@ -9520,6 +9543,7 @@ fn render_repl_help() -> String {
" Ctrl-R Reverse-search prompt history".to_string(),
" Tab Complete commands, modes, and recent sessions".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Esc Interrupt the running turn".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
" Auto-save .claw/sessions/<workspace-fingerprint>/<session-id>.jsonl"
.to_string(),
@ -12503,11 +12527,20 @@ impl runtime::HookProgressReporter for CliHookProgressReporter {
struct CliPermissionPrompter {
current_mode: PermissionMode,
stdin_gate: Option<interrupt::StdinPromptGate>,
}
impl CliPermissionPrompter {
fn new(current_mode: PermissionMode) -> Self {
Self { current_mode }
Self {
current_mode,
stdin_gate: None,
}
}
fn with_stdin_gate(mut self, stdin_gate: interrupt::StdinPromptGate) -> Self {
self.stdin_gate = Some(stdin_gate);
self
}
}
@ -12516,6 +12549,12 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
&mut self,
request: &runtime::PermissionRequest,
) -> runtime::PermissionPromptDecision {
// Take stdin back from the Esc listener (restoring canonical
// mode) for the duration of this line-based prompt.
let _stdin_lease = self
.stdin_gate
.as_ref()
.map(interrupt::StdinPromptGate::lease);
println!();
println!("Permission approval required");
println!(" Tool {}", request.tool_name);