detached 13b8947

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
274274
275275 ## Queued messages
276276
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.
278278
279279 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.
280280
@@ -289,7 +289,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
289289 | `alt+enter` | Newline. |
290290 | `tab` | Complete the selected slash command. |
291291 | `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. |
293293 | `ctrl+d` | Exit on empty input. |
294294 | `ctrl+l` | Redraw the screen. |
295295 | `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 messages275 ## 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) {
10401040 // Global keys.
10411041 switch k.Kind {
10421042 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()
10481059 i.armCtrlCExit()
10491060 return false
10501061 }
@@ -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 exit1043+ // While busy: do NOT cancel the turn. ctrl+c during a
1044- // hint stays armed so a quick second ctrl+c after the turn1044+ // 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 false1060 return false
1050 }1061 }
M internal/tui/view.go +11 -17
@@ -367,26 +367,20 @@ const (
367367 fnv64aPrime uint64 = 0x100000001b3
368368 )
369369
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
379375
380376 // 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.
386382 func assistantBodyWidth(outer int) int {
387- if outer > maxAssistantWidth {
388- return maxAssistantWidth
389- }
383+ outer -= assistantBodyRightPad
390384 if outer < 1 {
391385 return 1
392386 }
@@ -367,26 +367,20 @@ const (
367 fnv64aPrime uint64 = 0x100000001b3367 fnv64aPrime uint64 = 0x100000001b3
368 )368 )
369 369
370-// maxAssistantWidth caps the rendered width of assistant prose370+// assistantBodyRightPad is the blank gutter kept on the right
371-// (and the code fences embedded in it) in both the finalised371+// side of every assistant prose line so text doesn't kiss the
372-// transcript and the streaming overlay. Unbounded lines on372+// terminal edge. Matches the 4-cell left indent, so a line of
373-// ultra-wide terminals (300+ columns) produce prose that's hard373+// 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 assistant376 // assistantBodyWidth returns the usable width for the assistant
381-// message body (markdown prose + code fence rules), clamped at377+// message body (markdown prose + code fences). Uses the full
382-// maxAssistantWidth and at 1 so wrap helpers don't divide by378+// column width passed in minus assistantBodyRightPad, clamped
383-// zero on absurdly narrow terminals. outer is the total width379+// at 1 so wrap helpers don't divide by zero on absurdly narrow
384-// of the column the body will sit inside (the terminal width380+// 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 1385 return 1
392 }386 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close