Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
70 changes: 57 additions & 13 deletions keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 34 additions & 10 deletions quit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
}
}
8 changes: 6 additions & 2 deletions status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 17 additions & 1 deletion status_test.go
Original file line number Diff line number Diff line change
@@ -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{
Expand Down
8 changes: 8 additions & 0 deletions update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading