diff --git a/README.md b/README.md index e53570e..9d9cb3d 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ own front-end settings are separate; see [Configuration](#configuration). | `F1` | Show the help card | | `wheel` (with `--mouse`) | Scroll the transcript · click tool rows, turn heads, and the cockpit | | `⏎` (disconnected, empty input) | Retry the connection | -| `^C` | Quit | +| `^C` | Quit (confirm: `y` or a second `^C`) | **Every printable character always types.** No bare letter, digit, or punctuation key is ever bound in the composer — actions live on chords and @@ -342,6 +342,7 @@ full command and press `⏎`. |---------|--------| | `/help` | Show available commands and key bindings | | `/clear` | Clear the conversation (two-step confirm; idle only) | +| `/new` | Start a fresh session — new ID, empty context; the old one stays resumable via `/sessions` (idle only) | | `/copy` | Copy the last reply to the clipboard (OSC 52) | | `/retry` | Re-send the last prompt (queues it if a turn is running) | | `/theme [name]` | Switch the color theme at runtime and persist it (`ember-dark` · `ember-light` · `high-contrast` · `classic`) | diff --git a/internal/tui/approval.go b/internal/tui/approval.go index e31dc11..456bcd8 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -79,8 +79,7 @@ func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.vp.GotoBottom() return m, nil case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") } return m, nil } @@ -115,8 +114,7 @@ func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.vp.GotoBottom() return m, nil case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") default: // Single printable runes only — modifiers (ctrl+X, alt+X) must not // splice escape bytes into the confirmation buffer. diff --git a/internal/tui/cockpit.go b/internal/tui/cockpit.go index dbf26ad..7c2fe6c 100644 --- a/internal/tui/cockpit.go +++ b/internal/tui/cockpit.go @@ -61,8 +61,7 @@ func (m *Model) handleCockpitMsg(msg cockpitMsg) tea.Cmd { func (m *Model) handlePopoverKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "h", "q": m.popover = false m.refresh() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 8907362..bf4bf72 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -35,6 +35,21 @@ func slashCommands() []command { } return m.armConfirm(confirmClear, "the conversation") }}, + {"new", "start a fresh session (the old one stays resumable)", func(m *Model, _ string) tea.Cmd { + // Idle-only like /clear, plus the connection-scoped state a + // forced redial would destroy: pending approvals die with the + // socket, and queued prompts belong to THIS conversation. + if m.busy { + return m.transientNoteCmd("can't start a new session while a turn runs — esc cancels it first") + } + if len(m.approvals) > 0 { + return m.transientNoteCmd("answer the pending approval first — it dies with the connection") + } + if len(m.queue) > 0 { + return m.transientNoteCmd("drain the prompt queue first (ctrl+q)") + } + return m.startFreshSession() + }}, {"copy", "copy the last reply to the clipboard (OSC 52)", func(m *Model, _ string) tea.Cmd { return m.copyLastReply() }}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 185580c..18091f9 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -297,6 +297,17 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/quit did not set quitting") } }, + "/new": func(t *testing.T, m *Model) { + if m.sessionID != "" || m.authToken != "" { + t.Fatalf("session identity not dropped: sid=%q", m.sessionID) + } + if len(m.msgs) != 0 { + t.Fatalf("transcript not wiped: %d msgs", len(m.msgs)) + } + if !m.freshStart { + t.Fatal("freshStart not armed for the reconnect note") + } + }, "/plan": func(t *testing.T, m *Model) { if m.panel != panelPlan { t.Fatalf("/plan opened panel %d", m.panel) @@ -330,6 +341,9 @@ func TestE2EAllCommands(t *testing.T) { case "/cancel": m.busy = true m.sessionID, m.authToken = "s1", "a1" + case "/new": + m.sessionID, m.authToken = "s1", "a1" + m.msgs = append(m.msgs, message{role: roleUser, content: "x"}) case "/attach": dir := t.TempDir() path := filepath.Join(dir, "notes.txt") diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 9ada397..6b03acf 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -157,10 +157,15 @@ func TestCommandPopupKeepsTyping(t *testing.T) { if got := m.ta.Value(); got != "/" { t.Errorf("input after backspace = %q, want %q", got, "/") } - // ctrl+c still quits while the popup has capture. + // ctrl+c arms the quit gate while the popup has capture; a second ^C + // confirms. + m.Update(key("ctrl+c")) + if m.confirm != confirmQuit { + t.Error("ctrl+c should arm the quit gate while the popup is open") + } m.Update(key("ctrl+c")) if !m.quitting { - t.Error("ctrl+c should quit while the popup is open") + t.Error("second ctrl+c should quit while the popup is open") } } diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go index 23de53c..5264208 100644 --- a/internal/tui/coverage_test.go +++ b/internal/tui/coverage_test.go @@ -289,7 +289,11 @@ func TestTinyHelpers(t *testing.T) { func TestQuitKeys(t *testing.T) { m := wired(t) m.Update(key("ctrl+c")) + if m.confirm != confirmQuit || m.quitting { + t.Fatalf("ctrl+c should arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting) + } + m.Update(key("ctrl+c")) if !m.quitting { - t.Error("ctrl+c should set quitting") + t.Error("second ctrl+c should set quitting") } } diff --git a/internal/tui/dispatch_test.go b/internal/tui/dispatch_test.go index 542e9c3..324eea6 100644 --- a/internal/tui/dispatch_test.go +++ b/internal/tui/dispatch_test.go @@ -75,8 +75,12 @@ func TestPanelKeyBoundsAndQuit(t *testing.T) { t.Errorf("panelSel = %d", m.panelSel) } m.Update(key("ctrl+c")) + if m.confirm != confirmQuit || m.quitting { + t.Fatalf("ctrl+c in panel should arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting) + } + m.Update(key("ctrl+c")) if !m.quitting { - t.Error("ctrl+c in panel should quit") + t.Error("second ctrl+c in panel should quit") } } diff --git a/internal/tui/events.go b/internal/tui/events.go index df97b08..b4b5cfd 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -375,7 +375,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.relayout() // the busy status line is gone with the socket if cmd := m.scheduleReconnect(0); cmd != nil { m.status = "reconnecting…" - m.addTransientNote("connection lost — reconnecting…") + if m.freshStart { + m.addTransientNote("starting a fresh session…") + } else { + m.addTransientNote("connection lost — reconnecting…") + } m.refresh() // The interim note fades via the sweep; the reconnect outcome // (success or the ⏎-retry hint) replaces it within seconds. diff --git a/internal/tui/find.go b/internal/tui/find.go index ccda2f0..09d0243 100644 --- a/internal/tui/find.go +++ b/internal/tui/find.go @@ -40,8 +40,7 @@ func (m *Model) closeFind() { func (m *Model) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "alt+f": m.closeFind() return m, nil diff --git a/internal/tui/find_test.go b/internal/tui/find_test.go index 5c743f4..6c232a1 100644 --- a/internal/tui/find_test.go +++ b/internal/tui/find_test.go @@ -142,10 +142,14 @@ func TestFindKeyRouting(t *testing.T) { seedConversation(m) m.Update(key("alt+f")) - // ctrl+c quits from the find bar like from anywhere else. + // ctrl+c arms the quit gate from the find bar like from anywhere else. + m.Update(key("ctrl+c")) + if m.confirm != confirmQuit || m.quitting { + t.Fatalf("ctrl+c in the find bar did not arm the gate: confirm=%v quitting=%v", m.confirm, m.quitting) + } m.Update(key("ctrl+c")) if !m.quitting { - t.Error("ctrl+c in the find bar did not quit") + t.Error("second ctrl+c in the find bar did not quit") } // Backspace pops the query and rescans; past the last rune it is a diff --git a/internal/tui/input.go b/internal/tui/input.go index ce57461..07a8e9d 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -41,8 +41,7 @@ func (m *Model) handleACKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.closeAC() return m, nil case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") } // Any other key is plain input: forward it to the textarea, then // re-evaluate the popup against the new value — typing narrows the diff --git a/internal/tui/model.go b/internal/tui/model.go index 3b2ce7c..cee3f02 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -271,6 +271,7 @@ type Model struct { planAvail planAvailability // endpoint health tri-state planTrig bool // a plan tool_call awaits tail-batch pickup planResetPending bool // session changed; reset+refetch at tail + freshStart bool // /new drop: reconnect lands on a fresh session planDebSeq int // debounce window sequence planReqSeq int // fetch request sequence planPollSeq int // armed poll tick sequence @@ -726,8 +727,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc": // The run's kill switch sits behind the same two-step gate as every // other destructive action — esc is too easy to hit by accident. @@ -908,6 +908,29 @@ func (m *Model) clearConversation() { m.refresh() } +// startFreshSession tears down the current conversation AND its server-side +// session: /clear only wipes the local view, while the session (history, +// context) keeps accumulating on the server. Dropping sessionID/authToken +// before the forced redial leaves adoptSession nothing to re-adopt, so the +// fresh connection stays sessionless — and odek mints a brand-new session +// (new ID, empty history and memory buffer) on the connection's first prompt. +// The old session stays on disk, resumable via /sessions. +func (m *Model) startFreshSession() tea.Cmd { + m.clearConversation() + m.sessionID = "" + m.authToken = "" + m.pendModel = m.model // the new session re-asserts the active model + m.resetPlanState() + m.freshStart = true + cl := m.cl + return func() tea.Msg { + if cl != nil { + _ = cl.Close() // the drop runs the standard disconnect→reconnect flow + } + return nil + } +} + // curApproval returns the head of the approval queue, or nil when empty. func (m *Model) curApproval() *client.Event { if len(m.approvals) == 0 { diff --git a/internal/tui/new_session_test.go b/internal/tui/new_session_test.go new file mode 100644 index 0000000..ebadc57 --- /dev/null +++ b/internal/tui/new_session_test.go @@ -0,0 +1,146 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// /new starts a genuinely fresh session: the transcript is wiped AND the +// connection drops its session identity, so the forced redial lands on a +// sessionless connection — odek binds sessions per connection, and the next +// prompt on a sessionless one mints a brand-new session server-side. The old +// session stays on disk, resumable via /sessions. + +// runNew invokes the /new command through the registry. +func runNew(m *Model) tea.Cmd { + for _, c := range slashCommands() { + if c.name == "new" { + return c.run(m, "") + } + } + return nil +} + +func TestNewCommandRegistered(t *testing.T) { + for _, c := range slashCommands() { + if c.name == "new" { + if c.desc == "" { + t.Fatal("/new registered with an empty description") + } + return + } + } + t.Fatal("/new is not in the command registry") +} + +func seedSessionState(m *Model) { + m.sessionID = "s1" + m.authToken = "a1" + m.model = "glm-5.3-flash" + seedConversation(m) + m.planInit = true + m.planVer = 2 + m.planAvail = planAvailable +} + +func TestNewGuards(t *testing.T) { + t.Run("busy", func(t *testing.T) { + m := wired(t) + seedSessionState(m) + busyTurn(m) + + if cmd := runNew(m); cmd == nil { + t.Fatal("busy /new returned no command (want a note cmd)") + } + if m.sessionID != "s1" || len(m.msgs) == 0 { + t.Fatalf("busy /new must be a no-op: sid=%q msgs=%d", m.sessionID, len(m.msgs)) + } + }) + t.Run("pending approval", func(t *testing.T) { + m := wired(t) + seedSessionState(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr-1"}) + + if cmd := runNew(m); cmd == nil { + t.Fatal("/new with a pending approval returned no command (want a note cmd)") + } + if m.sessionID != "s1" || len(m.msgs) == 0 { + t.Fatalf("/new must not run with a pending approval: sid=%q msgs=%d", m.sessionID, len(m.msgs)) + } + }) + t.Run("queued prompts", func(t *testing.T) { + m := wired(t) + seedSessionState(m) + m.queue = []string{"queued for THIS conversation"} + + if cmd := runNew(m); cmd == nil { + t.Fatal("/new with a queue returned no command (want a note cmd)") + } + if m.sessionID != "s1" || len(m.queue) != 1 { + t.Fatalf("/new must not run with queued prompts: sid=%q queue=%d", m.sessionID, len(m.queue)) + } + }) +} + +func TestNewResetsAndDropsIdentity(t *testing.T) { + m := wired(t) + seedSessionState(m) + + cmd := runNew(m) + if cmd == nil { + t.Fatal("/new returned no command (want the socket close)") + } + + if m.sessionID != "" || m.authToken != "" { + t.Fatalf("session identity not dropped: sid=%q tok=%q", m.sessionID, m.authToken) + } + if len(m.msgs) != 0 || m.turnStats != nil || m.toolTotal != 0 { + t.Fatalf("transcript not wiped: msgs=%d stats=%d tools=%d", len(m.msgs), len(m.turnStats), m.toolTotal) + } + if m.planInit || m.planAvail != planUnknown || m.planVer != 0 { + t.Fatalf("plan state not reset: init=%v avail=%v ver=%d", m.planInit, m.planAvail, m.planVer) + } + if m.pendModel != "glm-5.3-flash" { + t.Errorf("model not re-asserted on the next prompt: pendModel=%q", m.pendModel) + } + if !m.freshStart { + t.Error("freshStart flag not armed — reconnect will show the resume note") + } + // The reconnect must have nothing to re-adopt: adoptSession no-ops on an + // empty session id, so the fresh connection stays sessionless. + if cmd := m.adoptSession(); cmd != nil { + t.Error("adoptSession is not a no-op after /new — the old session would be re-adopted") + } +} + +func TestNewReconnectLandsFresh(t *testing.T) { + m := wired(t) + seedSessionState(m) + runNew(m) + m.disconn = true // the socket close lands as EventDisconnected + + m.handleReconnect(reconnectMsg{cl: m.cl}) + + if m.disconn || m.status != "ready" { + t.Fatalf("reconnect did not land: disconn=%v status=%q", m.disconn, m.status) + } + if m.freshStart { + t.Error("freshStart flag not consumed by the reconnect") + } + if m.sessionID != "" { + t.Fatalf("reconnect re-adopted a session: %q", m.sessionID) + } + found := false + for _, n := range m.notices { + if strings.Contains(n, "fresh session") { + found = true + } + } + if !found { + t.Errorf("no fresh-session note after reconnect: %v", m.notices) + } +} diff --git a/internal/tui/palette.go b/internal/tui/palette.go index ab4b301..6e9e4fc 100644 --- a/internal/tui/palette.go +++ b/internal/tui/palette.go @@ -218,8 +218,7 @@ func fuzzyScore(query, s string) (int, bool) { func (m *Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "ctrl+k": m.pal.open = false m.relayout() diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 5d3d46f..756c6f6 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -52,6 +52,7 @@ const ( confirmClear confirmCancel confirmStopAgent + confirmQuit ) // handleConfirmKey resolves an armed delete: y fires it against the @@ -79,6 +80,16 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // armed degrades to a notice — the terminal state still comes // from subagent_state, never from the ack. return m, m.stopAgent(m.stopTarget) + case confirmQuit: + m.quitting = true + return m, tea.Quit + } + case "ctrl+c": + // A second ^C is the muscle-memory confirm for the quit gate — + // for every other armed action it disarms like any key. + if kind == confirmQuit { + m.quitting = true + return m, tea.Quit } } m.refresh() @@ -97,6 +108,8 @@ func (m *Model) armConfirm(kind confirmKind, what string) tea.Cmd { verb = "cancel " case confirmStopAgent: verb = "stop " + case confirmQuit: + verb = "quit " } m.panelMsg = verb + what + "? y confirm · any other key cancels" m.refresh() @@ -246,8 +259,7 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.panelDetail && mgmtPanel(m.panel) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "q": m.closeDetail() return m, nil @@ -303,8 +315,7 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "ctrl+r", "ctrl+o", "q": m.closePanel() return m, nil @@ -472,8 +483,7 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handlePanelEditKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc": m.panelEdit = panelEditNone m.panelDraft = "" diff --git a/internal/tui/queue.go b/internal/tui/queue.go index 94a4aad..5affb38 100644 --- a/internal/tui/queue.go +++ b/internal/tui/queue.go @@ -227,8 +227,7 @@ func (m *Model) queueStripKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch msg.String() { case "ctrl+c": - m.quitting = true - return m, tea.Quit + return m, m.armConfirm(confirmQuit, "bodek") case "esc", "enter", "ctrl+q": m.qfocus = false case "up", "k": diff --git a/internal/tui/queue_strip_test.go b/internal/tui/queue_strip_test.go index c5657da..b026382 100644 --- a/internal/tui/queue_strip_test.go +++ b/internal/tui/queue_strip_test.go @@ -228,11 +228,16 @@ func TestQueueStripKeyboardFocus(t *testing.T) { t.Errorf("typing after esc must reach the input, got %q", m.ta.Value()) } - // ctrl+c still quits while focused — the strip never traps the exit. + // ctrl+c still reaches the gate while focused — the strip never traps + // the exit; a second ^C confirms it. m.Update(key("ctrl+q")) m.Update(key("ctrl+c")) + if m.confirm != confirmQuit || m.quitting { + t.Fatalf("ctrl+c must still arm the gate from queue focus: confirm=%v quitting=%v", m.confirm, m.quitting) + } + m.Update(key("ctrl+c")) if !m.quitting { - t.Error("ctrl+c must still quit from queue focus mode") + t.Error("second ctrl+c must quit from queue focus mode") } } diff --git a/internal/tui/quit_confirm_test.go b/internal/tui/quit_confirm_test.go new file mode 100644 index 0000000..dafb4ec --- /dev/null +++ b/internal/tui/quit_confirm_test.go @@ -0,0 +1,118 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ^C is the one key that used to kill the app from anywhere — a stray +// terminal-focused chord could end a session mid-run. It now rides the same +// two-step confirm gate as every other destructive action: the first ^C arms +// the gate, y (or a second ^C) fires, any other key disarms. + +func TestCtrlCArmsQuitConfirm(t *testing.T) { + m := newTestModel() + + m.Update(key("ctrl+c")) + + if m.confirm != confirmQuit { + t.Fatalf("ctrl+c did not arm confirmQuit: %v", m.confirm) + } + if m.quitting { + t.Fatal("first ctrl+c quit outright — the gate never armed") + } + if got := plain(m.View()); !strings.Contains(got, "quit bodek?") { + t.Errorf("footer does not show the quit confirm gate:\n%s", got) + } +} + +func TestQuitGateYFires(t *testing.T) { + m := newTestModel() + + m.Update(key("ctrl+c")) + _, cmd := m.Update(key("y")) + + if !m.quitting { + t.Fatal("y on an armed quit gate did not quit") + } + if cmd == nil { + t.Fatal("y on an armed quit gate returned no command") + } +} + +func TestQuitGateSecondCtrlCConfirms(t *testing.T) { + // Double-^C is the muscle-memory confirm: the second press fires. + m := newTestModel() + + m.Update(key("ctrl+c")) + _, cmd := m.Update(key("ctrl+c")) + + if !m.quitting { + t.Fatal("a second ctrl+c did not confirm the quit gate") + } + if cmd == nil { + t.Fatal("second ctrl+c returned no command") + } +} + +func TestQuitGateOtherKeyDisarms(t *testing.T) { + m := newTestModel() + + m.Update(key("ctrl+c")) + m.Update(key("n")) + + if m.confirm != confirmNone { + t.Errorf("any other key must disarm the gate, got %v", m.confirm) + } + if m.quitting { + t.Error("disarming the gate quit anyway") + } + // The disarming keypress is consumed by the gate — it never types. + if got := m.ta.Value(); got != "" { + t.Errorf("the disarm keypress leaked into the input: %q", got) + } + // And quit stays two-step afterwards: re-arm, not quit. + m.Update(key("ctrl+c")) + if m.confirm != confirmQuit || m.quitting { + t.Fatalf("post-disarm ctrl+c: confirm=%v quitting=%v", m.confirm, m.quitting) + } +} + +func TestQuitGateArmsFromEveryContext(t *testing.T) { + // ^C is reachable from every rung of the modality ladder — the gate must + // arm from each, never quit on the first press. + cases := map[string]func(m *Model){ + "composer": func(m *Model) {}, + "palette": func(m *Model) { m.Update(key("ctrl+k")) }, + "find bar": func(m *Model) { m.Update(key("alt+f")) }, + "ac popup": func(m *Model) { m.ta.SetValue("/"); m.syncAC() }, + "panel": func(m *Model) { m.panel = panelSessions }, + "popover": func(m *Model) { m.popover = true }, + "queue focus": func(m *Model) { m.queue = []string{"q"}; m.Update(key("ctrl+q")) }, + "approval": func(m *Model) { m.handleEvent(client.Event{Type: "approval_request", ID: "apr-q"}) }, + } + for name, setup := range cases { + t.Run(name, func(t *testing.T) { + m := newTestModel() + setup(m) + + m.Update(key("ctrl+c")) + + if m.confirm != confirmQuit { + t.Fatalf("ctrl+c from %s did not arm confirmQuit: %v", name, m.confirm) + } + if m.quitting { + t.Fatalf("ctrl+c from %s quit outright", name) + } + if got := plain(m.View()); !strings.Contains(got, "quit bodek?") { + t.Fatalf("gate not rendered from %s:\n%s", name, got) + } + // y fires from anywhere too. + if _, cmd := m.Update(key("y")); !m.quitting || cmd == nil { + t.Fatalf("y from %s did not quit (quitting=%v cmd=%v)", name, m.quitting, cmd) + } + }) + } +} diff --git a/internal/tui/reconnect.go b/internal/tui/reconnect.go index a731771..11cf958 100644 --- a/internal/tui/reconnect.go +++ b/internal/tui/reconnect.go @@ -58,6 +58,12 @@ func (m *Model) handleReconnect(msg reconnectMsg) (tea.Model, tea.Cmd) { // buffer) without waiting for a prompt, and every prompt still carries // session_id + auth_token as the belt-and-suspenders fallback. note := m.transientNoteCmd("reconnected to odek serve — the session resumes on your next prompt") + if m.freshStart { + // /new dropped the identity on purpose: nothing was resumed — + // the first prompt mints a brand-new session server-side. + m.freshStart = false + note = m.transientNoteCmd("fresh session — your next prompt starts a new conversation") + } m.refresh() return m, tea.Batch(listen(m.events), m.adoptSession(), m.sendQueued(), note) } diff --git a/internal/tui/view.go b/internal/tui/view.go index f79c7f7..dbf7e5c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1048,6 +1048,16 @@ func (m *Model) approvalBody() string { func (m *Model) footer() string { th := m.th + // The quit gate outranks every context — ^C can arm it from panels, + // overlays, approvals, and the composer alike, so the gate must show + // wherever that keypress landed. + if m.confirm == confirmQuit { + return m.panelFooter( + th.footerDanger.Render("quit bodek?"), + th.footerKey.Render("y")+th.footerDanger.Render(" quit"), + th.footer.Render("any other key cancels"), + ) + } if a := m.curApproval(); a != nil { if a.Friction { return th.footer.Render(" type the word approve + ⏎ · esc denies")