Vectrum
Sign In

Merge master: one model list, one effort list, one way to apply a setting

master grew /model, /effort and a slash-command menu while this branch was
building the setup wizard, and the two arrived at the same problems
independently. Merging them meant deleting the loser of each pair rather
than keeping both.

llm: master's ListModels wins — it paginates, and its Model carries the
context window. Mine is dropped, along with the setHeaders it added
(newRequest does that job for every route) and PriceLabel (PriceOf, with
basePrice, already reads dated and bracketed ids that mine missed).
PrettyModel goes back to being prettyModel in tui, where its one caller
is. What llm gains is PricedModels: the ids it can price, which is the
list the wizard falls back to.

The two front ends now describe a model through the same helpers, so the
wizard and the picker cannot disagree about what a model is called or
what it costs. They still differ where they should: a catalogue that will
not answer is an error the picker reports and closes on, because there is
a session behind it, and a fallback the wizard has to have, because on a
first run there is nothing behind it.

config drops its copy of the effort levels for llm.ValidEffort. Two lists
of the same five strings is one list that will be wrong.

Agent.SetEffort was added by both; master's is mutex-guarded and comes
with Effort(), so mine goes.

Applying the wizard's answers moves out of main.go and into the TUI,
through the same setters /model and /effort use — useModel and useEffort,
split out of applyModel and applyEffort so the wizard can set four things
and report them in one line instead of three. main.go is left with
SaveConfig, which is the only part of this it has any business knowing
about.

/setup joins the builtins catalogue, so it is in /help and the completion
menu rather than being a command you had to already know. The effort pill
points at /effort again, now that /effort exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 2 parents 2e61c4b 52abb14commit57899a9

Showing 31 changed files with 2479 additions and 479 deletions.

Merge commit — showing changes against the first parent.

+105−44README.mdView File
@@ -24,9 +24,10 @@ API:
2424 # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
2525 ```
2626
27- Flags override the environment. `-auth-token` sends `Authorization: Bearer`,
28- which is what OAuth tokens and most gateways want; `-api-key` sends
29- `x-api-key`. A non-default endpoint announces itself on stderr, because your
27+ Flags beat the saved config, which beats the built-in defaults; the config is
28+ what [the setup wizard](#setup) writes on a first run. `-auth-token` sends
29+ `Authorization: Bearer`, which is what OAuth tokens and most gateways want;
30+ `-api-key` sends `x-api-key`. A non-default endpoint announces itself on stderr, because your
3031 prompt and every file the agent reads go wherever that URL points.
3132
3233 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
@@ -72,6 +73,7 @@ to six lines.
7273 | Key | Does |
7374 | --- | --- |
7475 | `enter` | send |
76+ | `/` | open the command menu — see below |
7577 | `ctrl+j` | newline without sending |
7678 | `shift+tab` | cycle the approval mode |
7779 | `ctrl+r` | expand elided tool output |
@@ -79,7 +81,71 @@ to six lines.
7981 | `esc` | interrupt the turn |
8082 | `ctrl+c` | interrupt the turn; again when idle to exit |
8183
82- Commands: `/allowed`, `/clear`, `/help`, `/setup`, `/skills`, `/exit`.
84+ Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
85+ `/setup`, `/skills`, and one per discovered skill.
86+
87+ A slash at the start of an empty prompt opens the command menu, which filters
88+ as you type — a name that starts with what you typed before one that merely
89+ contains it:
90+
91+ ```
92+ ╭──────────────────────────────────────────────────────────────────────────────╮
93+ │ › /s │
94+ ╰──────────────────────────────────────────────────────────────────────────────╯
95+ ❯ /skills the instruction sets found at startup
96+ /systematic-debugging find the cause before changing code (project)
97+ /brainstorming explore an idea before building it (personal)
98+ ```
99+
100+ `↑`/`↓` move, `tab` or `enter` completes the highlighted name into the prompt —
101+ completing is not sending, so there is room to type arguments for a skill or a
102+ level for `/effort` — and `esc` dismisses the menu without touching what you
103+ typed. The menu offers exactly the commands the TUI answers, so nothing it
104+ suggests can come back as "unknown command".
105+
106+ `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and
107+ opens a picker on the answer:
108+
109+ ```
110+ Select model
111+ from anthropic · ↑↓ move · enter apply · esc cancel
112+ 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok
113+ 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓
114+ ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok
115+ 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok
116+ ```
117+
118+ The cursor starts on the model in force, which is also the one marked. The id,
119+ the context window and the list price come from the endpoint and from the
120+ pricing table; a model neither knows about simply says less. The switch applies
121+ from the next turn — the conversation so far is replayed to the new model — and
122+ costs already incurred stay priced at the model that incurred them, because a
123+ session that switched models was not all billed at the model it ended on.
124+
125+ Not every endpoint serves that route. When the fetch fails the picker says so,
126+ names the host and the reason, and closes; `/model <id>` and the `-model` flag
127+ still switch without asking anyone's permission.
128+
129+ `/effort` opens a slider in place of the prompt, on the level in force — an
130+ unset level is the API's own default, `high`, so the marker starts there rather
131+ than at an end:
132+
133+ ```
134+ Faster Smarter
135+ ──────────────────────────────────────▲───────────────────────────────────
136+ low medium high xhigh max
137+ ←→ move · 1-5 jump · enter apply · esc cancel
138+ ```
139+
140+ Moving the marker changes nothing until `enter`; `esc` leaves the level alone.
141+ `/effort xhigh` still applies a level directly for anyone who can type it, and
142+ either way it is the `-effort` flag reached from the keyboard. The level in
143+ force shows above the prompt on the right. A terminal too narrow for five
144+ labels gets `◀ high ▶` and the same keys rather than a track with the words
145+ sliced in half.
146+
147+ `/model` and `/effort` change the session. `/setup` changes what the next
148+ session starts with — see [Setup](#setup).
83149
84150 `shift+tab` cycles between asking before every mutating call and accepting them
85151 all, which is `-yolo` reached from the keyboard. The mode line says which is in
@@ -108,32 +174,34 @@ mark assembles, the name lights up, and it asks four questions one at a time.
108174
109175 Which model should the agent use?
110176
111- ▌ ◆ Opus 5 $5 / $25 per Mtok
112- ◇ Sonnet 5 $2 / $10 per Mtok
113- ◇ Haiku 4.5 $1 / $5 per Mtok
177+ ▌ ◆ Claude Opus 5 $5 / $25 per Mtok
178+ ◇ Claude Sonnet 5 $2 / $10 per Mtok
179+ ◇ Claude Haiku 4.5 $1 / $5 per Mtok
114180 ◇ custom…
115181
116- claude-opus-5
182+ claude-opus-5 · 1.0M token context
117183
118184 ↑↓ move enter next esc back ctrl+c cancel
119185 ```
120186
121187 | Question | Answers |
122188 | --- | --- |
123- | Model | whatever `GET /v1/models` reports, priced from what this build knows, plus `custom…` for any id |
124- | Effort | `low` `medium` `high` `xhigh` `max` |
189+ | Model | whatever `GET /v1/models` returns, plus `custom…` for any id |
190+ | Effort | the API's own `llm.EffortLevels`: `low` `medium` `high` `xhigh` `max` |
125191 | Approvals | ask before every mutating call, or accept them all |
126192 | Theme | `ember` `mint` `iris` `mono` |
127193
128- The model list is fetched when the wizard opens. Against a gateway that does
129- not serve a catalogue — the common case — it falls back to the models this
130- build can price and says so rather than pretending the short list is the whole
131- truth. `custom…` is there because a gateway may serve something no build has
194+ It is the same catalogue and the same pricing table `/model` uses, through the
195+ same `GET /v1/models` call, so the two cannot disagree about what a model is
196+ called or what it costs. Where they differ is failure: `/model` reports the
197+ error and closes, because there is a session to go back to. The wizard cannot
198+ do that on a first run, so it falls back to the models this build can price and
199+ says so. `custom…` is there because a gateway may serve something no build has
132200 heard of.
133201
134- The theme step previews by applying the palette for real: reading a swatch
135- tells you nothing about what a transcript looks like in it. Cancelling puts the
136- old one back.
202+ The theme step previews by applying the palette for real — reading a swatch
203+ tells you nothing about what a transcript looks like in it — and it recolours
204+ the whole TUI, pickers and slider included. Cancelling puts the old one back.
137205
138206 Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is
139207 honoured):
@@ -153,33 +221,26 @@ precedence is **flag, then config, then the built-in default**, so
153221 `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a
154222 default is honoured, but `-yolo` can only turn approvals off, never on — a file
155223 should not be able to quietly grant the agent more than the command line did.
156-
157- `/setup` reopens the wizard at any time and applies the answers to the running
158- session. It is refused mid-turn: changing the model while a turn is in flight
159- would finish it on a different model than it started on. A config file that
160- cannot be parsed is reported and then ignored, and is never overwritten until
161- you finish the wizard.
162-
163- The whole thing is one screen at a time, `↑↓` and `enter`, `esc` to go back.
164- Animations collapse to their final frame under `NO_COLOR`, a `dumb` terminal,
165- or a window under 60×20, and any key skips the one playing.
166-
167- ### Driving it without an API key
168-
169- `internal/tui/testdata/mockapi` is a fake Messages API that streams a canned
170- reply and asks to run a shell command, which is enough to exercise streaming,
171- tool rendering, approve, deny and interrupt for nothing:
172-
173- ```bash
174- go run ./internal/tui/testdata/mockapi &
175- mkdir -p /tmp/demo && git -C /tmp/demo init -q
176- go build -o harness ./cmd/harness
177- ./harness -C /tmp/demo -base-url http://127.0.0.1:8899 -api-key mock
178- ```
179-
180- Type anything, and the approval prompt appears. `-slow 150ms` stretches the
181- stream out if you want to watch the spinner or test `ctrl+c` mid-turn. It sits
182- under `testdata` so `./...` ignores it.
224+ A value this build does not recognise is dropped rather than forwarded to the
225+ API, and effort is checked against `llm.EffortLevels` rather than a second copy
226+ of the same five strings.
227+
228+ `/setup` reopens the wizard at any time. Finishing applies the answers through
229+ the same setters `/model` and `/effort` use, so a setting chosen in the wizard
230+ and the same setting chosen from the prompt cannot come out different — the
231+ wizard just reports them in one line instead of three. It is refused mid-turn:
232+ changing the model while a turn is in flight would finish it on a different
233+ model than it started. A config file that cannot be parsed is reported and then
234+ ignored, and is never overwritten until you finish the wizard.
235+
236+ `/model` and `/effort` change the running session and say "from the next turn
237+ on"; they do not write to the file. The wizard is the thing that decides what
238+ the *next* session starts with, which is why it is a separate screen rather
239+ than a fourth picker.
240+
241+ The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to
242+ their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20,
243+ and any key skips the one playing.
183244
184245 ## Tools
185246
+7−30cmd/harness/main.goView File
@@ -91,7 +91,7 @@ func run() error {
9191 }
9292 defer log.Close()
9393 log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
94- "root": root, "model": client.Model, "base_url": client.BaseURL,
94+ "root": root, "model": client.Model(), "base_url": client.BaseURL,
9595 }})
9696
9797 // The TUI takes over the screen, so anything worth saying before the loop
@@ -212,7 +212,9 @@ func run() error {
212212 // the next one should default to.
213213 Config: cfg,
214214 FirstRun: !haveConfig && cfgErr == nil,
215- OnConfig: applyConfig(a, client, policy),
215+ // Applying the answers is the TUI's own job; persisting them is
216+ // the only part that belongs out here.
217+ SaveConfig: config.Save,
216218 })
217219 }
218220
@@ -224,7 +226,7 @@ func run() error {
224226 }
225227
226228 fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
227- client.Model, root, strings.Join(toolNames(reg), ", "))
229+ client.Model(), root, strings.Join(toolNames(reg), ", "))
228230
229231 for {
230232 fmt.Print("> ")
@@ -255,31 +257,6 @@ func run() error {
255257 }
256258 }
257259
258- // applyConfig makes a finished wizard's answers true for the running process
259- // and then writes them down, returning the one line the transcript should say
260- // about it.
261- //
262- // Applying before saving is deliberate: a config directory that cannot be
263- // written should still leave you with the session you just asked for, and a
264- // warning rather than a wizard that silently did nothing.
265- func applyConfig(a *agent.Agent, client *llm.Client, policy *approval.Policy) func(config.Config) string {
266- return func(cfg config.Config) string {
267- client.Model = cfg.Model
268- a.SetEffort(cfg.Effort)
269- policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll
270-
271- if err := config.Save(cfg); err != nil {
272- return fmt.Sprintf("settings are in force for this session, but saving them failed: %v", err)
273- }
274- note := fmt.Sprintf("setup saved — %s · effort %s · %s · %s theme",
275- llm.PrettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme)
276- if policy.YOLO {
277- note += "\naccept-all is on — tool calls run without asking."
278- }
279- return note
280- }
281- }
282-
283260 // once runs a single prompt, prints the session totals, and saves the session.
284261 func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
285262 turnCtx, endTurn := beginTurn(ctx)
@@ -288,9 +265,9 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir,
288265
289266 render.Flush()
290267 usage, turns := a.State().Usage()
291- render.Usage(usage, turns, client.Model)
268+ render.Usage(usage, turns, client.Model())
292269
293- if err := a.Save(sessionDir, id, root, client.Model); err != nil {
270+ if err := a.Save(sessionDir, id, root, client.Model()); err != nil {
294271 fmt.Fprintln(os.Stderr, "harness: saving session:", err)
295272 }
296273 if errors.Is(runErr, context.Canceled) {
+2−2evals/runner.goView File
@@ -97,7 +97,7 @@ func run() error {
9797 if err := os.MkdirAll(runRoot, 0o755); err != nil {
9898 return err
9999 }
100- fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model, runRoot)
100+ fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model(), runRoot)
101101
102102 type job struct {
103103 task Task
@@ -163,7 +163,7 @@ func run() error {
163163 // The return value is named so the deferred timing assignment lands in the
164164 // value actually returned rather than in a copy already made.
165165 func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) {
166- res = Result{Task: task.Name, Run: runNo, Model: client.Model}
166+ res = Result{Task: task.Name, Run: runNo, Model: client.Model()}
167167 start := time.Now()
168168 defer func() { res.WallMS = time.Since(start).Milliseconds() }()
169169
+22−11internal/agent/loop.goView File
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "strings"
9+ "sync"
910
1011 "harness/internal/llm"
1112 "harness/internal/metrics"
@@ -126,6 +127,25 @@ type Agent struct {
126127 // lastInputTokens is the size of the previous request as the API measured
127128 // it, cached and uncached parts together.
128129 lastInputTokens int
130+
131+ // mu guards the settings a front end can change while the loop is
132+ // running. Only effort is one today; the loop reads it once per request.
133+ mu sync.Mutex
134+ }
135+
136+ // SetEffort changes the effort level the turns that follow will ask for. An
137+ // empty level means the API's own default.
138+ func (a *Agent) SetEffort(level string) {
139+ a.mu.Lock()
140+ defer a.mu.Unlock()
141+ a.opts.Effort = level
142+ }
143+
144+ // Effort reports the level currently in force.
145+ func (a *Agent) Effort() string {
146+ a.mu.Lock()
147+ defer a.mu.Unlock()
148+ return a.opts.Effort
129149 }
130150
131151 // New wires an agent. state and log may be freshly created by the caller so the
@@ -144,15 +164,6 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
144164 // State exposes the conversation for callers that need to inspect or persist it.
145165 func (a *Agent) State() *State { return a.state }
146166
147- // SetEffort changes the reasoning effort for subsequent turns. The setup
148- // wizard needs it because the effort is read when each request is built, so a
149- // value chosen mid-session would otherwise not take until a restart.
150- //
151- // It is safe only between turns: the caller is the Bubble Tea update loop,
152- // which cannot run while a turn is in flight on its own goroutine, and /setup
153- // is refused while one is.
154- func (a *Agent) SetEffort(effort string) { a.opts.Effort = effort }
155-
156167 func (a *Agent) emit(ev Event) {
157168 if a.Observe != nil {
158169 a.Observe(ev)
@@ -329,8 +340,8 @@ func (a *Agent) request() llm.Request {
329340 Tools: defs,
330341 Thinking: llm.Adaptive(),
331342 }
332- if a.opts.Effort != "" {
333- req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort}
343+ if effort := a.Effort(); effort != "" {
344+ req.OutputConfig = &llm.OutputConfig{Effort: effort}
334345 }
335346 return req
336347 }
+26−0internal/agent/loop_test.goView File
@@ -527,3 +527,29 @@ func TestSystemPromptIsStableAcrossRequests(t *testing.T) {
527527 t.Error("the system prompt changed between two requests in one session")
528528 }
529529 }
530+
531+ // Effort is a knob the TUI turns mid-session, so a request carries whatever
532+ // was last set rather than whatever the process started with.
533+ func TestSetEffortAppliesToTheNextRequest(t *testing.T) {
534+ f := newFakeAPI(t, msg("end_turn", textBlock("one")), msg("end_turn", textBlock("two")))
535+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
536+
537+ if _, err := a.Run(context.Background(), "first"); err != nil {
538+ t.Fatalf("Run: %v", err)
539+ }
540+ if oc := f.requests[0].OutputConfig; oc != nil {
541+ t.Errorf("output_config = %+v with no effort set, want it omitted so the API picks", oc)
542+ }
543+
544+ a.SetEffort("low")
545+ if got := a.Effort(); got != "low" {
546+ t.Errorf("Effort() = %q, want low", got)
547+ }
548+ if _, err := a.Run(context.Background(), "second"); err != nil {
549+ t.Fatalf("Run: %v", err)
550+ }
551+ oc := f.requests[1].OutputConfig
552+ if oc == nil || oc.Effort != "low" {
553+ t.Errorf("output_config = %+v, want effort low", oc)
554+ }
555+ }
+6−9internal/config/config.goView File
@@ -13,6 +13,8 @@ import (
1313 "io/fs"
1414 "os"
1515 "path/filepath"
16+
17+ "harness/internal/llm"
1618 )
1719
1820 // Filename is the config's name inside the harness config directory.
@@ -30,10 +32,6 @@ const (
3032 ApprovalsAcceptAll = "accept-all"
3133 )
3234
33- // Efforts are the values the API's output_config.effort accepts, in the order
34- // the wizard offers them.
35- var Efforts = []string{"low", "medium", "high", "xhigh", "max"}
36-
3735 // Themes are the TUI palettes, in the order the wizard offers them. The names
3836 // live here rather than in the tui package so that reading a config never
3937 // depends on a terminal front end being linked in — the eval runner has none.
@@ -47,7 +45,7 @@ type Config struct {
4745 Version int `json:"version"`
4846 // Model is an API model id, not a display name.
4947 Model string `json:"model,omitempty"`
50- // Effort is one of Efforts.
48+ // Effort is one of llm.EffortLevels.
5149 Effort string `json:"effort,omitempty"`
5250 // Approvals is ApprovalsAsk or ApprovalsAcceptAll.
5351 Approvals string `json:"approvals,omitempty"`
@@ -98,7 +96,9 @@ func Load() (Config, bool, error) {
9896 // field rather than be forwarded to the API, which would reject the request
9997 // with an error pointing at nothing the user can see.
10098 func sanitize(c Config) Config {
101- if !ValidEffort(c.Effort) {
99+ // Effort is validated against the API's own list rather than a copy of it
100+ // here: two lists of the same five strings is one list that will be wrong.
101+ if !llm.ValidEffort(c.Effort) {
102102 c.Effort = ""
103103 }
104104 if c.Approvals != ApprovalsAsk && c.Approvals != ApprovalsAcceptAll {
@@ -110,9 +110,6 @@ func sanitize(c Config) Config {
110110 return c
111111 }
112112
113- // ValidEffort reports whether e is one of the levels the API accepts.
114- func ValidEffort(e string) bool { return contains(Efforts, e) }
115-
116113 // ValidTheme reports whether name is a palette this build knows.
117114 func ValidTheme(name string) bool { return contains(Themes, name) }
118115
+13−8internal/config/config_test.goView File
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "harness/internal/llm"
45 "os"
56 "path/filepath"
67 "strings"
@@ -120,15 +121,19 @@ func TestLoadDropsValuesItDoesNotRecognise(t *testing.T) {
120121 }
121122 }
122123
123- func TestValidEffort(t *testing.T) {
124- for _, e := range Efforts {
125- if !ValidEffort(e) {
126- t.Errorf("%q is in Efforts but does not validate", e)
124+ // A level the API would reject must not survive a round trip, however it got
125+ // into the file.
126+ func TestLoadDropsAnEffortTheAPIWouldReject(t *testing.T) {
127+ for _, level := range llm.EffortLevels {
128+ if err := Save(Config{Effort: level}); err != nil {
129+ t.Fatal(err)
127130 }
128- }
129- for _, e := range []string{"", "extreme", "High", "none"} {
130- if ValidEffort(e) {
131- t.Errorf("%q validated", e)
131+ got, _, err := Load()
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ if got.Effort != level {
136+ t.Errorf("%q did not survive a round trip: got %q", level, got.Effort)
132137 }
133138 }
134139 }
+50−21internal/llm/client.goView File
@@ -11,6 +11,7 @@ import (
1111 "os"
1212 "strconv"
1313 "strings"
14+ "sync"
1415 "time"
1516 )
1617
@@ -28,7 +29,6 @@ const (
2829 // Client talks to POST /v1/messages.
2930 type Client struct {
3031 BaseURL string
31- Model string
3232 MaxTokens int
3333 MaxRetries int
3434 // RetryDelay is the base of the exponential backoff between retries.
@@ -37,6 +37,26 @@ type Client struct {
3737
3838 apiKey string
3939 authToken string
40+
41+ // model is behind a lock because a front end can switch it while the
42+ // agent is between turns. It is the one field that moves after
43+ // construction, so it is the one that is not public.
44+ mu sync.Mutex
45+ model string
46+ }
47+
48+ // Model reports the model requests default to.
49+ func (c *Client) Model() string {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ return c.model
53+ }
54+
55+ // SetModel changes the model the requests that follow will ask for.
56+ func (c *Client) SetModel(id string) {
57+ c.mu.Lock()
58+ defer c.mu.Unlock()
59+ c.model = id
4060 }
4161
4262 // Option configures a Client.
@@ -55,7 +75,7 @@ func WithBaseURL(u string) Option {
5575 func WithModel(m string) Option {
5676 return func(c *Client) {
5777 if m != "" {
58- c.Model = m
78+ c.model = m
5979 }
6080 }
6181 }
@@ -89,7 +109,7 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h
89109 func New(opts ...Option) *Client {
90110 c := &Client{
91111 BaseURL: DefaultBaseURL,
92- Model: DefaultModel,
112+ model: DefaultModel,
93113 MaxTokens: DefaultMaxTokens,
94114 MaxRetries: defaultMaxRetries,
95115 RetryDelay: 500 * time.Millisecond,
@@ -122,20 +142,6 @@ func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL }
122142 // HasCredentials reports whether the client can authenticate.
123143 func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" }
124144
125- // setHeaders applies the version and the credential. Every request the client
126- // makes goes through here, so a change to how the harness authenticates cannot
127- // reach one endpoint and miss another.
128- func (c *Client) setHeaders(r *http.Request) {
129- r.Header.Set("anthropic-version", apiVersion)
130- if c.apiKey != "" {
131- r.Header.Set("x-api-key", c.apiKey)
132- return
133- }
134- // OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
135- r.Header.Set("authorization", "Bearer "+c.authToken)
136- r.Header.Set("anthropic-beta", oauthBeta)
137- }
138-
139145 // APIError is a non-2xx response.
140146 type APIError struct {
141147 StatusCode int
@@ -168,7 +174,7 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
168174 return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
169175 }
170176 if req.Model == "" {
171- req.Model = c.Model
177+ req.Model = c.Model()
172178 }
173179 if req.MaxTokens == 0 {
174180 req.MaxTokens = c.MaxTokens
@@ -201,13 +207,36 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
201207 return nil, lastErr
202208 }
203209
210+ // newRequest builds a request to the endpoint with the version and the
211+ // credential already on it. Every call the client makes goes through here, so
212+ // a second route cannot end up authenticating differently from the first.
213+ func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
214+ var rdr io.Reader
215+ if body != nil {
216+ rdr = bytes.NewReader(body)
217+ }
218+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
219+ if err != nil {
220+ return nil, err
221+ }
222+ req.Header.Set("content-type", "application/json")
223+ req.Header.Set("anthropic-version", apiVersion)
224+ switch {
225+ case c.apiKey != "":
226+ req.Header.Set("x-api-key", c.apiKey)
227+ default:
228+ // OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
229+ req.Header.Set("authorization", "Bearer "+c.authToken)
230+ req.Header.Set("anthropic-beta", oauthBeta)
231+ }
232+ return req, nil
233+ }
234+
204235 func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
205- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
236+ httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body)
206237 if err != nil {
207238 return nil, err
208239 }
209- httpReq.Header.Set("content-type", "application/json")
210- c.setHeaders(httpReq)
211240
212241 httpResp, err := c.HTTP.Do(httpReq)
213242 if err != nil {
+32−0internal/llm/client_test.goView File
@@ -3,6 +3,7 @@ package llm
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
67 "io"
78 "net/http"
89 "net/http/httptest"
@@ -284,3 +285,34 @@ func TestCustomBaseURLIsActuallyUsed(t *testing.T) {
284285 t.Errorf("path = %q, want /v1/messages", gotPath)
285286 }
286287 }
288+
289+ // The model is switchable mid-session, so what a request carries is whatever
290+ // was last set rather than what the client was built with.
291+ func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) {
292+ var models []string
293+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
294+ var body map[string]any
295+ raw, _ := io.ReadAll(r.Body)
296+ json.Unmarshal(raw, &body)
297+ models = append(models, fmt.Sprint(body["model"]))
298+ io.WriteString(w, okResponse)
299+ })
300+ WithModel("claude-opus-5")(c)
301+
302+ msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}}
303+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
304+ t.Fatalf("CreateMessage: %v", err)
305+ }
306+
307+ c.SetModel("claude-haiku-4-5")
308+ if got := c.Model(); got != "claude-haiku-4-5" {
309+ t.Errorf("Model() = %q", got)
310+ }
311+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
312+ t.Fatalf("CreateMessage: %v", err)
313+ }
314+
315+ if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want {
316+ t.Errorf("models sent = %v, want %q", models, want)
317+ }
318+ }
+58−91internal/llm/models.goView File
@@ -6,115 +6,82 @@ import (
66 "fmt"
77 "io"
88 "net/http"
9- "sort"
9+ "net/url"
1010 "time"
1111 )
1212
13- // Model is one entry in the catalogue at GET /v1/models.
13+ // modelPageSize is what one GET /v1/models asks for. The list is short enough
14+ // that this is almost always the only request.
15+ const modelPageSize = 100
16+
17+ // maxModelPages stops a paginating endpoint that never says it is done from
18+ // looping forever behind a picker that is waiting on it.
19+ const maxModelPages = 10
20+
21+ // Model is one entry from GET /v1/models. MaxInputTokens is the context
22+ // window; it was added to the API in 2026 and is zero on an endpoint that
23+ // does not report it, which the caller has to treat as "not said" rather than
24+ // as a model with no context.
1425 type Model struct {
15- ID string `json:"id"`
16- DisplayName string `json:"display_name"`
17- Deprecated bool `json:"deprecated"`
26+ ID string `json:"id"`
27+ DisplayName string `json:"display_name"`
28+ CreatedAt time.Time `json:"created_at"`
29+ MaxInputTokens int `json:"max_input_tokens"`
30+ MaxTokens int `json:"max_tokens"`
1831 }
1932
20- // catalogueTimeout is deliberately short. This runs while a person is looking
21- // at a wizard that cannot show them a list yet, and a gateway that black-holes
22- // the request must not hold the setup open for the client's ten-minute
23- // message timeout.
24- const catalogueTimeout = 10 * time.Second
33+ type modelPage struct {
34+ Data []Model `json:"data"`
35+ HasMore bool `json:"has_more"`
36+ LastID string `json:"last_id"`
37+ }
2538
26- // ListModels reads the model catalogue.
39+ // ListModels returns the models the endpoint offers, in the order it gave
40+ // them — which from Anthropic is newest first.
2741 //
28- // It is best-effort by design: plenty of endpoints this harness can be pointed
29- // at speak /v1/messages and nothing else, so every caller is expected to have
30- // a fallback — KnownModels — rather than to treat an error here as fatal.
42+ // Not every endpoint serves this route: a gateway that only proxies
43+ // /v1/messages answers 404, and the error says so rather than being turned
44+ // into an empty list, because "no models" and "this endpoint cannot be asked"
45+ // are different things to tell someone.
3146 func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
3247 if !c.HasCredentials() {
3348 return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
3449 }
35- ctx, cancel := context.WithTimeout(ctx, catalogueTimeout)
36- defer cancel()
37-
38- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/models?limit=100", nil)
39- if err != nil {
40- return nil, err
41- }
42- c.setHeaders(req)
43-
44- resp, err := c.HTTP.Do(req)
45- if err != nil {
46- return nil, fmt.Errorf("llm: listing models: %w", err)
47- }
48- defer resp.Body.Close()
49-
50- // Capped: an endpoint that answers an unknown route with a whole HTML
51- // page should not be read into memory in full to be thrown away.
52- raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
53- if err != nil {
54- return nil, fmt.Errorf("llm: listing models: %w", err)
55- }
56- if resp.StatusCode != http.StatusOK {
57- return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw)
58- }
5950
60- var out struct {
61- Data []Model `json:"data"`
62- }
63- if err := json.Unmarshal(raw, &out); err != nil {
64- return nil, fmt.Errorf("llm: %s did not return a model list: %w", c.BaseURL, err)
65- }
51+ var out []Model
52+ after := ""
53+ for page := 0; page < maxModelPages; page++ {
54+ q := url.Values{"limit": {fmt.Sprint(modelPageSize)}}
55+ if after != "" {
56+ q.Set("after_id", after)
57+ }
6658
67- models := make([]Model, 0, len(out.Data))
68- for _, m := range out.Data {
69- // A deprecated model still answers requests, which is exactly why it
70- // should not be offered as a fresh default.
71- if m.ID == "" || m.Deprecated {
72- continue
59+ req, err := c.newRequest(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
60+ if err != nil {
61+ return nil, err
7362 }
74- if m.DisplayName == "" {
75- m.DisplayName = m.ID
63+ resp, err := c.HTTP.Do(req)
64+ if err != nil {
65+ return nil, fmt.Errorf("llm: %w", err)
7666 }
77- models = append(models, m)
78- }
79- if len(models) == 0 {
80- return nil, fmt.Errorf("llm: %s returned no models", c.BaseURL)
81- }
82- return models, nil
83- }
84-
85- // KnownModels is the fallback catalogue: what this build knows how to price.
86- //
87- // Pricing is the honest basis for the list. A model the harness cannot price
88- // is one whose cost would silently read as blank in the status bar, so the set
89- // it can price is exactly the set it can fully describe.
90- func KnownModels() []Model {
91- out := make([]Model, 0, len(prices))
92- for id := range prices {
93- out = append(out, Model{ID: id, DisplayName: displayName(id)})
94- }
95- // Map iteration is random; the wizard needs the same order every time.
96- sort.Slice(out, func(i, j int) bool {
97- ri, rj := familyRank(out[i].ID), familyRank(out[j].ID)
98- if ri != rj {
99- return ri < rj
67+ raw, err := io.ReadAll(resp.Body)
68+ resp.Body.Close()
69+ if err != nil {
70+ return nil, fmt.Errorf("llm: reading models: %w", err)
71+ }
72+ if resp.StatusCode != http.StatusOK {
73+ return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw)
10074 }
101- return out[i].ID > out[j].ID // newer version first within a family
102- })
103- return out
104- }
10575
106- // familyRank orders the families the way someone choosing between them thinks
107- // about it: the flagship first, the cheap one last.
108- func familyRank(id string) int {
109- for i, fam := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} {
110- if len(id) >= len(fam) && id[:len(fam)] == fam {
111- return i
76+ var p modelPage
77+ if err := json.Unmarshal(raw, &p); err != nil {
78+ return nil, fmt.Errorf("llm: decoding models: %w", err)
11279 }
80+ out = append(out, p.Data...)
81+ if !p.HasMore || p.LastID == "" {
82+ break
83+ }
84+ after = p.LastID
11385 }
114- return 99
86+ return out, nil
11587 }
116-
117- // displayName stands in for the API's display_name on a model we only know
118- // statically. It is derived rather than tabulated: a table would be one more
119- // place to forget to update when a model is added to prices.
120- func displayName(id string) string { return "Claude " + PrettyModel(id) }
+67−80internal/llm/models_test.goView File
@@ -2,113 +2,100 @@ package llm
22
33 import (
44 "context"
5+ "fmt"
56 "net/http"
6- "net/http/httptest"
77 "strings"
88 "testing"
99 )
1010
11- func TestListModelsReadsTheCatalogue(t *testing.T) {
12- var gotPath, gotKey string
13- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14- gotPath, gotKey = r.URL.Path, r.Header.Get("x-api-key")
15- w.Header().Set("content-type", "application/json")
16- w.Write([]byte(`{"data":[
17- {"id":"claude-opus-5","display_name":"Claude Opus 5","type":"model"},
18- {"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","type":"model"}
19- ],"has_more":false}`))
20- }))
21- defer srv.Close()
11+ func TestListModelsReadsEveryPage(t *testing.T) {
12+ var paths []string
13+ var headers http.Header
14+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
15+ headers = r.Header.Clone()
16+ paths = append(paths, r.URL.RequestURI())
17+ if after := r.URL.Query().Get("after_id"); after == "" {
18+ fmt.Fprint(w, `{"data":[
19+ {"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-02-01T00:00:00Z"},
20+ {"type":"model","id":"claude-sonnet-5","display_name":"Claude Sonnet 5","created_at":"2025-09-29T00:00:00Z"}],
21+ "has_more":true,"last_id":"claude-sonnet-5"}`)
22+ return
23+ }
24+ fmt.Fprint(w, `{"data":[
25+ {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","created_at":"2025-10-01T00:00:00Z"}],
26+ "has_more":false}`)
27+ })
2228
23- c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
2429 got, err := c.ListModels(context.Background())
2530 if err != nil {
2631 t.Fatalf("ListModels: %v", err)
2732 }
28- if gotPath != "/v1/models" {
29- t.Errorf("asked %s, want /v1/models", gotPath)
33+
34+ var ids []string
35+ for _, m := range got {
36+ ids = append(ids, m.ID)
3037 }
31- if gotKey != "k" {
32- t.Errorf("the catalogue request was not authenticated: x-api-key = %q", gotKey)
38+ if want := "claude-opus-5 claude-sonnet-5 claude-haiku-4-5-20251001"; strings.Join(ids, " ") != want {
39+ t.Errorf("ids = %v, want %s in the order the API gave them", ids, want)
3340 }
34- if len(got) != 2 {
35- t.Fatalf("got %d models, want 2: %+v", len(got), got)
41+ if got[0].DisplayName != "Claude Opus 5" {
42+ t.Errorf("display name = %q", got[0].DisplayName)
3643 }
37- if got[0].ID != "claude-opus-5" || got[0].DisplayName != "Claude Opus 5" {
38- t.Errorf("first model = %+v", got[0])
44+ if len(paths) != 2 {
45+ t.Fatalf("requests = %v, want a second one for the second page", paths)
3946 }
40- }
41-
42- // Pointed at a gateway that does not serve the catalogue, the call has to fail
43- // in a way the caller can fall back from — not hang, and not panic on a body
44- // that is HTML.
45- func TestListModelsFailsCleanlyOnAGatewayWithoutIt(t *testing.T) {
46- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47- w.WriteHeader(http.StatusNotFound)
48- w.Write([]byte("<html>no such route</html>"))
49- }))
50- defer srv.Close()
51-
52- c := New(WithBaseURL(srv.URL), WithAPIKey("k"))
53- if _, err := c.ListModels(context.Background()); err == nil {
54- t.Fatal("a 404 catalogue came back as success")
47+ if !strings.HasPrefix(paths[0], "/v1/models?") {
48+ t.Errorf("path = %q, want /v1/models", paths[0])
49+ }
50+ if !strings.Contains(paths[1], "after_id=claude-sonnet-5") {
51+ t.Errorf("second request = %q, want it to carry the last id", paths[1])
52+ }
53+ if headers.Get("x-api-key") != "test-key" || headers.Get("anthropic-version") == "" {
54+ t.Errorf("credentials or version missing: %v", headers)
5555 }
5656 }
5757
58- func TestListModelsRefusesWithoutCredentials(t *testing.T) {
59- c := New(WithBaseURL("http://127.0.0.1:1"))
60- c.apiKey, c.authToken = "", ""
58+ // Not every endpoint serves this route. The error has to name what went wrong
59+ // so the picker can say it out loud.
60+ func TestListModelsReportsAnEndpointThatWillNotAnswer(t *testing.T) {
61+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
62+ http.Error(w, `{"type":"error","error":{"type":"not_found_error","message":"unknown route"}}`, http.StatusNotFound)
63+ })
64+
6165 _, err := c.ListModels(context.Background())
6266 if err == nil {
63- t.Fatal("listed models with no credentials")
67+ t.Fatal("a 404 produced no error")
6468 }
65- if !strings.Contains(err.Error(), "credentials") {
66- t.Errorf("the error does not name the cause: %v", err)
69+ if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "not_found") {
70+ t.Errorf("error = %q, want it to say what the endpoint answered", err)
6771 }
6872 }
6973
70- // Deprecated models are still returned by the API. Offering one as a default
71- // in a fresh setup is a trap, so they are filtered out.
72- func TestListModelsDropsDeprecatedOnes(t *testing.T) {
73- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74- w.Write([]byte(`{"data":[
75- {"id":"claude-opus-5","display_name":"Claude Opus 5"},
76- {"id":"claude-2.1","display_name":"Claude 2.1","deprecated":true}
77- ]}`))
78- }))
79- defer srv.Close()
74+ // A bearer credential is what OAuth tokens and most gateways use, and the
75+ // listing has to authenticate the same way the messages call does.
76+ func TestListModelsSendsABearerCredential(t *testing.T) {
77+ var headers http.Header
78+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
79+ headers = r.Header.Clone()
80+ fmt.Fprint(w, `{"data":[],"has_more":false}`)
81+ })
82+ WithAuthToken("oauth-token")(c)
8083
81- got, err := New(WithBaseURL(srv.URL), WithAPIKey("k")).ListModels(context.Background())
82- if err != nil {
83- t.Fatal(err)
84+ if _, err := c.ListModels(context.Background()); err != nil {
85+ t.Fatalf("ListModels: %v", err)
8486 }
85- for _, m := range got {
86- if m.ID == "claude-2.1" {
87- t.Error("a deprecated model was offered")
88- }
87+ if got := headers.Get("authorization"); got != "Bearer oauth-token" {
88+ t.Errorf("authorization = %q", got)
89+ }
90+ if headers.Get("anthropic-beta") == "" {
91+ t.Error("the oauth beta flag is missing")
8992 }
9093 }
9194
92- // The fallback is what the wizard shows when the catalogue cannot be reached,
93- // so it has to stand on its own: every entry priced, and the default present.
94- func TestKnownModelsIsAUsableFallback(t *testing.T) {
95- known := KnownModels()
96- if len(known) == 0 {
97- t.Fatal("no fallback models")
98- }
99- var hasDefault bool
100- for _, m := range known {
101- if m.ID == DefaultModel {
102- hasDefault = true
103- }
104- if _, ok := prices[m.ID]; !ok {
105- t.Errorf("%s is offered but has no pricing", m.ID)
106- }
107- if m.DisplayName == "" {
108- t.Errorf("%s has no display name", m.ID)
109- }
110- }
111- if !hasDefault {
112- t.Errorf("the fallback list does not include the default model %s", DefaultModel)
95+ func TestListModelsRefusesWithoutCredentials(t *testing.T) {
96+ c := New(WithBaseURL("http://127.0.0.1:1"))
97+ c.apiKey, c.authToken = "", ""
98+ if _, err := c.ListModels(context.Background()); err == nil {
99+ t.Fatal("listing without credentials should not even be attempted")
113100 }
114101 }
+0−65internal/llm/name.godeleted
@@ -1,65 +0,0 @@
1- package llm
2-
3- import (
4- "strings"
5- "unicode"
6- )
7-
8- // PrettyModel turns an API model id into the name a person uses for it:
9- // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes
10- // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which
11- // matters because this also runs against gateways serving models we have
12- // never heard of.
13- func PrettyModel(id string) string {
14- s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest")
15- // Anything bracketed is a variant tag, not part of the name: opus-5[1m].
16- tag := ""
17- if i := strings.IndexByte(s, '['); i >= 0 {
18- tag, s = s[i:], s[:i]
19- }
20- parts := strings.Split(s, "-")
21- if len(parts) < 2 {
22- return id
23- }
24- // A trailing eight-digit date stamp is a build, not a version.
25- if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) {
26- parts = parts[:len(parts)-1]
27- }
28- family, rest := parts[0], parts[1:]
29- if family == "" || !isAlpha(family) {
30- return id
31- }
32- name := strings.ToUpper(family[:1]) + family[1:]
33- var version []string
34- for _, p := range rest {
35- if !allDigits(p) {
36- return id
37- }
38- version = append(version, p)
39- }
40- if len(version) > 0 {
41- name += " " + strings.Join(version, ".")
42- }
43- return name + tag
44- }
45-
46- func allDigits(s string) bool {
47- if s == "" {
48- return false
49- }
50- for _, r := range s {
51- if !unicode.IsDigit(r) {
52- return false
53- }
54- }
55- return true
56- }
57-
58- func isAlpha(s string) bool {
59- for _, r := range s {
60- if !unicode.IsLetter(r) {
61- return false
62- }
63- }
64- return s != ""
65- }
+69−15internal/llm/pricing.goView File
@@ -1,8 +1,8 @@
11 package llm
22
33 import (
4- "fmt"
5- "strconv"
4+ "sort"
5+ "strings"
66 )
77
88 // Price is per-million-token list pricing.
@@ -32,7 +32,7 @@ var prices = map[string]Price{
3232 // Cost prices one usage record. The boolean reports whether the model's
3333 // pricing is known; a made-up number in METRICS.md is worse than a blank.
3434 func (u Usage) Cost(model string) (float64, bool) {
35- p, ok := prices[model]
35+ p, ok := prices[basePrice(model)]
3636 if !ok {
3737 return 0, false
3838 }
@@ -42,17 +42,71 @@ func (u Usage) Cost(model string) (float64, bool) {
4242 float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
4343 }
4444
45- // PriceLabel renders a model's list pricing as the wizard shows it:
46- // "$5 / $25 per Mtok", input then output. A model with no known pricing gets
47- // an empty label rather than a zero, because "$0" beside a frontier model is
48- // a worse answer than saying nothing.
49- func PriceLabel(model string) string {
50- p, ok := prices[model]
51- if !ok {
52- return ""
45+ // PriceOf reports the list pricing for a model, if this harness knows it.
46+ // The picker shows what it knows and says nothing about what it does not.
47+ func PriceOf(model string) (Price, bool) {
48+ p, ok := prices[basePrice(model)]
49+ return p, ok
50+ }
51+
52+ // basePrice strips the decoration the API puts on a model id — a bracketed
53+ // variant tag, a -latest or -YYYYMMDD suffix — leaving the name the price is
54+ // listed under. A dated id and a context-window variant are the same model
55+ // billed at the same rate, and /v1/models hands out both.
56+ func basePrice(model string) string {
57+ if i := strings.IndexByte(model, '['); i >= 0 {
58+ model = model[:i]
59+ }
60+ model = strings.TrimSuffix(model, "-latest")
61+ if i := strings.LastIndexByte(model, '-'); i >= 0 {
62+ if suffix := model[i+1:]; len(suffix) == 8 && allDigits(suffix) {
63+ model = model[:i]
64+ }
65+ }
66+ return model
67+ }
68+
69+ func allDigits(s string) bool {
70+ for _, r := range s {
71+ if r < '0' || r > '9' {
72+ return false
73+ }
74+ }
75+ return len(s) > 0
76+ }
77+
78+ // PricedModels are the ids this build has list pricing for, flagship first and
79+ // cheapest last within a family.
80+ //
81+ // It exists for the setup wizard, which has to offer a usable list even when
82+ // the endpoint will not serve /v1/models — a gateway that only proxies
83+ // /v1/messages is the common case, and a first run cannot be left with nothing
84+ // to pick. The set it can price is the set it can fully describe, which is
85+ // what makes this the honest fallback rather than a second hardcoded list.
86+ func PricedModels() []string {
87+ out := make([]string, 0, len(prices))
88+ for id := range prices {
89+ out = append(out, id)
90+ }
91+ // Map order is random; a picker has to look the same every time.
92+ sort.Slice(out, func(i, j int) bool {
93+ if ri, rj := familyRank(out[i]), familyRank(out[j]); ri != rj {
94+ return ri < rj
95+ }
96+ return out[i] > out[j] // newer version first within a family
97+ })
98+ return out
99+ }
100+
101+ // familyRank orders the families the way someone choosing between them does:
102+ // the flagship first, the cheap one last, anything unrecognised after both.
103+ func familyRank(id string) int {
104+ for i, family := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} {
105+ if strings.HasPrefix(id, family) {
106+ return i
107+ }
53108 }
54- // Shortest representation that round-trips: the whole-dollar rates read
55- // as "$5" rather than "$5.00", and a fractional one keeps its decimals.
56- num := func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
57- return fmt.Sprintf("$%s / $%s per Mtok", num(p.InputPerMTok), num(p.OutputPerMTok))
109+ // Unrecognised: after every known family, and left to the id tiebreak to
110+ // order among themselves.
111+ return len(prices)
58112 }
+72−0internal/llm/pricing_test.goaddedView File
@@ -0,0 +1,72 @@
1+ package llm
2+
3+ import (
4+ "strings"
5+ "testing"
6+ )
7+
8+ // The model picker offers whatever /v1/models returns, which is dated ids and
9+ // context-window variants. Those are the same model at the same price, so
10+ // pricing has to look past the decoration rather than report "unknown".
11+ func TestCostPricesDecoratedModelIDs(t *testing.T) {
12+ u := Usage{InputTokens: 1_000_000}
13+ base, ok := u.Cost("claude-sonnet-5")
14+ if !ok {
15+ t.Fatal("the bare id is not priced")
16+ }
17+ for _, id := range []string{
18+ "claude-sonnet-5-20250930",
19+ "claude-sonnet-5[1m]",
20+ "claude-sonnet-5-latest",
21+ } {
22+ got, ok := u.Cost(id)
23+ if !ok {
24+ t.Errorf("Cost(%q) reported no price", id)
25+ continue
26+ }
27+ if got != base {
28+ t.Errorf("Cost(%q) = %v, want the same %v as the bare id", id, got, base)
29+ }
30+ }
31+
32+ // A model nobody has priced still reports nothing rather than guessing.
33+ if _, ok := u.Cost("some-gateway/mystery-model"); ok {
34+ t.Error("an unknown model was given a price")
35+ }
36+ if _, ok := u.Cost("claude-something-9-20990101"); ok {
37+ t.Error("an unknown Claude model was given a price")
38+ }
39+ }
40+
41+ // The wizard's fallback list has to stand on its own: everything in it priced,
42+ // the default present, and the same order every time.
43+ func TestPricedModelsIsAUsableFallback(t *testing.T) {
44+ got := PricedModels()
45+ if len(got) != len(prices) {
46+ t.Fatalf("PricedModels returned %d of %d priced models", len(got), len(prices))
47+ }
48+
49+ var hasDefault bool
50+ for _, id := range got {
51+ if id == DefaultModel {
52+ hasDefault = true
53+ }
54+ if _, ok := PriceOf(id); !ok {
55+ t.Errorf("%s is offered but cannot be priced", id)
56+ }
57+ }
58+ if !hasDefault {
59+ t.Errorf("the fallback omits the default model %s", DefaultModel)
60+ }
61+ if first := got[0]; !strings.HasPrefix(first, "claude-opus") {
62+ t.Errorf("the list opens on %s, want the flagship family first", first)
63+ }
64+
65+ // Map iteration is random, so an unsorted implementation passes once in a
66+ // while and fails in front of a user.
67+ for i := 0; i < 20; i++ {
68+ if again := PricedModels(); strings.Join(again, ",") != strings.Join(got, ",") {
69+ t.Fatalf("the order is not stable:\n%v\n%v", got, again)
70+ }
71+ }
72+ }
+8−2internal/llm/stream.goView File
@@ -38,7 +38,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St
3838 return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
3939 }
4040 if req.Model == "" {
41- req.Model = c.Model
41+ req.Model = c.Model()
4242 }
4343 if req.MaxTokens == 0 {
4444 req.MaxTokens = c.MaxTokens
@@ -85,7 +85,13 @@ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(St
8585 }
8686 httpReq.Header.Set("content-type", "application/json")
8787 httpReq.Header.Set("accept", "text/event-stream")
88- c.setHeaders(httpReq)
88+ httpReq.Header.Set("anthropic-version", apiVersion)
89+ if c.apiKey != "" {
90+ httpReq.Header.Set("x-api-key", c.apiKey)
91+ } else {
92+ httpReq.Header.Set("authorization", "Bearer "+c.authToken)
93+ httpReq.Header.Set("anthropic-beta", oauthBeta)
94+ }
8995
9096 httpResp, err := c.HTTP.Do(httpReq)
9197 if err != nil {
+15−0internal/llm/types.goView File
@@ -172,6 +172,21 @@ type OutputConfig struct {
172172 Effort string `json:"effort,omitempty"`
173173 }
174174
175+ // EffortLevels are the values output_config.effort accepts, cheapest first.
176+ // Anything else is a 400 from the API, so callers check before sending.
177+ var EffortLevels = []string{"low", "medium", "high", "xhigh", "max"}
178+
179+ // ValidEffort reports whether level is one the API accepts. The empty string
180+ // is not: it means "say nothing", which callers handle by omitting the field.
181+ func ValidEffort(level string) bool {
182+ for _, l := range EffortLevels {
183+ if l == level {
184+ return true
185+ }
186+ }
187+ return false
188+ }
189+
175190 // Request is the body of POST /v1/messages.
176191 type Request struct {
177192 Model string `json:"model"`
+15−0internal/llm/types_test.goView File
@@ -122,3 +122,18 @@ func TestResponseAccessors(t *testing.T) {
122122 t.Errorf("Message() = %+v, want all 4 blocks under the assistant role", msg)
123123 }
124124 }
125+
126+ // The effort levels are a closed set, and both the -effort flag and the TUI's
127+ // /effort command answer out of it rather than keeping their own copy.
128+ func TestValidEffort(t *testing.T) {
129+ for _, level := range EffortLevels {
130+ if !ValidEffort(level) {
131+ t.Errorf("ValidEffort(%q) = false for a level we advertise", level)
132+ }
133+ }
134+ for _, level := range []string{"", "HIGH", "higher", "none"} {
135+ if ValidEffort(level) {
136+ t.Errorf("ValidEffort(%q) = true, want false", level)
137+ }
138+ }
139+ }
+86−0internal/tui/commands.goaddedView File
@@ -0,0 +1,86 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "strings"
6+ )
7+
8+ // command is one thing the prompt box accepts with a leading slash: either a
9+ // control the TUI answers itself, or a skill the agent is asked to invoke.
10+ type command struct {
11+ name string // without the slash
12+ args string // what may follow the name; "" when it takes none
13+ desc string
14+ tag string // "personal" or "project" for a skill; "" for a built-in
15+ }
16+
17+ // builtins are the commands submit answers without going near the model. The
18+ // order is the order the menu offers them in — most reached-for first, not
19+ // alphabetical, because the first row is the one a person lands on.
20+ //
21+ // This list is also what /help prints and what the completion menu offers, so
22+ // none of the three can drift away from the others.
23+ var builtins = []command{
24+ {name: "help", desc: "the commands and the keys"},
25+ {name: "skills", desc: "the instruction sets found at startup"},
26+ {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
27+ {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
28+ {name: "setup", desc: "the full-screen wizard for the saved defaults"},
29+ {name: "clear", desc: "empty the transcript"},
30+ {name: "allowed", desc: "what is permanently allowed this session"},
31+ {name: "exit", desc: "leave the harness"},
32+ }
33+
34+ // commands is the catalogue: the built-ins, then every discovered skill.
35+ func (m *model) commands() []command {
36+ out := make([]command, 0, len(builtins)+len(m.opts.Skills))
37+ out = append(out, builtins...)
38+ for _, s := range m.opts.Skills {
39+ desc := s.Description
40+ if desc == "" {
41+ desc = "a skill with no description"
42+ }
43+ out = append(out, command{name: s.Name, args: "arguments", desc: desc, tag: s.Source})
44+ }
45+ return out
46+ }
47+
48+ // matchCommands narrows the catalogue to what has been typed after the slash.
49+ // A name that starts with it comes before one that merely contains it, so
50+ // "/sk" offers /skills before a skill called "ask-the-docs".
51+ func matchCommands(all []command, typed string) []command {
52+ if typed == "" {
53+ return append([]command(nil), all...)
54+ }
55+ var prefix, contains []command
56+ for _, c := range all {
57+ switch {
58+ case strings.HasPrefix(c.name, typed):
59+ prefix = append(prefix, c)
60+ case strings.Contains(c.name, typed):
61+ contains = append(contains, c)
62+ }
63+ }
64+ return append(prefix, contains...)
65+ }
66+
67+ // helpText is built from the catalogue rather than written out beside it.
68+ func (m *model) helpText() string {
69+ var b strings.Builder
70+ b.WriteString("commands:")
71+ for _, c := range builtins {
72+ name := "/" + c.name
73+ if c.args != "" {
74+ name += " <" + c.args + ">"
75+ }
76+ fmt.Fprintf(&b, "\n %-18s %s", name, c.desc)
77+ }
78+ if n := len(m.opts.Skills); n > 0 {
79+ fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
80+ }
81+ b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
82+ "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" +
83+ "\n ctrl+r toggle full tool output · pgup/pgdn scroll" +
84+ "\n esc or ctrl+c interrupt a turn · ctrl+c again to exit")
85+ return b.String()
86+ }
+175−0internal/tui/complete.goaddedView File
@@ -0,0 +1,175 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "strings"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+ )
10+
11+ // menuMaxRows caps the completion list. Past about eight rows the menu is
12+ // eating the transcript, and a list that long is faster to filter than to
13+ // scroll anyway.
14+ const menuMaxRows = 8
15+
16+ // menu is the slash-command completion list under the prompt. It is closed
17+ // when it holds no items; there is no separate flag to disagree with that.
18+ type menu struct {
19+ items []command
20+ idx int
21+ // dismissed is the prompt text the menu was last closed on. Accepting a
22+ // completion leaves "/clear" in the box, which is exactly the text that
23+ // would re-open the menu, so closing remembers what it closed on and
24+ // stays shut until something else is typed.
25+ dismissed string
26+ }
27+
28+ func (m *model) menuOpen() bool { return len(m.menu.items) > 0 }
29+
30+ // menuQuery reports what has been typed after the slash, and whether a
31+ // command is being typed at all. A slash anywhere but the start is prose; a
32+ // space means the name is settled and what follows is its arguments.
33+ func menuQuery(value string) (string, bool) {
34+ if !strings.HasPrefix(value, "/") {
35+ return "", false
36+ }
37+ rest := value[1:]
38+ if strings.ContainsAny(rest, " \t\n") {
39+ return "", false
40+ }
41+ return strings.ToLower(rest), true
42+ }
43+
44+ // refreshMenu recomputes the list from what is in the prompt box. It runs
45+ // after every keystroke the textarea sees, so the list follows the text
46+ // rather than being opened and closed by particular keys.
47+ func (m *model) refreshMenu() {
48+ value := m.input.Value()
49+ typed, typing := menuQuery(value)
50+ if !typing || value == m.menu.dismissed || m.phase != phaseIdle {
51+ m.menu.items, m.menu.idx = nil, 0
52+ return
53+ }
54+
55+ // Keep the highlight on the command it was on while the list narrows
56+ // around it; otherwise start at the top.
57+ var was string
58+ if m.menuOpen() && m.menu.idx < len(m.menu.items) {
59+ was = m.menu.items[m.menu.idx].name
60+ }
61+ m.menu.items, m.menu.idx = matchCommands(m.commands(), typed), 0
62+ for i, c := range m.menu.items {
63+ if c.name == was {
64+ m.menu.idx = i
65+ }
66+ }
67+ }
68+
69+ func (m *model) closeMenu() {
70+ m.menu.items, m.menu.idx = nil, 0
71+ m.menu.dismissed = m.input.Value()
72+ }
73+
74+ // onMenuKey handles the keys that belong to an open menu, and reports whether
75+ // it took the key. Everything it does not take falls through to the prompt,
76+ // which is what keeps typing a filter rather than a command of its own.
77+ func (m *model) onMenuKey(msg tea.KeyMsg) (tea.Cmd, bool) {
78+ n := len(m.menu.items)
79+ switch msg.String() {
80+ case "up", "ctrl+p":
81+ m.menu.idx = (m.menu.idx - 1 + n) % n
82+ return nil, true
83+ case "down", "ctrl+n":
84+ m.menu.idx = (m.menu.idx + 1) % n
85+ return nil, true
86+ case "tab", "enter":
87+ m.acceptCompletion()
88+ return nil, true
89+ case "esc":
90+ m.closeMenu()
91+ return nil, true
92+ }
93+ return nil, false
94+ }
95+
96+ // acceptCompletion puts the highlighted command in the prompt box. It does
97+ // not send it: a command that takes arguments is half-typed at this point, and
98+ // one that does not is one more enter away.
99+ func (m *model) acceptCompletion() {
100+ c := m.menu.items[m.menu.idx]
101+ text := "/" + c.name
102+ if c.args != "" {
103+ text += " "
104+ }
105+ m.input.SetValue(text)
106+ m.input.CursorEnd()
107+ m.closeMenu()
108+ m.growInput()
109+ }
110+
111+ // menuRows is how many lines the menu may take: what is left after the rest of
112+ // the bottom chrome and one row of transcript, capped.
113+ func (m *model) menuRows() int {
114+ // activity line, the prompt box's three, the status line, the mode line,
115+ // and a row of transcript to keep the menu from swallowing the screen.
116+ room := m.height - 6 - m.input.Height()
117+ return maxInt(1, minInt(menuMaxRows, room))
118+ }
119+
120+ // menuView renders the list under the prompt: the highlighted row in coral
121+ // with its description beside it, the rest dimmed.
122+ func (m *model) menuView() string {
123+ if !m.menuOpen() {
124+ return ""
125+ }
126+ rows := m.menuRows()
127+ shown := rows
128+ if len(m.menu.items) > rows {
129+ // The last row goes to the count of what did not fit.
130+ shown = maxInt(1, rows-1)
131+ }
132+ start := 0
133+ if m.menu.idx >= shown {
134+ start = m.menu.idx - shown + 1
135+ }
136+ end := minInt(len(m.menu.items), start+shown)
137+
138+ // One column for the names, so the descriptions line up. A long skill
139+ // name is left to overflow its column rather than being truncated: the
140+ // name is the thing you are picking, and half of it is no use.
141+ width := 0
142+ for _, c := range m.menu.items[start:end] {
143+ width = maxInt(width, lipgloss.Width("/"+c.name))
144+ }
145+ width = minInt(width, maxInt(12, m.width/3))
146+
147+ var b strings.Builder
148+ for i := start; i < end; i++ {
149+ c := m.menu.items[i]
150+ name := pad("/"+c.name, width)
151+ desc := c.desc
152+ if c.tag != "" {
153+ desc += " (" + c.tag + ")"
154+ }
155+ var line string
156+ if i == m.menu.idx {
157+ line = styKey.Render("❯ "+name) + " " + styText.Render(desc)
158+ } else {
159+ line = " " + styDim.Render(name) + " " + styFaint.Render(desc)
160+ }
161+ b.WriteString(truncateStyled(" "+line, maxInt(1, m.width)) + "\n")
162+ }
163+ if n := len(m.menu.items) - (end - start); n > 0 {
164+ b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), maxInt(1, m.width)) + "\n")
165+ }
166+ return strings.TrimRight(b.String(), "\n")
167+ }
168+
169+ // pad widens s to width cells, leaving anything already wider alone.
170+ func pad(s string, width int) string {
171+ if n := width - lipgloss.Width(s); n > 0 {
172+ return s + strings.Repeat(" ", n)
173+ }
174+ return s
175+ }
+222−0internal/tui/complete_test.goaddedView File
@@ -0,0 +1,222 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+
9+ "harness/internal/skills"
10+ )
11+
12+ // The menu belongs to a slash command being typed, and to nothing else: a
13+ // message that merely mentions one must not open it.
14+ func TestMenuQuery(t *testing.T) {
15+ cases := []struct {
16+ in string
17+ want string
18+ wantOpen bool
19+ }{
20+ {"/", "", true},
21+ {"/ef", "ef", true},
22+ {"/EF", "ef", true},
23+ {"", "", false},
24+ {"hello", "", false},
25+ {"see /help for that", "", false},
26+ {"/effort high", "", false}, // the name is settled; the menu is done
27+ {"/help\nmore", "", false}, // a second line means a message, not a command
28+ {" /help", "", false},
29+ }
30+ for _, tc := range cases {
31+ got, open := menuQuery(tc.in)
32+ if open != tc.wantOpen || got != tc.want {
33+ t.Errorf("menuQuery(%q) = %q, %v; want %q, %v", tc.in, got, open, tc.want, tc.wantOpen)
34+ }
35+ }
36+ }
37+
38+ func names(cmds []command) []string {
39+ var out []string
40+ for _, c := range cmds {
41+ out = append(out, c.name)
42+ }
43+ return out
44+ }
45+
46+ // Typing narrows the list, and a name that starts with what was typed beats
47+ // one that merely contains it.
48+ func TestMatchCommandsRanksPrefixesFirst(t *testing.T) {
49+ all := []command{
50+ {name: "allowed"},
51+ {name: "clear"},
52+ {name: "effort"},
53+ {name: "reformat-everything"},
54+ }
55+ cases := map[string][]string{
56+ "": {"allowed", "clear", "effort", "reformat-everything"},
57+ "e": {"effort", "allowed", "clear", "reformat-everything"},
58+ "ef": {"effort", "reformat-everything"},
59+ "format": {"reformat-everything"},
60+ "zz": nil,
61+ }
62+ for typed, want := range cases {
63+ got := names(matchCommands(all, typed))
64+ if strings.Join(got, ",") != strings.Join(want, ",") {
65+ t.Errorf("matchCommands(%q) = %v, want %v", typed, got, want)
66+ }
67+ }
68+ }
69+
70+ // The catalogue is the union of what the TUI answers itself and what the
71+ // session discovered, so a skill is reachable from the menu by name.
72+ func TestCommandsListBuiltinsAndSkills(t *testing.T) {
73+ m := newTestModel(t)
74+ m.opts.Skills = []skills.Skill{
75+ {Name: "brainstorming", Description: "explore an idea", Source: "personal"},
76+ }
77+ got := names(m.commands())
78+ for _, want := range []string{"help", "effort", "skills", "exit", "brainstorming"} {
79+ if !strings.Contains(strings.Join(got, ","), want) {
80+ t.Errorf("command %q missing from the catalogue %v", want, got)
81+ }
82+ }
83+ for _, c := range m.commands() {
84+ if c.desc == "" {
85+ t.Errorf("command /%s has no description to show in the menu", c.name)
86+ }
87+ }
88+ }
89+
90+ // Every name the menu offers must be one submit actually answers; otherwise
91+ // the menu is advertising commands that report themselves as unknown.
92+ func TestEveryOfferedCommandIsHandled(t *testing.T) {
93+ catalogue := newTestModel(t).commands()
94+ for _, c := range catalogue {
95+ m := newTestModel(t)
96+ m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
97+ m.submit("/" + c.name)
98+ if out := m.tr.render(); strings.Contains(out, "unknown command") {
99+ t.Errorf("/%s is offered by the menu but not handled: %q", c.name, out)
100+ }
101+ }
102+ }
103+
104+ // Moving and accepting: the highlighted name lands in the prompt box, and
105+ // accepting is not sending — the turn starts on the next enter.
106+ func TestMenuCompletesWithoutSending(t *testing.T) {
107+ m := newTestModel(t)
108+ typeIn(m, "/")
109+ if !m.menuOpen() {
110+ t.Fatal("typing a slash did not open the menu")
111+ }
112+ first := m.menu.items[0].name
113+
114+ m.Update(key("down"))
115+ second := m.menu.items[m.menu.idx].name
116+ if second == first {
117+ t.Fatal("down did not move the highlight")
118+ }
119+
120+ m.Update(key("enter"))
121+ if m.menuOpen() {
122+ t.Error("the menu stayed open after accepting")
123+ }
124+ if got := m.input.Value(); !strings.HasPrefix(got, "/"+second) {
125+ t.Errorf("input = %q, want it completed to /%s", got, second)
126+ }
127+ if m.phase != phaseIdle {
128+ t.Error("accepting a completion started a turn; it should only fill the box")
129+ }
130+ }
131+
132+ // A command that takes arguments is completed with the space already typed;
133+ // one that does not is completed bare and the menu does not re-open on it.
134+ func TestCompletionTrailingSpaceFollowsArguments(t *testing.T) {
135+ m := newTestModel(t)
136+ m.opts.Skills = []skills.Skill{{Name: "brainstorming", Description: "explore an idea"}}
137+
138+ typeIn(m, "/brainstorm")
139+ m.Update(key("tab"))
140+ if got := m.input.Value(); got != "/brainstorming " {
141+ t.Errorf("input = %q, want a trailing space for a command that takes arguments", got)
142+ }
143+
144+ m2 := newTestModel(t)
145+ typeIn(m2, "/clear")
146+ m2.Update(key("tab"))
147+ if got := m2.input.Value(); got != "/clear" {
148+ t.Errorf("input = %q, want no trailing space on an argument-free command", got)
149+ }
150+ if m2.menuOpen() {
151+ t.Error("the menu re-opened on the text it had just completed")
152+ }
153+ }
154+
155+ // Escape is for dismissing the menu, and only that: the text survives.
156+ func TestEscapeClosesMenuKeepingText(t *testing.T) {
157+ m := newTestModel(t)
158+ typeIn(m, "/help")
159+ if !m.menuOpen() {
160+ t.Fatal("menu not open")
161+ }
162+
163+ m.Update(key("esc"))
164+ if m.menuOpen() {
165+ t.Error("escape did not close the menu")
166+ }
167+ if got := m.input.Value(); got != "/help" {
168+ t.Errorf("input = %q, want the typed text left alone", got)
169+ }
170+
171+ // With the menu gone, enter sends what was typed.
172+ m.Update(key("enter"))
173+ if !strings.Contains(m.tr.render(), "commands:") {
174+ t.Error("enter after dismissing the menu did not run the command")
175+ }
176+ }
177+
178+ // Typing past the last match closes the menu rather than showing an empty box.
179+ func TestMenuClosesWhenNothingMatches(t *testing.T) {
180+ m := newTestModel(t)
181+ typeIn(m, "/zzzq")
182+ if m.menuOpen() {
183+ t.Errorf("menu open with no matches: %v", names(m.menu.items))
184+ }
185+ }
186+
187+ // The menu sits in the bottom chrome, which must still leave the transcript
188+ // somewhere to live on a short terminal.
189+ func TestMenuFitsTheTerminal(t *testing.T) {
190+ for _, h := range []int{10, 24, 60} {
191+ m := newTestModel(t)
192+ m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
193+ typeIn(m, "/")
194+ if !m.menuOpen() {
195+ t.Fatalf("height %d: menu did not open", h)
196+ }
197+ view := m.View()
198+ if got := strings.Count(view, "\n") + 1; got > h {
199+ t.Errorf("height %d: view is %d lines, one row of the menu too many", h, got)
200+ }
201+ if !strings.Contains(view, "/help") {
202+ t.Errorf("height %d: the menu rendered nothing", h)
203+ }
204+ }
205+ }
206+
207+ // While the agent has the turn, enter is refused and esc means interrupt, so
208+ // a menu opened under it would offer keys that do not do what it says.
209+ func TestMenuStaysShutWhileTheAgentWorks(t *testing.T) {
210+ m := newTestModel(t)
211+ m.phase = phaseRunning
212+
213+ typeIn(m, "/he")
214+
215+ if m.menuOpen() {
216+ t.Error("the menu opened during a turn")
217+ }
218+ m.Update(key("esc"))
219+ if !m.interrupting {
220+ t.Error("esc was eaten by the menu instead of interrupting the turn")
221+ }
222+ }
+159−0internal/tui/effort.goaddedView File
@@ -0,0 +1,159 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/textarea"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "harness/internal/llm"
11+ )
12+
13+ // defaultEffort is where the marker sits when nothing has been chosen. The
14+ // API's own default is high, so an unset level is not "no level": it is that
15+ // one, and the slider says so rather than starting at an end.
16+ const defaultEffort = "high"
17+
18+ // openEffort puts the slider on screen, on the level currently in force.
19+ func (m *model) openEffort() tea.Cmd {
20+ m.effortIdx = effortIndex(m.opts.Effort)
21+ m.phase = phaseEffort
22+ m.closeMenu()
23+ return m.sync()
24+ }
25+
26+ func effortIndex(level string) int {
27+ if level == "" {
28+ level = defaultEffort
29+ }
30+ for i, l := range llm.EffortLevels {
31+ if l == level {
32+ return i
33+ }
34+ }
35+ return effortIndex("")
36+ }
37+
38+ // onEffortKey drives the slider. It answers every key while the slider is up:
39+ // there is no prompt behind it to type into, so nothing falls through.
40+ func (m *model) onEffortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
41+ last := len(llm.EffortLevels) - 1
42+ switch key := msg.String(); key {
43+ case "left", "ctrl+b":
44+ // A slider has ends. Running past one holds rather than wrapping,
45+ // which would put max one keypress to the left of low.
46+ m.effortIdx = maxInt(0, m.effortIdx-1)
47+
48+ case "right", "ctrl+f":
49+ m.effortIdx = minInt(last, m.effortIdx+1)
50+
51+ case "home":
52+ m.effortIdx = 0
53+
54+ case "end":
55+ m.effortIdx = last
56+
57+ case "enter", "tab":
58+ m.phase = phaseIdle
59+ m.applyEffort(llm.EffortLevels[m.effortIdx])
60+ return m, tea.Batch(m.sync(), textarea.Blink)
61+
62+ case "esc", "ctrl+c":
63+ m.phase = phaseIdle
64+ return m, tea.Batch(m.sync(), textarea.Blink)
65+
66+ default:
67+ // 1 through 5 jump straight to a level. A digit past the last one is
68+ // not a level, so it does nothing rather than clamping to an end the
69+ // user did not aim at.
70+ if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
71+ if i := int(key[0] - '1'); i <= last {
72+ m.effortIdx = i
73+ }
74+ }
75+ }
76+ return m, m.sync()
77+ }
78+
79+ // useEffort records the level and tells the agent. The agent is nil in tests;
80+ // the level still takes, because the chrome reads it from here.
81+ //
82+ // It is separate from applyEffort for the same reason useModel is separate
83+ // from applyModel: the wizard sets several things and reports them together.
84+ func (m *model) useEffort(level string) {
85+ m.opts.Effort = level
86+ if m.opts.Agent != nil {
87+ m.opts.Agent.SetEffort(level)
88+ }
89+ }
90+
91+ // applyEffort records the level and says so in the transcript.
92+ func (m *model) applyEffort(level string) {
93+ m.useEffort(level)
94+ m.tr.addNotice("effort: " + level + " — from the next turn on")
95+ }
96+
97+ // effortView is the slider: the poles, the track with the marker riding it,
98+ // the labels under it, and the keys that drive it.
99+ //
100+ // Faster Smarter
101+ // ─────────────────────────────▲───────────────────────────────────
102+ // low medium high xhigh max
103+ func (m *model) effortView() string {
104+ width := maxInt(1, m.width)
105+
106+ starts := effortSlots(width - 1)
107+ if starts == nil {
108+ // Too narrow for a track. The level and the keys still fit, and they
109+ // are the part that does the work.
110+ level := llm.EffortLevels[m.effortIdx]
111+ compact := styFaint.Render("◀ ") + styKey.Render(level) + styFaint.Render(" ▶")
112+ return truncateStyled(" "+compact, width) + "\n" +
113+ truncateStyled(" "+styFaint.Render("enter apply · esc cancel"), width)
114+ }
115+
116+ // The track is plain runes until the marker is placed on it, so the
117+ // marker's column is arithmetic rather than a sum of escape sequences.
118+ track := []rune(strings.Repeat("─", width-1))
119+ var labels strings.Builder
120+ col := 0
121+ for i, level := range llm.EffortLevels {
122+ if gap := starts[i] - col; gap > 0 {
123+ labels.WriteString(strings.Repeat(" ", gap))
124+ col += gap
125+ }
126+ style := styDim
127+ if i == m.effortIdx {
128+ style = styKey
129+ track[starts[i]+lipgloss.Width(level)/2] = '▲'
130+ }
131+ labels.WriteString(style.Render(level))
132+ col += lipgloss.Width(level)
133+ }
134+
135+ return spread(styDim.Render("Faster"), styDim.Render("Smarter"), width) + "\n" +
136+ " " + styFaint.Render(string(track)) + "\n" +
137+ " " + labels.String() + "\n" +
138+ truncateStyled(" "+styFaint.Render("←→ move · 1-5 jump · enter apply · esc cancel"), width)
139+ }
140+
141+ // effortSlots is the column each label starts at, spread evenly across width.
142+ // It returns nil when the labels cannot be laid out with room to breathe,
143+ // which is the caller's cue to render something simpler.
144+ func effortSlots(width int) []int {
145+ n := len(llm.EffortLevels)
146+ longest := 0
147+ for _, l := range llm.EffortLevels {
148+ longest = maxInt(longest, lipgloss.Width(l))
149+ }
150+ slot := width / n
151+ if slot < longest+2 {
152+ return nil
153+ }
154+ starts := make([]int, n)
155+ for i, l := range llm.EffortLevels {
156+ starts[i] = i*slot + (slot-lipgloss.Width(l))/2
157+ }
158+ return starts
159+ }
+245−0internal/tui/effort_test.goaddedView File
@@ -0,0 +1,245 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+
9+ "harness/internal/llm"
10+ )
11+
12+ // levelAt is where a level sits on the track, so tests can talk about
13+ // positions without hard-coding the order.
14+ func levelAt(name string) int {
15+ for i, l := range llm.EffortLevels {
16+ if l == name {
17+ return i
18+ }
19+ }
20+ return -1
21+ }
22+
23+ // runeIndex is strings.Index counted in cells rather than in bytes.
24+ func runeIndex(haystack, needle string) int {
25+ i := strings.Index(haystack, needle)
26+ if i < 0 {
27+ return -1
28+ }
29+ return len([]rune(haystack[:i]))
30+ }
31+
32+ // Bare /effort opens the slider on the level in force. Unset is not "nothing
33+ // selected": the API's default is high, and that is where the marker belongs.
34+ func TestEffortPickerOpensOnTheLevelInForce(t *testing.T) {
35+ for _, tc := range []struct{ effort, want string }{
36+ {"", "high"},
37+ {"low", "low"},
38+ {"max", "max"},
39+ } {
40+ m := newTestModel(t)
41+ m.opts.Effort = tc.effort
42+ m.submit("/effort")
43+
44+ if m.phase != phaseEffort {
45+ t.Fatalf("effort %q: phase = %v, want the picker open", tc.effort, m.phase)
46+ }
47+ if got := llm.EffortLevels[m.effortIdx]; got != tc.want {
48+ t.Errorf("effort %q: marker on %q, want %q", tc.effort, got, tc.want)
49+ }
50+ }
51+ }
52+
53+ // It is a slider, so the ends are ends: moving past one stays put rather than
54+ // wrapping around to the opposite extreme.
55+ func TestEffortPickerMovesAndClampsAtTheEnds(t *testing.T) {
56+ m := newTestModel(t)
57+ m.opts.Effort = "high"
58+ m.submit("/effort")
59+
60+ m.Update(key("right"))
61+ if got := m.effortIdx; got != levelAt("xhigh") {
62+ t.Errorf("right put the marker on %q", llm.EffortLevels[got])
63+ }
64+ m.Update(key("left"))
65+ m.Update(key("left"))
66+ if got := m.effortIdx; got != levelAt("medium") {
67+ t.Errorf("two lefts put the marker on %q", llm.EffortLevels[got])
68+ }
69+
70+ for i := 0; i < 5; i++ {
71+ m.Update(key("left"))
72+ }
73+ if got := m.effortIdx; got != 0 {
74+ t.Errorf("marker ran off the cheap end to %d", got)
75+ }
76+ for i := 0; i < 9; i++ {
77+ m.Update(key("right"))
78+ }
79+ if got, want := m.effortIdx, len(llm.EffortLevels)-1; got != want {
80+ t.Errorf("marker = %d, want it held at %d", got, want)
81+ }
82+ }
83+
84+ func TestEffortPickerNumbersJumpToALevel(t *testing.T) {
85+ m := newTestModel(t)
86+ m.submit("/effort")
87+
88+ m.Update(key("1"))
89+ if got := llm.EffortLevels[m.effortIdx]; got != "low" {
90+ t.Errorf("1 selected %q, want low", got)
91+ }
92+ m.Update(key("5"))
93+ if got := llm.EffortLevels[m.effortIdx]; got != "max" {
94+ t.Errorf("5 selected %q, want max", got)
95+ }
96+ // There is no sixth level; the key is not a level, so nothing moves.
97+ m.Update(key("6"))
98+ if got := llm.EffortLevels[m.effortIdx]; got != "max" {
99+ t.Errorf("6 moved the marker to %q", got)
100+ }
101+ }
102+
103+ // Moving the marker changes nothing until enter; enter closes the picker and
104+ // the level takes.
105+ func TestEffortPickerAppliesOnEnter(t *testing.T) {
106+ m := newTestModel(t)
107+ m.opts.Effort = "high"
108+ m.submit("/effort")
109+
110+ m.Update(key("right"))
111+ if m.opts.Effort != "high" {
112+ t.Errorf("effort = %q — moving the marker must not change anything", m.opts.Effort)
113+ }
114+
115+ m.Update(key("enter"))
116+ if m.opts.Effort != "xhigh" {
117+ t.Errorf("effort = %q, want xhigh", m.opts.Effort)
118+ }
119+ if m.phase != phaseIdle {
120+ t.Errorf("phase = %v after applying, want the prompt back", m.phase)
121+ }
122+ if !strings.Contains(m.tr.render(), "xhigh") {
123+ t.Error("the change was not reported in the transcript")
124+ }
125+ if !strings.Contains(m.activity(), "xhigh") {
126+ t.Error("the effort pill did not follow")
127+ }
128+ }
129+
130+ func TestEffortPickerCancelsOnEscape(t *testing.T) {
131+ m := newTestModel(t)
132+ m.opts.Effort = "low"
133+ m.submit("/effort")
134+
135+ m.Update(key("right"))
136+ m.Update(key("esc"))
137+
138+ if m.opts.Effort != "low" {
139+ t.Errorf("effort = %q, want the level left alone", m.opts.Effort)
140+ }
141+ if m.phase != phaseIdle {
142+ t.Errorf("phase = %v after cancelling, want the prompt back", m.phase)
143+ }
144+ }
145+
146+ // Typing the level is still the fast path: it applies without a picker to
147+ // dismiss.
148+ func TestTypedLevelSkipsThePicker(t *testing.T) {
149+ m := newTestModel(t)
150+ m.submit("/effort max")
151+
152+ if m.phase != phaseIdle {
153+ t.Errorf("phase = %v, want no picker for a level that was typed out", m.phase)
154+ }
155+ if m.opts.Effort != "max" {
156+ t.Errorf("effort = %q, want max", m.opts.Effort)
157+ }
158+ }
159+
160+ // The marker has to sit over the label it selects — that alignment is the
161+ // whole illusion of a slider.
162+ func TestSliderMarkerSitsOverTheSelectedLabel(t *testing.T) {
163+ for _, width := range []int{60, 80, 120} {
164+ for i, level := range llm.EffortLevels {
165+ m := newTestModel(t)
166+ m.Update(tea.WindowSizeMsg{Width: width, Height: 30})
167+ m.submit("/effort")
168+ m.effortIdx = i
169+
170+ lines := strings.Split(m.effortView(), "\n")
171+ if len(lines) < 4 {
172+ t.Fatalf("width %d: the slider is %d lines: %q", width, len(lines), lines)
173+ }
174+ // Columns are cells, not bytes: the track is drawn in box
175+ // characters that are three bytes each.
176+ track, labels := lines[1], lines[2]
177+ marker := runeIndex(track, "▲")
178+ if marker < 0 {
179+ t.Fatalf("width %d: no marker on the track %q", width, track)
180+ }
181+ start := runeIndex(labels, level)
182+ if start < 0 {
183+ t.Fatalf("width %d: %q missing from the labels %q", width, level, labels)
184+ }
185+ if marker < start || marker >= start+len(level) {
186+ t.Errorf("width %d, %s: marker at column %d, label spans %d-%d\n%s\n%s",
187+ width, level, marker, start, start+len(level)-1, track, labels)
188+ }
189+ }
190+ }
191+ }
192+
193+ func TestSliderShowsEveryLevelAndItsPoles(t *testing.T) {
194+ m := newTestModel(t)
195+ m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
196+ m.submit("/effort")
197+
198+ view := m.effortView()
199+ for _, want := range append([]string{"Faster", "Smarter", "enter", "esc"}, llm.EffortLevels...) {
200+ if !strings.Contains(view, want) {
201+ t.Errorf("%q missing from the slider:\n%s", want, view)
202+ }
203+ }
204+ }
205+
206+ // A terminal too narrow for five labels gets the level and the keys rather
207+ // than a track with the words sliced in half.
208+ func TestSliderFallsBackOnANarrowTerminal(t *testing.T) {
209+ m := newTestModel(t)
210+ m.Update(tea.WindowSizeMsg{Width: 30, Height: 20})
211+ m.opts.Effort = "xhigh"
212+ m.submit("/effort")
213+
214+ view := m.effortView()
215+ if !strings.Contains(view, "xhigh") {
216+ t.Errorf("the selected level is missing:\n%s", view)
217+ }
218+ if strings.Contains(view, "medium") {
219+ t.Errorf("a label that cannot fit was rendered anyway:\n%s", view)
220+ }
221+ for _, line := range strings.Split(view, "\n") {
222+ if len(line) > 30 {
223+ t.Errorf("line is %d cells wide on a 30-cell terminal: %q", len(line), line)
224+ }
225+ }
226+ if got := strings.Count(m.View(), "\n") + 1; got > 20 {
227+ t.Errorf("the screen is %d lines on a 20-line terminal", got)
228+ }
229+ }
230+
231+ // While the picker owns the screen there is no prompt to type into, so keys
232+ // that are not its own must not leak into the textarea.
233+ func TestPickerSwallowsTyping(t *testing.T) {
234+ m := newTestModel(t)
235+ m.submit("/effort")
236+
237+ typeIn(m, "hello")
238+
239+ if got := m.input.Value(); got != "" {
240+ t.Errorf("input = %q, want the picker to have eaten those keys", got)
241+ }
242+ if m.phase != phaseEffort {
243+ t.Errorf("phase = %v, want the picker still open", m.phase)
244+ }
245+ }
+59−4internal/tui/format.goView File
@@ -5,6 +5,7 @@ import (
55 "net/url"
66 "os"
77 "strings"
8+ "unicode"
89
910 "harness/internal/llm"
1011 )
@@ -20,10 +21,43 @@ func human(n int) string {
2021 return fmt.Sprint(n)
2122 }
2223
23- // prettyModel names a model the way a person does. The transform itself lives
24- // in llm, next to the ids it is about, so the wizard's list and the status bar
25- // cannot disagree about what a model is called.
26- func prettyModel(id string) string { return llm.PrettyModel(id) }
24+ // prettyModel turns an API model id into the name a person uses for it:
25+ // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes
26+ // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which
27+ // matters because this also runs against gateways serving models we have
28+ // never heard of.
29+ func prettyModel(id string) string {
30+ s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest")
31+ // Anything bracketed is a variant tag, not part of the name: opus-5[1m].
32+ tag := ""
33+ if i := strings.IndexByte(s, '['); i >= 0 {
34+ tag, s = s[i:], s[:i]
35+ }
36+ parts := strings.Split(s, "-")
37+ if len(parts) < 2 {
38+ return id
39+ }
40+ // A trailing eight-digit date stamp is a build, not a version.
41+ if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) {
42+ parts = parts[:len(parts)-1]
43+ }
44+ family, rest := parts[0], parts[1:]
45+ if family == "" || !isAlpha(family) {
46+ return id
47+ }
48+ name := strings.ToUpper(family[:1]) + family[1:]
49+ var version []string
50+ for _, p := range rest {
51+ if !allDigits(p) {
52+ return id
53+ }
54+ version = append(version, p)
55+ }
56+ if len(version) > 0 {
57+ name += " " + strings.Join(version, ".")
58+ }
59+ return name + tag
60+ }
2761
2862 // endpointLabel names where the prompts are going. The default endpoint gets
2963 // a short name; anything else shows its host, because "somewhere that is not
@@ -55,6 +89,27 @@ func camel(name string) string {
5589 return strings.Join(parts, "")
5690 }
5791
92+ func allDigits(s string) bool {
93+ if s == "" {
94+ return false
95+ }
96+ for _, r := range s {
97+ if !unicode.IsDigit(r) {
98+ return false
99+ }
100+ }
101+ return true
102+ }
103+
104+ func isAlpha(s string) bool {
105+ for _, r := range s {
106+ if !unicode.IsLetter(r) {
107+ return false
108+ }
109+ }
110+ return s != ""
111+ }
112+
58113 func maxInt(a, b int) int {
59114 if a > b {
60115 return a
+182−48internal/tui/model.goView File
@@ -51,10 +51,11 @@ type Options struct {
5151 // to load — which is what opens the setup wizard unprompted.
5252 Config config.Config
5353 FirstRun bool
54- // OnConfig applies a finished wizard's answers to the running program and
55- // saves them. It returns what to say in the transcript, which is how the
56- // TUI reports a save that failed without knowing anything about files.
57- OnConfig func(config.Config) string
54+ // SaveConfig persists a finished wizard's answers. Applying them is the
55+ // TUI's own job — it is the same work /model and /effort already do — so
56+ // all that is left for the caller is the file, which is the one part the
57+ // TUI has no business knowing about.
58+ SaveConfig func(config.Config) error
5859 }
5960
6061 type phase int
@@ -63,6 +64,12 @@ const (
6364 phaseIdle phase = iota
6465 phaseRunning
6566 phaseApproving
67+ // phaseEffort is the effort slider: the prompt is gone until a level is
68+ // picked or the slider is dismissed.
69+ phaseEffort
70+ // phaseModel is the model picker, which starts on a wait: the list comes
71+ // from the endpoint.
72+ phaseModel
6673 )
6774
6875 // inputMaxLines caps how far the prompt box grows before it scrolls. Past
@@ -84,12 +91,18 @@ type model struct {
8491 ready bool
8592
8693 // Turn bookkeeping.
87- turn int
88- started time.Time
89- usage llm.Usage
90- turns int
91- lastCost float64
94+ turn int
95+ started time.Time
96+ usage llm.Usage
97+ turns int
98+ // cost is the running bill, accumulated as usage is reported and priced
99+ // at the model in force at that moment: a session that switched models
100+ // was not all billed at the model it happens to end on. priced is what
101+ // has already been counted, so a turn's totals — which on a resumed
102+ // session include history this process never saw — are charged once.
103+ cost float64
92104 costKnown bool
105+ priced llm.Usage
93106 // ctxTokens is the last turn's input size — what the model actually read,
94107 // which is the number the context gauge is about. It replaces itself each
95108 // turn rather than accumulating, and compaction makes it drop.
@@ -98,6 +111,18 @@ type model struct {
98111 // open with the same word.
99112 verbSeed int
100113
114+ // menu is the slash-command completion list under the prompt.
115+ menu menu
116+
117+ // effortIdx is where the marker sits while the effort slider is up.
118+ effortIdx int
119+
120+ // The model picker: what the endpoint offered, where the cursor is, and
121+ // whether we are still waiting to be told.
122+ models []llm.Model
123+ modelIdx int
124+ modelsLoading bool
125+
101126 // pending approvals. The agent blocks on one at a time, but a queue costs
102127 // nothing and means a second request can never be dropped.
103128 pending []approvalMsg
@@ -230,28 +255,47 @@ func (m *model) openWizard() tea.Cmd {
230255
231256 // closeWizard puts the chat back on screen, applying the answers if there
232257 // were any. A cancelled wizard changes nothing at all.
258+ //
259+ // Applying goes through the same setters /model and /effort use, so a setting
260+ // chosen in the wizard and the same setting chosen from the prompt cannot come
261+ // out different. What the wizard adds is one summary line instead of their
262+ // three separate notices — the done screen has already shown the detail.
233263 func (m *model) closeWizard() tea.Cmd {
234264 w := m.wiz
235265 m.wiz = nil
236266
237- if w.result != nil {
238- m.opts.Config = *w.result
239- m.opts.Effort = w.result.Effort
240- if m.opts.OnConfig != nil {
241- if note := m.opts.OnConfig(*w.result); note != "" {
242- m.tr.addNotice(note)
243- }
244- }
245- } else {
267+ if w.result == nil {
246268 m.tr.addNotice("setup cancelled — nothing was changed")
269+ m.refreshSplash()
270+ m.input.Focus()
271+ return tea.Batch(m.sync(), textarea.Blink)
272+ }
273+
274+ cfg := *w.result
275+ m.opts.Config = cfg
276+ m.useModel(cfg.Model)
277+ m.useEffort(cfg.Effort)
278+ if m.opts.Policy != nil {
279+ m.opts.Policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll
280+ }
281+
282+ note := fmt.Sprintf("setup: %s · effort %s · approvals %s · %s theme — from the next turn on",
283+ prettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme)
284+ if m.opts.SaveConfig != nil {
285+ if err := m.opts.SaveConfig(cfg); err != nil {
286+ // The settings are already in force; only the file failed. Saying
287+ // so beats a wizard that looks like it did nothing.
288+ note += "\nthese are in force for this session, but saving them failed: " + err.Error()
289+ }
247290 }
248-
249- // The welcome block names the model, which the wizard may have just
250- // changed, and every entry was laid out in the old palette.
251- if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw {
252- m.tr.entries[0].text = splash(m.opts, m.width)
291+ m.tr.addNotice(note)
292+ if m.opts.Policy != nil && m.opts.Policy.YOLO {
293+ m.tr.addNotice("accept-all is on — tool calls run without asking.")
253294 }
254- m.tr.dirty = true
295+
296+ // Every entry was laid out in the old palette, and the welcome block names
297+ // a model that may have just changed.
298+ m.refreshSplash()
255299 m.input.Focus()
256300 return tea.Batch(m.sync(), textarea.Blink)
257301 }
@@ -267,7 +311,7 @@ func (m *model) runTurn(prompt string) tea.Cmd {
267311 _, err := a.Run(ctx, prompt)
268312 cancel()
269313
270- if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil {
314+ if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil {
271315 err = fmt.Errorf("saving session: %w", saveErr)
272316 }
273317 u, turns := a.State().Usage()
@@ -299,8 +343,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
299343 case tea.KeyMsg:
300344 return m.onKey(msg)
301345
346+ case modelsMsg:
347+ return m, m.onModels(msg)
348+
302349 case spinner.TickMsg:
303- if m.phase == phaseRunning {
350+ if m.phase == phaseRunning || m.modelsLoading {
304351 var cmd tea.Cmd
305352 m.spin, cmd = m.spin.Update(msg)
306353 return m, cmd
@@ -331,6 +378,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
331378
332379 case evUsage:
333380 m.usage = addUsage(m.usage, msg.usage)
381+ m.addCost(msg.usage)
334382 m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens
335383 return m, nil
336384
@@ -361,17 +409,28 @@ func (m *model) resize(w, h int) tea.Cmd {
361409 } else {
362410 m.vp.Width = w
363411 }
364- // The welcome block is laid out for a width, so it is re-rendered rather
365- // than wrapped: it is the one entry that owns its own lines.
366- if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw {
367- m.tr.entries[0].text = splash(m.opts, w)
368- m.tr.dirty = true
369- }
412+ m.refreshSplash()
370413 m.vp.SetContent(m.tr.render())
371414 m.vp.GotoBottom()
372415 return nil
373416 }
374417
418+ // refreshSplash re-renders the welcome block. It is the one entry that owns
419+ // its own lines — laid out for a width, and naming the model and the endpoint
420+ // — so when any of those change it is rebuilt rather than wrapped or left to
421+ // describe a session that has moved on.
422+ func (m *model) refreshSplash() {
423+ if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw {
424+ return
425+ }
426+ width := m.width
427+ if width <= 0 {
428+ width = 80
429+ }
430+ m.tr.entries[0].text = splash(m.opts, width)
431+ m.tr.dirty = true
432+ }
433+
375434 // sync re-renders the scrollback, keeping the view pinned to the bottom unless
376435 // the user has scrolled up to read something.
377436 func (m *model) sync() tea.Cmd {
@@ -390,7 +449,7 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
390449 m.phase = phaseIdle
391450 m.interrupting = false
392451 m.usage, m.turns = msg.usage, msg.turns
393- m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model)
452+ m.addCost(subUsage(msg.usage, m.priced))
394453 m.tr.closeOpen()
395454
396455 // Any approval still outstanding belongs to a turn that no longer exists.
@@ -414,6 +473,20 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
414473 if m.phase == phaseApproving {
415474 return m.onApprovalKey(msg)
416475 }
476+ if m.phase == phaseEffort {
477+ return m.onEffortKey(msg)
478+ }
479+ if m.phase == phaseModel {
480+ return m.onModelKey(msg)
481+ }
482+
483+ // An open menu owns the keys that drive it — including enter and esc,
484+ // which mean something else the rest of the time.
485+ if m.menuOpen() {
486+ if cmd, took := m.onMenuKey(msg); took {
487+ return m, cmd
488+ }
489+ }
417490
418491 switch msg.String() {
419492 case "ctrl+c":
@@ -462,12 +535,14 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
462535 }
463536 m.input.Reset()
464537 m.growInput()
538+ m.closeMenu()
465539 return m.submit(line)
466540 }
467541
468542 var cmd tea.Cmd
469543 m.input, cmd = m.input.Update(msg)
470544 m.growInput()
545+ m.refreshMenu()
471546 return m, cmd
472547 }
473548
@@ -517,9 +592,15 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
517592 return m, tea.Quit
518593
519594 case line == "/help":
520- m.tr.addNotice(helpText)
595+ m.tr.addNotice(m.helpText())
521596 return m, m.sync()
522597
598+ case line == "/model" || strings.HasPrefix(line, "/model "):
599+ return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model")))
600+
601+ case line == "/effort" || strings.HasPrefix(line, "/effort "):
602+ return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
603+
523604 case line == "/clear":
524605 m.tr.clear()
525606 return m, m.sync()
@@ -566,6 +647,32 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
566647 return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line))
567648 }
568649
650+ // setModel answers /model. Bare, it asks the endpoint what it serves and
651+ // opens the picker on the answer; with an id, it switches straight away,
652+ // which also works on an endpoint that does not list its models.
653+ func (m *model) setModel(id string) tea.Cmd {
654+ if id == "" {
655+ return m.openModelPicker()
656+ }
657+ m.applyModel(id)
658+ return m.sync()
659+ }
660+
661+ // setEffort answers /effort. Bare, it opens the slider; with a level, it
662+ // applies it directly, which is the fast path for anyone who knows what they
663+ // want and can type it.
664+ func (m *model) setEffort(level string) tea.Cmd {
665+ switch {
666+ case level == "":
667+ return m.openEffort()
668+ case !llm.ValidEffort(level):
669+ m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", "))
670+ default:
671+ m.applyEffort(level)
672+ }
673+ return m.sync()
674+ }
675+
569676 // isSkill reports whether a slash command names a discovered skill. Matching
570677 // here rather than sending every unrecognised slash to the model means a typo
571678 // is caught by the harness instead of puzzled over by the agent.
@@ -667,11 +774,6 @@ func matches(keys []string, key string) bool {
667774
668775 // ---- view ---------------------------------------------------------------
669776
670- const helpText = `commands: /allowed /clear /help /setup /skills /exit
671- keys: enter send · ctrl+j newline · shift+tab cycle approval mode
672- ctrl+r toggle full tool output · pgup/pgdn scroll
673- esc or ctrl+c interrupt a turn · ctrl+c again to exit`
674-
675777 func (m *model) View() string {
676778 if !m.ready {
677779 return "starting…"
@@ -700,10 +802,18 @@ func (m *model) View() string {
700802 func (m *model) bottom() string {
701803 var b strings.Builder
702804 b.WriteString(m.activity() + "\n")
703- if m.phase == phaseApproving && len(m.pending) > 0 {
805+ switch {
806+ case m.phase == phaseApproving && len(m.pending) > 0:
704807 b.WriteString(m.approvalView() + "\n")
705- } else {
808+ case m.phase == phaseEffort:
809+ b.WriteString(m.effortView() + "\n")
810+ case m.phase == phaseModel:
811+ b.WriteString(m.modelView() + "\n")
812+ default:
706813 b.WriteString(m.inputBox() + "\n")
814+ if v := m.menuView(); v != "" {
815+ b.WriteString(v + "\n")
816+ }
707817 }
708818 b.WriteString(m.status() + "\n")
709819 b.WriteString(m.modeLine())
@@ -711,9 +821,9 @@ func (m *model) bottom() string {
711821 }
712822
713823 // activity is the line above the prompt: what the agent is doing on the left,
714- // the effort setting on the right. The effort pill names the command that
715- // changes it, because a setting you can see and cannot reach is worse than one
716- // you cannot see.
824+ // the effort setting on the right. The pill names /effort rather than /setup:
825+ // it reports the level this session is running at, and the command that
826+ // changes that is the one worth naming beside it.
717827 func (m *model) activity() string {
718828 var left string
719829 switch m.phase {
@@ -736,7 +846,7 @@ func (m *model) activity() string {
736846
737847 right := ""
738848 if m.opts.Effort != "" {
739- right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /setup")
849+ right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
740850 }
741851 return spread(left, right, m.width)
742852 }
@@ -762,7 +872,7 @@ func (m *model) inputBox() string {
762872 func (m *model) status() string {
763873 parts := []string{
764874 styName.Render(username()),
765- styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)),
875+ styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model())),
766876 }
767877
768878 if pill, ok := m.contextPill(); ok {
@@ -779,8 +889,8 @@ func (m *model) status() string {
779889 }
780890 io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens))
781891 parts = append(parts, styDim.Render(io))
782- if cost, known := u.Cost(m.opts.Client.Model); known {
783- parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost)))
892+ if m.costKnown {
893+ parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost)))
784894 }
785895 }
786896 return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width))
@@ -874,6 +984,30 @@ func username() string {
874984 return "you"
875985 }
876986
987+ // addCost prices one piece of usage at the model in force and adds it to the
988+ // running total, recording that those tokens have now been paid for.
989+ func (m *model) addCost(u llm.Usage) {
990+ if u == (llm.Usage{}) {
991+ return
992+ }
993+ m.priced = addUsage(m.priced, u)
994+ if c, known := u.Cost(m.opts.Client.Model()); known {
995+ m.cost += c
996+ m.costKnown = true
997+ }
998+ }
999+
1000+ // subUsage is a minus b, floored at zero per field: totals only ever grow, and
1001+ // a negative remainder would mean charging backwards.
1002+ func subUsage(a, b llm.Usage) llm.Usage {
1003+ return llm.Usage{
1004+ InputTokens: maxInt(0, a.InputTokens-b.InputTokens),
1005+ OutputTokens: maxInt(0, a.OutputTokens-b.OutputTokens),
1006+ CacheReadInputTokens: maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens),
1007+ CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens),
1008+ }
1009+ }
1010+
8771011 func addUsage(a, b llm.Usage) llm.Usage {
8781012 a.InputTokens += b.InputTokens
8791013 a.OutputTokens += b.OutputTokens
+265−0internal/tui/model_picker.goaddedView File
@@ -0,0 +1,265 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/textarea"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+
13+ "harness/internal/llm"
14+ )
15+
16+ // modelFetchTimeout bounds the wait. Someone is sitting in front of a picker
17+ // that says it is asking; a minute of that is not a picker, it is a hang.
18+ const modelFetchTimeout = 20 * time.Second
19+
20+ // modelsMsg carries the answer to GET /v1/models back into the UI.
21+ type modelsMsg struct {
22+ models []llm.Model
23+ err error
24+ }
25+
26+ // fetchModels asks the endpoint what it serves. It runs off the UI goroutine
27+ // like any other command, and reports failure as a value rather than by
28+ // leaving the picker waiting forever.
29+ func fetchModels(c *llm.Client) tea.Cmd {
30+ return func() tea.Msg {
31+ ctx, cancel := context.WithTimeout(context.Background(), modelFetchTimeout)
32+ defer cancel()
33+ models, err := c.ListModels(ctx)
34+ return modelsMsg{models: models, err: err}
35+ }
36+ }
37+
38+ // openModelPicker puts the picker up and starts asking.
39+ func (m *model) openModelPicker() tea.Cmd {
40+ m.phase = phaseModel
41+ m.models, m.modelIdx, m.modelsLoading = nil, 0, true
42+ m.closeMenu()
43+ return tea.Batch(m.sync(), m.spin.Tick, fetchModels(m.opts.Client))
44+ }
45+
46+ // onModels lands the answer. An answer nobody is waiting for any more is
47+ // dropped: the picker was escaped, and reopening it over whatever the user
48+ // went on to do would be the UI arguing with them.
49+ func (m *model) onModels(msg modelsMsg) tea.Cmd {
50+ if m.phase != phaseModel {
51+ return nil
52+ }
53+ m.modelsLoading = false
54+ m.phase = phaseIdle
55+
56+ switch {
57+ case msg.err != nil:
58+ m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " +
59+ msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works")
60+ case len(msg.models) == 0:
61+ m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works")
62+ default:
63+ m.phase = phaseModel
64+ m.models = msg.models
65+ m.modelIdx = modelIndex(msg.models, m.opts.Client.Model())
66+ }
67+ return tea.Batch(m.sync(), textarea.Blink)
68+ }
69+
70+ // modelIndex finds the model in force, so the picker opens where the session
71+ // already is rather than at the top of a list.
72+ func modelIndex(models []llm.Model, current string) int {
73+ for i, mo := range models {
74+ if mo.ID == current {
75+ return i
76+ }
77+ }
78+ return 0
79+ }
80+
81+ // onModelKey drives the picker. While the fetch is in flight there is nothing
82+ // to move through, so only the ways out are live.
83+ func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
84+ key := msg.String()
85+ if key == "esc" || key == "ctrl+c" {
86+ m.phase = phaseIdle
87+ m.models, m.modelsLoading = nil, false
88+ return m, tea.Batch(m.sync(), textarea.Blink)
89+ }
90+ if m.modelsLoading || len(m.models) == 0 {
91+ return m, nil
92+ }
93+
94+ last := len(m.models) - 1
95+ switch key {
96+ case "up", "ctrl+p":
97+ m.modelIdx = maxInt(0, m.modelIdx-1)
98+ case "down", "ctrl+n":
99+ m.modelIdx = minInt(last, m.modelIdx+1)
100+ case "home":
101+ m.modelIdx = 0
102+ case "end":
103+ m.modelIdx = last
104+ case "enter", "tab":
105+ id := m.models[m.modelIdx].ID
106+ m.phase = phaseIdle
107+ m.models = nil
108+ m.applyModel(id)
109+ return m, tea.Batch(m.sync(), textarea.Blink)
110+ default:
111+ if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
112+ if i := int(key[0] - '1'); i <= last {
113+ m.modelIdx = i
114+ }
115+ }
116+ }
117+ return m, m.sync()
118+ }
119+
120+ // useModel switches the client. The session keeps its history: the
121+ // conversation so far is replayed to the new model on the next turn, which is
122+ // the point of switching mid-session.
123+ //
124+ // It is separate from applyModel because the setup wizard changes several
125+ // settings at once and says so in one line, where the picker changes this one
126+ // and announces it. (setModel is taken: that is the /model command.)
127+ func (m *model) useModel(id string) {
128+ if id == "" {
129+ return
130+ }
131+ m.opts.Client.SetModel(id)
132+ m.refreshSplash()
133+ }
134+
135+ // applyModel switches the client and says so.
136+ func (m *model) applyModel(id string) {
137+ m.useModel(id)
138+ m.tr.addNotice("model: " + prettyModel(id) + " (" + id + ") — from the next turn on")
139+ }
140+
141+ // modelView is the picker: a heading, where the list came from, and a row per
142+ // model with what it is called, its id, and what it costs.
143+ func (m *model) modelView() string {
144+ width := maxInt(1, m.width)
145+ head := " " + styHeading.Render("Select model")
146+ from := styDim.Render("from " + endpointLabel(m.opts.Client))
147+
148+ if m.modelsLoading {
149+ return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width)
150+ }
151+
152+ rows := m.modelRows()
153+ shown := rows
154+ if len(m.models) > rows {
155+ shown = maxInt(1, rows-1)
156+ }
157+ start := 0
158+ if m.modelIdx >= shown {
159+ start = m.modelIdx - shown + 1
160+ }
161+ end := minInt(len(m.models), start+shown)
162+
163+ // One column for the names, so the ids line up under each other.
164+ names := make([]string, len(m.models))
165+ col := 0
166+ for i, mo := range m.models {
167+ names[i] = mo.DisplayName
168+ if names[i] == "" {
169+ names[i] = prettyModel(mo.ID)
170+ }
171+ if i >= start && i < end {
172+ col = maxInt(col, lipgloss.Width(names[i]))
173+ }
174+ }
175+ col = minInt(col, maxInt(10, width/3))
176+
177+ var b strings.Builder
178+ b.WriteString(head + "\n")
179+ b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter apply · esc cancel"), width) + "\n")
180+ for i := start; i < end; i++ {
181+ mo := m.models[i]
182+ marker, name := " ", styDim.Render(pad(names[i], col))
183+ if i == m.modelIdx {
184+ marker, name = styKey.Render("❯ "), styKey.Render(pad(names[i], col))
185+ }
186+ // An endpoint that gave no display name leaves the id as the name;
187+ // repeating it in the detail column says nothing twice.
188+ detail := ""
189+ if names[i] != mo.ID {
190+ detail = mo.ID
191+ }
192+ if mo.MaxInputTokens > 0 {
193+ if detail != "" {
194+ detail += " · "
195+ }
196+ detail += human(mo.MaxInputTokens) + " in"
197+ }
198+ if p, ok := llm.PriceOf(mo.ID); ok {
199+ if detail != "" {
200+ detail += " · "
201+ }
202+ detail += fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok)
203+ }
204+ line := marker + fmt.Sprintf("%d. ", i+1) + name + " " + styFaint.Render(detail)
205+ if mo.ID == m.opts.Client.Model() {
206+ line += styOK.Render(" ✓")
207+ }
208+ b.WriteString(truncateStyled(" "+line, width) + "\n")
209+ }
210+ if n := len(m.models) - (end - start); n > 0 {
211+ b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n")
212+ }
213+ return strings.TrimRight(b.String(), "\n")
214+ }
215+
216+ // modelRows is how many model rows fit: the same arithmetic the completion
217+ // menu does, minus the heading and the line saying where the list came from.
218+ func (m *model) modelRows() int {
219+ room := m.height - 6
220+ return maxInt(1, minInt(modelMaxRows, room))
221+ }
222+
223+ // modelMaxRows caps the list. Anthropic serves a couple of dozen models and
224+ // nobody scrolls a picker looking for the twenty-fourth.
225+ const modelMaxRows = 10
226+
227+ // ---- describing a model -------------------------------------------------
228+ //
229+ // The picker and the setup wizard show the same models and must say the same
230+ // things about them, so what a model is called and what it costs is decided
231+ // once, here.
232+
233+ // pricedModels is the list to fall back on when the endpoint will not answer
234+ // GET /v1/models. The picker treats that as an error and says so — there is a
235+ // session to go back to. The wizard cannot: it is the first run, and a model
236+ // has to be chosen before there is anything to go back to.
237+ func pricedModels() []llm.Model {
238+ ids := llm.PricedModels()
239+ out := make([]llm.Model, 0, len(ids))
240+ for _, id := range ids {
241+ out = append(out, llm.Model{ID: id, DisplayName: prettyModel(id)})
242+ }
243+ return out
244+ }
245+
246+ // priceLabel is a model's list pricing, or "" when this build cannot price it.
247+ // A blank says "not known"; a zero would say "free", which is worse than
248+ // saying nothing.
249+ func priceLabel(id string) string {
250+ p, ok := llm.PriceOf(id)
251+ if !ok {
252+ return ""
253+ }
254+ return fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok)
255+ }
256+
257+ // modelDetail is the line under a highlighted model: its id, and its context
258+ // window when the endpoint reported one.
259+ func modelDetail(m llm.Model) string {
260+ detail := m.ID
261+ if m.MaxInputTokens > 0 {
262+ detail += " · " + human(m.MaxInputTokens) + " token context"
263+ }
264+ return detail
265+ }
+344−0internal/tui/model_picker_test.goaddedView File

Large diffs are not rendered by default.

+68−13internal/tui/model_test.goView File

Large diffs are not rendered by default.

+1−1internal/tui/splash.goView File

Large diffs are not rendered by default.

+18−28internal/tui/wizard.goView File

Large diffs are not rendered by default.

+87−5internal/tui/wizard_test.goView File

Large diffs are not rendered by default.

+1−2internal/tui/wizard_view.goView File

Large diffs are not rendered by default.