tweak(tui): ctrl+c no longer interrupts a running turn

A single ctrl+c during a busy turn used to cancel the turn
(same as esc). That misfired a lot in practice because ctrl+c
is reflex muscle-memory ("be quiet" in a shell) rather than a
deliberate decision to kill a multi-minute model call you have
already paid tokens for. Users kept aborting expensive turns by
accident.

New behavior:

- busy + first ctrl+c  -> arms the exit hint, status line
                          reads "press ctrl+c again to exit,
                          esc to cancel the turn"; the turn
                          keeps running.
- busy + second ctrl+c (within ctrlCExitWindow = 2s)
                       -> exits zot.
- busy + esc           -> cancels the running turn (unchanged).
- idle + ctrl+c        -> clears editor/queue as before;
                          second press within 2s exits.

The double-tap-to-exit pattern now works the same from busy and
idle, which also matches the habits from python repls and
similar tools.

Also:
- assistant body keeps a 4-cell right gutter that mirrors the
  4-space left indent so wrapped prose sits in a symmetric
  column instead of kissing the terminal edge on ultra-wide
  windows. The prose cap itself is gone; the new
  assistantBodyRightPad constant replaces maxAssistantWidth.

- README Keys table + Queued messages paragraph updated to
  describe the new ctrl+c / esc split so the docs match the
  code.
This commit is contained in:
patriceckhart 2026-04-20 18:23:59 +02:00
parent 5a70030bb6
commit 13b8947fba
3 changed files with 29 additions and 24 deletions

View file

@ -274,7 +274,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
## Queued messages
You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` or `ctrl+c` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups.
You can keep typing while the agent is working. Pressing `enter` during a turn queues the message instead of interrupting: it shows up above the status bar as `sliding in: <text>` and is delivered as the next user turn the moment the current one finishes. Queue as many as you want; they run in order. `esc` cancels the active turn and drops the queue so a runaway turn doesn't flood you with stale follow-ups; `ctrl+c` while busy arms the exit hint instead of interrupting, a second `ctrl+c` within two seconds exits zot.
Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
@ -289,7 +289,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
| `alt+enter` | Newline. |
| `tab` | Complete the selected slash command. |
| `esc` | Cancel the current turn (while busy); clear input (while idle). |
| `ctrl+c` | Clear the input and queue (or cancel the current turn). Press again within 2s to exit. |
| `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
| `ctrl+d` | Exit on empty input. |
| `ctrl+l` | Redraw the screen. |
| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). |

View file

@ -1040,11 +1040,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
// Global keys.
switch k.Kind {
case tui.KeyCtrlC:
// While busy: cancel the active turn (same as esc). The exit
// hint stays armed so a quick second ctrl+c after the turn
// dies still exits, matching habits from other repls.
if i.busy && i.cancelTurn != nil {
i.cancelTurn()
// While busy: do NOT cancel the turn. ctrl+c during a
// running turn is almost always reflex muscle memory
// ("be quiet" in a shell) rather than a deliberate
// decision to kill a multi-minute model call that's
// already cost tokens. Use esc to interrupt a turn; use
// a deliberate double-ctrl+c to exit zot entirely. First
// press arms the exit hint, second press within
// ctrlCExitWindow quits.
if i.busy {
if i.ctrlCExitArmed() {
return true
}
i.mu.Lock()
i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
i.statusErr = ""
i.mu.Unlock()
i.armCtrlCExit()
return false
}

View file

@ -367,26 +367,20 @@ const (
fnv64aPrime uint64 = 0x100000001b3
)
// maxAssistantWidth caps the rendered width of assistant prose
// (and the code fences embedded in it) in both the finalised
// transcript and the streaming overlay. Unbounded lines on
// ultra-wide terminals (300+ columns) produce prose that's hard
// to read and rule strokes that run edge-to-edge in the window.
// Tool output (read, bash, edit diffs) is unaffected — it
// deliberately uses the full width so long paths and diff rows
// aren't artificially truncated.
const maxAssistantWidth = 120
// assistantBodyRightPad is the blank gutter kept on the right
// side of every assistant prose line so text doesn't kiss the
// terminal edge. Matches the 4-cell left indent, so a line of
// fully-wrapped prose sits in a symmetric column.
const assistantBodyRightPad = 4
// assistantBodyWidth returns the usable width for the assistant
// message body (markdown prose + code fence rules), clamped at
// maxAssistantWidth and at 1 so wrap helpers don't divide by
// zero on absurdly narrow terminals. outer is the total width
// of the column the body will sit inside (the terminal width
// minus any surrounding indent).
// message body (markdown prose + code fences). Uses the full
// column width passed in minus assistantBodyRightPad, clamped
// at 1 so wrap helpers don't divide by zero on absurdly narrow
// terminals. The right-side padding keeps a small breathing
// column to the terminal edge that mirrors the left indent.
func assistantBodyWidth(outer int) int {
if outer > maxAssistantWidth {
return maxAssistantWidth
}
outer -= assistantBodyRightPad
if outer < 1 {
return 1
}