From 50b612ce6ecbf45ef7abf64d9877afb7bd201f65 Mon Sep 17 00:00:00 2001 From: tdwd Date: Tue, 8 Sep 2026 11:29:04 +0200 Subject: [PATCH] ui: name the backend that is actually answering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt said "Ask Claude…", the reply label said claude, the approval bar said "claude wants:" and the header said "Claude on your Max plan" — while codex was answering. Each site wrote the name out, so adding a backend left all four wrong. agentname.go is now the one place a backend is named, and the model carries which one it is. A third backend is a case in one switch rather than a hunt for string literals. Looking for the same shape rather than the same symptom found three more: - /model offered opus, sonnet and haiku on codex. Those are rows that cannot be selected. codex names its models outright, so model/list is fetched at Initialize and forwarded as a frame — a request's reply never reaches the adapter otherwise — and the fallback is per backend. - ctrl+r mixed both backends' sessions. Resuming a claude session under codex hands thread/resume an id it has never seen. sessionInfo now records the backend, and the claude-only filesystem source is skipped elsewhere. Records written before there was a second backend carry no value, and empty means claude, so nothing needs migrating. - @path is NOT expanded by codex. Probing both showed claude injects the file's contents while codex sees only the text and must read the file itself, so the help line differs per backend rather than asserting claude's behaviour twice. Still claude-only and still offered on codex: /sysprompt and /mcp. Those need a capability set rather than a label, so they are left for that work. --- README.md | 6 ++- agentname.go | 40 ++++++++++++++++++++ agentname_test.go | 83 +++++++++++++++++++++++++++++++++++++++++ chrome.go | 4 +- claude_sessions.go | 15 +++++++- claude_sessions_test.go | 6 +-- codexcalls.go | 1 + codexlive_test.go | 52 ++++++++++++++++++++++++++ codexsession.go | 42 ++++++++++++++++++++- codexstream.go | 5 +++ commandlist.go | 2 +- help.go | 17 ++++++--- input_test.go | 4 +- jump_test.go | 2 +- keys.go | 4 +- launch.go | 1 + main.go | 1 + model.go | 9 +++-- models.go | 20 +++++++--- render.go | 2 +- sessions.go | 18 ++++++++- sessions_test.go | 60 +++++++++++++++++++++++------ stream.go | 2 +- view.go | 6 +-- 24 files changed, 355 insertions(+), 47 deletions(-) create mode 100644 agentname.go create mode 100644 agentname_test.go diff --git a/README.md b/README.md index 0a73584..01136a9 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,10 @@ The codex backend is newer and narrower than the claude one: - File changes render as real diff cards, in both the unified and side-by-side styles. Other tool calls render as plain cards rather than the typed ones claude gets. -- Session replay, `/compact` and the slash-command palette are claude-only so - far. +- Session replay, `/compact`, `/sysprompt` and the slash-command palette are + claude-only so far. +- `@path` inserts a path but does not inject the file. Only claude expands an + `@` mention into file contents; codex reads the file itself with a tool. `CATHODE_CODEX_LIVE=1 go test -run TestCodexLive ./...` exercises the backend against the real CLI. It spends a turn on your subscription, so it is off by diff --git a/agentname.go b/agentname.go new file mode 100644 index 0000000..bce00b4 --- /dev/null +++ b/agentname.go @@ -0,0 +1,40 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +// ---- what the running backend is called on screen ---- +// +// Every user-visible mention of the agent goes through here. Before this, the +// name was written out at each site — the prompt placeholder, the reply label, +// the approval bar, the header — and adding a second backend left all four +// saying "claude" while codex was answering. +// +// One function rather than four constants, so a third backend is a case in one +// switch instead of a hunt for string literals. Anything that still hardcodes a +// name is either about claude specifically (its config dir, its CLI flags) or a +// bug. + +// agentName is the backend's name as the transcript and prompt refer to it. +// Lowercase: the chrome applies its own casing (studly, leet) on top. +func agentName(backend string) string { + if backend == backendCodex { + return "codex" + } + return "claude" +} + +// agentTagline is the header's subtitle. It names the plan each backend rides, +// because riding a subscription rather than an API key is the point of the +// program and the header is where that is said. +func agentTagline(backend string) string { + if backend == backendCodex { + return "codex on your ChatGPT plan" + } + return "claude on your Max plan" +} + +// promptPlaceholder is the empty-input hint. +func promptPlaceholder(backend string) string { + return "Ask " + agentName(backend) + "… (enter sends · alt+enter / ctrl+j / \\↵ for a new line)" +} diff --git a/agentname_test.go b/agentname_test.go new file mode 100644 index 0000000..a860e17 --- /dev/null +++ b/agentname_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Triple Down AB +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// Every user-visible mention of the agent follows the backend. These four sites +// each hardcoded "claude", so a codex session was answered by something the +// whole screen called claude. +func TestAgentLabelsFollowTheBackend(t *testing.T) { + for _, c := range []struct{ backend, want string }{ + {backendClaude, "claude"}, + {backendCodex, "codex"}, + {"", "claude"}, // unset means the default backend, not a blank label + } { + if got := agentName(c.backend); got != c.want { + t.Errorf("agentName(%q) = %q, want %q", c.backend, got, c.want) + } + if got := promptPlaceholder(c.backend); !strings.Contains(got, "Ask "+c.want) { + t.Errorf("placeholder for %q = %q, want it to name %q", c.backend, got, c.want) + } + if got := agentTagline(c.backend); !strings.Contains(got, c.want) { + t.Errorf("tagline for %q = %q, want it to name %q", c.backend, got, c.want) + } + } + // The taglines name different plans, so one is not silently reused. + if agentTagline(backendClaude) == agentTagline(backendCodex) { + t.Error("both backends share a tagline; each rides a different plan") + } +} + +// The placeholder is set once when the prompt is built, so a model constructed +// for codex must not carry claude's. +func TestPromptPlaceholderComesFromTheLaunchConfig(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + m := newModel(launchConfig{Engine: &fakeEngine{}, Backend: backendCodex, Mode: "ask", Spinner: "bar"}) + if got := m.input.Placeholder; !strings.Contains(got, "Ask codex") { + t.Errorf("placeholder = %q, want it to name codex", got) + } +} + +// A backend's aliases are meaningless to another one. Offering "opus" on codex +// is not a harmless default: it is a row that cannot work. +func TestModelFallbackNeverOffersAnotherBackendsModels(t *testing.T) { + claude := fallbackModelItems(backendClaude) + if len(claude) != 3 || claude[0].id != "opus" { + t.Errorf("claude fallback = %+v, want its three aliases", claude) + } + for _, it := range fallbackModelItems(backendCodex) { + switch it.id { + case "opus", "sonnet", "haiku": + t.Errorf("codex fallback offers claude's %q", it.id) + } + } +} + +// The live list replaces the fallback. codex sends it as a fetched frame rather +// than on the stream, because model/list is a request. +func TestCodexModelsFrameFillsThePicker(t *testing.T) { + m, _ := newTestModel(t, "") + m.backend = backendCodex + + if got := m.modelItems(); len(got) == 0 || got[0].id != "" { + t.Fatalf("before the list arrives, want the placeholder row, got %+v", got) + } + + models := []ModelChoice{{Value: "gpt-x", DisplayName: "GPT-X", Description: "the one"}} + b, err := json.Marshal(models) + if err != nil { + t.Fatal(err) + } + m.handleCodexEvent(codexFrame{Method: codexModelsMethod, Params: b}) + + items := m.modelItems() + if len(items) != 1 || items[0].id != "gpt-x" || items[0].title != "GPT-X" { + t.Errorf("picker rows = %+v, want the reported model", items) + } +} diff --git a/chrome.go b/chrome.go index 51ec10f..eceea6f 100644 --- a/chrome.go +++ b/chrome.go @@ -15,7 +15,7 @@ import ( // wordmark itself animates per the chosen header style (set via /settings), // its color band drifting with phase (incremented by rainbowTickMsg in the // update loop). -func bbsBanner(width, phase int, style string) string { +func bbsBanner(width, phase int, style, backend string) string { if width < 24 { width = 24 } @@ -23,7 +23,7 @@ func bbsBanner(width, phase int, style string) string { hdrDeco.Render("░▒▓█"), renderHeader(style, studly(appName), phase), hdrDeco.Render("█▓▒░"), - hdrSub.Render(ornDeco+" "+flavor("Claude on your Max plan")+" "+ornDeco)) + hdrSub.Render(ornDeco+" "+flavor(agentTagline(backend))+" "+ornDeco)) return hdrBox.Width(width - 2).Render(title) } diff --git a/claude_sessions.go b/claude_sessions.go index 8ca7d4c..8ab927d 100644 --- a/claude_sessions.go +++ b/claude_sessions.go @@ -109,10 +109,21 @@ func firstText(raw json.RawMessage) string { // store-only entries (matching the cwd filter) as a fallback. Empty cwd // disables the store filter and skips the filesystem source, which the tests // use to assert pure store behaviour. -func sessionItems(s *sessionStore, cwd string) []pickerItem { - merged := mergeWithStore(listClaudeSessions(cwd), s, cwd) +func sessionItems(s *sessionStore, cwd, backend string) []pickerItem { + // Only claude keeps per-project session JSONLs, so the filesystem source is + // claude's alone. Offering those rows on another backend is not a cosmetic + // mismatch: resuming one hands a claude session id to a CLI that has never + // seen it, and the handshake fails. + var fs []sessionInfo + if sessionBackend(backend) == backendClaude { + fs = listClaudeSessions(cwd) + } + merged := mergeWithStore(fs, s, cwd) items := make([]pickerItem, 0, len(merged)) for _, e := range merged { + if sessionBackend(e.Backend) != sessionBackend(backend) { + continue + } title := short(e.ID) if e.First != "" { title = short(e.ID) + " " + e.First diff --git a/claude_sessions_test.go b/claude_sessions_test.go index f593000..65e4934 100644 --- a/claude_sessions_test.go +++ b/claude_sessions_test.go @@ -88,11 +88,11 @@ func TestSessionItemsMergesClaudeAndStore(t *testing.T) { s := newTestStore(t) // Store-only entry for the same cwd — must still show up. - s.Touch("storeonly", "sonnet", cwd, "prior prompt", time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + s.Touch("storeonly", "sonnet", cwd, "prior prompt", backendClaude, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) // Different cwd — must be filtered out. - s.Touch("elsewhere", "sonnet", "/work/repoB", "other", time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + s.Touch("elsewhere", "sonnet", "/work/repoB", "other", backendClaude, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) - items := sessionItems(s, cwd) + items := sessionItems(s, cwd, backendClaude) ids := map[string]bool{} for _, it := range items { ids[it.id] = true diff --git a/codexcalls.go b/codexcalls.go index df16880..73e2621 100644 --- a/codexcalls.go +++ b/codexcalls.go @@ -20,6 +20,7 @@ type codexMsg struct{ frame codexFrame } const ( codexClosedMethod = "cathode/closed" // the subprocess exited codexErrorMethod = "cathode/error" // a call failed, and nobody was waiting + codexModelsMethod = "cathode/models" // the model list, fetched not streamed ) // codexCallTimeout bounds a request that gets no reply. It is generous because diff --git a/codexlive_test.go b/codexlive_test.go index aad286a..ff20d12 100644 --- a/codexlive_test.go +++ b/codexlive_test.go @@ -199,3 +199,55 @@ func TestCodexLiveEditRendersAsADiffCard(t *testing.T) { } } } + +// The /model picker must offer codex's own models, not claude's aliases. +// model/list is a request, so the list reaches the UI as a fetched frame; this +// checks that round trip against the real catalogue. +func TestCodexLiveModelListReachesThePicker(t *testing.T) { + if os.Getenv("CATHODE_CODEX_LIVE") == "" { + t.Skip("set CATHODE_CODEX_LIVE=1 to run against the real codex CLI") + } + e, err := newCodexEngine(codexEngineConfig{Mode: "plan", Cwd: t.TempDir()}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + defer e.Close() + + frames := make(chan codexFrame, 64) + e.mu.Lock() + e.sink = func(f codexFrame) { frames <- f } + e.mu.Unlock() + + if err := e.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + + m, _ := newTestModel(t, "") + m.backend = backendCodex + deadline := time.After(30 * time.Second) + for { + select { + case f := <-frames: + m.handleCodexEvent(f) + if f.Method != codexModelsMethod { + continue + } + items := m.modelItems() + if len(items) == 0 { + t.Fatal("the model frame arrived but the picker is empty") + } + for _, it := range items { + t.Logf("model row: %s — %s", it.title, it.subtitle) + switch it.id { + case "opus", "sonnet", "haiku": + t.Errorf("codex picker offers claude's %q", it.id) + case "": + t.Error("a row with no id cannot be selected") + } + } + return + case <-deadline: + t.Fatal("no model list within the deadline") + } + } +} diff --git a/codexsession.go b/codexsession.go index c82c001..2be9d44 100644 --- a/codexsession.go +++ b/codexsession.go @@ -26,7 +26,47 @@ func (e *codexEngine) Initialize() error { if err := e.notify("initialized", map[string]any{}); err != nil { return err } - return e.openThread() + if err := e.openThread(); err != nil { + return err + } + // The model list is a request, not part of the stream, so it cannot reach + // the UI the way claude's does (its list rides the initialize reply, which + // the adapter already sees). Fetch it and forward it as a frame, so the + // picker is populated by the same path either way. Non-blocking: a session + // works without it, and `fire` surfaces a failure rather than hiding it. + return e.fire("model/list", map[string]any{}, e.emitModels) +} + +// emitModels turns a model/list reply into the frame the adapter consumes. +// Hidden entries are dropped: codex marks superseded models that way, and the +// picker should offer what the CLI itself would offer. +func (e *codexEngine) emitModels(res json.RawMessage) { + var out struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Hidden bool `json:"hidden"` + } `json:"data"` + } + if json.Unmarshal(res, &out) != nil { + return + } + models := make([]ModelChoice, 0, len(out.Data)) + for _, m := range out.Data { + if m.Hidden || m.ID == "" { + continue + } + models = append(models, ModelChoice{Value: m.ID, DisplayName: m.DisplayName, Description: m.Description}) + } + if len(models) == 0 { + return + } + b, err := json.Marshal(models) + if err != nil { + return + } + e.emit(codexFrame{Method: codexModelsMethod, Params: b}) } // openThread starts a fresh thread, or resumes one when main was given an id. diff --git a/codexstream.go b/codexstream.go index c5ca604..554d895 100644 --- a/codexstream.go +++ b/codexstream.go @@ -33,6 +33,11 @@ func (m *model) handleCodexEvent(f codexFrame) { m.codexItem(f, false) case "thread/tokenUsage/updated": m.noteCodexTokens(f) + case codexModelsMethod: + var models []ModelChoice + if json.Unmarshal(f.Params, &models) == nil && len(models) > 0 { + m.models = models + } case "error", codexErrorMethod: var p struct { Message string `json:"message"` diff --git a/commandlist.go b/commandlist.go index 088db0c..0c3e0a1 100644 --- a/commandlist.go +++ b/commandlist.go @@ -203,7 +203,7 @@ func slashCommands() []slashCmd { desc: "resume a previous session", exec: func(m *model, _ string) (model, tea.Cmd) { cwd, _ := os.Getwd() - m.picker = newPicker("sessions", "RESUME SESSION", sessionItems(m.sessions, cwd), m.w, m.h) + m.picker = newPicker("sessions", "RESUME SESSION", sessionItems(m.sessions, cwd, m.backend), m.w, m.h) return *m, nil }, }, diff --git a/help.go b/help.go index 485379f..7a4ff02 100644 --- a/help.go +++ b/help.go @@ -16,7 +16,7 @@ import ( // helpModalView is the boxed, centered version of the help text. Rendered by // View() through lipgloss.Place so it looks like a floating modal. -func helpModalView(termW, termH int) string { +func helpModalView(termW, termH int, backend string) string { w := termW - 8 if w < 48 { w = 48 @@ -24,7 +24,7 @@ func helpModalView(termW, termH int) string { if w > 78 { w = 78 } - body := dTitle.Render(" HELP ") + "\n" + helpText() + "\n" + + body := dTitle.Render(" HELP ") + "\n" + helpText(backend) + "\n" + cDim.Render(" [esc / ?] close") box := lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). @@ -35,13 +35,20 @@ func helpModalView(termW, termH int) string { } // helpText is what /help prints into the transcript. -func helpText() string { +func helpText(backend string) string { cmds := slashCommands() sort.SliceStable(cmds, func(a, b int) bool { return cmds[a].name < cmds[b].name }) var b strings.Builder b.WriteString("keybindings:\n") b.WriteString(" enter send · alt+enter / ctrl+j / \\↵ insert a line break\n") - b.WriteString(" @ inline file picker — inserts @path (claude expands it to file contents)\n") + // Only claude injects the file's contents for an @path; codex receives the + // text and has to read the file itself. Verified by probing both. Saying so + // matters because the two look identical while typing. + if backend == backendCodex { + b.WriteString(" @ inline file picker — inserts @path (codex reads the file itself)\n") + } else { + b.WriteString(" @ inline file picker — inserts @path (claude expands it to file contents)\n") + } b.WriteString(" shift+tab cycle mode (plan → ask → build)\n") b.WriteString(" ctrl+r resume a session\n") b.WriteString(" ctrl+t slash command palette\n") @@ -57,6 +64,6 @@ func helpText() string { for _, c := range cmds { b.WriteString(fmt.Sprintf(" /%-10s %s\n", c.name, c.desc)) } - b.WriteString(" any other /command is forwarded to claude (custom & plugin commands)\n") + b.WriteString(" any other /command is forwarded to " + agentName(backend) + " (custom & plugin commands)\n") return strings.TrimRight(b.String(), "\n") } diff --git a/input_test.go b/input_test.go index ee13ffd..a26b674 100644 --- a/input_test.go +++ b/input_test.go @@ -14,8 +14,8 @@ import ( func inputModel(val string) model { m := model{w: 50, h: 20, ready: true} - m.input = newPromptArea() // same config as the real app (keymap, prompt, …) - m.setPromptWidth(40) // inner wrap width 38 (prompt "› " is 2 cells) + m.input = newPromptArea(backendClaude) // same config as the real app (keymap, prompt, …) + m.setPromptWidth(40) // inner wrap width 38 (prompt "› " is 2 cells) m.input.SetValue(val) m.input.Focus() return m diff --git a/jump_test.go b/jump_test.go index 4ea106b..44e23da 100644 --- a/jump_test.go +++ b/jump_test.go @@ -104,7 +104,7 @@ func TestJumpPromptStopsAtTop(t *testing.T) { func TestJumpPromptEmpty(t *testing.T) { m := model{w: 60, h: 24, ready: true, follow: true} m.vp = newTranscriptViewport(58, 6) - m.input = newPromptArea() + m.input = newPromptArea(backendClaude) m.jumpPrompt(-1) if _, _, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyShiftUp}); !handled { t.Error("shift+up should be consumed by the jump handler") diff --git a/keys.go b/keys.go index f10136f..9a4b1a2 100644 --- a/keys.go +++ b/keys.go @@ -131,7 +131,7 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { switch msg.String() { case "ctrl+r": cwd, _ := os.Getwd() - m.picker = newPicker("sessions", "RESUME SESSION", sessionItems(m.sessions, cwd), m.w, m.h) + m.picker = newPicker("sessions", "RESUME SESSION", sessionItems(m.sessions, cwd, m.backend), m.w, m.h) return m, nil, true case "ctrl+t": m.picker = newPicker("slash", "COMMANDS", m.paletteItems(), m.w, m.h) @@ -350,7 +350,7 @@ func (m *model) sendTurn(text string) tea.Cmd { // doesn't overwrite it with a follow-up correction. if !steering && m.session != "" { cwd, _ := os.Getwd() - m.sessions.Touch(m.session, m.modelID, cwd, truncFirst(text), time.Now()) + m.sessions.Touch(m.session, m.modelID, cwd, truncFirst(text), m.backend, time.Now()) } m.busy = true return m.armSpinnerIfNeeded() diff --git a/launch.go b/launch.go index af08163..f9a650a 100644 --- a/launch.go +++ b/launch.go @@ -18,6 +18,7 @@ package main // questions. type launchConfig struct { Engine Engine + Backend string // claude | codex; names the agent on screen (agentname.go) Approvals *Approvals // nil when nothing is gated (bypass mode) Mode string // ask | plan | build | bypass Spinner string // throbber style id diff --git a/main.go b/main.go index 6fcd919..54cb6b0 100644 --- a/main.go +++ b/main.go @@ -133,6 +133,7 @@ func main() { m := newModel(launchConfig{ Engine: engine, + Backend: *backend, Approvals: approvals, Mode: *mode, Spinner: *spin, diff --git a/model.go b/model.go index 317044a..1b9f896 100644 --- a/model.go +++ b/model.go @@ -60,6 +60,7 @@ type bodyKey struct { // external services up top, modal flags, widgets, then session/turn state. type model struct { engine Engine + backend string // which agent CLI is being driven; see agentname.go approvals *Approvals md *glamour.TermRenderer hist *history @@ -195,9 +196,9 @@ type model struct { // its config so tests exercise the real keymap: Enter is reserved for sending // (handled in handleEnter), so the textarea's newline binding is rebound to // alt+enter / ctrl+j (plus a trailing "\" + enter — see handleEnter). -func newPromptArea() textarea.Model { +func newPromptArea(backend string) textarea.Model { ta := textarea.New() - ta.Placeholder = "Ask Claude… (enter sends · alt+enter / ctrl+j / \\↵ for a new line)" + ta.Placeholder = promptPlaceholder(backend) ta.Prompt = "› " ta.CharLimit = 0 // MaxHeight caps the *content*, not the display: bubbles defaults it to 99 @@ -219,7 +220,7 @@ func (m *model) setPromptWidth(w int) { } func newModel(cfg launchConfig) model { - ta := newPromptArea() + ta := newPromptArea(cfg.Backend) ta.Focus() sp := spinner.New() @@ -233,7 +234,7 @@ func newModel(cfg launchConfig) model { st := loadSettings() applyTheme(st.Theme) // re-skin all styles to the persisted theme before first paint m := model{ - engine: cfg.Engine, approvals: cfg.Approvals, + engine: cfg.Engine, backend: cfg.Backend, approvals: cfg.Approvals, hist: openHistory(), sessions: openSessionStore(), input: ta, sp: sp, diff --git a/models.go b/models.go index 5f96303..5c0c10b 100644 --- a/models.go +++ b/models.go @@ -15,7 +15,7 @@ import ( // falls back to the three standard aliases. func (m *model) modelItems() []pickerItem { if len(m.models) == 0 { - return fallbackModelItems() + return fallbackModelItems(m.backend) } items := make([]pickerItem, 0, len(m.models)) for _, mc := range m.models { @@ -24,10 +24,20 @@ func (m *model) modelItems() []pickerItem { return items } -// fallbackModelItems is the static list used before the initialize handshake -// replies (or if it never does). Aliases, so they resolve to whatever the -// subscription's current generation maps to. -func fallbackModelItems() []pickerItem { +// fallbackModelItems is the static list used before the live list arrives (or +// if it never does). +// +// Per backend, because a backend's aliases are meaningless to another one: +// offering "opus" on codex is not a harmless default, it is a row that cannot +// work. codex has no aliases of its own — its models are named outright by +// model/list — so it gets a row that says the list has not arrived rather than +// an invented one. +func fallbackModelItems(backend string) []pickerItem { + if backend == backendCodex { + return []pickerItem{ + {id: "", title: "(model list unavailable)", subtitle: "codex has not reported its models yet"}, + } + } return []pickerItem{ {id: "opus", title: "opus", subtitle: "most capable — deep reasoning, big refactors"}, {id: "sonnet", title: "sonnet", subtitle: "balanced — the everyday default"}, diff --git a/render.go b/render.go index 557f15f..28053c7 100644 --- a/render.go +++ b/render.go @@ -89,7 +89,7 @@ func (m *model) renderEntry(e entry) string { body = strings.TrimRight(out, "\n") } } - return cName.Render(ornBullet+" "+studly("claude")) + "\n" + body + return cName.Render(ornBullet+" "+studly(agentName(m.backend))) + "\n" + body case entThinking: // Extended thinking: dim + italic so it reads as the model's scratch work, // visually subordinate to the actual reply. diff --git a/sessions.go b/sessions.go index 58d216e..89dbc2f 100644 --- a/sessions.go +++ b/sessions.go @@ -22,6 +22,19 @@ type sessionInfo struct { Cwd string `json:"cwd"` LastUsed time.Time `json:"last_used"` First string `json:"first,omitempty"` // truncated first user prompt, if known + // Backend is which agent CLI owns this id. Empty means claude: every record + // written before cathode had a second backend is one of its sessions, so the + // zero value is the right default and no migration is needed. + Backend string `json:"backend,omitempty"` +} + +// sessionBackend normalises a stored value. Kept as one function because the +// empty-means-claude rule is read in two places and must not drift. +func sessionBackend(v string) string { + if v == "" { + return backendClaude + } + return v } // sessionStore is the on-disk session index, persisted as JSONL at @@ -67,7 +80,7 @@ func (s *sessionStore) load() { // Touch upserts a session. Empty model/cwd/first don't overwrite existing // values (so a follow-up Touch carrying only LastUsed preserves prior // metadata). LastUsed is always bumped. -func (s *sessionStore) Touch(id, model, cwd, first string, now time.Time) { +func (s *sessionStore) Touch(id, model, cwd, first, backend string, now time.Time) { if id == "" { return } @@ -75,6 +88,9 @@ func (s *sessionStore) Touch(id, model, cwd, first string, now time.Time) { defer s.mu.Unlock() cur := s.entries[id] cur.ID = id + if backend != "" { + cur.Backend = backend + } if model != "" { cur.Model = model } diff --git a/sessions_test.go b/sessions_test.go index 85a2878..8d7c97a 100644 --- a/sessions_test.go +++ b/sessions_test.go @@ -23,7 +23,7 @@ func newTestStore(t *testing.T) *sessionStore { func TestSessionTouchUpsertsAndBumps(t *testing.T) { s := newTestStore(t) t0 := time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC) - s.Touch("abc123", "sonnet", "/work/repo", "fix the bug", t0) + s.Touch("abc123", "sonnet", "/work/repo", "fix the bug", backendClaude, t0) got := s.entries["abc123"] if got.Model != "sonnet" || got.Cwd != "/work/repo" || got.First != "fix the bug" { @@ -32,7 +32,7 @@ func TestSessionTouchUpsertsAndBumps(t *testing.T) { // Empty-field follow-up: must not overwrite existing values, must bump time. t1 := t0.Add(5 * time.Minute) - s.Touch("abc123", "", "", "", t1) + s.Touch("abc123", "", "", "", backendClaude, t1) got = s.entries["abc123"] if got.Model != "sonnet" || got.Cwd != "/work/repo" || got.First != "fix the bug" { t.Fatalf("follow-up Touch clobbered metadata: %+v", got) @@ -45,9 +45,9 @@ func TestSessionTouchUpsertsAndBumps(t *testing.T) { // TestSessionAllSortsByRecency pins that the picker gets newest-first ordering. func TestSessionAllSortsByRecency(t *testing.T) { s := newTestStore(t) - s.Touch("older", "m", "/a", "p1", time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) - s.Touch("middle", "m", "/b", "p2", time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) - s.Touch("newest", "m", "/c", "p3", time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + s.Touch("older", "m", "/a", "p1", backendClaude, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + s.Touch("middle", "m", "/b", "p2", backendClaude, time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) + s.Touch("newest", "m", "/c", "p3", backendClaude, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) got := s.All() if len(got) != 3 { @@ -64,12 +64,12 @@ func TestSessionAllSortsByRecency(t *testing.T) { func TestSessionItemsFiltersByCwd(t *testing.T) { s := newTestStore(t) t0 := time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC) - s.Touch("here1", "m", "/work/repoA", "p", t0) - s.Touch("here2", "m", "/work/repoA/", "p", t0) // trailing slash → same after Clean - s.Touch("other", "m", "/work/repoB", "p", t0) - s.Touch("legacy", "m", "", "p", t0) // pre-cwd entry + s.Touch("here1", "m", "/work/repoA", "p", backendClaude, t0) + s.Touch("here2", "m", "/work/repoA/", "p", backendClaude, t0) // trailing slash → same after Clean + s.Touch("other", "m", "/work/repoB", "p", backendClaude, t0) + s.Touch("legacy", "m", "", "p", backendClaude, t0) // pre-cwd entry - items := sessionItems(s, "/work/repoA") + items := sessionItems(s, "/work/repoA", backendClaude) if len(items) != 2 { t.Fatalf("len=%d, want 2 (here1, here2); got %+v", len(items), items) } @@ -85,7 +85,7 @@ func TestSessionItemsFiltersByCwd(t *testing.T) { } // Empty cwd disables the filter — used by tests that don't care about it. - if len(sessionItems(s, "")) != 4 { + if len(sessionItems(s, "", backendClaude)) != 4 { t.Fatalf("empty-cwd filter should return all entries") } } @@ -141,3 +141,41 @@ func sameArgv(a, b []string) bool { } return true } + +// The picker must only offer sessions this backend can actually resume. +// +// Handing a claude session id to codex is not a cosmetic mismatch: thread/resume +// has never seen that id and the handshake fails. Records written before cathode +// had a second backend carry no backend at all, and those are claude's. +func TestSessionItemsFilterByBackend(t *testing.T) { + s := newTestStore(t) + const cwd = "/work/repo" + t0 := time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + s.Touch("claude-one", "sonnet", cwd, "p", backendClaude, t0) + s.Touch("codex-one", "gpt-x", cwd, "p", backendCodex, t0) + s.Touch("legacy", "sonnet", cwd, "p", "", t0) // pre-dates the field + + ids := func(backend string) map[string]bool { + out := map[string]bool{} + for _, it := range sessionItems(s, cwd, backend) { + out[it.id] = true + } + return out + } + + c := ids(backendClaude) + if !c["claude-one"] || !c["legacy"] { + t.Errorf("claude picker = %v, want its own session and the untagged one", c) + } + if c["codex-one"] { + t.Error("claude picker offers a codex thread") + } + + x := ids(backendCodex) + if !x["codex-one"] { + t.Errorf("codex picker = %v, want its own thread", x) + } + if x["claude-one"] || x["legacy"] { + t.Errorf("codex picker offers claude sessions: %v", x) + } +} diff --git a/stream.go b/stream.go index a87743a..d6dac69 100644 --- a/stream.go +++ b/stream.go @@ -19,7 +19,7 @@ func (m *model) handleEvent(e Envelope) { case "init": m.session, m.modelID = e.Session, e.Model cwd, _ := os.Getwd() - m.sessions.Touch(e.Session, e.Model, cwd, "", time.Now()) + m.sessions.Touch(e.Session, e.Model, cwd, "", m.backend, time.Now()) // The server list only arrives here (not in the initialize handshake), // so cache it for the /mcp picker; keep the last non-empty snapshot the // way commands/agents are handled. diff --git a/view.go b/view.go index 41115b0..465d066 100644 --- a/view.go +++ b/view.go @@ -26,7 +26,7 @@ func (m model) View() string { return placeOverlay(bg, m.picker.View(), m.w, m.h) } if m.help { - return placeOverlay(bg, helpModalView(m.w, m.h), m.w, m.h) + return placeOverlay(bg, helpModalView(m.w, m.h, m.backend), m.w, m.h) } // The inline @-file menu floats just above the prompt rather than centered: // its last row sits on the line above the prompt (banner+divider+viewport is @@ -52,7 +52,7 @@ func (m model) renderBackground() string { prompt := m.input.View() if m.pending != nil { prompt = approveBar.Render(fmt.Sprintf(" ►◄ %s %s [ENTER] %s (default) [ESC] %s ◄► ", - studly("claude wants:"), strings.ToUpper(m.pending.toolName), + studly(agentName(m.backend)+" wants:"), strings.ToUpper(m.pending.toolName), leet("ALLOW"), leet("DENY"))) } @@ -68,7 +68,7 @@ func (m model) renderBackground() string { var parts []string if m.topChromeRows() > 0 { parts = append(parts, - bbsBanner(m.w, m.colorPhase, m.headerStyle), + bbsBanner(m.w, m.colorPhase, m.headerStyle, m.backend), sceneDivider(leet("session"), m.w), ) }