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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions agentname.go
Original file line number Diff line number Diff line change
@@ -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)"
}
83 changes: 83 additions & 0 deletions agentname_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ 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
}
title := fmt.Sprintf("%s %s %s %s",
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)
}

Expand Down
15 changes: 13 additions & 2 deletions claude_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions claude_sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions codexcalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions codexlive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
42 changes: 41 additions & 1 deletion codexsession.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions codexstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion commandlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
Expand Down
17 changes: 12 additions & 5 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ 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
}
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()).
Expand All @@ -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")
Expand All @@ -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")
}
4 changes: 2 additions & 2 deletions input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion jump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading