Vectrum
Sign In

Merge master: the setup wizard meets the borrowed login

Two credential stories had to become one. Master centralised every
outbound request behind newRequest so a second route could not
authenticate differently from the first; this branch made the credential
resolve once per call so a token that expires mid-session can be
replaced. They wanted the same thing, so newRequest now takes the
resolved credential as a value and both properties hold at once —
including for ListModels, which master added and which the model picker
calls, and which now resolves a credential like everything else.

Verified against the live API that GET /v1/models works on a borrowed
OAuth credential, so master's /model picker works without an API key.

main.go keeps the client options as a slice, because the Claude Code
fallback may rebuild the client and replaying the same list is what
makes the rebuilt one provably the client that passed the endpoint
check. Config resolution runs first, credential resolution second, and
there is still exactly one HasCredentials check.

The "no credentials" text existed in three copies after the merge, one
per route; it is one errNoCredentials now, since callers comparing them
should not have to hope they were typed alike.

All four credential gates re-verified on the merged tree: the borrowed
login works end to end, a non-default endpoint never reads the file,
-no-claude-code-auth never reads it, and an explicit key is not
overridden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 2 parents 8823937 57899a9commite76b36c

Showing 33 changed files with 5053 additions and 130 deletions.

Merge commit — showing changes against the first parent.

+148−18README.mdView File
@@ -31,10 +31,12 @@ API:
3131 # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
3232 ```
3333
34- Flags override the environment. `-auth-token` sends `Authorization: Bearer`,
35- which is what OAuth tokens and most gateways want; `-api-key` sends
36- `x-api-key`. A non-default endpoint announces itself on stderr, because your
37- prompt and every file the agent reads go wherever that URL points.
34+ Flags beat the saved config, which beats the built-in defaults; the config is
35+ what [the setup wizard](#setup) writes on a first run. `-auth-token` sends
36+ `Authorization: Bearer`, which is what OAuth tokens and most gateways want;
37+ `-api-key` sends `x-api-key`. A non-default endpoint announces itself on
38+ stderr, because your prompt and every file the agent reads go wherever that URL
39+ points.
3840
3941 The Claude Code login is the last resort, tried only when no flag and no
4042 environment variable supplied a credential, and only against Anthropic's own
@@ -94,6 +96,7 @@ to six lines.
9496 | Key | Does |
9597 | --- | --- |
9698 | `enter` | send |
99+ | `/` | open the command menu — see below |
97100 | `ctrl+j` | newline without sending |
98101 | `shift+tab` | cycle the approval mode |
99102 | `ctrl+r` | expand elided tool output |
@@ -101,7 +104,71 @@ to six lines.
101104 | `esc` | interrupt the turn |
102105 | `ctrl+c` | interrupt the turn; again when idle to exit |
103106
104- Commands: `/allowed`, `/clear`, `/help`, `/exit`, `/skills`.
107+ Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
108+ `/setup`, `/skills`, and one per discovered skill.
109+
110+ A slash at the start of an empty prompt opens the command menu, which filters
111+ as you type — a name that starts with what you typed before one that merely
112+ contains it:
113+
114+ ```
115+ ╭──────────────────────────────────────────────────────────────────────────────╮
116+ │ › /s │
117+ ╰──────────────────────────────────────────────────────────────────────────────╯
118+ ❯ /skills the instruction sets found at startup
119+ /systematic-debugging find the cause before changing code (project)
120+ /brainstorming explore an idea before building it (personal)
121+ ```
122+
123+ `↑`/`↓` move, `tab` or `enter` completes the highlighted name into the prompt —
124+ completing is not sending, so there is room to type arguments for a skill or a
125+ level for `/effort` — and `esc` dismisses the menu without touching what you
126+ typed. The menu offers exactly the commands the TUI answers, so nothing it
127+ suggests can come back as "unknown command".
128+
129+ `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and
130+ opens a picker on the answer:
131+
132+ ```
133+ Select model
134+ from anthropic · ↑↓ move · enter apply · esc cancel
135+ 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok
136+ 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓
137+ ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok
138+ 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok
139+ ```
140+
141+ The cursor starts on the model in force, which is also the one marked. The id,
142+ the context window and the list price come from the endpoint and from the
143+ pricing table; a model neither knows about simply says less. The switch applies
144+ from the next turn — the conversation so far is replayed to the new model — and
145+ costs already incurred stay priced at the model that incurred them, because a
146+ session that switched models was not all billed at the model it ended on.
147+
148+ Not every endpoint serves that route. When the fetch fails the picker says so,
149+ names the host and the reason, and closes; `/model <id>` and the `-model` flag
150+ still switch without asking anyone's permission.
151+
152+ `/effort` opens a slider in place of the prompt, on the level in force — an
153+ unset level is the API's own default, `high`, so the marker starts there rather
154+ than at an end:
155+
156+ ```
157+ Faster Smarter
158+ ──────────────────────────────────────▲───────────────────────────────────
159+ low medium high xhigh max
160+ ←→ move · 1-5 jump · enter apply · esc cancel
161+ ```
162+
163+ Moving the marker changes nothing until `enter`; `esc` leaves the level alone.
164+ `/effort xhigh` still applies a level directly for anyone who can type it, and
165+ either way it is the `-effort` flag reached from the keyboard. The level in
166+ force shows above the prompt on the right. A terminal too narrow for five
167+ labels gets `◀ high ▶` and the same keys rather than a track with the words
168+ sliced in half.
169+
170+ `/model` and `/effort` change the session. `/setup` changes what the next
171+ session starts with — see [Setup](#setup).
105172
106173 `shift+tab` cycles between asking before every mutating call and accepting them
107174 all, which is `-yolo` reached from the keyboard. The mode line says which is in
@@ -119,22 +186,84 @@ The TUI is skipped automatically when stdin or stdout is not a terminal, and by
119186 consume the same `agent.Event` stream, so neither can show something the other
120187 cannot.
121188
122- ### Driving it without an API key
189+ ## Setup
123190
124- `internal/tui/testdata/mockapi` is a fake Messages API that streams a canned
125- reply and asks to run a shell command, which is enough to exercise streaming,
126- tool rendering, approve, deny and interrupt for nothing:
191+ The first run on a terminal opens a full-screen wizard instead of the chat: the
192+ mark assembles, the name lights up, and it asks four questions one at a time.
127193
128- ```bash
129- go run ./internal/tui/testdata/mockapi &
130- mkdir -p /tmp/demo && git -C /tmp/demo init -q
131- go build -o harness ./cmd/harness
132- ./harness -C /tmp/demo -base-url http://127.0.0.1:8899 -api-key mock
194+ ```
195+ harness setup step 1 of 4
196+ ◆━━━━━━━━━━━━━━━◇───────────────◇───────────────◇
197+
198+ Which model should the agent use?
199+
200+ ▌ ◆ Claude Opus 5 $5 / $25 per Mtok
201+ ◇ Claude Sonnet 5 $2 / $10 per Mtok
202+ ◇ Claude Haiku 4.5 $1 / $5 per Mtok
203+ ◇ custom…
204+
205+ claude-opus-5 · 1.0M token context
206+
207+ ↑↓ move enter next esc back ctrl+c cancel
208+ ```
209+
210+ | Question | Answers |
211+ | --- | --- |
212+ | Model | whatever `GET /v1/models` returns, plus `custom…` for any id |
213+ | Effort | the API's own `llm.EffortLevels`: `low` `medium` `high` `xhigh` `max` |
214+ | Approvals | ask before every mutating call, or accept them all |
215+ | Theme | `ember` `mint` `iris` `mono` |
216+
217+ It is the same catalogue and the same pricing table `/model` uses, through the
218+ same `GET /v1/models` call, so the two cannot disagree about what a model is
219+ called or what it costs. Where they differ is failure: `/model` reports the
220+ error and closes, because there is a session to go back to. The wizard cannot
221+ do that on a first run, so it falls back to the models this build can price and
222+ says so. `custom…` is there because a gateway may serve something no build has
223+ heard of.
224+
225+ The theme step previews by applying the palette for real — reading a swatch
226+ tells you nothing about what a transcript looks like in it — and it recolours
227+ the whole TUI, pickers and slider included. Cancelling puts the old one back.
228+
229+ Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is
230+ honoured):
231+
232+ ```json
233+ {
234+ "version": 1,
235+ "model": "claude-opus-5",
236+ "effort": "high",
237+ "approvals": "ask",
238+ "theme": "ember"
239+ }
133240 ```
134241
135- Type anything, and the approval prompt appears. `-slow 150ms` stretches the
136- stream out if you want to watch the spinner or test `ctrl+c` mid-turn. It sits
137- under `testdata` so `./...` ignores it.
242+ Nothing in that file is required, and none of it wins an argument with a flag:
243+ precedence is **flag, then config, then the built-in default**, so
244+ `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a
245+ default is honoured, but `-yolo` can only turn approvals off, never on — a file
246+ should not be able to quietly grant the agent more than the command line did.
247+ A value this build does not recognise is dropped rather than forwarded to the
248+ API, and effort is checked against `llm.EffortLevels` rather than a second copy
249+ of the same five strings.
250+
251+ `/setup` reopens the wizard at any time. Finishing applies the answers through
252+ the same setters `/model` and `/effort` use, so a setting chosen in the wizard
253+ and the same setting chosen from the prompt cannot come out different — the
254+ wizard just reports them in one line instead of three. It is refused mid-turn:
255+ changing the model while a turn is in flight would finish it on a different
256+ model than it started. A config file that cannot be parsed is reported and then
257+ ignored, and is never overwritten until you finish the wizard.
258+
259+ `/model` and `/effort` change the running session and say "from the next turn
260+ on"; they do not write to the file. The wizard is the thing that decides what
261+ the *next* session starts with, which is why it is a separate screen rather
262+ than a fourth picker.
263+
264+ The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to
265+ their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20,
266+ and any key skips the one playing.
138267
139268 ## Tools
140269
@@ -259,10 +388,11 @@ internal/llm Messages API: types, HTTP, SSE, pricing
259388 internal/agent turn loop, conversation state, compaction, sessions
260389 internal/tools the tool registry and the tools
261390 internal/skills discovery and parsing of SKILL.md
391+ internal/config saved defaults: model, effort, approvals, theme
262392 internal/approval allow / deny / ask, with shell analysis
263393 internal/metrics JSONL event log
264394 internal/ui line-based terminal rendering
265- internal/tui full-screen Bubble Tea front end
395+ internal/tui full-screen Bubble Tea front end, and the setup wizard
266396 evals task suite and runner
267397 ```
268398
+40−10cmd/harness/main.goView File
@@ -17,6 +17,7 @@ import (
1717 "harness/internal/agent"
1818 "harness/internal/approval"
1919 "harness/internal/claudecode"
20+ "harness/internal/config"
2021 "harness/internal/llm"
2122 "harness/internal/metrics"
2223 "harness/internal/skills"
@@ -64,8 +65,17 @@ func run() error {
6465 return err
6566 }
6667
68+ // Saved preferences sit under the flags and over the built-in defaults. A
69+ // config that cannot be read is reported and then ignored: the harness
70+ // still starts, and the wizard can write a good one over it.
71+ cfg, haveConfig, cfgErr := config.Load()
72+
73+ // Held as a slice because the Claude Code fallback below may have to
74+ // rebuild the client with one more option, and replaying the same list is
75+ // how the rebuilt client is guaranteed to be the one that passed the
76+ // endpoint check.
6777 opts := []llm.Option{
68- llm.WithModel(*model),
78+ llm.WithModel(config.Or(*model, cfg.Model)),
6979 llm.WithBaseURL(*baseURL),
7080 llm.WithAPIKey(*apiKey),
7181 llm.WithAuthToken(*authToken),
@@ -111,6 +121,12 @@ func run() error {
111121 return errNoCredentials
112122 }
113123
124+ effortSetting := config.Or(*effort, cfg.Effort)
125+ // An accept-all default is a decision about what the agent may do
126+ // unattended, so -yolo can turn it on but a config can only be the default
127+ // for a session where the flag said nothing.
128+ acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll
129+
114130 session := time.Now().UTC().Format("20060102T150405Z")
115131 log, err := metrics.Open(filepath.Join(root, *logPath), session)
116132 if err != nil {
@@ -118,7 +134,7 @@ func run() error {
118134 }
119135 defer log.Close()
120136 log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
121- "root": root, "model": client.Model, "base_url": client.BaseURL,
137+ "root": root, "model": client.Model(), "base_url": client.BaseURL,
122138 }})
123139
124140 // The TUI takes over the screen, so anything worth saying before the loop
@@ -134,6 +150,10 @@ func run() error {
134150 fmt.Fprintln(os.Stderr, "harness: "+msg)
135151 }
136152
153+ if cfgErr != nil {
154+ notice("%v — using defaults; /setup will write a fresh one", cfgErr)
155+ }
156+
137157 if !client.IsDefaultEndpoint() {
138158 // Worth saying out loud: the prompt and every file the agent reads go
139159 // to this endpoint, not to Anthropic.
@@ -195,14 +215,14 @@ func run() error {
195215 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
196216 approver := &ui.Approver{In: in, Out: os.Stdout}
197217 policy := approval.New(approver.Prompt)
198- policy.YOLO = *yolo
199- if *yolo && !useTUI {
218+ policy.YOLO = acceptAll
219+ if acceptAll && !useTUI {
200220 // The TUI says this itself, in the banner.
201221 fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.")
202222 }
203223 a := agent.New(client, reg, state, log, agent.Options{
204224 MaxTurns: *maxTurns,
205- Effort: *effort,
225+ Effort: effortSetting,
206226 ContextWindow: *contextWindow,
207227 CompactAt: *compactAt,
208228 NoCache: *noCache,
@@ -236,14 +256,24 @@ func run() error {
236256 SessionID: session,
237257 ToolNames: toolNames(reg),
238258 Verbose: *verbose,
239- YOLO: *yolo,
259+ YOLO: acceptAll,
240260 Skills: found,
241261
242262 Version: version,
243- Effort: *effort,
263+ Effort: effortSetting,
244264 ContextWindow: *contextWindow,
245265
246266 Banner: banner,
267+
268+ // No config on disk is what "first run" means, and it is the only
269+ // thing that opens the wizard unasked. A flag-only invocation is
270+ // still a first run: the flags say what this run does, not what
271+ // the next one should default to.
272+ Config: cfg,
273+ FirstRun: !haveConfig && cfgErr == nil,
274+ // Applying the answers is the TUI's own job; persisting them is
275+ // the only part that belongs out here.
276+ SaveConfig: config.Save,
247277 })
248278 }
249279
@@ -255,7 +285,7 @@ func run() error {
255285 }
256286
257287 fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
258- client.Model, root, strings.Join(toolNames(reg), ", "))
288+ client.Model(), root, strings.Join(toolNames(reg), ", "))
259289
260290 for {
261291 fmt.Print("> ")
@@ -294,9 +324,9 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir,
294324
295325 render.Flush()
296326 usage, turns := a.State().Usage()
297- render.Usage(usage, turns, client.Model)
327+ render.Usage(usage, turns, client.Model())
298328
299- if err := a.Save(sessionDir, id, root, client.Model); err != nil {
329+ if err := a.Save(sessionDir, id, root, client.Model()); err != nil {
300330 fmt.Fprintln(os.Stderr, "harness: saving session:", err)
301331 }
302332 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−2internal/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"
@@ -135,6 +136,25 @@ type Agent struct {
135136 // lastInputTokens is the size of the previous request as the API measured
136137 // it, cached and uncached parts together.
137138 lastInputTokens int
139+
140+ // mu guards the settings a front end can change while the loop is
141+ // running. Only effort is one today; the loop reads it once per request.
142+ mu sync.Mutex
143+ }
144+
145+ // SetEffort changes the effort level the turns that follow will ask for. An
146+ // empty level means the API's own default.
147+ func (a *Agent) SetEffort(level string) {
148+ a.mu.Lock()
149+ defer a.mu.Unlock()
150+ a.opts.Effort = level
151+ }
152+
153+ // Effort reports the level currently in force.
154+ func (a *Agent) Effort() string {
155+ a.mu.Lock()
156+ defer a.mu.Unlock()
157+ return a.opts.Effort
138158 }
139159
140160 // New wires an agent. state and log may be freshly created by the caller so the
@@ -327,8 +347,8 @@ func (a *Agent) request() llm.Request {
327347 Tools: defs,
328348 Thinking: llm.Adaptive(),
329349 }
330- if a.opts.Effort != "" {
331- req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort}
350+ if effort := a.Effort(); effort != "" {
351+ req.OutputConfig = &llm.OutputConfig{Effort: effort}
332352 }
333353 return req
334354 }
+26−0internal/agent/loop_test.goView File
@@ -566,3 +566,29 @@ func TestNoPrefixMeansOneBlockAsBefore(t *testing.T) {
566566 t.Error("the prompt must be unchanged when no prefix is set")
567567 }
568568 }
569+
570+ // Effort is a knob the TUI turns mid-session, so a request carries whatever
571+ // was last set rather than whatever the process started with.
572+ func TestSetEffortAppliesToTheNextRequest(t *testing.T) {
573+ f := newFakeAPI(t, msg("end_turn", textBlock("one")), msg("end_turn", textBlock("two")))
574+ a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
575+
576+ if _, err := a.Run(context.Background(), "first"); err != nil {
577+ t.Fatalf("Run: %v", err)
578+ }
579+ if oc := f.requests[0].OutputConfig; oc != nil {
580+ t.Errorf("output_config = %+v with no effort set, want it omitted so the API picks", oc)
581+ }
582+
583+ a.SetEffort("low")
584+ if got := a.Effort(); got != "low" {
585+ t.Errorf("Effort() = %q, want low", got)
586+ }
587+ if _, err := a.Run(context.Background(), "second"); err != nil {
588+ t.Fatalf("Run: %v", err)
589+ }
590+ oc := f.requests[1].OutputConfig
591+ if oc == nil || oc.Effort != "low" {
592+ t.Errorf("output_config = %+v, want effort low", oc)
593+ }
594+ }
+174−0internal/config/config.goaddedView File
@@ -0,0 +1,174 @@
1+ // Package config is the handful of preferences the setup wizard writes down.
2+ //
3+ // It holds defaults, never facts: every field is something a flag or an
4+ // environment variable may override for a given run. Nothing here is required
5+ // for the harness to start, which is what lets a missing file mean "first
6+ // run" rather than "broken install".
7+ package config
8+
9+ import (
10+ "encoding/json"
11+ "errors"
12+ "fmt"
13+ "io/fs"
14+ "os"
15+ "path/filepath"
16+
17+ "harness/internal/llm"
18+ )
19+
20+ // Filename is the config's name inside the harness config directory.
21+ const Filename = "config.json"
22+
23+ // Version is stamped on every write. It exists so a future change of shape
24+ // has something to branch on rather than guessing from which keys are present.
25+ const Version = 1
26+
27+ // Approval modes, as written to the file. They are spelled out rather than
28+ // stored as a bool, because "approvals: false" reads as if approvals were
29+ // broken rather than as if they were turned off deliberately.
30+ const (
31+ ApprovalsAsk = "ask"
32+ ApprovalsAcceptAll = "accept-all"
33+ )
34+
35+ // Themes are the TUI palettes, in the order the wizard offers them. The names
36+ // live here rather than in the tui package so that reading a config never
37+ // depends on a terminal front end being linked in — the eval runner has none.
38+ // The tui package maps each name to actual colours.
39+ var Themes = []string{"ember", "mint", "iris", "mono"}
40+
41+ // Config is what the wizard saves. Every field is optional: an empty one means
42+ // "no preference", which falls through to the flag, the environment, or the
43+ // built-in default, in that order.
44+ type Config struct {
45+ Version int `json:"version"`
46+ // Model is an API model id, not a display name.
47+ Model string `json:"model,omitempty"`
48+ // Effort is one of llm.EffortLevels.
49+ Effort string `json:"effort,omitempty"`
50+ // Approvals is ApprovalsAsk or ApprovalsAcceptAll.
51+ Approvals string `json:"approvals,omitempty"`
52+ // Theme is one of Themes.
53+ Theme string `json:"theme,omitempty"`
54+ }
55+
56+ // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or
57+ // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset.
58+ func Path() (string, error) {
59+ dir, err := os.UserConfigDir()
60+ if err != nil {
61+ return "", fmt.Errorf("config: no config directory: %w", err)
62+ }
63+ return filepath.Join(dir, "harness", Filename), nil
64+ }
65+
66+ // Load reads the config. The boolean reports whether a usable one was found;
67+ // a missing file is not an error, because that is exactly what the first run
68+ // looks like and the wizard is the answer to it.
69+ //
70+ // A file that exists but cannot be read or parsed comes back as an error with
71+ // found=false. The caller is expected to carry on with defaults and say so:
72+ // what it must not do is treat the file as absent, because the next Save would
73+ // then overwrite settings the user still has.
74+ func Load() (Config, bool, error) {
75+ path, err := Path()
76+ if err != nil {
77+ return Config{}, false, err
78+ }
79+ raw, err := os.ReadFile(path)
80+ if errors.Is(err, fs.ErrNotExist) {
81+ return Config{}, false, nil
82+ }
83+ if err != nil {
84+ return Config{}, false, fmt.Errorf("config: reading %s: %w", path, err)
85+ }
86+
87+ var cfg Config
88+ if err := json.Unmarshal(raw, &cfg); err != nil {
89+ return Config{}, false, fmt.Errorf("config: %s is not valid JSON: %w", path, err)
90+ }
91+ return sanitize(cfg), true, nil
92+ }
93+
94+ // sanitize drops values this build does not recognise. A config written by a
95+ // newer harness, or edited by hand, should degrade to the default for that
96+ // field rather than be forwarded to the API, which would reject the request
97+ // with an error pointing at nothing the user can see.
98+ func sanitize(c Config) Config {
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) {
102+ c.Effort = ""
103+ }
104+ if c.Approvals != ApprovalsAsk && c.Approvals != ApprovalsAcceptAll {
105+ c.Approvals = ""
106+ }
107+ if !ValidTheme(c.Theme) {
108+ c.Theme = ""
109+ }
110+ return c
111+ }
112+
113+ // ValidTheme reports whether name is a palette this build knows.
114+ func ValidTheme(name string) bool { return contains(Themes, name) }
115+
116+ func contains(set []string, v string) bool {
117+ for _, s := range set {
118+ if s == v {
119+ return true
120+ }
121+ }
122+ return false
123+ }
124+
125+ // Save writes the config, creating the directory if it is not there yet.
126+ //
127+ // The write goes to a temporary file that is then renamed over the target, so
128+ // an interrupted or failed write leaves the previous config intact instead of
129+ // truncating it to nothing.
130+ func Save(c Config) error {
131+ path, err := Path()
132+ if err != nil {
133+ return err
134+ }
135+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
136+ return fmt.Errorf("config: creating %s: %w", filepath.Dir(path), err)
137+ }
138+
139+ c.Version = Version
140+ body, err := json.MarshalIndent(c, "", " ")
141+ if err != nil {
142+ return fmt.Errorf("config: encoding: %w", err)
143+ }
144+ body = append(body, '\n')
145+
146+ tmp, err := os.CreateTemp(filepath.Dir(path), Filename+".*")
147+ if err != nil {
148+ return fmt.Errorf("config: %w", err)
149+ }
150+ defer os.Remove(tmp.Name()) // no-op once the rename below succeeds
151+ if _, err := tmp.Write(body); err != nil {
152+ tmp.Close()
153+ return fmt.Errorf("config: writing %s: %w", tmp.Name(), err)
154+ }
155+ if err := tmp.Close(); err != nil {
156+ return fmt.Errorf("config: writing %s: %w", tmp.Name(), err)
157+ }
158+ if err := os.Rename(tmp.Name(), path); err != nil {
159+ return fmt.Errorf("config: replacing %s: %w", path, err)
160+ }
161+ return nil
162+ }
163+
164+ // Or returns the first non-empty of its arguments. It is how precedence is
165+ // spelled at the call sites: Or(flag, cfg.Model) is "the flag, else what was
166+ // configured, else whatever the client defaults to".
167+ func Or(vals ...string) string {
168+ for _, v := range vals {
169+ if v != "" {
170+ return v
171+ }
172+ }
173+ return ""
174+ }
+154−0internal/config/config_test.goaddedView File
@@ -0,0 +1,154 @@
1+ package config
2+
3+ import (
4+ "harness/internal/llm"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ )
10+
11+ // isolate points the config at a temp directory, so a test never reads or
12+ // writes the developer's real ~/.config/harness.
13+ func isolate(t *testing.T) string {
14+ t.Helper()
15+ dir := t.TempDir()
16+ t.Setenv("XDG_CONFIG_HOME", dir)
17+ return filepath.Join(dir, "harness", Filename)
18+ }
19+
20+ func TestLoadReportsMissingWithoutError(t *testing.T) {
21+ isolate(t)
22+
23+ cfg, found, err := Load()
24+ if err != nil {
25+ t.Fatalf("a missing config is the first-run signal, not an error: %v", err)
26+ }
27+ if found {
28+ t.Error("reported a config that does not exist")
29+ }
30+ if cfg != (Config{}) {
31+ t.Errorf("a missing config should come back zeroed, got %+v", cfg)
32+ }
33+ }
34+
35+ func TestSaveThenLoadRoundTrips(t *testing.T) {
36+ path := isolate(t)
37+
38+ want := Config{Model: "claude-sonnet-5", Effort: "high", Approvals: ApprovalsAcceptAll, Theme: "iris"}
39+ if err := Save(want); err != nil {
40+ t.Fatalf("Save: %v", err)
41+ }
42+ if _, err := os.Stat(path); err != nil {
43+ t.Fatalf("Save did not write %s: %v", path, err)
44+ }
45+
46+ got, found, err := Load()
47+ if err != nil {
48+ t.Fatalf("Load: %v", err)
49+ }
50+ if !found {
51+ t.Fatal("Load did not find the config Save just wrote")
52+ }
53+ want.Version = Version // stamped on write
54+ if got != want {
55+ t.Errorf("round trip changed the config:\n got %+v\nwant %+v", got, want)
56+ }
57+ }
58+
59+ // A file we cannot parse must not be silently replaced: the user's settings
60+ // are still in there, and the next Save would destroy them without the error
61+ // ever being shown.
62+ func TestLoadRejectsMalformedWithoutDiscardingIt(t *testing.T) {
63+ path := isolate(t)
64+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ cfg, found, err := Load()
72+ if err == nil {
73+ t.Fatal("a malformed config loaded cleanly")
74+ }
75+ if found {
76+ t.Error("a malformed config was reported as usable")
77+ }
78+ if cfg != (Config{}) {
79+ t.Errorf("a malformed config produced values: %+v", cfg)
80+ }
81+ if b, _ := os.ReadFile(path); string(b) != "{not json" {
82+ t.Errorf("Load rewrote the file it could not parse: %q", b)
83+ }
84+ if !strings.Contains(err.Error(), path) {
85+ t.Errorf("the error does not say which file to fix: %v", err)
86+ }
87+ }
88+
89+ func TestSaveCreatesTheDirectory(t *testing.T) {
90+ path := isolate(t)
91+ if err := Save(Config{Model: "claude-opus-5"}); err != nil {
92+ t.Fatalf("Save into a directory that does not exist yet: %v", err)
93+ }
94+ if _, err := os.Stat(path); err != nil {
95+ t.Fatal(err)
96+ }
97+ }
98+
99+ // Unknown values are how a config written by a newer build, or edited by
100+ // hand, reaches an older one. They are dropped rather than passed through to
101+ // the API, which would fail the request with something unhelpful.
102+ func TestLoadDropsValuesItDoesNotRecognise(t *testing.T) {
103+ path := isolate(t)
104+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
105+ t.Fatal(err)
106+ }
107+ body := `{"version":1,"model":"claude-opus-5","effort":"extreme","approvals":"sometimes","theme":"neon"}`
108+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
109+ t.Fatal(err)
110+ }
111+
112+ cfg, found, err := Load()
113+ if err != nil || !found {
114+ t.Fatalf("Load(found=%v): %v", found, err)
115+ }
116+ if cfg.Model != "claude-opus-5" {
117+ t.Errorf("a recognised field was dropped: %q", cfg.Model)
118+ }
119+ if cfg.Effort != "" || cfg.Approvals != "" || cfg.Theme != "" {
120+ t.Errorf("unrecognised values survived: %+v", cfg)
121+ }
122+ }
123+
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)
130+ }
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)
137+ }
138+ }
139+ }
140+
141+ // The config is only ever a default: a flag says what this run should do, and
142+ // it has to win.
143+ func TestFlagsOverrideTheConfig(t *testing.T) {
144+ cfg := Config{Model: "claude-sonnet-5", Effort: "low"}
145+ if got := Or("claude-opus-5", cfg.Model); got != "claude-opus-5" {
146+ t.Errorf("the flag lost to the config: %q", got)
147+ }
148+ if got := Or("", cfg.Model); got != "claude-sonnet-5" {
149+ t.Errorf("an unset flag did not fall through to the config: %q", got)
150+ }
151+ if got := Or("", ""); got != "" {
152+ t.Errorf("two empties produced %q", got)
153+ }
154+ }
+55−9internal/llm/client.goView File
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "io"
910 "math"
@@ -11,6 +12,7 @@ import (
1112 "os"
1213 "strconv"
1314 "strings"
15+ "sync"
1416 "time"
1517 )
1618
@@ -28,7 +30,6 @@ const (
2830 // Client talks to POST /v1/messages.
2931 type Client struct {
3032 BaseURL string
31- Model string
3233 MaxTokens int
3334 MaxRetries int
3435 // RetryDelay is the base of the exponential backoff between retries.
@@ -38,8 +39,34 @@ type Client struct {
3839 apiKey string
3940 authToken string
4041 tokenSource func(context.Context) (string, error)
42+
43+ // model is behind a lock because a front end can switch it while the
44+ // agent is between turns. It is the one field that moves after
45+ // construction, so it is the one that is not public.
46+ mu sync.Mutex
47+ model string
48+ }
49+
50+ // Model reports the model requests default to.
51+ func (c *Client) Model() string {
52+ c.mu.Lock()
53+ defer c.mu.Unlock()
54+ return c.model
4155 }
4256
57+ // SetModel changes the model the requests that follow will ask for.
58+ func (c *Client) SetModel(id string) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+ c.model = id
62+ }
63+
64+ // errNoCredentials is what every route reports when it has nothing to
65+ // authenticate with. It is one value rather than three copies of a string
66+ // because ListModels, CreateMessage and StreamMessage all reach it, and a
67+ // caller comparing them should not have to hope they were typed alike.
68+ var errNoCredentials = errors.New("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
69+
4370 // Option configures a Client.
4471 type Option func(*Client)
4572
@@ -56,7 +83,7 @@ func WithBaseURL(u string) Option {
5683 func WithModel(m string) Option {
5784 return func(c *Client) {
5885 if m != "" {
59- c.Model = m
86+ c.model = m
6087 }
6188 }
6289 }
@@ -101,7 +128,7 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h
101128 func New(opts ...Option) *Client {
102129 c := &Client{
103130 BaseURL: DefaultBaseURL,
104- Model: DefaultModel,
131+ model: DefaultModel,
105132 MaxTokens: DefaultMaxTokens,
106133 MaxRetries: defaultMaxRetries,
107134 RetryDelay: 500 * time.Millisecond,
@@ -208,14 +235,14 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
208235 // Checked once, up front: a missing credential is permanent, and retrying
209236 // it three times with backoff just delays the error.
210237 if !c.HasCredentials() {
211- return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
238+ return nil, errNoCredentials
212239 }
213240 a, err := c.resolveAuth(ctx)
214241 if err != nil {
215242 return nil, err
216243 }
217244 if req.Model == "" {
218- req.Model = c.Model
245+ req.Model = c.Model()
219246 }
220247 if req.MaxTokens == 0 {
221248 req.MaxTokens = c.MaxTokens
@@ -248,14 +275,33 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
248275 return nil, lastErr
249276 }
250277
278+ // newRequest builds a request to the endpoint with the version and the
279+ // credential already on it. Every call the client makes goes through here, so
280+ // a second route cannot end up authenticating differently from the first.
281+ //
282+ // The credential arrives as a value rather than being read off the client,
283+ // because it may have to be refreshed and that has to happen once per call,
284+ // outside the retry loop — see resolveAuth.
285+ func (c *Client) newRequest(ctx context.Context, method, path string, body []byte, a auth) (*http.Request, error) {
286+ var rdr io.Reader
287+ if body != nil {
288+ rdr = bytes.NewReader(body)
289+ }
290+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
291+ if err != nil {
292+ return nil, err
293+ }
294+ req.Header.Set("content-type", "application/json")
295+ req.Header.Set("anthropic-version", apiVersion)
296+ a.apply(req.Header)
297+ return req, nil
298+ }
299+
251300 func (c *Client) attempt(ctx context.Context, body []byte, a auth) (*Response, error) {
252- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
301+ httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body, a)
253302 if err != nil {
254303 return nil, err
255304 }
256- httpReq.Header.Set("content-type", "application/json")
257- httpReq.Header.Set("anthropic-version", apiVersion)
258- a.apply(httpReq.Header)
259305
260306 httpResp, err := c.HTTP.Do(httpReq)
261307 if err != nil {
+31−0internal/llm/client_test.goView File
@@ -403,3 +403,34 @@ func TestStreamUsesTheTokenSource(t *testing.T) {
403403 t.Errorf("anthropic-beta = %q, want %s", got.Get("anthropic-beta"), oauthBeta)
404404 }
405405 }
406+
407+ // The model is switchable mid-session, so what a request carries is whatever
408+ // was last set rather than what the client was built with.
409+ func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) {
410+ var models []string
411+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
412+ var body map[string]any
413+ raw, _ := io.ReadAll(r.Body)
414+ json.Unmarshal(raw, &body)
415+ models = append(models, fmt.Sprint(body["model"]))
416+ io.WriteString(w, okResponse)
417+ })
418+ WithModel("claude-opus-5")(c)
419+
420+ msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}}
421+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
422+ t.Fatalf("CreateMessage: %v", err)
423+ }
424+
425+ c.SetModel("claude-haiku-4-5")
426+ if got := c.Model(); got != "claude-haiku-4-5" {
427+ t.Errorf("Model() = %q", got)
428+ }
429+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
430+ t.Fatalf("CreateMessage: %v", err)
431+ }
432+
433+ if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want {
434+ t.Errorf("models sent = %v, want %q", models, want)
435+ }
436+ }
+93−0internal/llm/models.goaddedView File
@@ -0,0 +1,93 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "time"
11+ )
12+
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.
25+ type Model struct {
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"`
31+ }
32+
33+ type modelPage struct {
34+ Data []Model `json:"data"`
35+ HasMore bool `json:"has_more"`
36+ LastID string `json:"last_id"`
37+ }
38+
39+ // ListModels returns the models the endpoint offers, in the order it gave
40+ // them — which from Anthropic is newest first.
41+ //
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.
46+ func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
47+ if !c.HasCredentials() {
48+ return nil, errNoCredentials
49+ }
50+ // Resolved once for the whole pagination loop rather than per page: a
51+ // borrowed credential may need refreshing, and that is a network call.
52+ a, err := c.resolveAuth(ctx)
53+ if err != nil {
54+ return nil, err
55+ }
56+
57+ var out []Model
58+ after := ""
59+ for page := 0; page < maxModelPages; page++ {
60+ q := url.Values{"limit": {fmt.Sprint(modelPageSize)}}
61+ if after != "" {
62+ q.Set("after_id", after)
63+ }
64+
65+ req, err := c.newRequest(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil, a)
66+ if err != nil {
67+ return nil, err
68+ }
69+ resp, err := c.HTTP.Do(req)
70+ if err != nil {
71+ return nil, fmt.Errorf("llm: %w", err)
72+ }
73+ raw, err := io.ReadAll(resp.Body)
74+ resp.Body.Close()
75+ if err != nil {
76+ return nil, fmt.Errorf("llm: reading models: %w", err)
77+ }
78+ if resp.StatusCode != http.StatusOK {
79+ return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw)
80+ }
81+
82+ var p modelPage
83+ if err := json.Unmarshal(raw, &p); err != nil {
84+ return nil, fmt.Errorf("llm: decoding models: %w", err)
85+ }
86+ out = append(out, p.Data...)
87+ if !p.HasMore || p.LastID == "" {
88+ break
89+ }
90+ after = p.LastID
91+ }
92+ return out, nil
93+ }
+101−0internal/llm/models_test.goaddedView File
@@ -0,0 +1,101 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "strings"
8+ "testing"
9+ )
10+
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+ })
28+
29+ got, err := c.ListModels(context.Background())
30+ if err != nil {
31+ t.Fatalf("ListModels: %v", err)
32+ }
33+
34+ var ids []string
35+ for _, m := range got {
36+ ids = append(ids, m.ID)
37+ }
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)
40+ }
41+ if got[0].DisplayName != "Claude Opus 5" {
42+ t.Errorf("display name = %q", got[0].DisplayName)
43+ }
44+ if len(paths) != 2 {
45+ t.Fatalf("requests = %v, want a second one for the second page", paths)
46+ }
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)
55+ }
56+ }
57+
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+
65+ _, err := c.ListModels(context.Background())
66+ if err == nil {
67+ t.Fatal("a 404 produced no error")
68+ }
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)
71+ }
72+ }
73+
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)
83+
84+ if _, err := c.ListModels(context.Background()); err != nil {
85+ t.Fatalf("ListModels: %v", err)
86+ }
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")
92+ }
93+ }
94+
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")
100+ }
101+ }
+75−1internal/llm/pricing.goView File
@@ -1,5 +1,10 @@
11 package llm
22
3+ import (
4+ "sort"
5+ "strings"
6+ )
7+
38 // Price is per-million-token list pricing.
49 type Price struct {
510 InputPerMTok float64
@@ -27,7 +32,7 @@ var prices = map[string]Price{
2732 // Cost prices one usage record. The boolean reports whether the model's
2833 // pricing is known; a made-up number in METRICS.md is worse than a blank.
2934 func (u Usage) Cost(model string) (float64, bool) {
30- p, ok := prices[model]
35+ p, ok := prices[basePrice(model)]
3136 if !ok {
3237 return 0, false
3338 }
@@ -36,3 +41,72 @@ func (u Usage) Cost(model string) (float64, bool) {
3641 float64(u.CacheReadInputTokens)*p.InputPerMTok*cacheReadMultiplier +
3742 float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
3843 }
44+
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+ }
108+ }
109+ // Unrecognised: after every known family, and left to the id tiebreak to
110+ // order among themselves.
111+ return len(prices)
112+ }
+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+ }
+6−7internal/llm/stream.goView File
@@ -2,7 +2,6 @@ package llm
22
33 import (
44 "bufio"
5- "bytes"
65 "context"
76 "encoding/json"
87 "fmt"
@@ -35,14 +34,14 @@ type StreamEvent struct {
3534 // returns the assembled response. The callback may be nil.
3635 func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(StreamEvent)) (*Response, error) {
3736 if !c.HasCredentials() {
38- return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or log in with Claude Code")
37+ return nil, errNoCredentials
3938 }
4039 a, err := c.resolveAuth(ctx)
4140 if err != nil {
4241 return nil, err
4342 }
4443 if req.Model == "" {
45- req.Model = c.Model
44+ req.Model = c.Model()
4645 }
4746 if req.MaxTokens == 0 {
4847 req.MaxTokens = c.MaxTokens
@@ -83,14 +82,14 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St
8382 }
8483
8584 func (c *Client) streamAttempt(ctx context.Context, body []byte, a auth, onEvent func(StreamEvent)) (*Response, error) {
86- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
85+ httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body, a)
8786 if err != nil {
8887 return nil, err
8988 }
90- httpReq.Header.Set("content-type", "application/json")
89+ // The only thing that distinguishes a streaming request from a buffered
90+ // one on the wire. Everything else, including the credential, comes from
91+ // newRequest so the two routes cannot drift apart.
9192 httpReq.Header.Set("accept", "text/event-stream")
92- httpReq.Header.Set("anthropic-version", apiVersion)
93- a.apply(httpReq.Header)
9493
9594 httpResp, err := c.HTTP.Do(httpReq)
9695 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+ }
+230−0internal/tui/anim.goaddedView File
@@ -0,0 +1,230 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "math"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/charmbracelet/x/ansi"
12+ )
13+
14+ // Animation primitives for the setup wizard.
15+ //
16+ // All of it is string arithmetic on a fixed frame budget: there is no
17+ // goroutine, no clock beyond Bubble Tea's tick, and every animation is a pure
18+ // function of an integer frame number. That is what makes the animations
19+ // testable — a test asks for frame n and gets the same thing the terminal
20+ // would draw — and what makes them skippable, since jumping to the last frame
21+ // is just passing a bigger number.
22+
23+ // frameInterval is one animation frame. About 30fps: fast enough that a wipe
24+ // reads as motion, slow enough that a terminal over ssh keeps up.
25+ const frameInterval = 33 * time.Millisecond
26+
27+ // ease is the smoothstep curve, on [0,1]. Linear motion in a terminal reads as
28+ // mechanical; easing in and out reads as something sliding to a stop.
29+ func ease(t float64) float64 {
30+ t = clamp01(t)
31+ return t * t * (3 - 2*t)
32+ }
33+
34+ // easeOut decelerates only. It is for things that arrive — a panel coming to
35+ // rest — where an ease-in at the start reads as lag.
36+ func easeOut(t float64) float64 {
37+ t = clamp01(t)
38+ return 1 - (1-t)*(1-t)
39+ }
40+
41+ func clamp01(t float64) float64 {
42+ switch {
43+ case t < 0:
44+ return 0
45+ case t > 1:
46+ return 1
47+ }
48+ return t
49+ }
50+
51+ // progress turns a frame number into a 0..1 position over n frames. A zero or
52+ // negative budget is already finished, which is how the reduced-motion path
53+ // collapses every animation to its final frame without a special case at each
54+ // call site.
55+ func progress(frame, n int) float64 {
56+ if n <= 0 {
57+ return 1
58+ }
59+ return clamp01(float64(frame) / float64(n))
60+ }
61+
62+ // blend mixes two adaptive colours, channel by channel, on both the light and
63+ // the dark variant. It is what lets a letter ignite from faint to the accent
64+ // rather than snapping between the two.
65+ func blend(a, b lipgloss.AdaptiveColor, t float64) lipgloss.AdaptiveColor {
66+ return lipgloss.AdaptiveColor{
67+ Light: blendHex(a.Light, b.Light, t),
68+ Dark: blendHex(a.Dark, b.Dark, t),
69+ }
70+ }
71+
72+ // blendHex interpolates two "#rrggbb" strings. Anything it cannot parse comes
73+ // back as one of the endpoints rather than as a broken colour string, because
74+ // a malformed colour is rendered by lipgloss as no colour at all — which would
75+ // show up as a hole in the animation rather than as an error anyone could act
76+ // on.
77+ func blendHex(a, b string, t float64) string {
78+ ar, ag, ab, aok := parseHex(a)
79+ br, bg, bb, bok := parseHex(b)
80+ if !aok || !bok {
81+ if t < 0.5 {
82+ return a
83+ }
84+ return b
85+ }
86+ t = clamp01(t)
87+ lerp := func(x, y int) int { return int(math.Round(float64(x) + (float64(y)-float64(x))*t)) }
88+ return fmt.Sprintf("#%02x%02x%02x", lerp(ar, br), lerp(ag, bg), lerp(ab, bb))
89+ }
90+
91+ func parseHex(s string) (r, g, b int, ok bool) {
92+ s = strings.TrimPrefix(s, "#")
93+ if len(s) != 6 {
94+ return 0, 0, 0, false
95+ }
96+ v, err := strconv.ParseUint(s, 16, 32)
97+ if err != nil {
98+ return 0, 0, 0, false
99+ }
100+ return int(v >> 16 & 0xff), int(v >> 8 & 0xff), int(v & 0xff), true
101+ }
102+
103+ // triangle is a 0..1 triangle wave over period frames, for anything that
104+ // breathes. Triangle rather than sine because the ends are what you notice — a
105+ // sine lingers at both extremes and reads as a stutter at this frame rate.
106+ // (The name pulse is taken by the spinner.)
107+ func triangle(frame, period int) float64 {
108+ if period <= 1 {
109+ return 1
110+ }
111+ p := frame % period
112+ half := period / 2
113+ if p < half {
114+ return float64(p) / float64(half)
115+ }
116+ return 1 - float64(p-half)/float64(period-half)
117+ }
118+
119+ // slide pushes next in from the right while prev leaves to the left, as one
120+ // strip of film moving across the frame.
121+ //
122+ // Both panels are cut with an ANSI-aware slicer, so a line's colours survive
123+ // being cut in half. They are padded to a common height and width first: a
124+ // ragged panel would otherwise shear, with each line starting its slide from
125+ // wherever its own text happened to end.
126+ func slide(prev, next string, width, height int, t float64) string {
127+ width, height = maxInt(width, 1), maxInt(height, 1)
128+ off := int(math.Round(ease(t) * float64(width)))
129+
130+ p := padPanel(prev, width, height)
131+ n := padPanel(next, width, height)
132+
133+ out := make([]string, height)
134+ for i := range out {
135+ switch {
136+ case off <= 0:
137+ out[i] = p[i]
138+ case off >= width:
139+ out[i] = n[i]
140+ default:
141+ // The tail of the outgoing panel, then the head of the incoming
142+ // one: together exactly one screen wide.
143+ out[i] = ansi.Cut(p[i], off, width) + ansi.Cut(n[i], 0, off)
144+ }
145+ }
146+ return strings.Join(out, "\n")
147+ }
148+
149+ // padPanel squares a rendered panel off to width by height, so it can be cut
150+ // at any column without the cut landing past the end of a short line.
151+ func padPanel(s string, width, height int) []string {
152+ lines := strings.Split(s, "\n")
153+ if len(lines) > height {
154+ lines = lines[:height]
155+ }
156+ out := make([]string, height)
157+ for i := range out {
158+ line := ""
159+ if i < len(lines) {
160+ line = lines[i]
161+ }
162+ if w := lipgloss.Width(line); w < width {
163+ line += strings.Repeat(" ", width-w)
164+ } else if w > width {
165+ line = ansi.Truncate(line, width, "")
166+ }
167+ out[i] = line
168+ }
169+ return out
170+ }
171+
172+ // reveal returns the first n display cells of s, for a typewriter. It counts
173+ // cells rather than bytes so a multi-byte character is never half-drawn.
174+ func reveal(s string, n int) string {
175+ if n <= 0 {
176+ return ""
177+ }
178+ if lipgloss.Width(s) <= n {
179+ return s
180+ }
181+ return ansi.Cut(s, 0, n)
182+ }
183+
184+ // sweep renders a bar filled to t, in the accent, with the leading cell
185+ // brightened so the fill has a head rather than an edge.
186+ func sweep(width int, t float64, fill lipgloss.AdaptiveColor) string {
187+ width = maxInt(width, 1)
188+ done := int(math.Round(clamp01(t) * float64(width)))
189+
190+ var b strings.Builder
191+ for i := 0; i < width; i++ {
192+ switch {
193+ case i < done-1:
194+ b.WriteString(lipgloss.NewStyle().Foreground(fill).Render("━"))
195+ case i == done-1:
196+ // The head is the accent lifted toward the text colour: a moving
197+ // highlight, which is what makes the bar look like it is being
198+ // drawn rather than growing.
199+ b.WriteString(lipgloss.NewStyle().Foreground(blend(fill, colText, 0.45)).Bold(true).Render("━"))
200+ default:
201+ b.WriteString(styFaint.Render("─"))
202+ }
203+ }
204+ return b.String()
205+ }
206+
207+ // ignite renders text letter by letter, each one fading from `from` to `to` as
208+ // a wave passes over it. The wave is a few cells wide, so the word lights up
209+ // as a sweep rather than as a row of independent blinks.
210+ func ignite(text string, from, to lipgloss.AdaptiveColor, t float64, bold bool) string {
211+ runes := []rune(text)
212+ if len(runes) == 0 {
213+ return ""
214+ }
215+ // The head travels past the end of the word by the width of the wave, so
216+ // the last letter finishes rather than stopping mid-fade.
217+ const wave = 4.0
218+ head := clamp01(t) * (float64(len(runes)) + wave)
219+
220+ var b strings.Builder
221+ for i, r := range runes {
222+ lit := clamp01((head - float64(i)) / wave)
223+ st := lipgloss.NewStyle().Foreground(blend(from, to, lit))
224+ if bold && lit > 0.5 {
225+ st = st.Bold(true)
226+ }
227+ b.WriteString(st.Render(string(r)))
228+ }
229+ return b.String()
230+ }
+229−0internal/tui/anim_test.goaddedView File
@@ -0,0 +1,229 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/muesli/termenv"
9+ )
10+
11+ // A slide is only correct if every intermediate frame is exactly as wide as
12+ // the screen. One cell out and the terminal reflows mid-animation, which looks
13+ // like the whole UI tearing.
14+ func TestSlideKeepsEveryFrameTheSameSize(t *testing.T) {
15+ forceColour(t)
16+
17+ prev := styCoral.Render("aaaaaaaaaa") + "\n" + styDim.Render("bbbb")
18+ next := styOK.Render("cccc") + "\n" + styErr.Render("dddddddddddddddd")
19+
20+ const width, height = 20, 4
21+ for frame := 0; frame <= slideFrames; frame++ {
22+ got := slide(prev, next, width, height, progress(frame, slideFrames))
23+ lines := strings.Split(got, "\n")
24+ if len(lines) != height {
25+ t.Fatalf("frame %d is %d lines, want %d", frame, len(lines), height)
26+ }
27+ for i, line := range lines {
28+ if w := lipgloss.Width(line); w != width {
29+ t.Errorf("frame %d line %d is %d cells, want %d", frame, i, w, width)
30+ }
31+ }
32+ }
33+ }
34+
35+ // The ends have to be exact: a slide that does not start on the old screen or
36+ // finish on the new one shows a jump at one end or the other.
37+ func TestSlideEndsAreTheTwoPanels(t *testing.T) {
38+ prev, next := "old ", "new "
39+ if got := slide(prev, next, 4, 1, 0); got != prev {
40+ t.Errorf("at t=0 the slide shows %q, want the outgoing panel", got)
41+ }
42+ if got := slide(prev, next, 4, 1, 1); got != next {
43+ t.Errorf("at t=1 the slide shows %q, want the incoming panel", got)
44+ }
45+ }
46+
47+ // A ragged panel — short lines, or more lines than fit — must be squared off
48+ // rather than sheared, or each row slides from a different place.
49+ func TestSlideSquaresOffRaggedPanels(t *testing.T) {
50+ got := slide("a\nbbbbbbbb\n", "x", 4, 2, 0.5)
51+ for i, line := range strings.Split(got, "\n") {
52+ if w := lipgloss.Width(line); w != 4 {
53+ t.Errorf("line %d is %d cells, want 4", i, w)
54+ }
55+ }
56+ }
57+
58+ func TestBlendHexInterpolates(t *testing.T) {
59+ cases := []struct {
60+ a, b string
61+ t float64
62+ want string
63+ }{
64+ {"#000000", "#ffffff", 0, "#000000"},
65+ {"#000000", "#ffffff", 1, "#ffffff"},
66+ {"#000000", "#ffffff", 0.5, "#808080"},
67+ {"#ff0000", "#0000ff", 0.5, "#800080"},
68+ // Out of range is clamped rather than extrapolated into nonsense.
69+ {"#000000", "#ffffff", 2, "#ffffff"},
70+ {"#000000", "#ffffff", -1, "#000000"},
71+ }
72+ for _, c := range cases {
73+ if got := blendHex(c.a, c.b, c.t); got != c.want {
74+ t.Errorf("blendHex(%s, %s, %v) = %s, want %s", c.a, c.b, c.t, got, c.want)
75+ }
76+ }
77+ }
78+
79+ // An unparseable colour must degrade to one of the endpoints. lipgloss renders
80+ // a malformed colour as no colour at all, which would show up as a hole in the
81+ // animation rather than as anything anyone could debug.
82+ func TestBlendHexSurvivesAColourItCannotParse(t *testing.T) {
83+ for _, c := range []struct{ a, b string }{
84+ {"not-a-colour", "#ffffff"},
85+ {"#000000", "13"},
86+ {"", ""},
87+ } {
88+ if got := blendHex(c.a, c.b, 0.2); got != c.a {
89+ t.Errorf("blendHex(%q, %q, 0.2) = %q, want %q", c.a, c.b, got, c.a)
90+ }
91+ if got := blendHex(c.a, c.b, 0.8); got != c.b {
92+ t.Errorf("blendHex(%q, %q, 0.8) = %q, want %q", c.a, c.b, got, c.b)
93+ }
94+ }
95+ }
96+
97+ // progress with no frames to run is already finished. That is the single
98+ // branch every animation's reduced-motion path goes through.
99+ func TestProgressWithNoBudgetIsComplete(t *testing.T) {
100+ if got := progress(0, 0); got != 1 {
101+ t.Errorf("progress(0, 0) = %v, want 1", got)
102+ }
103+ if got := progress(5, 10); got != 0.5 {
104+ t.Errorf("progress(5, 10) = %v, want 0.5", got)
105+ }
106+ if got := progress(99, 10); got != 1 {
107+ t.Errorf("progress past the end = %v, want 1", got)
108+ }
109+ }
110+
111+ func TestEaseHitsItsEndpoints(t *testing.T) {
112+ for _, f := range []func(float64) float64{ease, easeOut} {
113+ if got := f(0); got != 0 {
114+ t.Errorf("ease(0) = %v, want 0", got)
115+ }
116+ if got := f(1); got != 1 {
117+ t.Errorf("ease(1) = %v, want 1", got)
118+ }
119+ }
120+ // Smoothstep is symmetric about the middle; easeOut is not, and is ahead
121+ // of linear there — which is what "decelerates" means.
122+ if got := ease(0.5); got != 0.5 {
123+ t.Errorf("ease(0.5) = %v, want 0.5", got)
124+ }
125+ if easeOut(0.5) <= 0.5 {
126+ t.Errorf("easeOut(0.5) = %v, want more than linear", easeOut(0.5))
127+ }
128+ }
129+
130+ // The triangle wave has to actually reach both ends, or a breathing cursor
131+ // either never lights up or never dims.
132+ func TestTriangleReachesBothEnds(t *testing.T) {
133+ var lo, hi float64 = 1, 0
134+ for f := 0; f < breathPeriod; f++ {
135+ v := triangle(f, breathPeriod)
136+ if v < 0 || v > 1 {
137+ t.Fatalf("triangle(%d) = %v, outside [0,1]", f, v)
138+ }
139+ lo, hi = minFloat(lo, v), maxFloat(hi, v)
140+ }
141+ if lo > 0.01 {
142+ t.Errorf("the wave never dims below %v", lo)
143+ }
144+ if hi < 0.99 {
145+ t.Errorf("the wave never brightens past %v", hi)
146+ }
147+ }
148+
149+ func TestSweepFillsLeftToRight(t *testing.T) {
150+ forceColour(t)
151+ for _, tc := range []struct{ t, wantFilled float64 }{{0, 0}, {0.5, 10}, {1, 20}} {
152+ got := sweep(20, tc.t, colCoral)
153+ if w := lipgloss.Width(got); w != 20 {
154+ t.Errorf("t=%v: the bar is %d cells, want 20", tc.t, w)
155+ }
156+ if n := strings.Count(stripANSI(got), "━"); float64(n) != tc.wantFilled {
157+ t.Errorf("t=%v: %d cells filled, want %v", tc.t, n, tc.wantFilled)
158+ }
159+ }
160+ }
161+
162+ // reveal is the typewriter. It counts cells, so a multi-byte character is
163+ // either drawn or not drawn, never halved.
164+ func TestRevealCountsCellsNotBytes(t *testing.T) {
165+ const s = "héllo wörld"
166+ for n := 0; n <= lipgloss.Width(s); n++ {
167+ got := reveal(s, n)
168+ if w := lipgloss.Width(got); w != n {
169+ t.Errorf("reveal(%d) is %d cells: %q", n, w, got)
170+ }
171+ }
172+ if got := reveal(s, 99); got != s {
173+ t.Errorf("revealing past the end gave %q", got)
174+ }
175+ }
176+
177+ // ignite has to finish: at t=1 every letter is fully in the target colour, or
178+ // the title settles a shade off and stays there.
179+ func TestIgniteFinishesEveryLetter(t *testing.T) {
180+ forceColour(t)
181+
182+ // At t=1 every letter must be painted the target colour exactly: the same
183+ // sequence lipgloss emits for a plain render in it.
184+ lit := ignite("harness", colFaint, colCoral, 1, false)
185+ want := termenv.TrueColor.Color(colCoral.Dark).Sequence(false)
186+ if n := strings.Count(lit, want); n != len("harness") {
187+ t.Errorf("%d of %d letters reached the target colour at t=1", n, len("harness"))
188+ }
189+
190+ if got := stripANSI(lit); got != "harness" {
191+ t.Errorf("ignite changed the text to %q", got)
192+ }
193+ if got := stripANSI(ignite("harness", colFaint, colCoral, 0, false)); got != "harness" {
194+ t.Errorf("at t=0 the text is %q, want it present but unlit", got)
195+ }
196+ }
197+
198+ // stripANSI removes escape sequences, for assertions about what was written
199+ // rather than how it was painted.
200+ func stripANSI(s string) string {
201+ var out strings.Builder
202+ for i := 0; i < len(s); {
203+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
204+ j := i + 2
205+ for j < len(s) && s[j] != 'm' {
206+ j++
207+ }
208+ i = j + 1
209+ continue
210+ }
211+ out.WriteByte(s[i])
212+ i++
213+ }
214+ return out.String()
215+ }
216+
217+ func minFloat(a, b float64) float64 {
218+ if a < b {
219+ return a
220+ }
221+ return b
222+ }
223+
224+ func maxFloat(a, b float64) float64 {
225+ if a > b {
226+ return a
227+ }
228+ return b
229+ }
+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+ }
+257−27internal/tui/model.goView File

Large diffs are not rendered by default.

+265−0internal/tui/model_picker.goaddedView File

Large diffs are not rendered by default.

+344−0internal/tui/model_picker_test.goaddedView File

Large diffs are not rendered by default.

+187−1internal/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.

+47−52internal/tui/styles.goView File

Large diffs are not rendered by default.

+203−0internal/tui/theme.goaddedView File

Large diffs are not rendered by default.

+538−0internal/tui/wizard.goaddedView File

Large diffs are not rendered by default.

+439−0internal/tui/wizard_test.goaddedView File

Large diffs are not rendered by default.

+397−0internal/tui/wizard_view.goaddedView File

Large diffs are not rendered by default.