From 25c1bd4752aca33a592cadca0ac48f4778423749 Mon Sep 17 00:00:00 2001 From: tdwd Date: Wed, 29 Jul 2026 15:43:03 +0200 Subject: [PATCH] Interrupt on Ctrl+C, quit only on a second press Ctrl+C exited immediately, so a reflexive tap dropped the whole session. Give it the Claude-Code cadence instead: the first press interrupts the running turn (or, when idle, just arms) and the status bar shows a "^C again to exit" hint; a second press within a 2s window quits. Esc and Ctrl+C now share one interrupt(); quitting still returns tea.Quit without closing the engine (the one-ctrl+c-freeze fix). During a pending approval, Ctrl+C denies the tool (cancels the action) and arms the window rather than denying-and-quitting. --- commands.go | 3 ++- keys.go | 70 ++++++++++++++++++++++++++++++++++++++++---------- model.go | 4 +++ quit_test.go | 44 +++++++++++++++++++++++-------- status.go | 8 ++++-- status_test.go | 18 ++++++++++++- update.go | 8 ++++++ view.go | 2 +- 8 files changed, 129 insertions(+), 28 deletions(-) diff --git a/commands.go b/commands.go index af05cc3..d7bfab1 100644 --- a/commands.go +++ b/commands.go @@ -322,7 +322,8 @@ func helpText() string { b.WriteString(" ↑ / ↓ history · cursor between lines (multi-line) · scroll (mouse off)\n") b.WriteString(" ctrl+↑ / ↓ prompt history (always)\n") b.WriteString(" shift+scroll drop into select mode (terminals that forward it; /mouse returns)\n") - b.WriteString(" esc / ctrl+c quit\n") + b.WriteString(" esc interrupt the running turn (or quit when idle)\n") + b.WriteString(" ctrl+c interrupt the running turn · again to quit\n") b.WriteString("commands:\n") for _, c := range cmds { b.WriteString(fmt.Sprintf(" /%-10s %s\n", c.name, c.desc)) diff --git a/keys.go b/keys.go index 01fd5b9..48ed916 100644 --- a/keys.go +++ b/keys.go @@ -234,9 +234,7 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { switch msg.Type { case tea.KeyCtrlC: - // Just quit — the subprocess is closed in main after Run() returns. - // Closing it here would block the Update loop (see Engine.Close). - return m, tea.Quit, true + return m.handleCtrlC() case tea.KeyEsc: return m.handleEsc() case tea.KeyEnter: @@ -260,8 +258,14 @@ func (m model) handleApprovalKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { m.pending = nil return m, waitApproval(m.approvals), true case "ctrl+c": + // Deny this tool (cancel the pending action) and arm the exit hint, but + // don't quit on the first press — a second Ctrl+C within the window does, + // matching the main handler. The waiter is re-armed so the session runs on. m.pending.reply <- approvalReply{allow: false} - return m, tea.Quit, true + m.add(entInfo, "✗ denied "+m.pending.toolName) + m.pending = nil + m.ctrlCAt = time.Now() + return m, tea.Batch(waitApproval(m.approvals), ctrlCHintTick()), true default: m.pending.reply <- approvalReply{allow: true} m.add(entInfo, "✓ approved "+m.pending.toolName) @@ -309,19 +313,59 @@ func (m *model) cancelQuestion() tea.Cmd { // dropping the whole session. func (m model) handleEsc() (model, tea.Cmd, bool) { if m.busy { - if err := m.engine.Interrupt(); err != nil { - m.add(entError, "interrupt failed: "+err.Error()) - } else { - m.add(entInfo, "✗ interrupted") - } - // Flip busy off proactively: claude may still emit late events, but - // the user gets the prompt back immediately. - m.busy = false - return m, nil, true + return m.interrupt(), nil, true } return m, tea.Quit, true } +// ctrlCExitWindow is how long after a Ctrl+C a second one still means "exit". +const ctrlCExitWindow = 2 * time.Second + +// ctrlCHintMsg fires ctrlCExitWindow after a Ctrl+C so the status bar repaints +// once the "again to exit" hint has lapsed (the frame it triggers recomputes +// ctrlCArmed, which is now false). See handleCtrlC. +type ctrlCHintMsg struct{} + +func ctrlCHintTick() tea.Cmd { + return tea.Tick(ctrlCExitWindow, func(time.Time) tea.Msg { return ctrlCHintMsg{} }) +} + +// ctrlCArmed reports whether a Ctrl+C landed recently enough that the next one +// exits — which is also when the status bar shows the "^C again to exit" hint. +func (m model) ctrlCArmed() bool { + return !m.ctrlCAt.IsZero() && time.Since(m.ctrlCAt) < ctrlCExitWindow +} + +// handleCtrlC gives Ctrl+C the Claude-Code cadence: the first press cancels the +// running turn (or, when idle, just arms) and shows an "again to exit" hint; a +// second press within ctrlCExitWindow quits. So a reflexive Ctrl+C stops the +// work instead of dropping the whole session, but a deliberate double-tap still +// exits fast. Quitting returns tea.Quit without closing the engine — main tears +// the subprocess down after Run() (closing it here deadlocks; see Engine.Close). +func (m model) handleCtrlC() (model, tea.Cmd, bool) { + if m.ctrlCArmed() { + return m, tea.Quit, true + } + m.ctrlCAt = time.Now() + if m.busy { + m = m.interrupt() + } + return m, ctrlCHintTick(), true +} + +// interrupt aborts the in-flight turn and hands the prompt back. Shared by Esc +// and Ctrl+C. busy is flipped off proactively — claude may still emit a few late +// events, but the user gets the prompt back immediately. +func (m model) interrupt() model { + if err := m.engine.Interrupt(); err != nil { + m.add(entError, "interrupt failed: "+err.Error()) + } else { + m.add(entInfo, "✗ interrupted") + } + m.busy = false + return m +} + // handleEnter dispatches a non-empty submission: slash commands run in-process, // busy turns enqueue, otherwise we send to claude. func (m model) handleEnter() (model, tea.Cmd, bool) { diff --git a/model.go b/model.go index ccf8f01..1ff7621 100644 --- a/model.go +++ b/model.go @@ -105,6 +105,10 @@ type model struct { // left untouched overnight goes fully quiescent (no per-frame redraws / GC // churn) and wakes on the next interaction. See shouldAnimateHeader. lastActivity time.Time + // ctrlCAt is when Ctrl+C was last pressed. The first press cancels the running + // turn (or, idle, does nothing) and arms an "again to exit" hint; a second + // press within ctrlCExitWindow quits. Zero means unarmed. See handleCtrlC. + ctrlCAt time.Time entries []entry // content accumulates the rendered transcript so each new entry is appended diff --git a/quit_test.go b/quit_test.go index b742e30..d2f133f 100644 --- a/quit_test.go +++ b/quit_test.go @@ -20,12 +20,7 @@ func isQuit(cmd tea.Cmd) bool { // goroutine (the "one ctrl+c freezes" bug). The subprocess is torn down in main // after Run() instead. A nil engine here would panic if Close were still called. func TestQuitPathsDoNotTouchEngine(t *testing.T) { - // ctrl+c with nothing pending - if _, cmd, handled := (model{}).handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}); !handled || !isQuit(cmd) { - t.Error("ctrl+c should quit") - } - - // esc while idle (no queue, not busy) + // esc while idle (not busy) quits if _, cmd, handled := (model{}).handleEsc(); !handled || !isQuit(cmd) { t.Error("esc while idle should quit") } @@ -35,14 +30,43 @@ func TestQuitPathsDoNotTouchEngine(t *testing.T) { t.Error("/quit should quit") } - // ctrl+c during a pending approval: denies (buffered reply) then quits + // A second ctrl+c (the model returned by the first, now armed) quits. + armed, _, _ := (model{}).handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}) + if _, cmd, handled := armed.handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}); !handled || !isQuit(cmd) { + t.Error("second ctrl+c should quit") + } +} + +// Ctrl+C no longer exits on the first press: idle it arms the "again to exit" +// window, and during a turn it interrupts. Only a second press within the +// window quits. (Don't run the returned Cmd — it's the hint tick, which blocks +// ctrlCExitWindow; assert on the armed state instead.) +func TestCtrlCArmsBeforeQuitting(t *testing.T) { + m, _, handled := (model{}).handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}) + if !handled { + t.Fatal("ctrl+c should be handled") + } + if !m.ctrlCArmed() { + t.Error("first ctrl+c should arm the exit window, not quit") + } +} + +// Ctrl+C during a pending approval denies the tool (cancels the action) and +// arms the exit window, but does not quit on the first press. +func TestCtrlCDuringApprovalDeniesAndArms(t *testing.T) { pending := &approvalReq{toolName: "Edit", reply: make(chan approvalReply, 1)} m := model{pending: pending} - _, cmd, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}) - if !handled || !isQuit(cmd) { - t.Error("ctrl+c during approval should quit") + m, _, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlC}) + if !handled { + t.Fatal("ctrl+c during approval should be handled") } if (<-pending.reply).allow { t.Error("ctrl+c during approval should deny the pending request") } + if m.pending != nil { + t.Error("ctrl+c should clear the pending approval") + } + if !m.ctrlCArmed() { + t.Error("ctrl+c during approval should arm the exit window") + } } diff --git a/status.go b/status.go index 06f12ed..bc99145 100644 --- a/status.go +++ b/status.go @@ -158,7 +158,7 @@ func shortModel(s string) string { // bbsStatus renders the DOS-style full-width status line. Each segment is // styled individually with the cyan background so the bar stays contiguous // even when nested-style chunks (the context-bar gradient) emit SGR resets. -func bbsStatus(mode, model, session, branch string, cost float64, ctxTok, outTok, ctxLimit int, busy bool, spin string, width int) string { +func bbsStatus(mode, model, session, branch string, cost float64, ctxTok, outTok, ctxLimit int, busy, armed bool, spin string, width int) string { if width < 1 { width = 1 } @@ -213,8 +213,12 @@ func bbsStatus(mode, model, session, branch string, cost float64, ctxTok, outTok // The live state indicator — spinner + WORKING while busy, READY otherwise — // is pushed flush to the right edge, so the working spinner animates in the - // bottom-right corner with the gap padding swallowed in the middle. + // bottom-right corner with the gap padding swallowed in the middle. A recent + // Ctrl+C replaces it with the exit hint until the window lapses. right := sbarBase.Render(state + " ") + if armed { + right = sbarRed.Render("^C AGAIN TO EXIT ") + } if gap := width - lipgloss.Width(left) - lipgloss.Width(right); gap > 0 { return left + sbarBase.Render(strings.Repeat(" ", gap)) + right } diff --git a/status_test.go b/status_test.go index 8e02b0c..a6700ef 100644 --- a/status_test.go +++ b/status_test.go @@ -1,6 +1,22 @@ package main -import "testing" +import ( + "strings" + "testing" +) + +// A recent Ctrl+C (armed) swaps the right-hand READY/WORKING indicator for the +// "again to exit" hint, so the user sees that a second press quits. +func TestStatusShowsCtrlCExitHint(t *testing.T) { + ready := bbsStatus("ask", "opus", "sess", "", 0, 0, 0, 200_000, false, false, "", 80) + if !strings.Contains(ready, "READY") || strings.Contains(ready, "AGAIN TO EXIT") { + t.Errorf("unarmed status should show READY, not the exit hint:\n%s", ready) + } + armed := bbsStatus("ask", "opus", "sess", "", 0, 0, 0, 200_000, false, true, "", 80) + if !strings.Contains(armed, "AGAIN TO EXIT") || strings.Contains(armed, "READY") { + t.Errorf("armed status should show the exit hint, not READY:\n%s", armed) + } +} func TestShortModel(t *testing.T) { cases := map[string]string{ diff --git a/update.go b/update.go index 1d71ae2..cc667e9 100644 --- a/update.go +++ b/update.go @@ -104,6 +104,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scrollTicking = false } + case ctrlCHintMsg: + // The exit window lapsed: clear the arm so the status drops the "again to + // exit" hint on this frame. Guarded so a newer Ctrl+C that re-armed within + // the window isn't cleared by an older tick. + if !m.ctrlCArmed() { + m.ctrlCAt = time.Time{} + } + case streamMsg: m.handleEvent(msg.env) diff --git a/view.go b/view.go index bca81ea..ff7a059 100644 --- a/view.go +++ b/view.go @@ -55,7 +55,7 @@ func (m model) renderBackground() string { } parts = append(parts, prompt, - bbsStatus(m.mode, m.modelID, m.session, gitBranch(), m.lastCost, m.ctxTokens, m.outTokens, m.ctxLimit, m.busy, m.sp.View(), m.w), + bbsStatus(m.mode, m.modelID, m.session, gitBranch(), m.lastCost, m.ctxTokens, m.outTokens, m.ctxLimit, m.busy, m.ctrlCArmed(), m.sp.View(), m.w), ) return strings.Join(parts, "\n") }