From 05d9505ef4206a7e450a8e38fa71e56cb7596ba9 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 1 Sep 2026 20:25:08 +0200 Subject: [PATCH 1/2] feat(tui): two-step confirm gate for ctrl+c quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ^C arms the standard confirm gate (y or a second ^C fires, any other key disarms and is consumed); all 11 quit sites now route through armConfirm(confirmQuit) instead of quitting outright, and the gate renders from the top of footer() so it is visible from every context that can arm it — panels, overlays, approvals, composer. --- internal/tui/approval.go | 6 +- internal/tui/cockpit.go | 3 +- internal/tui/commands_test.go | 9 ++- internal/tui/coverage_test.go | 6 +- internal/tui/dispatch_test.go | 6 +- internal/tui/find.go | 3 +- internal/tui/find_test.go | 8 +- internal/tui/input.go | 3 +- internal/tui/model.go | 27 ++++++- internal/tui/palette.go | 3 +- internal/tui/panels.go | 22 ++++-- internal/tui/queue.go | 3 +- internal/tui/queue_strip_test.go | 9 ++- internal/tui/quit_confirm_test.go | 118 ++++++++++++++++++++++++++++++ internal/tui/view.go | 10 +++ 15 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 internal/tui/quit_confirm_test.go 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_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/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/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/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") From 3ece1aff719b4eb70c591b8d6b0a82cd5aeb735f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 1 Sep 2026 20:25:15 +0200 Subject: [PATCH 2/2] feat(tui): add /new command to start a fresh session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /clear only wipes the local view — the session kept accumulating history and context server-side. odek binds sessions per connection and mints a new one on a sessionless connection's first prompt, so /new drops the session identity and closes the socket: the standard reconnect lands sessionless and the next prompt starts a genuinely new session. The old one stays resumable via /sessions. Idle-only like /clear, plus guards for pending approvals and queued prompts — both would be destroyed by the forced redial. --- README.md | 3 +- internal/tui/commands.go | 15 +++ internal/tui/commands_e2e_test.go | 14 +++ internal/tui/events.go | 6 +- internal/tui/new_session_test.go | 146 ++++++++++++++++++++++++++++++ internal/tui/reconnect.go | 6 ++ 6 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 internal/tui/new_session_test.go 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/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/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/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/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) }