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.- commit
- 13b8947fba46b28b56de019b9b327bfc05d74d5a
- author
- patriceckhart · · 2026-04-20 16:23 UTC
- parent
- 5a70030
- signature
- unsigned
- refs
- tag: v0.0.73
3 files · +29 -24
M README.md +2 -2
| @@ -274,7 +274,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||
| 274 | 274 | ||
| 275 | 275 | ## Queued messages | |
| 276 | 276 | ||
| 277 | - | 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. | |
| 277 | + | 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. | |
| 278 | 278 | ||
| 279 | 279 | 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. | |
| 280 | 280 | ||
| @@ -289,7 +289,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||
| 289 | 289 | | `alt+enter` | Newline. | | |
| 290 | 290 | | `tab` | Complete the selected slash command. | | |
| 291 | 291 | | `esc` | Cancel the current turn (while busy); clear input (while idle). | | |
| 292 | - | | `ctrl+c` | Clear the input and queue (or cancel the current turn). Press again within 2s to exit. | | |
| 292 | + | | `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. | | |
| 293 | 293 | | `ctrl+d` | Exit on empty input. | | |
| 294 | 294 | | `ctrl+l` | Redraw the screen. | | |
| 295 | 295 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| @@ -274,7 +274,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 274 | 274 | ||||
| 275 | ## Queued messages | 275 | ## Queued messages | ||
| 276 | 276 | ||||
| 277 | - | 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. | 277 | + | 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. |
| 278 | 278 | ||||
| 279 | 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. | 279 | 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. | ||
| 280 | 280 | ||||
| @@ -289,7 +289,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||||
| 289 | | `alt+enter` | Newline. | | 289 | | `alt+enter` | Newline. | | ||
| 290 | | `tab` | Complete the selected slash command. | | 290 | | `tab` | Complete the selected slash command. | | ||
| 291 | | `esc` | Cancel the current turn (while busy); clear input (while idle). | | 291 | | `esc` | Cancel the current turn (while busy); clear input (while idle). | | ||
| 292 | - | | `ctrl+c` | Clear the input and queue (or cancel the current turn). Press again within 2s to exit. | | 292 | + | | `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. | |
| 293 | | `ctrl+d` | Exit on empty input. | | 293 | | `ctrl+d` | Exit on empty input. | | ||
| 294 | | `ctrl+l` | Redraw the screen. | | 294 | | `ctrl+l` | Redraw the screen. | | ||
| 295 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 295 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
M internal/agent/modes/interactive.go +16 -5
| @@ -1040,11 +1040,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1040 | 1040 | // Global keys. | |
| 1041 | 1041 | switch k.Kind { | |
| 1042 | 1042 | case tui.KeyCtrlC: | |
| 1043 | - | // While busy: cancel the active turn (same as esc). The exit | |
| 1044 | - | // hint stays armed so a quick second ctrl+c after the turn | |
| 1045 | - | // dies still exits, matching habits from other repls. | |
| 1046 | - | if i.busy && i.cancelTurn != nil { | |
| 1047 | - | i.cancelTurn() | |
| 1043 | + | // While busy: do NOT cancel the turn. ctrl+c during a | |
| 1044 | + | // running turn is almost always reflex muscle memory | |
| 1045 | + | // ("be quiet" in a shell) rather than a deliberate | |
| 1046 | + | // decision to kill a multi-minute model call that's | |
| 1047 | + | // already cost tokens. Use esc to interrupt a turn; use | |
| 1048 | + | // a deliberate double-ctrl+c to exit zot entirely. First | |
| 1049 | + | // press arms the exit hint, second press within | |
| 1050 | + | // ctrlCExitWindow quits. | |
| 1051 | + | if i.busy { | |
| 1052 | + | if i.ctrlCExitArmed() { | |
| 1053 | + | return true | |
| 1054 | + | } | |
| 1055 | + | i.mu.Lock() | |
| 1056 | + | i.statusOK = "press ctrl+c again to exit, esc to cancel the turn" | |
| 1057 | + | i.statusErr = "" | |
| 1058 | + | i.mu.Unlock() | |
| 1048 | 1059 | i.armCtrlCExit() | |
| 1049 | 1060 | return false | |
| 1050 | 1061 | } | |
| @@ -1040,11 +1040,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1040 | // Global keys. | 1040 | // Global keys. | ||
| 1041 | switch k.Kind { | 1041 | switch k.Kind { | ||
| 1042 | case tui.KeyCtrlC: | 1042 | case tui.KeyCtrlC: | ||
| 1043 | - | // While busy: cancel the active turn (same as esc). The exit | 1043 | + | // While busy: do NOT cancel the turn. ctrl+c during a |
| 1044 | - | // hint stays armed so a quick second ctrl+c after the turn | 1044 | + | // running turn is almost always reflex muscle memory |
| 1045 | - | // dies still exits, matching habits from other repls. | 1045 | + | // ("be quiet" in a shell) rather than a deliberate |
| 1046 | - | if i.busy && i.cancelTurn != nil { | 1046 | + | // decision to kill a multi-minute model call that's |
| 1047 | - | i.cancelTurn() | 1047 | + | // already cost tokens. Use esc to interrupt a turn; use |
| 1048 | + | // a deliberate double-ctrl+c to exit zot entirely. First | |||
| 1049 | + | // press arms the exit hint, second press within | |||
| 1050 | + | // ctrlCExitWindow quits. | |||
| 1051 | + | if i.busy { | |||
| 1052 | + | if i.ctrlCExitArmed() { | |||
| 1053 | + | return true | |||
| 1054 | + | } | |||
| 1055 | + | i.mu.Lock() | |||
| 1056 | + | i.statusOK = "press ctrl+c again to exit, esc to cancel the turn" | |||
| 1057 | + | i.statusErr = "" | |||
| 1058 | + | i.mu.Unlock() | |||
| 1048 | i.armCtrlCExit() | 1059 | i.armCtrlCExit() | ||
| 1049 | return false | 1060 | return false | ||
| 1050 | } | 1061 | } | ||
M internal/tui/view.go +11 -17
| @@ -367,26 +367,20 @@ const ( | |||
| 367 | 367 | fnv64aPrime uint64 = 0x100000001b3 | |
| 368 | 368 | ) | |
| 369 | 369 | ||
| 370 | - | // maxAssistantWidth caps the rendered width of assistant prose | |
| 371 | - | // (and the code fences embedded in it) in both the finalised | |
| 372 | - | // transcript and the streaming overlay. Unbounded lines on | |
| 373 | - | // ultra-wide terminals (300+ columns) produce prose that's hard | |
| 374 | - | // to read and rule strokes that run edge-to-edge in the window. | |
| 375 | - | // Tool output (read, bash, edit diffs) is unaffected — it | |
| 376 | - | // deliberately uses the full width so long paths and diff rows | |
| 377 | - | // aren't artificially truncated. | |
| 378 | - | const maxAssistantWidth = 120 | |
| 370 | + | // assistantBodyRightPad is the blank gutter kept on the right | |
| 371 | + | // side of every assistant prose line so text doesn't kiss the | |
| 372 | + | // terminal edge. Matches the 4-cell left indent, so a line of | |
| 373 | + | // fully-wrapped prose sits in a symmetric column. | |
| 374 | + | const assistantBodyRightPad = 4 | |
| 379 | 375 | ||
| 380 | 376 | // assistantBodyWidth returns the usable width for the assistant | |
| 381 | - | // message body (markdown prose + code fence rules), clamped at | |
| 382 | - | // maxAssistantWidth and at 1 so wrap helpers don't divide by | |
| 383 | - | // zero on absurdly narrow terminals. outer is the total width | |
| 384 | - | // of the column the body will sit inside (the terminal width | |
| 385 | - | // minus any surrounding indent). | |
| 377 | + | // message body (markdown prose + code fences). Uses the full | |
| 378 | + | // column width passed in minus assistantBodyRightPad, clamped | |
| 379 | + | // at 1 so wrap helpers don't divide by zero on absurdly narrow | |
| 380 | + | // terminals. The right-side padding keeps a small breathing | |
| 381 | + | // column to the terminal edge that mirrors the left indent. | |
| 386 | 382 | func assistantBodyWidth(outer int) int { | |
| 387 | - | if outer > maxAssistantWidth { | |
| 388 | - | return maxAssistantWidth | |
| 389 | - | } | |
| 383 | + | outer -= assistantBodyRightPad | |
| 390 | 384 | if outer < 1 { | |
| 391 | 385 | return 1 | |
| 392 | 386 | } | |
| @@ -367,26 +367,20 @@ const ( | |||||
| 367 | fnv64aPrime uint64 = 0x100000001b3 | 367 | fnv64aPrime uint64 = 0x100000001b3 | ||
| 368 | ) | 368 | ) | ||
| 369 | 369 | ||||
| 370 | - | // maxAssistantWidth caps the rendered width of assistant prose | 370 | + | // assistantBodyRightPad is the blank gutter kept on the right |
| 371 | - | // (and the code fences embedded in it) in both the finalised | 371 | + | // side of every assistant prose line so text doesn't kiss the |
| 372 | - | // transcript and the streaming overlay. Unbounded lines on | 372 | + | // terminal edge. Matches the 4-cell left indent, so a line of |
| 373 | - | // ultra-wide terminals (300+ columns) produce prose that's hard | 373 | + | // fully-wrapped prose sits in a symmetric column. |
| 374 | - | // to read and rule strokes that run edge-to-edge in the window. | 374 | + | const assistantBodyRightPad = 4 |
| 375 | - | // Tool output (read, bash, edit diffs) is unaffected — it | |||
| 376 | - | // deliberately uses the full width so long paths and diff rows | |||
| 377 | - | // aren't artificially truncated. | |||
| 378 | - | const maxAssistantWidth = 120 | |||
| 379 | 375 | ||||
| 380 | // assistantBodyWidth returns the usable width for the assistant | 376 | // assistantBodyWidth returns the usable width for the assistant | ||
| 381 | - | // message body (markdown prose + code fence rules), clamped at | 377 | + | // message body (markdown prose + code fences). Uses the full |
| 382 | - | // maxAssistantWidth and at 1 so wrap helpers don't divide by | 378 | + | // column width passed in minus assistantBodyRightPad, clamped |
| 383 | - | // zero on absurdly narrow terminals. outer is the total width | 379 | + | // at 1 so wrap helpers don't divide by zero on absurdly narrow |
| 384 | - | // of the column the body will sit inside (the terminal width | 380 | + | // terminals. The right-side padding keeps a small breathing |
| 385 | - | // minus any surrounding indent). | 381 | + | // column to the terminal edge that mirrors the left indent. |
| 386 | func assistantBodyWidth(outer int) int { | 382 | func assistantBodyWidth(outer int) int { | ||
| 387 | - | if outer > maxAssistantWidth { | 383 | + | outer -= assistantBodyRightPad |
| 388 | - | return maxAssistantWidth | |||
| 389 | - | } | |||
| 390 | if outer < 1 { | 384 | if outer < 1 { | ||
| 391 | return 1 | 385 | return 1 | ||
| 392 | } | 386 | } | ||