Summary
When the harness canvas is focused and a turn is Busy, Escape should cancel that turn — the same abort as the ■ Stop icon (protocol v9 queueCancelFromUi → host takePendingCancel → AbortController).
Today Stop is pointer-only. The keyboard table has Enter / Ctrl+Enter / arrows. Escape does nothing to the in-flight round, so a keyboard operator has no cancel chord.
Observed
- Focus the canvas (
#harness-canvas, tabIndex={0} when ready).
- Send a prompt so lifecycle is Busy (Waiting for model / tools).
- Press Escape.
Actual: the turn keeps running. Composer may keep the caret. Only ■ Stop (or host nav away) aborts.
Expected: Escape aborts the in-flight round the same way ■ does — pending cancel, host abort, existing Stop/error paint. Composer text is left alone (Stop does not clear the field).
Idle (no turn) Escape stays a no-op for cancel — do not invent “clear composer” or “leave the page.”
Likely seam
Cancel path already exists; it is not bound to a key.
| Piece |
Role |
native/harness/src/ui.zig ■ button (Busy only) |
bridge.queueCancelFromUi() |
native/harness/src/bridge.zig queueCancelFromUi |
No-op unless lifecycle == .busy or pending submit; sets has_pending_cancel |
Host poll HarnessHost.tsx |
takePendingCancel() → abortRef.current?.abort() |
Composer event scan (ui.zig) |
Ctrl/Cmd+Enter + ↑/↓ history — no .escape |
| Keyboard table |
docs/harness-limits.md — no Escape row |
Do not add a second cancel protocol. Bind Escape to the existing v9 Stop path.
Who already owns Escape
| Context |
Current Esc |
Keep |
DOM NavMenu open |
closes the menu (app/components/NavMenu.tsx) |
yes — host chrome, not canvas |
| Queue-row editor open |
queue_band.handleEscape → cancelEdit() (does not abort the turn) |
yes — closer overlay wins |
| Model picker |
no Esc handler today |
if Esc should close the picker first, lock that; otherwise Busy cancel is fine |
| Busy, harness focused, no overlay |
nothing |
this issue |
Scan Escape at frame scope (canvas focused), not only when the composer TextEntry is focused — “harness focused” includes transcript click / canvas tabIndex. Queue-edit handler already runs during queue_band.paint; it marks the event handled, so a later Busy cancel scan must skip handled events (same pattern as the composer Enter chord).
Goal
- Escape while Busy + harness focused → same cancel as ■ Stop.
- Idle → no cancel, no composer wipe.
- Queue-row edit still dismisses the editor first (no turn abort on that stroke).
- Open DOM nav menu still closes on Esc (focus is not the canvas).
- Docs: one Keyboard row in
docs/harness-limits.md.
Non-goals
- New bridge exports / protocol bump (v9 cancel is enough).
- Esc to close the tab / blur the canvas / Clear session.
- Dual DOM Stop button.
- Changing abort semantics (partial transcript persist, cwd-on-cancel, usage carry-forward stay as they are).
Related
native/harness/src/ui.zig — Busy ■ → queueCancelFromUi; composer key scan
native/harness/src/bridge.zig — queueCancelFromUi / inv_has_pending_cancel
native/harness/src/ui/queue_band.zig — Esc dismisses queue-row edit
app/harness/HarnessHost.tsx — canvas tabIndex={0}; poll takePendingCancel
docs/harness-limits.md — Keyboard & focus
Suggested next: start a turn, keep the canvas focused, press Esc — the busy row should end like ■ Stop. Then Esc while editing a queued follow-up should only close the editor.
Summary
When the harness canvas is focused and a turn is Busy, Escape should cancel that turn — the same abort as the ■ Stop icon (protocol v9
queueCancelFromUi→ hosttakePendingCancel→AbortController).Today Stop is pointer-only. The keyboard table has Enter / Ctrl+Enter / arrows. Escape does nothing to the in-flight round, so a keyboard operator has no cancel chord.
Observed
#harness-canvas,tabIndex={0}when ready).Actual: the turn keeps running. Composer may keep the caret. Only ■ Stop (or host nav away) aborts.
Expected: Escape aborts the in-flight round the same way ■ does — pending cancel, host abort, existing Stop/error paint. Composer text is left alone (Stop does not clear the field).
Idle (no turn) Escape stays a no-op for cancel — do not invent “clear composer” or “leave the page.”
Likely seam
Cancel path already exists; it is not bound to a key.
native/harness/src/ui.zig■ button (Busy only)bridge.queueCancelFromUi()native/harness/src/bridge.zigqueueCancelFromUilifecycle == .busyor pending submit; setshas_pending_cancelHarnessHost.tsxtakePendingCancel()→abortRef.current?.abort()ui.zig).escapedocs/harness-limits.md— no Escape rowDo not add a second cancel protocol. Bind Escape to the existing v9 Stop path.
Who already owns Escape
NavMenuopenapp/components/NavMenu.tsx)queue_band.handleEscape→cancelEdit()(does not abort the turn)Scan Escape at frame scope (canvas focused), not only when the composer TextEntry is focused — “harness focused” includes transcript click / canvas tabIndex. Queue-edit handler already runs during
queue_band.paint; it marks the event handled, so a later Busy cancel scan must skip handled events (same pattern as the composer Enter chord).Goal
docs/harness-limits.md.Non-goals
Related
native/harness/src/ui.zig— Busy ■ →queueCancelFromUi; composer key scannative/harness/src/bridge.zig—queueCancelFromUi/inv_has_pending_cancelnative/harness/src/ui/queue_band.zig— Esc dismisses queue-row editapp/harness/HarnessHost.tsx— canvastabIndex={0}; polltakePendingCanceldocs/harness-limits.md— Keyboard & focusSuggested next: start a turn, keep the canvas focused, press Esc — the busy row should end like ■ Stop. Then Esc while editing a queued follow-up should only close the editor.