From 13b8947fba46b28b56de019b9b327bfc05d74d5a Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 20 Apr 2026 18:23:59 +0200 Subject: [PATCH] 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. --- README.md | 4 ++-- internal/agent/modes/interactive.go | 21 ++++++++++++++++----- internal/tui/view.go | 28 +++++++++++----------------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 45f7568..1629ed0 100644 --- a/README.md +++ b/README.md @@ -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: ` 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: ` 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). | diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 0183ee5..d79de9b 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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 } diff --git a/internal/tui/view.go b/internal/tui/view.go index 645f6b9..1595193 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 }