Problem
The TUI interrupt path is not cancellation-first. Once the double-Escape gesture is recognized, requestTurnInterrupt() marks local state but serializes the actual stop behind queue convergence:
await settlePendingEnqueues();
const retracted = (await input.driver.retractQueued?.()) ?? '';
const fallback = await takePendingFallbackSettled();
// ...refill editor...
await input.driver.stop();
This is in packages/cli/src/pi-tui-runner.ts around the requestTurnInterrupt implementation.
settlePendingEnqueues() does not wait for the model provider response. It waits for outstanding Runtime Host turn.message.submit RPCs used by Steer/Queue. Those operations normally only mutate queue/admission/receipt state, but an RPC delayed by transport, Session admission, storage, or a fallback retry becomes an unbounded barrier in front of stop. queue.retract then adds a second Host round trip before cancellation is even requested.
After stop is finally sent, the TUI driver uses turn.stop. Runtime Host requestStop() awaits stopRoot(), and stopRoot() awaits active.done, so the control response includes backend abort, active tool cleanup, process termination grace, I/O drain, and durable terminal publication. During that entire interval the TUI continues to render Working…; there is no Cancelling… state.
This combines two distinct delays into one apparently ignored keypress:
- pre-stop delay: pending enqueue settlement + queue retract;
- post-stop delay: tool/backend cleanup + durable terminal convergence.
The Host already exposes the atomic turn.interrupt operation, which commits the queue stop fence, retracts queued entries, and owns the matching root Turn. Desktop already uses it in createRuntimeHostSessionStop; the TUI still composes queue.retract and turn.stop client-side.
User-visible trigger
The interrupt binding is itself difficult to discover: a running Turn requires two Escapes within 600 ms. The first Escape has no visible effect or hint. The binding appears only in /help, not in the startup guidance or running footer. This overlaps the discoverability discussion in #3538; this issue focuses on cancellation ordering, acknowledgement, and feedback rather than keybinding selection.
Ctrl+C is not a safe discoverability fallback because it also owns process-exit behavior: a second press while interruption is converging exits, and two presses while idle exit the TUI.
Reproduction and measurements
Tested with the installed Maka 0.2.0 TUI in a fresh tmux session on macOS, using qwen3.8-max through alibaba-token-plan-cn.
Provider stream
- Start a prompt and interrupt while the first model response is pending.
- Send Esc twice within 600 ms.
The durable aborted event was committed within the measurement resolution of the second Escape, and the TUI returned to idle in under 170 ms. This confirms the basic provider abort path can be fast.
Cooperative Bash process
- Ask the agent to execute
sleep 120.
- Wait for the Bash tool to show
running.
- Press one Escape: after one second the process is still alive and no stop was issued.
- Press Escape twice within 600 ms.
Observed after the second Escape:
- child process exit: approximately 63 ms;
- TUI idle: approximately 132 ms;
abort_requested to tool_completed: 20 ms;
abort_requested to durable aborted: 27 ms.
Process ignoring SIGTERM
Repeat with:
Observed after the second Escape:
- child process group exit: approximately 2.13 s;
- TUI idle: approximately 2.20 s.
This matches DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000. The grace may be intentional, but rendering Working… throughout makes it look as though interrupt was not accepted.
Missing deterministic regression case
A focused TUI test can expose the pre-stop barrier without timing or a real provider:
- use a driver whose
steer()/queueMessage() returns a Promise that does not settle;
- submit a mid-Turn message so it enters
pendingEnqueueTasks;
- trigger interrupt;
- assert stop/interrupt authority is invoked promptly.
Today step 4 never occurs because settlePendingEnqueues() waits first.
Expected behavior
- User interrupt has control-plane priority over pending message submission.
- Acceptance is visible immediately, independently of backend/tool cleanup.
- Queue contents are not lost or duplicated when enqueue and interrupt race.
- The running footer changes from
Working… to Cancelling… as soon as the gesture is accepted.
- Terminal durability remains authoritative; fast acknowledgement must not fake completion.
Proposed direction
- Give the TUI driver a first-class interrupt method backed by Host
turn.interrupt, rather than composing queue.retract followed by turn.stop.
- Commit the stop/queue fence before waiting for client-side enqueue Promises. Host serialization can determine whether an enqueue committed before the fence and is returned as retracted, or lost the race and must be restored from its failed client request.
- Split interrupt acceptance from terminal convergence if needed: return/emit an accepted or stopping snapshot once the stop fence is durable and abort delivery has begun; observe the final terminal through the existing subscription.
- Render local
Cancelling… immediately from interruptRequested, with elapsed cleanup time if convergence is slow.
- Keep graceful process cleanup, but consider a shorter user-stop grace or an explicit force-stop escalation separately; it should not be required to fix acknowledgement and feedback.
Acceptance criteria
- A never-settling enqueue request cannot prevent interrupt dispatch.
- Interrupt + enqueue races preserve each queued message exactly once, either in Host retraction output or restored client-side.
- TUI shows
Cancelling… in the same render tick as interrupt recognition.
- Cooperative provider and Bash cancellation remain sub-second in integration coverage.
- A SIGTERM-resistant process may take the configured grace, but the UI truthfully reports cancellation in progress.
- TUI and Desktop use the same Runtime Host interrupt authority instead of divergent client-composed sequences.
Related: #3538 (composer reachability/discoverability), #3556 (queue-code simplification).
Investigated with AI assistance. Runtime behavior was verified against local source and a real tmux TUI run.
Problem
The TUI interrupt path is not cancellation-first. Once the double-Escape gesture is recognized,
requestTurnInterrupt()marks local state but serializes the actual stop behind queue convergence:This is in
packages/cli/src/pi-tui-runner.tsaround therequestTurnInterruptimplementation.settlePendingEnqueues()does not wait for the model provider response. It waits for outstanding Runtime Hostturn.message.submitRPCs used by Steer/Queue. Those operations normally only mutate queue/admission/receipt state, but an RPC delayed by transport, Session admission, storage, or a fallback retry becomes an unbounded barrier in front of stop.queue.retractthen adds a second Host round trip before cancellation is even requested.After stop is finally sent, the TUI driver uses
turn.stop. Runtime HostrequestStop()awaitsstopRoot(), andstopRoot()awaitsactive.done, so the control response includes backend abort, active tool cleanup, process termination grace, I/O drain, and durable terminal publication. During that entire interval the TUI continues to renderWorking…; there is noCancelling…state.This combines two distinct delays into one apparently ignored keypress:
The Host already exposes the atomic
turn.interruptoperation, which commits the queue stop fence, retracts queued entries, and owns the matching root Turn. Desktop already uses it increateRuntimeHostSessionStop; the TUI still composesqueue.retractandturn.stopclient-side.User-visible trigger
The interrupt binding is itself difficult to discover: a running Turn requires two Escapes within 600 ms. The first Escape has no visible effect or hint. The binding appears only in
/help, not in the startup guidance or running footer. This overlaps the discoverability discussion in #3538; this issue focuses on cancellation ordering, acknowledgement, and feedback rather than keybinding selection.Ctrl+Cis not a safe discoverability fallback because it also owns process-exit behavior: a second press while interruption is converging exits, and two presses while idle exit the TUI.Reproduction and measurements
Tested with the installed Maka 0.2.0 TUI in a fresh tmux session on macOS, using
qwen3.8-maxthroughalibaba-token-plan-cn.Provider stream
The durable
abortedevent was committed within the measurement resolution of the second Escape, and the TUI returned to idle in under 170 ms. This confirms the basic provider abort path can be fast.Cooperative Bash process
sleep 120.running.Observed after the second Escape:
abort_requestedtotool_completed: 20 ms;abort_requestedto durableaborted: 27 ms.Process ignoring SIGTERM
Repeat with:
Observed after the second Escape:
This matches
DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000. The grace may be intentional, but renderingWorking…throughout makes it look as though interrupt was not accepted.Missing deterministic regression case
A focused TUI test can expose the pre-stop barrier without timing or a real provider:
steer()/queueMessage()returns a Promise that does not settle;pendingEnqueueTasks;Today step 4 never occurs because
settlePendingEnqueues()waits first.Expected behavior
Working…toCancelling…as soon as the gesture is accepted.Proposed direction
turn.interrupt, rather than composingqueue.retractfollowed byturn.stop.Cancelling…immediately frominterruptRequested, with elapsed cleanup time if convergence is slow.Acceptance criteria
Cancelling…in the same render tick as interrupt recognition.Related: #3538 (composer reachability/discoverability), #3556 (queue-code simplification).
Investigated with AI assistance. Runtime behavior was verified against local source and a real tmux TUI run.