feat(cli): press Esc to interrupt a running turn - #3255
Conversation
Add a shared interrupt flag that the conversation loop polls at safe points: before each model request, when a stream call fails, and before each pending tool execution. When the flag is set the turn winds down instead of failing: tools that have not run yet receive synthesized error tool_results so every tool_use stays answered and the session remains valid for the next request. TurnSummary gains an interrupted field so callers can distinguish a user-initiated stop from a completed turn, and the session tracer records a turn_interrupted event (with iteration and phase) instead of turn_failed / turn_completed. Groundwork for Esc-to-interrupt (ultraworkers#3196): a follow-up wires this signal into the streaming API client and the CLI input listener.
Wire the runtime's TurnInterruptSignal through the CLI turn lifecycle: - AnthropicRuntimeClient races the streaming request against the interrupt flag with tokio::select!; when the flag trips, the request future is dropped, aborting the in-flight HTTP stream for every provider variant. - BuiltRuntime installs the shared signal into both the conversation loop and the API client. - The per-turn Ctrl+C monitor now interrupts the whole turn instead of only aborting running hooks, so Ctrl+C mid-turn returns to the REPL prompt with partial output instead of leaving the request running. - The REPL spinner reports an interrupted turn distinctly from a completed one. Part of the Esc-to-interrupt work (ultraworkers#3196); the Esc key listener lands separately.
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 (ultraworkers#3196).
|
Pressing Esc to cut in-flight requests is such a quality-of-life win 😌 Maintainer? Turn off weaves from non-maintainers → |
|
Nice work! The three-layer approach (runtime flag, in-flight cancellation via tokio::select!, and CLI integration) is clean and well-structured. Esc is the natural key for this — much better than having to Ctrl+C and lose session state. The synthesized error tool_results for pending tool_uses is a thoughtful touch that keeps the turn consistent. |
|
Much-needed UX improvement — Esc to interrupt is table-stakes for any interactive CLI. The crossterm event loop integration looks clean. Thanks @amazd! |
|
Nice, the |

Summary
Press Esc to gracefully interrupt a running turn and return to the REPL prompt with the session intact. Previously the only way to stop a long-running turn was Ctrl+C, which left the in-flight request running.
This lands the feature in three layers:
TurnInterruptSignal(runtime) — a shared flag the conversation loop polls at safe points (before each model request, on stream failure, before each pending tool). When set, the turn winds down instead of failing: pendingtool_uses get synthesized errortool_results so every tool call stays answered and the session remains valid for the next request.TurnSummarygains aninterruptedfield and the tracer records aturn_interruptedevent.In-flight request cancellation (CLI) —
AnthropicRuntimeClientraces the streaming request against the interrupt flag withtokio::select!; tripping the flag drops the request future, aborting the HTTP stream for every provider. The per-turn Ctrl+C monitor now interrupts the whole turn rather than only aborting hooks.Esc key listener (CLI) —
EscapeInterruptMonitorswitches stdin to a minimal non-canonical mode (echo/line-buffering off;ISIG/OPOSTuntouched, unlike full raw mode) for the duration of a turn and watches for a lone ESC byte. Arrow/function-key escape sequences are recognized and swallowed. Permission prompts that read whole lines mid-turn coordinate stdin ownership viaStdinPromptGate, which restores canonical mode while a prompt holds a lease.The spinner shows
(esc to interrupt)andWorkerStatus/WorkerEventKindgain aninterruptedlifecycle state. Non-terminal stdin and non-unix platforms skip the listener; Ctrl+C interruption still works there.Closes #3196.
Testing
scripts/fmt.sh --check— cleaninterrupt_before_first_request_skips_the_api_call,interrupt_after_stream_synthesizes_results_for_pending_tools,stream_error_during_interrupt_is_reported_as_interruption,interrupted_status_serializes_and_displays_as_snake_case— pass🤖 Generated with Claude Code