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), ) }