master grew /model, /effort and a slash-command menu while this branch was building the setup wizard, and the two arrived at the same problems independently. Merging them meant deleting the loser of each pair rather than keeping both. llm: master's ListModels wins — it paginates, and its Model carries the context window. Mine is dropped, along with the setHeaders it added (newRequest does that job for every route) and PriceLabel (PriceOf, with basePrice, already reads dated and bracketed ids that mine missed). PrettyModel goes back to being prettyModel in tui, where its one caller is. What llm gains is PricedModels: the ids it can price, which is the list the wizard falls back to. The two front ends now describe a model through the same helpers, so the wizard and the picker cannot disagree about what a model is called or what it costs. They still differ where they should: a catalogue that will not answer is an error the picker reports and closes on, because there is a session behind it, and a fallback the wizard has to have, because on a first run there is nothing behind it. config drops its copy of the effort levels for llm.ValidEffort. Two lists of the same five strings is one list that will be wrong. Agent.SetEffort was added by both; master's is mutex-guarded and comes with Effort(), so mine goes. Applying the wizard's answers moves out of main.go and into the TUI, through the same setters /model and /effort use — useModel and useEffort, split out of applyModel and applyEffort so the wizard can set four things and report them in one line instead of three. main.go is left with SaveConfig, which is the only part of this it has any business knowing about. /setup joins the builtins catalogue, so it is in /help and the completion menu rather than being a command you had to already know. The effort pill points at /effort again, now that /effort exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 31 changed files with 2479 additions and 479 deletions.
| @@ -24,9 +24,10 @@ API: | ||
| 24 | 24 | # or ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN |
| 25 | 25 | ``` |
| 26 | 26 | |
| 27 | - Flags override the environment. `-auth-token` sends `Authorization: Bearer`, | |
| 28 | - which is what OAuth tokens and most gateways want; `-api-key` sends | |
| 29 | - `x-api-key`. A non-default endpoint announces itself on stderr, because your | |
| 27 | + Flags beat the saved config, which beats the built-in defaults; the config is | |
| 28 | + what [the setup wizard](#setup) writes on a first run. `-auth-token` sends | |
| 29 | + `Authorization: Bearer`, which is what OAuth tokens and most gateways want; | |
| 30 | + `-api-key` sends `x-api-key`. A non-default endpoint announces itself on stderr, because your | |
| 30 | 31 | prompt and every file the agent reads go wherever that URL points. |
| 31 | 32 | |
| 32 | 33 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| @@ -72,6 +73,7 @@ to six lines. | ||
| 72 | 73 | | Key | Does | |
| 73 | 74 | | --- | --- | |
| 74 | 75 | | `enter` | send | |
| 76 | + | `/` | open the command menu — see below | | |
| 75 | 77 | | `ctrl+j` | newline without sending | |
| 76 | 78 | | `shift+tab` | cycle the approval mode | |
| 77 | 79 | | `ctrl+r` | expand elided tool output | |
| @@ -79,7 +81,71 @@ to six lines. | ||
| 79 | 81 | | `esc` | interrupt the turn | |
| 80 | 82 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 81 | 83 | |
| 82 | - Commands: `/allowed`, `/clear`, `/help`, `/setup`, `/skills`, `/exit`. | |
| 84 | + Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`, | |
| 85 | + `/setup`, `/skills`, and one per discovered skill. | |
| 86 | + | |
| 87 | + A slash at the start of an empty prompt opens the command menu, which filters | |
| 88 | + as you type — a name that starts with what you typed before one that merely | |
| 89 | + contains it: | |
| 90 | + | |
| 91 | + ``` | |
| 92 | + ╭──────────────────────────────────────────────────────────────────────────────╮ | |
| 93 | + │ › /s │ | |
| 94 | + ╰──────────────────────────────────────────────────────────────────────────────╯ | |
| 95 | + ❯ /skills the instruction sets found at startup | |
| 96 | + /systematic-debugging find the cause before changing code (project) | |
| 97 | + /brainstorming explore an idea before building it (personal) | |
| 98 | + ``` | |
| 99 | + | |
| 100 | + `↑`/`↓` move, `tab` or `enter` completes the highlighted name into the prompt — | |
| 101 | + completing is not sending, so there is room to type arguments for a skill or a | |
| 102 | + level for `/effort` — and `esc` dismisses the menu without touching what you | |
| 103 | + typed. The menu offers exactly the commands the TUI answers, so nothing it | |
| 104 | + suggests can come back as "unknown command". | |
| 105 | + | |
| 106 | + `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and | |
| 107 | + opens a picker on the answer: | |
| 108 | + | |
| 109 | + ``` | |
| 110 | + Select model | |
| 111 | + from anthropic · ↑↓ move · enter apply · esc cancel | |
| 112 | + 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok | |
| 113 | + 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓ | |
| 114 | + ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok | |
| 115 | + 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok | |
| 116 | + ``` | |
| 117 | + | |
| 118 | + The cursor starts on the model in force, which is also the one marked. The id, | |
| 119 | + the context window and the list price come from the endpoint and from the | |
| 120 | + pricing table; a model neither knows about simply says less. The switch applies | |
| 121 | + from the next turn — the conversation so far is replayed to the new model — and | |
| 122 | + costs already incurred stay priced at the model that incurred them, because a | |
| 123 | + session that switched models was not all billed at the model it ended on. | |
| 124 | + | |
| 125 | + Not every endpoint serves that route. When the fetch fails the picker says so, | |
| 126 | + names the host and the reason, and closes; `/model <id>` and the `-model` flag | |
| 127 | + still switch without asking anyone's permission. | |
| 128 | + | |
| 129 | + `/effort` opens a slider in place of the prompt, on the level in force — an | |
| 130 | + unset level is the API's own default, `high`, so the marker starts there rather | |
| 131 | + than at an end: | |
| 132 | + | |
| 133 | + ``` | |
| 134 | + Faster Smarter | |
| 135 | + ──────────────────────────────────────▲─────────────────────────────────── | |
| 136 | + low medium high xhigh max | |
| 137 | + ←→ move · 1-5 jump · enter apply · esc cancel | |
| 138 | + ``` | |
| 139 | + | |
| 140 | + Moving the marker changes nothing until `enter`; `esc` leaves the level alone. | |
| 141 | + `/effort xhigh` still applies a level directly for anyone who can type it, and | |
| 142 | + either way it is the `-effort` flag reached from the keyboard. The level in | |
| 143 | + force shows above the prompt on the right. A terminal too narrow for five | |
| 144 | + labels gets `◀ high ▶` and the same keys rather than a track with the words | |
| 145 | + sliced in half. | |
| 146 | + | |
| 147 | + `/model` and `/effort` change the session. `/setup` changes what the next | |
| 148 | + session starts with — see [Setup](#setup). | |
| 83 | 149 | |
| 84 | 150 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 85 | 151 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -108,32 +174,34 @@ mark assembles, the name lights up, and it asks four questions one at a time. | ||
| 108 | 174 | |
| 109 | 175 | Which model should the agent use? |
| 110 | 176 | |
| 111 | - ▌ ◆ Opus 5 $5 / $25 per Mtok | |
| 112 | - ◇ Sonnet 5 $2 / $10 per Mtok | |
| 113 | - ◇ Haiku 4.5 $1 / $5 per Mtok | |
| 177 | + ▌ ◆ Claude Opus 5 $5 / $25 per Mtok | |
| 178 | + ◇ Claude Sonnet 5 $2 / $10 per Mtok | |
| 179 | + ◇ Claude Haiku 4.5 $1 / $5 per Mtok | |
| 114 | 180 | ◇ custom… |
| 115 | 181 | |
| 116 | - claude-opus-5 | |
| 182 | + claude-opus-5 · 1.0M token context | |
| 117 | 183 | |
| 118 | 184 | ↑↓ move enter next esc back ctrl+c cancel |
| 119 | 185 | ``` |
| 120 | 186 | |
| 121 | 187 | | Question | Answers | |
| 122 | 188 | | --- | --- | |
| 123 | - | Model | whatever `GET /v1/models` reports, priced from what this build knows, plus `custom…` for any id | | |
| 124 | - | Effort | `low` `medium` `high` `xhigh` `max` | | |
| 189 | + | Model | whatever `GET /v1/models` returns, plus `custom…` for any id | | |
| 190 | + | Effort | the API's own `llm.EffortLevels`: `low` `medium` `high` `xhigh` `max` | | |
| 125 | 191 | | Approvals | ask before every mutating call, or accept them all | |
| 126 | 192 | | Theme | `ember` `mint` `iris` `mono` | |
| 127 | 193 | |
| 128 | - The model list is fetched when the wizard opens. Against a gateway that does | |
| 129 | - not serve a catalogue — the common case — it falls back to the models this | |
| 130 | - build can price and says so rather than pretending the short list is the whole | |
| 131 | - truth. `custom…` is there because a gateway may serve something no build has | |
| 194 | + It is the same catalogue and the same pricing table `/model` uses, through the | |
| 195 | + same `GET /v1/models` call, so the two cannot disagree about what a model is | |
| 196 | + called or what it costs. Where they differ is failure: `/model` reports the | |
| 197 | + error and closes, because there is a session to go back to. The wizard cannot | |
| 198 | + do that on a first run, so it falls back to the models this build can price and | |
| 199 | + says so. `custom…` is there because a gateway may serve something no build has | |
| 132 | 200 | heard of. |
| 133 | 201 | |
| 134 | - The theme step previews by applying the palette for real: reading a swatch | |
| 135 | - tells you nothing about what a transcript looks like in it. Cancelling puts the | |
| 136 | - old one back. | |
| 202 | + The theme step previews by applying the palette for real — reading a swatch | |
| 203 | + tells you nothing about what a transcript looks like in it — and it recolours | |
| 204 | + the whole TUI, pickers and slider included. Cancelling puts the old one back. | |
| 137 | 205 | |
| 138 | 206 | Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is |
| 139 | 207 | honoured): |
| @@ -153,33 +221,26 @@ precedence is **flag, then config, then the built-in default**, so | ||
| 153 | 221 | `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a |
| 154 | 222 | default is honoured, but `-yolo` can only turn approvals off, never on — a file |
| 155 | 223 | should not be able to quietly grant the agent more than the command line did. |
| 156 | - | |
| 157 | - `/setup` reopens the wizard at any time and applies the answers to the running | |
| 158 | - session. It is refused mid-turn: changing the model while a turn is in flight | |
| 159 | - would finish it on a different model than it started on. A config file that | |
| 160 | - cannot be parsed is reported and then ignored, and is never overwritten until | |
| 161 | - you finish the wizard. | |
| 162 | - | |
| 163 | - The whole thing is one screen at a time, `↑↓` and `enter`, `esc` to go back. | |
| 164 | - Animations collapse to their final frame under `NO_COLOR`, a `dumb` terminal, | |
| 165 | - or a window under 60×20, and any key skips the one playing. | |
| 166 | - | |
| 167 | - ### Driving it without an API key | |
| 168 | - | |
| 169 | - `internal/tui/testdata/mockapi` is a fake Messages API that streams a canned | |
| 170 | - reply and asks to run a shell command, which is enough to exercise streaming, | |
| 171 | - tool rendering, approve, deny and interrupt for nothing: | |
| 172 | - | |
| 173 | - ```bash | |
| 174 | - go run ./internal/tui/testdata/mockapi & | |
| 175 | - mkdir -p /tmp/demo && git -C /tmp/demo init -q | |
| 176 | - go build -o harness ./cmd/harness | |
| 177 | - ./harness -C /tmp/demo -base-url http://127.0.0.1:8899 -api-key mock | |
| 178 | - ``` | |
| 179 | - | |
| 180 | - Type anything, and the approval prompt appears. `-slow 150ms` stretches the | |
| 181 | - stream out if you want to watch the spinner or test `ctrl+c` mid-turn. It sits | |
| 182 | - under `testdata` so `./...` ignores it. | |
| 224 | + A value this build does not recognise is dropped rather than forwarded to the | |
| 225 | + API, and effort is checked against `llm.EffortLevels` rather than a second copy | |
| 226 | + of the same five strings. | |
| 227 | + | |
| 228 | + `/setup` reopens the wizard at any time. Finishing applies the answers through | |
| 229 | + the same setters `/model` and `/effort` use, so a setting chosen in the wizard | |
| 230 | + and the same setting chosen from the prompt cannot come out different — the | |
| 231 | + wizard just reports them in one line instead of three. It is refused mid-turn: | |
| 232 | + changing the model while a turn is in flight would finish it on a different | |
| 233 | + model than it started. A config file that cannot be parsed is reported and then | |
| 234 | + ignored, and is never overwritten until you finish the wizard. | |
| 235 | + | |
| 236 | + `/model` and `/effort` change the running session and say "from the next turn | |
| 237 | + on"; they do not write to the file. The wizard is the thing that decides what | |
| 238 | + the *next* session starts with, which is why it is a separate screen rather | |
| 239 | + than a fourth picker. | |
| 240 | + | |
| 241 | + The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to | |
| 242 | + their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20, | |
| 243 | + and any key skips the one playing. | |
| 183 | 244 | |
| 184 | 245 | ## Tools |
| 185 | 246 | |
| @@ -91,7 +91,7 @@ func run() error { | ||
| 91 | 91 | } |
| 92 | 92 | defer log.Close() |
| 93 | 93 | log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{ |
| 94 | - "root": root, "model": client.Model, "base_url": client.BaseURL, | |
| 94 | + "root": root, "model": client.Model(), "base_url": client.BaseURL, | |
| 95 | 95 | }}) |
| 96 | 96 | |
| 97 | 97 | // The TUI takes over the screen, so anything worth saying before the loop |
| @@ -212,7 +212,9 @@ func run() error { | ||
| 212 | 212 | // the next one should default to. |
| 213 | 213 | Config: cfg, |
| 214 | 214 | FirstRun: !haveConfig && cfgErr == nil, |
| 215 | - OnConfig: applyConfig(a, client, policy), | |
| 215 | + // Applying the answers is the TUI's own job; persisting them is | |
| 216 | + // the only part that belongs out here. | |
| 217 | + SaveConfig: config.Save, | |
| 216 | 218 | }) |
| 217 | 219 | } |
| 218 | 220 | |
| @@ -224,7 +226,7 @@ func run() error { | ||
| 224 | 226 | } |
| 225 | 227 | |
| 226 | 228 | fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n", |
| 227 | - client.Model, root, strings.Join(toolNames(reg), ", ")) | |
| 229 | + client.Model(), root, strings.Join(toolNames(reg), ", ")) | |
| 228 | 230 | |
| 229 | 231 | for { |
| 230 | 232 | fmt.Print("> ") |
| @@ -255,31 +257,6 @@ func run() error { | ||
| 255 | 257 | } |
| 256 | 258 | } |
| 257 | 259 | |
| 258 | - // applyConfig makes a finished wizard's answers true for the running process | |
| 259 | - // and then writes them down, returning the one line the transcript should say | |
| 260 | - // about it. | |
| 261 | - // | |
| 262 | - // Applying before saving is deliberate: a config directory that cannot be | |
| 263 | - // written should still leave you with the session you just asked for, and a | |
| 264 | - // warning rather than a wizard that silently did nothing. | |
| 265 | - func applyConfig(a *agent.Agent, client *llm.Client, policy *approval.Policy) func(config.Config) string { | |
| 266 | - return func(cfg config.Config) string { | |
| 267 | - client.Model = cfg.Model | |
| 268 | - a.SetEffort(cfg.Effort) | |
| 269 | - policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll | |
| 270 | - | |
| 271 | - if err := config.Save(cfg); err != nil { | |
| 272 | - return fmt.Sprintf("settings are in force for this session, but saving them failed: %v", err) | |
| 273 | - } | |
| 274 | - note := fmt.Sprintf("setup saved — %s · effort %s · %s · %s theme", | |
| 275 | - llm.PrettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme) | |
| 276 | - if policy.YOLO { | |
| 277 | - note += "\naccept-all is on — tool calls run without asking." | |
| 278 | - } | |
| 279 | - return note | |
| 280 | - } | |
| 281 | - } | |
| 282 | - | |
| 283 | 260 | // once runs a single prompt, prints the session totals, and saves the session. |
| 284 | 261 | func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error { |
| 285 | 262 | turnCtx, endTurn := beginTurn(ctx) |
| @@ -288,9 +265,9 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, | ||
| 288 | 265 | |
| 289 | 266 | render.Flush() |
| 290 | 267 | usage, turns := a.State().Usage() |
| 291 | - render.Usage(usage, turns, client.Model) | |
| 268 | + render.Usage(usage, turns, client.Model()) | |
| 292 | 269 | |
| 293 | - if err := a.Save(sessionDir, id, root, client.Model); err != nil { | |
| 270 | + if err := a.Save(sessionDir, id, root, client.Model()); err != nil { | |
| 294 | 271 | fmt.Fprintln(os.Stderr, "harness: saving session:", err) |
| 295 | 272 | } |
| 296 | 273 | if errors.Is(runErr, context.Canceled) { |
| @@ -97,7 +97,7 @@ func run() error { | ||
| 97 | 97 | if err := os.MkdirAll(runRoot, 0o755); err != nil { |
| 98 | 98 | return err |
| 99 | 99 | } |
| 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) | |
| 101 | 101 | |
| 102 | 102 | type job struct { |
| 103 | 103 | task Task |
| @@ -163,7 +163,7 @@ func run() error { | ||
| 163 | 163 | // The return value is named so the deferred timing assignment lands in the |
| 164 | 164 | // value actually returned rather than in a copy already made. |
| 165 | 165 | 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()} | |
| 167 | 167 | start := time.Now() |
| 168 | 168 | defer func() { res.WallMS = time.Since(start).Milliseconds() }() |
| 169 | 169 | |
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "errors" |
| 7 | 7 | "fmt" |
| 8 | 8 | "strings" |
| 9 | + "sync" | |
| 9 | 10 | |
| 10 | 11 | "harness/internal/llm" |
| 11 | 12 | "harness/internal/metrics" |
| @@ -126,6 +127,25 @@ type Agent struct { | ||
| 126 | 127 | // lastInputTokens is the size of the previous request as the API measured |
| 127 | 128 | // it, cached and uncached parts together. |
| 128 | 129 | lastInputTokens int |
| 130 | + | |
| 131 | + // mu guards the settings a front end can change while the loop is | |
| 132 | + // running. Only effort is one today; the loop reads it once per request. | |
| 133 | + mu sync.Mutex | |
| 134 | + } | |
| 135 | + | |
| 136 | + // SetEffort changes the effort level the turns that follow will ask for. An | |
| 137 | + // empty level means the API's own default. | |
| 138 | + func (a *Agent) SetEffort(level string) { | |
| 139 | + a.mu.Lock() | |
| 140 | + defer a.mu.Unlock() | |
| 141 | + a.opts.Effort = level | |
| 142 | + } | |
| 143 | + | |
| 144 | + // Effort reports the level currently in force. | |
| 145 | + func (a *Agent) Effort() string { | |
| 146 | + a.mu.Lock() | |
| 147 | + defer a.mu.Unlock() | |
| 148 | + return a.opts.Effort | |
| 129 | 149 | } |
| 130 | 150 | |
| 131 | 151 | // New wires an agent. state and log may be freshly created by the caller so the |
| @@ -144,15 +164,6 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log | ||
| 144 | 164 | // State exposes the conversation for callers that need to inspect or persist it. |
| 145 | 165 | func (a *Agent) State() *State { return a.state } |
| 146 | 166 | |
| 147 | - // SetEffort changes the reasoning effort for subsequent turns. The setup | |
| 148 | - // wizard needs it because the effort is read when each request is built, so a | |
| 149 | - // value chosen mid-session would otherwise not take until a restart. | |
| 150 | - // | |
| 151 | - // It is safe only between turns: the caller is the Bubble Tea update loop, | |
| 152 | - // which cannot run while a turn is in flight on its own goroutine, and /setup | |
| 153 | - // is refused while one is. | |
| 154 | - func (a *Agent) SetEffort(effort string) { a.opts.Effort = effort } | |
| 155 | - | |
| 156 | 167 | func (a *Agent) emit(ev Event) { |
| 157 | 168 | if a.Observe != nil { |
| 158 | 169 | a.Observe(ev) |
| @@ -329,8 +340,8 @@ func (a *Agent) request() llm.Request { | ||
| 329 | 340 | Tools: defs, |
| 330 | 341 | Thinking: llm.Adaptive(), |
| 331 | 342 | } |
| 332 | - if a.opts.Effort != "" { | |
| 333 | - req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort} | |
| 343 | + if effort := a.Effort(); effort != "" { | |
| 344 | + req.OutputConfig = &llm.OutputConfig{Effort: effort} | |
| 334 | 345 | } |
| 335 | 346 | return req |
| 336 | 347 | } |
| @@ -527,3 +527,29 @@ func TestSystemPromptIsStableAcrossRequests(t *testing.T) { | ||
| 527 | 527 | t.Error("the system prompt changed between two requests in one session") |
| 528 | 528 | } |
| 529 | 529 | } |
| 530 | + | |
| 531 | + // Effort is a knob the TUI turns mid-session, so a request carries whatever | |
| 532 | + // was last set rather than whatever the process started with. | |
| 533 | + func TestSetEffortAppliesToTheNextRequest(t *testing.T) { | |
| 534 | + f := newFakeAPI(t, msg("end_turn", textBlock("one")), msg("end_turn", textBlock("two"))) | |
| 535 | + a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{}) | |
| 536 | + | |
| 537 | + if _, err := a.Run(context.Background(), "first"); err != nil { | |
| 538 | + t.Fatalf("Run: %v", err) | |
| 539 | + } | |
| 540 | + if oc := f.requests[0].OutputConfig; oc != nil { | |
| 541 | + t.Errorf("output_config = %+v with no effort set, want it omitted so the API picks", oc) | |
| 542 | + } | |
| 543 | + | |
| 544 | + a.SetEffort("low") | |
| 545 | + if got := a.Effort(); got != "low" { | |
| 546 | + t.Errorf("Effort() = %q, want low", got) | |
| 547 | + } | |
| 548 | + if _, err := a.Run(context.Background(), "second"); err != nil { | |
| 549 | + t.Fatalf("Run: %v", err) | |
| 550 | + } | |
| 551 | + oc := f.requests[1].OutputConfig | |
| 552 | + if oc == nil || oc.Effort != "low" { | |
| 553 | + t.Errorf("output_config = %+v, want effort low", oc) | |
| 554 | + } | |
| 555 | + } | |
| @@ -13,6 +13,8 @@ import ( | ||
| 13 | 13 | "io/fs" |
| 14 | 14 | "os" |
| 15 | 15 | "path/filepath" |
| 16 | + | |
| 17 | + "harness/internal/llm" | |
| 16 | 18 | ) |
| 17 | 19 | |
| 18 | 20 | // Filename is the config's name inside the harness config directory. |
| @@ -30,10 +32,6 @@ const ( | ||
| 30 | 32 | ApprovalsAcceptAll = "accept-all" |
| 31 | 33 | ) |
| 32 | 34 | |
| 33 | - // Efforts are the values the API's output_config.effort accepts, in the order | |
| 34 | - // the wizard offers them. | |
| 35 | - var Efforts = []string{"low", "medium", "high", "xhigh", "max"} | |
| 36 | - | |
| 37 | 35 | // Themes are the TUI palettes, in the order the wizard offers them. The names |
| 38 | 36 | // live here rather than in the tui package so that reading a config never |
| 39 | 37 | // depends on a terminal front end being linked in — the eval runner has none. |
| @@ -47,7 +45,7 @@ type Config struct { | ||
| 47 | 45 | Version int `json:"version"` |
| 48 | 46 | // Model is an API model id, not a display name. |
| 49 | 47 | Model string `json:"model,omitempty"` |
| 50 | - // Effort is one of Efforts. | |
| 48 | + // Effort is one of llm.EffortLevels. | |
| 51 | 49 | Effort string `json:"effort,omitempty"` |
| 52 | 50 | // Approvals is ApprovalsAsk or ApprovalsAcceptAll. |
| 53 | 51 | Approvals string `json:"approvals,omitempty"` |
| @@ -98,7 +96,9 @@ func Load() (Config, bool, error) { | ||
| 98 | 96 | // field rather than be forwarded to the API, which would reject the request |
| 99 | 97 | // with an error pointing at nothing the user can see. |
| 100 | 98 | func sanitize(c Config) Config { |
| 101 | - if !ValidEffort(c.Effort) { | |
| 99 | + // Effort is validated against the API's own list rather than a copy of it | |
| 100 | + // here: two lists of the same five strings is one list that will be wrong. | |
| 101 | + if !llm.ValidEffort(c.Effort) { | |
| 102 | 102 | c.Effort = "" |
| 103 | 103 | } |
| 104 | 104 | if c.Approvals != ApprovalsAsk && c.Approvals != ApprovalsAcceptAll { |
| @@ -110,9 +110,6 @@ func sanitize(c Config) Config { | ||
| 110 | 110 | return c |
| 111 | 111 | } |
| 112 | 112 | |
| 113 | - // ValidEffort reports whether e is one of the levels the API accepts. | |
| 114 | - func ValidEffort(e string) bool { return contains(Efforts, e) } | |
| 115 | - | |
| 116 | 113 | // ValidTheme reports whether name is a palette this build knows. |
| 117 | 114 | func ValidTheme(name string) bool { return contains(Themes, name) } |
| 118 | 115 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "harness/internal/llm" | |
| 4 | 5 | "os" |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "strings" |
| @@ -120,15 +121,19 @@ func TestLoadDropsValuesItDoesNotRecognise(t *testing.T) { | ||
| 120 | 121 | } |
| 121 | 122 | } |
| 122 | 123 | |
| 123 | - func TestValidEffort(t *testing.T) { | |
| 124 | - for _, e := range Efforts { | |
| 125 | - if !ValidEffort(e) { | |
| 126 | - t.Errorf("%q is in Efforts but does not validate", e) | |
| 124 | + // A level the API would reject must not survive a round trip, however it got | |
| 125 | + // into the file. | |
| 126 | + func TestLoadDropsAnEffortTheAPIWouldReject(t *testing.T) { | |
| 127 | + for _, level := range llm.EffortLevels { | |
| 128 | + if err := Save(Config{Effort: level}); err != nil { | |
| 129 | + t.Fatal(err) | |
| 127 | 130 | } |
| 128 | - } | |
| 129 | - for _, e := range []string{"", "extreme", "High", "none"} { | |
| 130 | - if ValidEffort(e) { | |
| 131 | - t.Errorf("%q validated", e) | |
| 131 | + got, _, err := Load() | |
| 132 | + if err != nil { | |
| 133 | + t.Fatal(err) | |
| 134 | + } | |
| 135 | + if got.Effort != level { | |
| 136 | + t.Errorf("%q did not survive a round trip: got %q", level, got.Effort) | |
| 132 | 137 | } |
| 133 | 138 | } |
| 134 | 139 | } |
| @@ -11,6 +11,7 @@ import ( | ||
| 11 | 11 | "os" |
| 12 | 12 | "strconv" |
| 13 | 13 | "strings" |
| 14 | + "sync" | |
| 14 | 15 | "time" |
| 15 | 16 | ) |
| 16 | 17 | |
| @@ -28,7 +29,6 @@ const ( | ||
| 28 | 29 | // Client talks to POST /v1/messages. |
| 29 | 30 | type Client struct { |
| 30 | 31 | BaseURL string |
| 31 | - Model string | |
| 32 | 32 | MaxTokens int |
| 33 | 33 | MaxRetries int |
| 34 | 34 | // RetryDelay is the base of the exponential backoff between retries. |
| @@ -37,6 +37,26 @@ type Client struct { | ||
| 37 | 37 | |
| 38 | 38 | apiKey string |
| 39 | 39 | authToken string |
| 40 | + | |
| 41 | + // model is behind a lock because a front end can switch it while the | |
| 42 | + // agent is between turns. It is the one field that moves after | |
| 43 | + // construction, so it is the one that is not public. | |
| 44 | + mu sync.Mutex | |
| 45 | + model string | |
| 46 | + } | |
| 47 | + | |
| 48 | + // Model reports the model requests default to. | |
| 49 | + func (c *Client) Model() string { | |
| 50 | + c.mu.Lock() | |
| 51 | + defer c.mu.Unlock() | |
| 52 | + return c.model | |
| 53 | + } | |
| 54 | + | |
| 55 | + // SetModel changes the model the requests that follow will ask for. | |
| 56 | + func (c *Client) SetModel(id string) { | |
| 57 | + c.mu.Lock() | |
| 58 | + defer c.mu.Unlock() | |
| 59 | + c.model = id | |
| 40 | 60 | } |
| 41 | 61 | |
| 42 | 62 | // Option configures a Client. |
| @@ -55,7 +75,7 @@ func WithBaseURL(u string) Option { | ||
| 55 | 75 | func WithModel(m string) Option { |
| 56 | 76 | return func(c *Client) { |
| 57 | 77 | if m != "" { |
| 58 | - c.Model = m | |
| 78 | + c.model = m | |
| 59 | 79 | } |
| 60 | 80 | } |
| 61 | 81 | } |
| @@ -89,7 +109,7 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h | ||
| 89 | 109 | func New(opts ...Option) *Client { |
| 90 | 110 | c := &Client{ |
| 91 | 111 | BaseURL: DefaultBaseURL, |
| 92 | - Model: DefaultModel, | |
| 112 | + model: DefaultModel, | |
| 93 | 113 | MaxTokens: DefaultMaxTokens, |
| 94 | 114 | MaxRetries: defaultMaxRetries, |
| 95 | 115 | RetryDelay: 500 * time.Millisecond, |
| @@ -122,20 +142,6 @@ func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL } | ||
| 122 | 142 | // HasCredentials reports whether the client can authenticate. |
| 123 | 143 | func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" } |
| 124 | 144 | |
| 125 | - // setHeaders applies the version and the credential. Every request the client | |
| 126 | - // makes goes through here, so a change to how the harness authenticates cannot | |
| 127 | - // reach one endpoint and miss another. | |
| 128 | - func (c *Client) setHeaders(r *http.Request) { | |
| 129 | - r.Header.Set("anthropic-version", apiVersion) | |
| 130 | - if c.apiKey != "" { | |
| 131 | - r.Header.Set("x-api-key", c.apiKey) | |
| 132 | - return | |
| 133 | - } | |
| 134 | - // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. | |
| 135 | - r.Header.Set("authorization", "Bearer "+c.authToken) | |
| 136 | - r.Header.Set("anthropic-beta", oauthBeta) | |
| 137 | - } | |
| 138 | - | |
| 139 | 145 | // APIError is a non-2xx response. |
| 140 | 146 | type APIError struct { |
| 141 | 147 | StatusCode int |
| @@ -168,7 +174,7 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 168 | 174 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 169 | 175 | } |
| 170 | 176 | if req.Model == "" { |
| 171 | - req.Model = c.Model | |
| 177 | + req.Model = c.Model() | |
| 172 | 178 | } |
| 173 | 179 | if req.MaxTokens == 0 { |
| 174 | 180 | req.MaxTokens = c.MaxTokens |
| @@ -201,13 +207,36 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err | ||
| 201 | 207 | return nil, lastErr |
| 202 | 208 | } |
| 203 | 209 | |
| 210 | + // newRequest builds a request to the endpoint with the version and the | |
| 211 | + // credential already on it. Every call the client makes goes through here, so | |
| 212 | + // a second route cannot end up authenticating differently from the first. | |
| 213 | + func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) { | |
| 214 | + var rdr io.Reader | |
| 215 | + if body != nil { | |
| 216 | + rdr = bytes.NewReader(body) | |
| 217 | + } | |
| 218 | + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr) | |
| 219 | + if err != nil { | |
| 220 | + return nil, err | |
| 221 | + } | |
| 222 | + req.Header.Set("content-type", "application/json") | |
| 223 | + req.Header.Set("anthropic-version", apiVersion) | |
| 224 | + switch { | |
| 225 | + case c.apiKey != "": | |
| 226 | + req.Header.Set("x-api-key", c.apiKey) | |
| 227 | + default: | |
| 228 | + // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. | |
| 229 | + req.Header.Set("authorization", "Bearer "+c.authToken) | |
| 230 | + req.Header.Set("anthropic-beta", oauthBeta) | |
| 231 | + } | |
| 232 | + return req, nil | |
| 233 | + } | |
| 234 | + | |
| 204 | 235 | func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { |
| 205 | - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body)) | |
| 236 | + httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body) | |
| 206 | 237 | if err != nil { |
| 207 | 238 | return nil, err |
| 208 | 239 | } |
| 209 | - httpReq.Header.Set("content-type", "application/json") | |
| 210 | - c.setHeaders(httpReq) | |
| 211 | 240 | |
| 212 | 241 | httpResp, err := c.HTTP.Do(httpReq) |
| 213 | 242 | if err != nil { |
| @@ -3,6 +3,7 @@ package llm | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "encoding/json" |
| 6 | + "fmt" | |
| 6 | 7 | "io" |
| 7 | 8 | "net/http" |
| 8 | 9 | "net/http/httptest" |
| @@ -284,3 +285,34 @@ func TestCustomBaseURLIsActuallyUsed(t *testing.T) { | ||
| 284 | 285 | t.Errorf("path = %q, want /v1/messages", gotPath) |
| 285 | 286 | } |
| 286 | 287 | } |
| 288 | + | |
| 289 | + // The model is switchable mid-session, so what a request carries is whatever | |
| 290 | + // was last set rather than what the client was built with. | |
| 291 | + func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) { | |
| 292 | + var models []string | |
| 293 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 294 | + var body map[string]any | |
| 295 | + raw, _ := io.ReadAll(r.Body) | |
| 296 | + json.Unmarshal(raw, &body) | |
| 297 | + models = append(models, fmt.Sprint(body["model"])) | |
| 298 | + io.WriteString(w, okResponse) | |
| 299 | + }) | |
| 300 | + WithModel("claude-opus-5")(c) | |
| 301 | + | |
| 302 | + msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}} | |
| 303 | + if _, err := c.CreateMessage(context.Background(), msg); err != nil { | |
| 304 | + t.Fatalf("CreateMessage: %v", err) | |
| 305 | + } | |
| 306 | + | |
| 307 | + c.SetModel("claude-haiku-4-5") | |
| 308 | + if got := c.Model(); got != "claude-haiku-4-5" { | |
| 309 | + t.Errorf("Model() = %q", got) | |
| 310 | + } | |
| 311 | + if _, err := c.CreateMessage(context.Background(), msg); err != nil { | |
| 312 | + t.Fatalf("CreateMessage: %v", err) | |
| 313 | + } | |
| 314 | + | |
| 315 | + if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want { | |
| 316 | + t.Errorf("models sent = %v, want %q", models, want) | |
| 317 | + } | |
| 318 | + } | |
| @@ -6,115 +6,82 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | 8 | "net/http" |
| 9 | - "sort" | |
| 9 | + "net/url" | |
| 10 | 10 | "time" |
| 11 | 11 | ) |
| 12 | 12 | |
| 13 | - // Model is one entry in the catalogue at GET /v1/models. | |
| 13 | + // modelPageSize is what one GET /v1/models asks for. The list is short enough | |
| 14 | + // that this is almost always the only request. | |
| 15 | + const modelPageSize = 100 | |
| 16 | + | |
| 17 | + // maxModelPages stops a paginating endpoint that never says it is done from | |
| 18 | + // looping forever behind a picker that is waiting on it. | |
| 19 | + const maxModelPages = 10 | |
| 20 | + | |
| 21 | + // Model is one entry from GET /v1/models. MaxInputTokens is the context | |
| 22 | + // window; it was added to the API in 2026 and is zero on an endpoint that | |
| 23 | + // does not report it, which the caller has to treat as "not said" rather than | |
| 24 | + // as a model with no context. | |
| 14 | 25 | type Model struct { |
| 15 | - ID string `json:"id"` | |
| 16 | - DisplayName string `json:"display_name"` | |
| 17 | - Deprecated bool `json:"deprecated"` | |
| 26 | + ID string `json:"id"` | |
| 27 | + DisplayName string `json:"display_name"` | |
| 28 | + CreatedAt time.Time `json:"created_at"` | |
| 29 | + MaxInputTokens int `json:"max_input_tokens"` | |
| 30 | + MaxTokens int `json:"max_tokens"` | |
| 18 | 31 | } |
| 19 | 32 | |
| 20 | - // catalogueTimeout is deliberately short. This runs while a person is looking | |
| 21 | - // at a wizard that cannot show them a list yet, and a gateway that black-holes | |
| 22 | - // the request must not hold the setup open for the client's ten-minute | |
| 23 | - // message timeout. | |
| 24 | - const catalogueTimeout = 10 * time.Second | |
| 33 | + type modelPage struct { | |
| 34 | + Data []Model `json:"data"` | |
| 35 | + HasMore bool `json:"has_more"` | |
| 36 | + LastID string `json:"last_id"` | |
| 37 | + } | |
| 25 | 38 | |
| 26 | - // ListModels reads the model catalogue. | |
| 39 | + // ListModels returns the models the endpoint offers, in the order it gave | |
| 40 | + // them — which from Anthropic is newest first. | |
| 27 | 41 | // |
| 28 | - // It is best-effort by design: plenty of endpoints this harness can be pointed | |
| 29 | - // at speak /v1/messages and nothing else, so every caller is expected to have | |
| 30 | - // a fallback — KnownModels — rather than to treat an error here as fatal. | |
| 42 | + // Not every endpoint serves this route: a gateway that only proxies | |
| 43 | + // /v1/messages answers 404, and the error says so rather than being turned | |
| 44 | + // into an empty list, because "no models" and "this endpoint cannot be asked" | |
| 45 | + // are different things to tell someone. | |
| 31 | 46 | func (c *Client) ListModels(ctx context.Context) ([]Model, error) { |
| 32 | 47 | if !c.HasCredentials() { |
| 33 | 48 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 34 | 49 | } |
| 35 | - ctx, cancel := context.WithTimeout(ctx, catalogueTimeout) | |
| 36 | - defer cancel() | |
| 37 | - | |
| 38 | - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/models?limit=100", nil) | |
| 39 | - if err != nil { | |
| 40 | - return nil, err | |
| 41 | - } | |
| 42 | - c.setHeaders(req) | |
| 43 | - | |
| 44 | - resp, err := c.HTTP.Do(req) | |
| 45 | - if err != nil { | |
| 46 | - return nil, fmt.Errorf("llm: listing models: %w", err) | |
| 47 | - } | |
| 48 | - defer resp.Body.Close() | |
| 49 | - | |
| 50 | - // Capped: an endpoint that answers an unknown route with a whole HTML | |
| 51 | - // page should not be read into memory in full to be thrown away. | |
| 52 | - raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) | |
| 53 | - if err != nil { | |
| 54 | - return nil, fmt.Errorf("llm: listing models: %w", err) | |
| 55 | - } | |
| 56 | - if resp.StatusCode != http.StatusOK { | |
| 57 | - return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw) | |
| 58 | - } | |
| 59 | 50 | |
| 60 | - var out struct { | |
| 61 | - Data []Model `json:"data"` | |
| 62 | - } | |
| 63 | - if err := json.Unmarshal(raw, &out); err != nil { | |
| 64 | - return nil, fmt.Errorf("llm: %s did not return a model list: %w", c.BaseURL, err) | |
| 65 | - } | |
| 51 | + var out []Model | |
| 52 | + after := "" | |
| 53 | + for page := 0; page < maxModelPages; page++ { | |
| 54 | + q := url.Values{"limit": {fmt.Sprint(modelPageSize)}} | |
| 55 | + if after != "" { | |
| 56 | + q.Set("after_id", after) | |
| 57 | + } | |
| 66 | 58 | |
| 67 | - models := make([]Model, 0, len(out.Data)) | |
| 68 | - for _, m := range out.Data { | |
| 69 | - // A deprecated model still answers requests, which is exactly why it | |
| 70 | - // should not be offered as a fresh default. | |
| 71 | - if m.ID == "" || m.Deprecated { | |
| 72 | - continue | |
| 59 | + req, err := c.newRequest(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil) | |
| 60 | + if err != nil { | |
| 61 | + return nil, err | |
| 73 | 62 | } |
| 74 | - if m.DisplayName == "" { | |
| 75 | - m.DisplayName = m.ID | |
| 63 | + resp, err := c.HTTP.Do(req) | |
| 64 | + if err != nil { | |
| 65 | + return nil, fmt.Errorf("llm: %w", err) | |
| 76 | 66 | } |
| 77 | - models = append(models, m) | |
| 78 | - } | |
| 79 | - if len(models) == 0 { | |
| 80 | - return nil, fmt.Errorf("llm: %s returned no models", c.BaseURL) | |
| 81 | - } | |
| 82 | - return models, nil | |
| 83 | - } | |
| 84 | - | |
| 85 | - // KnownModels is the fallback catalogue: what this build knows how to price. | |
| 86 | - // | |
| 87 | - // Pricing is the honest basis for the list. A model the harness cannot price | |
| 88 | - // is one whose cost would silently read as blank in the status bar, so the set | |
| 89 | - // it can price is exactly the set it can fully describe. | |
| 90 | - func KnownModels() []Model { | |
| 91 | - out := make([]Model, 0, len(prices)) | |
| 92 | - for id := range prices { | |
| 93 | - out = append(out, Model{ID: id, DisplayName: displayName(id)}) | |
| 94 | - } | |
| 95 | - // Map iteration is random; the wizard needs the same order every time. | |
| 96 | - sort.Slice(out, func(i, j int) bool { | |
| 97 | - ri, rj := familyRank(out[i].ID), familyRank(out[j].ID) | |
| 98 | - if ri != rj { | |
| 99 | - return ri < rj | |
| 67 | + raw, err := io.ReadAll(resp.Body) | |
| 68 | + resp.Body.Close() | |
| 69 | + if err != nil { | |
| 70 | + return nil, fmt.Errorf("llm: reading models: %w", err) | |
| 71 | + } | |
| 72 | + if resp.StatusCode != http.StatusOK { | |
| 73 | + return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw) | |
| 100 | 74 | } |
| 101 | - return out[i].ID > out[j].ID // newer version first within a family | |
| 102 | - }) | |
| 103 | - return out | |
| 104 | - } | |
| 105 | 75 | |
| 106 | - // familyRank orders the families the way someone choosing between them thinks | |
| 107 | - // about it: the flagship first, the cheap one last. | |
| 108 | - func familyRank(id string) int { | |
| 109 | - for i, fam := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} { | |
| 110 | - if len(id) >= len(fam) && id[:len(fam)] == fam { | |
| 111 | - return i | |
| 76 | + var p modelPage | |
| 77 | + if err := json.Unmarshal(raw, &p); err != nil { | |
| 78 | + return nil, fmt.Errorf("llm: decoding models: %w", err) | |
| 112 | 79 | } |
| 80 | + out = append(out, p.Data...) | |
| 81 | + if !p.HasMore || p.LastID == "" { | |
| 82 | + break | |
| 83 | + } | |
| 84 | + after = p.LastID | |
| 113 | 85 | } |
| 114 | - return 99 | |
| 86 | + return out, nil | |
| 115 | 87 | } |
| 116 | - | |
| 117 | - // displayName stands in for the API's display_name on a model we only know | |
| 118 | - // statically. It is derived rather than tabulated: a table would be one more | |
| 119 | - // place to forget to update when a model is added to prices. | |
| 120 | - func displayName(id string) string { return "Claude " + PrettyModel(id) } | |
| @@ -2,113 +2,100 @@ package llm | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | + "fmt" | |
| 5 | 6 | "net/http" |
| 6 | - "net/http/httptest" | |
| 7 | 7 | "strings" |
| 8 | 8 | "testing" |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | - func TestListModelsReadsTheCatalogue(t *testing.T) { | |
| 12 | - var gotPath, gotKey string | |
| 13 | - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 14 | - gotPath, gotKey = r.URL.Path, r.Header.Get("x-api-key") | |
| 15 | - w.Header().Set("content-type", "application/json") | |
| 16 | - w.Write([]byte(`{"data":[ | |
| 17 | - {"id":"claude-opus-5","display_name":"Claude Opus 5","type":"model"}, | |
| 18 | - {"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","type":"model"} | |
| 19 | - ],"has_more":false}`)) | |
| 20 | - })) | |
| 21 | - defer srv.Close() | |
| 11 | + func TestListModelsReadsEveryPage(t *testing.T) { | |
| 12 | + var paths []string | |
| 13 | + var headers http.Header | |
| 14 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + headers = r.Header.Clone() | |
| 16 | + paths = append(paths, r.URL.RequestURI()) | |
| 17 | + if after := r.URL.Query().Get("after_id"); after == "" { | |
| 18 | + fmt.Fprint(w, `{"data":[ | |
| 19 | + {"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-02-01T00:00:00Z"}, | |
| 20 | + {"type":"model","id":"claude-sonnet-5","display_name":"Claude Sonnet 5","created_at":"2025-09-29T00:00:00Z"}], | |
| 21 | + "has_more":true,"last_id":"claude-sonnet-5"}`) | |
| 22 | + return | |
| 23 | + } | |
| 24 | + fmt.Fprint(w, `{"data":[ | |
| 25 | + {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","created_at":"2025-10-01T00:00:00Z"}], | |
| 26 | + "has_more":false}`) | |
| 27 | + }) | |
| 22 | 28 | |
| 23 | - c := New(WithBaseURL(srv.URL), WithAPIKey("k")) | |
| 24 | 29 | got, err := c.ListModels(context.Background()) |
| 25 | 30 | if err != nil { |
| 26 | 31 | t.Fatalf("ListModels: %v", err) |
| 27 | 32 | } |
| 28 | - if gotPath != "/v1/models" { | |
| 29 | - t.Errorf("asked %s, want /v1/models", gotPath) | |
| 33 | + | |
| 34 | + var ids []string | |
| 35 | + for _, m := range got { | |
| 36 | + ids = append(ids, m.ID) | |
| 30 | 37 | } |
| 31 | - if gotKey != "k" { | |
| 32 | - t.Errorf("the catalogue request was not authenticated: x-api-key = %q", gotKey) | |
| 38 | + if want := "claude-opus-5 claude-sonnet-5 claude-haiku-4-5-20251001"; strings.Join(ids, " ") != want { | |
| 39 | + t.Errorf("ids = %v, want %s in the order the API gave them", ids, want) | |
| 33 | 40 | } |
| 34 | - if len(got) != 2 { | |
| 35 | - t.Fatalf("got %d models, want 2: %+v", len(got), got) | |
| 41 | + if got[0].DisplayName != "Claude Opus 5" { | |
| 42 | + t.Errorf("display name = %q", got[0].DisplayName) | |
| 36 | 43 | } |
| 37 | - if got[0].ID != "claude-opus-5" || got[0].DisplayName != "Claude Opus 5" { | |
| 38 | - t.Errorf("first model = %+v", got[0]) | |
| 44 | + if len(paths) != 2 { | |
| 45 | + t.Fatalf("requests = %v, want a second one for the second page", paths) | |
| 39 | 46 | } |
| 40 | - } | |
| 41 | - | |
| 42 | - // Pointed at a gateway that does not serve the catalogue, the call has to fail | |
| 43 | - // in a way the caller can fall back from — not hang, and not panic on a body | |
| 44 | - // that is HTML. | |
| 45 | - func TestListModelsFailsCleanlyOnAGatewayWithoutIt(t *testing.T) { | |
| 46 | - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 47 | - w.WriteHeader(http.StatusNotFound) | |
| 48 | - w.Write([]byte("<html>no such route</html>")) | |
| 49 | - })) | |
| 50 | - defer srv.Close() | |
| 51 | - | |
| 52 | - c := New(WithBaseURL(srv.URL), WithAPIKey("k")) | |
| 53 | - if _, err := c.ListModels(context.Background()); err == nil { | |
| 54 | - t.Fatal("a 404 catalogue came back as success") | |
| 47 | + if !strings.HasPrefix(paths[0], "/v1/models?") { | |
| 48 | + t.Errorf("path = %q, want /v1/models", paths[0]) | |
| 49 | + } | |
| 50 | + if !strings.Contains(paths[1], "after_id=claude-sonnet-5") { | |
| 51 | + t.Errorf("second request = %q, want it to carry the last id", paths[1]) | |
| 52 | + } | |
| 53 | + if headers.Get("x-api-key") != "test-key" || headers.Get("anthropic-version") == "" { | |
| 54 | + t.Errorf("credentials or version missing: %v", headers) | |
| 55 | 55 | } |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | - func TestListModelsRefusesWithoutCredentials(t *testing.T) { | |
| 59 | - c := New(WithBaseURL("http://127.0.0.1:1")) | |
| 60 | - c.apiKey, c.authToken = "", "" | |
| 58 | + // Not every endpoint serves this route. The error has to name what went wrong | |
| 59 | + // so the picker can say it out loud. | |
| 60 | + func TestListModelsReportsAnEndpointThatWillNotAnswer(t *testing.T) { | |
| 61 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 62 | + http.Error(w, `{"type":"error","error":{"type":"not_found_error","message":"unknown route"}}`, http.StatusNotFound) | |
| 63 | + }) | |
| 64 | + | |
| 61 | 65 | _, err := c.ListModels(context.Background()) |
| 62 | 66 | if err == nil { |
| 63 | - t.Fatal("listed models with no credentials") | |
| 67 | + t.Fatal("a 404 produced no error") | |
| 64 | 68 | } |
| 65 | - if !strings.Contains(err.Error(), "credentials") { | |
| 66 | - t.Errorf("the error does not name the cause: %v", err) | |
| 69 | + if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "not_found") { | |
| 70 | + t.Errorf("error = %q, want it to say what the endpoint answered", err) | |
| 67 | 71 | } |
| 68 | 72 | } |
| 69 | 73 | |
| 70 | - // Deprecated models are still returned by the API. Offering one as a default | |
| 71 | - // in a fresh setup is a trap, so they are filtered out. | |
| 72 | - func TestListModelsDropsDeprecatedOnes(t *testing.T) { | |
| 73 | - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 74 | - w.Write([]byte(`{"data":[ | |
| 75 | - {"id":"claude-opus-5","display_name":"Claude Opus 5"}, | |
| 76 | - {"id":"claude-2.1","display_name":"Claude 2.1","deprecated":true} | |
| 77 | - ]}`)) | |
| 78 | - })) | |
| 79 | - defer srv.Close() | |
| 74 | + // A bearer credential is what OAuth tokens and most gateways use, and the | |
| 75 | + // listing has to authenticate the same way the messages call does. | |
| 76 | + func TestListModelsSendsABearerCredential(t *testing.T) { | |
| 77 | + var headers http.Header | |
| 78 | + c := testClient(t, func(w http.ResponseWriter, r *http.Request) { | |
| 79 | + headers = r.Header.Clone() | |
| 80 | + fmt.Fprint(w, `{"data":[],"has_more":false}`) | |
| 81 | + }) | |
| 82 | + WithAuthToken("oauth-token")(c) | |
| 80 | 83 | |
| 81 | - got, err := New(WithBaseURL(srv.URL), WithAPIKey("k")).ListModels(context.Background()) | |
| 82 | - if err != nil { | |
| 83 | - t.Fatal(err) | |
| 84 | + if _, err := c.ListModels(context.Background()); err != nil { | |
| 85 | + t.Fatalf("ListModels: %v", err) | |
| 84 | 86 | } |
| 85 | - for _, m := range got { | |
| 86 | - if m.ID == "claude-2.1" { | |
| 87 | - t.Error("a deprecated model was offered") | |
| 88 | - } | |
| 87 | + if got := headers.Get("authorization"); got != "Bearer oauth-token" { | |
| 88 | + t.Errorf("authorization = %q", got) | |
| 89 | + } | |
| 90 | + if headers.Get("anthropic-beta") == "" { | |
| 91 | + t.Error("the oauth beta flag is missing") | |
| 89 | 92 | } |
| 90 | 93 | } |
| 91 | 94 | |
| 92 | - // The fallback is what the wizard shows when the catalogue cannot be reached, | |
| 93 | - // so it has to stand on its own: every entry priced, and the default present. | |
| 94 | - func TestKnownModelsIsAUsableFallback(t *testing.T) { | |
| 95 | - known := KnownModels() | |
| 96 | - if len(known) == 0 { | |
| 97 | - t.Fatal("no fallback models") | |
| 98 | - } | |
| 99 | - var hasDefault bool | |
| 100 | - for _, m := range known { | |
| 101 | - if m.ID == DefaultModel { | |
| 102 | - hasDefault = true | |
| 103 | - } | |
| 104 | - if _, ok := prices[m.ID]; !ok { | |
| 105 | - t.Errorf("%s is offered but has no pricing", m.ID) | |
| 106 | - } | |
| 107 | - if m.DisplayName == "" { | |
| 108 | - t.Errorf("%s has no display name", m.ID) | |
| 109 | - } | |
| 110 | - } | |
| 111 | - if !hasDefault { | |
| 112 | - t.Errorf("the fallback list does not include the default model %s", DefaultModel) | |
| 95 | + func TestListModelsRefusesWithoutCredentials(t *testing.T) { | |
| 96 | + c := New(WithBaseURL("http://127.0.0.1:1")) | |
| 97 | + c.apiKey, c.authToken = "", "" | |
| 98 | + if _, err := c.ListModels(context.Background()); err == nil { | |
| 99 | + t.Fatal("listing without credentials should not even be attempted") | |
| 113 | 100 | } |
| 114 | 101 | } |
| @@ -1,65 +0,0 @@ | ||
| 1 | - package llm | |
| 2 | - | |
| 3 | - import ( | |
| 4 | - "strings" | |
| 5 | - "unicode" | |
| 6 | - ) | |
| 7 | - | |
| 8 | - // PrettyModel turns an API model id into the name a person uses for it: | |
| 9 | - // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes | |
| 10 | - // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which | |
| 11 | - // matters because this also runs against gateways serving models we have | |
| 12 | - // never heard of. | |
| 13 | - func PrettyModel(id string) string { | |
| 14 | - s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest") | |
| 15 | - // Anything bracketed is a variant tag, not part of the name: opus-5[1m]. | |
| 16 | - tag := "" | |
| 17 | - if i := strings.IndexByte(s, '['); i >= 0 { | |
| 18 | - tag, s = s[i:], s[:i] | |
| 19 | - } | |
| 20 | - parts := strings.Split(s, "-") | |
| 21 | - if len(parts) < 2 { | |
| 22 | - return id | |
| 23 | - } | |
| 24 | - // A trailing eight-digit date stamp is a build, not a version. | |
| 25 | - if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) { | |
| 26 | - parts = parts[:len(parts)-1] | |
| 27 | - } | |
| 28 | - family, rest := parts[0], parts[1:] | |
| 29 | - if family == "" || !isAlpha(family) { | |
| 30 | - return id | |
| 31 | - } | |
| 32 | - name := strings.ToUpper(family[:1]) + family[1:] | |
| 33 | - var version []string | |
| 34 | - for _, p := range rest { | |
| 35 | - if !allDigits(p) { | |
| 36 | - return id | |
| 37 | - } | |
| 38 | - version = append(version, p) | |
| 39 | - } | |
| 40 | - if len(version) > 0 { | |
| 41 | - name += " " + strings.Join(version, ".") | |
| 42 | - } | |
| 43 | - return name + tag | |
| 44 | - } | |
| 45 | - | |
| 46 | - func allDigits(s string) bool { | |
| 47 | - if s == "" { | |
| 48 | - return false | |
| 49 | - } | |
| 50 | - for _, r := range s { | |
| 51 | - if !unicode.IsDigit(r) { | |
| 52 | - return false | |
| 53 | - } | |
| 54 | - } | |
| 55 | - return true | |
| 56 | - } | |
| 57 | - | |
| 58 | - func isAlpha(s string) bool { | |
| 59 | - for _, r := range s { | |
| 60 | - if !unicode.IsLetter(r) { | |
| 61 | - return false | |
| 62 | - } | |
| 63 | - } | |
| 64 | - return s != "" | |
| 65 | - } | |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | package llm |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "fmt" | |
| 5 | - "strconv" | |
| 4 | + "sort" | |
| 5 | + "strings" | |
| 6 | 6 | ) |
| 7 | 7 | |
| 8 | 8 | // Price is per-million-token list pricing. |
| @@ -32,7 +32,7 @@ var prices = map[string]Price{ | ||
| 32 | 32 | // Cost prices one usage record. The boolean reports whether the model's |
| 33 | 33 | // pricing is known; a made-up number in METRICS.md is worse than a blank. |
| 34 | 34 | func (u Usage) Cost(model string) (float64, bool) { |
| 35 | - p, ok := prices[model] | |
| 35 | + p, ok := prices[basePrice(model)] | |
| 36 | 36 | if !ok { |
| 37 | 37 | return 0, false |
| 38 | 38 | } |
| @@ -42,17 +42,71 @@ func (u Usage) Cost(model string) (float64, bool) { | ||
| 42 | 42 | float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | - // PriceLabel renders a model's list pricing as the wizard shows it: | |
| 46 | - // "$5 / $25 per Mtok", input then output. A model with no known pricing gets | |
| 47 | - // an empty label rather than a zero, because "$0" beside a frontier model is | |
| 48 | - // a worse answer than saying nothing. | |
| 49 | - func PriceLabel(model string) string { | |
| 50 | - p, ok := prices[model] | |
| 51 | - if !ok { | |
| 52 | - return "" | |
| 45 | + // PriceOf reports the list pricing for a model, if this harness knows it. | |
| 46 | + // The picker shows what it knows and says nothing about what it does not. | |
| 47 | + func PriceOf(model string) (Price, bool) { | |
| 48 | + p, ok := prices[basePrice(model)] | |
| 49 | + return p, ok | |
| 50 | + } | |
| 51 | + | |
| 52 | + // basePrice strips the decoration the API puts on a model id — a bracketed | |
| 53 | + // variant tag, a -latest or -YYYYMMDD suffix — leaving the name the price is | |
| 54 | + // listed under. A dated id and a context-window variant are the same model | |
| 55 | + // billed at the same rate, and /v1/models hands out both. | |
| 56 | + func basePrice(model string) string { | |
| 57 | + if i := strings.IndexByte(model, '['); i >= 0 { | |
| 58 | + model = model[:i] | |
| 59 | + } | |
| 60 | + model = strings.TrimSuffix(model, "-latest") | |
| 61 | + if i := strings.LastIndexByte(model, '-'); i >= 0 { | |
| 62 | + if suffix := model[i+1:]; len(suffix) == 8 && allDigits(suffix) { | |
| 63 | + model = model[:i] | |
| 64 | + } | |
| 65 | + } | |
| 66 | + return model | |
| 67 | + } | |
| 68 | + | |
| 69 | + func allDigits(s string) bool { | |
| 70 | + for _, r := range s { | |
| 71 | + if r < '0' || r > '9' { | |
| 72 | + return false | |
| 73 | + } | |
| 74 | + } | |
| 75 | + return len(s) > 0 | |
| 76 | + } | |
| 77 | + | |
| 78 | + // PricedModels are the ids this build has list pricing for, flagship first and | |
| 79 | + // cheapest last within a family. | |
| 80 | + // | |
| 81 | + // It exists for the setup wizard, which has to offer a usable list even when | |
| 82 | + // the endpoint will not serve /v1/models — a gateway that only proxies | |
| 83 | + // /v1/messages is the common case, and a first run cannot be left with nothing | |
| 84 | + // to pick. The set it can price is the set it can fully describe, which is | |
| 85 | + // what makes this the honest fallback rather than a second hardcoded list. | |
| 86 | + func PricedModels() []string { | |
| 87 | + out := make([]string, 0, len(prices)) | |
| 88 | + for id := range prices { | |
| 89 | + out = append(out, id) | |
| 90 | + } | |
| 91 | + // Map order is random; a picker has to look the same every time. | |
| 92 | + sort.Slice(out, func(i, j int) bool { | |
| 93 | + if ri, rj := familyRank(out[i]), familyRank(out[j]); ri != rj { | |
| 94 | + return ri < rj | |
| 95 | + } | |
| 96 | + return out[i] > out[j] // newer version first within a family | |
| 97 | + }) | |
| 98 | + return out | |
| 99 | + } | |
| 100 | + | |
| 101 | + // familyRank orders the families the way someone choosing between them does: | |
| 102 | + // the flagship first, the cheap one last, anything unrecognised after both. | |
| 103 | + func familyRank(id string) int { | |
| 104 | + for i, family := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} { | |
| 105 | + if strings.HasPrefix(id, family) { | |
| 106 | + return i | |
| 107 | + } | |
| 53 | 108 | } |
| 54 | - // Shortest representation that round-trips: the whole-dollar rates read | |
| 55 | - // as "$5" rather than "$5.00", and a fractional one keeps its decimals. | |
| 56 | - num := func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } | |
| 57 | - return fmt.Sprintf("$%s / $%s per Mtok", num(p.InputPerMTok), num(p.OutputPerMTok)) | |
| 109 | + // Unrecognised: after every known family, and left to the id tiebreak to | |
| 110 | + // order among themselves. | |
| 111 | + return len(prices) | |
| 58 | 112 | } |
| @@ -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 | + } | |
| @@ -38,7 +38,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St | ||
| 38 | 38 | return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") |
| 39 | 39 | } |
| 40 | 40 | if req.Model == "" { |
| 41 | - req.Model = c.Model | |
| 41 | + req.Model = c.Model() | |
| 42 | 42 | } |
| 43 | 43 | if req.MaxTokens == 0 { |
| 44 | 44 | req.MaxTokens = c.MaxTokens |
| @@ -85,7 +85,13 @@ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(St | ||
| 85 | 85 | } |
| 86 | 86 | httpReq.Header.Set("content-type", "application/json") |
| 87 | 87 | httpReq.Header.Set("accept", "text/event-stream") |
| 88 | - c.setHeaders(httpReq) | |
| 88 | + httpReq.Header.Set("anthropic-version", apiVersion) | |
| 89 | + if c.apiKey != "" { | |
| 90 | + httpReq.Header.Set("x-api-key", c.apiKey) | |
| 91 | + } else { | |
| 92 | + httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 93 | + httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 94 | + } | |
| 89 | 95 | |
| 90 | 96 | httpResp, err := c.HTTP.Do(httpReq) |
| 91 | 97 | if err != nil { |
| @@ -172,6 +172,21 @@ type OutputConfig struct { | ||
| 172 | 172 | Effort string `json:"effort,omitempty"` |
| 173 | 173 | } |
| 174 | 174 | |
| 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 | + | |
| 175 | 190 | // Request is the body of POST /v1/messages. |
| 176 | 191 | type Request struct { |
| 177 | 192 | Model string `json:"model"` |
| @@ -122,3 +122,18 @@ func TestResponseAccessors(t *testing.T) { | ||
| 122 | 122 | t.Errorf("Message() = %+v, want all 4 blocks under the assistant role", msg) |
| 123 | 123 | } |
| 124 | 124 | } |
| 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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "net/url" |
| 6 | 6 | "os" |
| 7 | 7 | "strings" |
| 8 | + "unicode" | |
| 8 | 9 | |
| 9 | 10 | "harness/internal/llm" |
| 10 | 11 | ) |
| @@ -20,10 +21,43 @@ func human(n int) string { | ||
| 20 | 21 | return fmt.Sprint(n) |
| 21 | 22 | } |
| 22 | 23 | |
| 23 | - // prettyModel names a model the way a person does. The transform itself lives | |
| 24 | - // in llm, next to the ids it is about, so the wizard's list and the status bar | |
| 25 | - // cannot disagree about what a model is called. | |
| 26 | - func prettyModel(id string) string { return llm.PrettyModel(id) } | |
| 24 | + // prettyModel turns an API model id into the name a person uses for it: | |
| 25 | + // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes | |
| 26 | + // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which | |
| 27 | + // matters because this also runs against gateways serving models we have | |
| 28 | + // never heard of. | |
| 29 | + func prettyModel(id string) string { | |
| 30 | + s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest") | |
| 31 | + // Anything bracketed is a variant tag, not part of the name: opus-5[1m]. | |
| 32 | + tag := "" | |
| 33 | + if i := strings.IndexByte(s, '['); i >= 0 { | |
| 34 | + tag, s = s[i:], s[:i] | |
| 35 | + } | |
| 36 | + parts := strings.Split(s, "-") | |
| 37 | + if len(parts) < 2 { | |
| 38 | + return id | |
| 39 | + } | |
| 40 | + // A trailing eight-digit date stamp is a build, not a version. | |
| 41 | + if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) { | |
| 42 | + parts = parts[:len(parts)-1] | |
| 43 | + } | |
| 44 | + family, rest := parts[0], parts[1:] | |
| 45 | + if family == "" || !isAlpha(family) { | |
| 46 | + return id | |
| 47 | + } | |
| 48 | + name := strings.ToUpper(family[:1]) + family[1:] | |
| 49 | + var version []string | |
| 50 | + for _, p := range rest { | |
| 51 | + if !allDigits(p) { | |
| 52 | + return id | |
| 53 | + } | |
| 54 | + version = append(version, p) | |
| 55 | + } | |
| 56 | + if len(version) > 0 { | |
| 57 | + name += " " + strings.Join(version, ".") | |
| 58 | + } | |
| 59 | + return name + tag | |
| 60 | + } | |
| 27 | 61 | |
| 28 | 62 | // endpointLabel names where the prompts are going. The default endpoint gets |
| 29 | 63 | // a short name; anything else shows its host, because "somewhere that is not |
| @@ -55,6 +89,27 @@ func camel(name string) string { | ||
| 55 | 89 | return strings.Join(parts, "") |
| 56 | 90 | } |
| 57 | 91 | |
| 92 | + func allDigits(s string) bool { | |
| 93 | + if s == "" { | |
| 94 | + return false | |
| 95 | + } | |
| 96 | + for _, r := range s { | |
| 97 | + if !unicode.IsDigit(r) { | |
| 98 | + return false | |
| 99 | + } | |
| 100 | + } | |
| 101 | + return true | |
| 102 | + } | |
| 103 | + | |
| 104 | + func isAlpha(s string) bool { | |
| 105 | + for _, r := range s { | |
| 106 | + if !unicode.IsLetter(r) { | |
| 107 | + return false | |
| 108 | + } | |
| 109 | + } | |
| 110 | + return s != "" | |
| 111 | + } | |
| 112 | + | |
| 58 | 113 | func maxInt(a, b int) int { |
| 59 | 114 | if a > b { |
| 60 | 115 | return a |
| @@ -51,10 +51,11 @@ type Options struct { | ||
| 51 | 51 | // to load — which is what opens the setup wizard unprompted. |
| 52 | 52 | Config config.Config |
| 53 | 53 | FirstRun bool |
| 54 | - // OnConfig applies a finished wizard's answers to the running program and | |
| 55 | - // saves them. It returns what to say in the transcript, which is how the | |
| 56 | - // TUI reports a save that failed without knowing anything about files. | |
| 57 | - OnConfig func(config.Config) string | |
| 54 | + // SaveConfig persists a finished wizard's answers. Applying them is the | |
| 55 | + // TUI's own job — it is the same work /model and /effort already do — so | |
| 56 | + // all that is left for the caller is the file, which is the one part the | |
| 57 | + // TUI has no business knowing about. | |
| 58 | + SaveConfig func(config.Config) error | |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | 61 | type phase int |
| @@ -63,6 +64,12 @@ const ( | ||
| 63 | 64 | phaseIdle phase = iota |
| 64 | 65 | phaseRunning |
| 65 | 66 | phaseApproving |
| 67 | + // phaseEffort is the effort slider: the prompt is gone until a level is | |
| 68 | + // picked or the slider is dismissed. | |
| 69 | + phaseEffort | |
| 70 | + // phaseModel is the model picker, which starts on a wait: the list comes | |
| 71 | + // from the endpoint. | |
| 72 | + phaseModel | |
| 66 | 73 | ) |
| 67 | 74 | |
| 68 | 75 | // inputMaxLines caps how far the prompt box grows before it scrolls. Past |
| @@ -84,12 +91,18 @@ type model struct { | ||
| 84 | 91 | ready bool |
| 85 | 92 | |
| 86 | 93 | // Turn bookkeeping. |
| 87 | - turn int | |
| 88 | - started time.Time | |
| 89 | - usage llm.Usage | |
| 90 | - turns int | |
| 91 | - lastCost float64 | |
| 94 | + turn int | |
| 95 | + started time.Time | |
| 96 | + usage llm.Usage | |
| 97 | + turns int | |
| 98 | + // cost is the running bill, accumulated as usage is reported and priced | |
| 99 | + // at the model in force at that moment: a session that switched models | |
| 100 | + // was not all billed at the model it happens to end on. priced is what | |
| 101 | + // has already been counted, so a turn's totals — which on a resumed | |
| 102 | + // session include history this process never saw — are charged once. | |
| 103 | + cost float64 | |
| 92 | 104 | costKnown bool |
| 105 | + priced llm.Usage | |
| 93 | 106 | // ctxTokens is the last turn's input size — what the model actually read, |
| 94 | 107 | // which is the number the context gauge is about. It replaces itself each |
| 95 | 108 | // turn rather than accumulating, and compaction makes it drop. |
| @@ -98,6 +111,18 @@ type model struct { | ||
| 98 | 111 | // open with the same word. |
| 99 | 112 | verbSeed int |
| 100 | 113 | |
| 114 | + // menu is the slash-command completion list under the prompt. | |
| 115 | + menu menu | |
| 116 | + | |
| 117 | + // effortIdx is where the marker sits while the effort slider is up. | |
| 118 | + effortIdx int | |
| 119 | + | |
| 120 | + // The model picker: what the endpoint offered, where the cursor is, and | |
| 121 | + // whether we are still waiting to be told. | |
| 122 | + models []llm.Model | |
| 123 | + modelIdx int | |
| 124 | + modelsLoading bool | |
| 125 | + | |
| 101 | 126 | // pending approvals. The agent blocks on one at a time, but a queue costs |
| 102 | 127 | // nothing and means a second request can never be dropped. |
| 103 | 128 | pending []approvalMsg |
| @@ -230,28 +255,47 @@ func (m *model) openWizard() tea.Cmd { | ||
| 230 | 255 | |
| 231 | 256 | // closeWizard puts the chat back on screen, applying the answers if there |
| 232 | 257 | // were any. A cancelled wizard changes nothing at all. |
| 258 | + // | |
| 259 | + // Applying goes through the same setters /model and /effort use, so a setting | |
| 260 | + // chosen in the wizard and the same setting chosen from the prompt cannot come | |
| 261 | + // out different. What the wizard adds is one summary line instead of their | |
| 262 | + // three separate notices — the done screen has already shown the detail. | |
| 233 | 263 | func (m *model) closeWizard() tea.Cmd { |
| 234 | 264 | w := m.wiz |
| 235 | 265 | m.wiz = nil |
| 236 | 266 | |
| 237 | - if w.result != nil { | |
| 238 | - m.opts.Config = *w.result | |
| 239 | - m.opts.Effort = w.result.Effort | |
| 240 | - if m.opts.OnConfig != nil { | |
| 241 | - if note := m.opts.OnConfig(*w.result); note != "" { | |
| 242 | - m.tr.addNotice(note) | |
| 243 | - } | |
| 244 | - } | |
| 245 | - } else { | |
| 267 | + if w.result == nil { | |
| 246 | 268 | m.tr.addNotice("setup cancelled — nothing was changed") |
| 269 | + m.refreshSplash() | |
| 270 | + m.input.Focus() | |
| 271 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 272 | + } | |
| 273 | + | |
| 274 | + cfg := *w.result | |
| 275 | + m.opts.Config = cfg | |
| 276 | + m.useModel(cfg.Model) | |
| 277 | + m.useEffort(cfg.Effort) | |
| 278 | + if m.opts.Policy != nil { | |
| 279 | + m.opts.Policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll | |
| 280 | + } | |
| 281 | + | |
| 282 | + note := fmt.Sprintf("setup: %s · effort %s · approvals %s · %s theme — from the next turn on", | |
| 283 | + prettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme) | |
| 284 | + if m.opts.SaveConfig != nil { | |
| 285 | + if err := m.opts.SaveConfig(cfg); err != nil { | |
| 286 | + // The settings are already in force; only the file failed. Saying | |
| 287 | + // so beats a wizard that looks like it did nothing. | |
| 288 | + note += "\nthese are in force for this session, but saving them failed: " + err.Error() | |
| 289 | + } | |
| 247 | 290 | } |
| 248 | - | |
| 249 | - // The welcome block names the model, which the wizard may have just | |
| 250 | - // changed, and every entry was laid out in the old palette. | |
| 251 | - if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw { | |
| 252 | - m.tr.entries[0].text = splash(m.opts, m.width) | |
| 291 | + m.tr.addNotice(note) | |
| 292 | + if m.opts.Policy != nil && m.opts.Policy.YOLO { | |
| 293 | + m.tr.addNotice("accept-all is on — tool calls run without asking.") | |
| 253 | 294 | } |
| 254 | - m.tr.dirty = true | |
| 295 | + | |
| 296 | + // Every entry was laid out in the old palette, and the welcome block names | |
| 297 | + // a model that may have just changed. | |
| 298 | + m.refreshSplash() | |
| 255 | 299 | m.input.Focus() |
| 256 | 300 | return tea.Batch(m.sync(), textarea.Blink) |
| 257 | 301 | } |
| @@ -267,7 +311,7 @@ func (m *model) runTurn(prompt string) tea.Cmd { | ||
| 267 | 311 | _, err := a.Run(ctx, prompt) |
| 268 | 312 | cancel() |
| 269 | 313 | |
| 270 | - if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil { | |
| 314 | + if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil { | |
| 271 | 315 | err = fmt.Errorf("saving session: %w", saveErr) |
| 272 | 316 | } |
| 273 | 317 | u, turns := a.State().Usage() |
| @@ -299,8 +343,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 299 | 343 | case tea.KeyMsg: |
| 300 | 344 | return m.onKey(msg) |
| 301 | 345 | |
| 346 | + case modelsMsg: | |
| 347 | + return m, m.onModels(msg) | |
| 348 | + | |
| 302 | 349 | case spinner.TickMsg: |
| 303 | - if m.phase == phaseRunning { | |
| 350 | + if m.phase == phaseRunning || m.modelsLoading { | |
| 304 | 351 | var cmd tea.Cmd |
| 305 | 352 | m.spin, cmd = m.spin.Update(msg) |
| 306 | 353 | return m, cmd |
| @@ -331,6 +378,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 331 | 378 | |
| 332 | 379 | case evUsage: |
| 333 | 380 | m.usage = addUsage(m.usage, msg.usage) |
| 381 | + m.addCost(msg.usage) | |
| 334 | 382 | m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens |
| 335 | 383 | return m, nil |
| 336 | 384 | |
| @@ -361,17 +409,28 @@ func (m *model) resize(w, h int) tea.Cmd { | ||
| 361 | 409 | } else { |
| 362 | 410 | m.vp.Width = w |
| 363 | 411 | } |
| 364 | - // The welcome block is laid out for a width, so it is re-rendered rather | |
| 365 | - // than wrapped: it is the one entry that owns its own lines. | |
| 366 | - if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw { | |
| 367 | - m.tr.entries[0].text = splash(m.opts, w) | |
| 368 | - m.tr.dirty = true | |
| 369 | - } | |
| 412 | + m.refreshSplash() | |
| 370 | 413 | m.vp.SetContent(m.tr.render()) |
| 371 | 414 | m.vp.GotoBottom() |
| 372 | 415 | return nil |
| 373 | 416 | } |
| 374 | 417 | |
| 418 | + // refreshSplash re-renders the welcome block. It is the one entry that owns | |
| 419 | + // its own lines — laid out for a width, and naming the model and the endpoint | |
| 420 | + // — so when any of those change it is rebuilt rather than wrapped or left to | |
| 421 | + // describe a session that has moved on. | |
| 422 | + func (m *model) refreshSplash() { | |
| 423 | + if len(m.tr.entries) == 0 || m.tr.entries[0].kind != entryRaw { | |
| 424 | + return | |
| 425 | + } | |
| 426 | + width := m.width | |
| 427 | + if width <= 0 { | |
| 428 | + width = 80 | |
| 429 | + } | |
| 430 | + m.tr.entries[0].text = splash(m.opts, width) | |
| 431 | + m.tr.dirty = true | |
| 432 | + } | |
| 433 | + | |
| 375 | 434 | // sync re-renders the scrollback, keeping the view pinned to the bottom unless |
| 376 | 435 | // the user has scrolled up to read something. |
| 377 | 436 | func (m *model) sync() tea.Cmd { |
| @@ -390,7 +449,7 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd { | ||
| 390 | 449 | m.phase = phaseIdle |
| 391 | 450 | m.interrupting = false |
| 392 | 451 | m.usage, m.turns = msg.usage, msg.turns |
| 393 | - m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model) | |
| 452 | + m.addCost(subUsage(msg.usage, m.priced)) | |
| 394 | 453 | m.tr.closeOpen() |
| 395 | 454 | |
| 396 | 455 | // Any approval still outstanding belongs to a turn that no longer exists. |
| @@ -414,6 +473,20 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 414 | 473 | if m.phase == phaseApproving { |
| 415 | 474 | return m.onApprovalKey(msg) |
| 416 | 475 | } |
| 476 | + if m.phase == phaseEffort { | |
| 477 | + return m.onEffortKey(msg) | |
| 478 | + } | |
| 479 | + if m.phase == phaseModel { | |
| 480 | + return m.onModelKey(msg) | |
| 481 | + } | |
| 482 | + | |
| 483 | + // An open menu owns the keys that drive it — including enter and esc, | |
| 484 | + // which mean something else the rest of the time. | |
| 485 | + if m.menuOpen() { | |
| 486 | + if cmd, took := m.onMenuKey(msg); took { | |
| 487 | + return m, cmd | |
| 488 | + } | |
| 489 | + } | |
| 417 | 490 | |
| 418 | 491 | switch msg.String() { |
| 419 | 492 | case "ctrl+c": |
| @@ -462,12 +535,14 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 462 | 535 | } |
| 463 | 536 | m.input.Reset() |
| 464 | 537 | m.growInput() |
| 538 | + m.closeMenu() | |
| 465 | 539 | return m.submit(line) |
| 466 | 540 | } |
| 467 | 541 | |
| 468 | 542 | var cmd tea.Cmd |
| 469 | 543 | m.input, cmd = m.input.Update(msg) |
| 470 | 544 | m.growInput() |
| 545 | + m.refreshMenu() | |
| 471 | 546 | return m, cmd |
| 472 | 547 | } |
| 473 | 548 | |
| @@ -517,9 +592,15 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 517 | 592 | return m, tea.Quit |
| 518 | 593 | |
| 519 | 594 | case line == "/help": |
| 520 | - m.tr.addNotice(helpText) | |
| 595 | + m.tr.addNotice(m.helpText()) | |
| 521 | 596 | return m, m.sync() |
| 522 | 597 | |
| 598 | + case line == "/model" || strings.HasPrefix(line, "/model "): | |
| 599 | + return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model"))) | |
| 600 | + | |
| 601 | + case line == "/effort" || strings.HasPrefix(line, "/effort "): | |
| 602 | + return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort"))) | |
| 603 | + | |
| 523 | 604 | case line == "/clear": |
| 524 | 605 | m.tr.clear() |
| 525 | 606 | return m, m.sync() |
| @@ -566,6 +647,32 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 566 | 647 | return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line)) |
| 567 | 648 | } |
| 568 | 649 | |
| 650 | + // setModel answers /model. Bare, it asks the endpoint what it serves and | |
| 651 | + // opens the picker on the answer; with an id, it switches straight away, | |
| 652 | + // which also works on an endpoint that does not list its models. | |
| 653 | + func (m *model) setModel(id string) tea.Cmd { | |
| 654 | + if id == "" { | |
| 655 | + return m.openModelPicker() | |
| 656 | + } | |
| 657 | + m.applyModel(id) | |
| 658 | + return m.sync() | |
| 659 | + } | |
| 660 | + | |
| 661 | + // setEffort answers /effort. Bare, it opens the slider; with a level, it | |
| 662 | + // applies it directly, which is the fast path for anyone who knows what they | |
| 663 | + // want and can type it. | |
| 664 | + func (m *model) setEffort(level string) tea.Cmd { | |
| 665 | + switch { | |
| 666 | + case level == "": | |
| 667 | + return m.openEffort() | |
| 668 | + case !llm.ValidEffort(level): | |
| 669 | + m.tr.addError("unknown effort " + level + " — one of " + strings.Join(llm.EffortLevels, ", ")) | |
| 670 | + default: | |
| 671 | + m.applyEffort(level) | |
| 672 | + } | |
| 673 | + return m.sync() | |
| 674 | + } | |
| 675 | + | |
| 569 | 676 | // isSkill reports whether a slash command names a discovered skill. Matching |
| 570 | 677 | // here rather than sending every unrecognised slash to the model means a typo |
| 571 | 678 | // is caught by the harness instead of puzzled over by the agent. |
| @@ -667,11 +774,6 @@ func matches(keys []string, key string) bool { | ||
| 667 | 774 | |
| 668 | 775 | // ---- view --------------------------------------------------------------- |
| 669 | 776 | |
| 670 | - const helpText = `commands: /allowed /clear /help /setup /skills /exit | |
| 671 | - keys: enter send · ctrl+j newline · shift+tab cycle approval mode | |
| 672 | - ctrl+r toggle full tool output · pgup/pgdn scroll | |
| 673 | - esc or ctrl+c interrupt a turn · ctrl+c again to exit` | |
| 674 | - | |
| 675 | 777 | func (m *model) View() string { |
| 676 | 778 | if !m.ready { |
| 677 | 779 | return "starting…" |
| @@ -700,10 +802,18 @@ func (m *model) View() string { | ||
| 700 | 802 | func (m *model) bottom() string { |
| 701 | 803 | var b strings.Builder |
| 702 | 804 | b.WriteString(m.activity() + "\n") |
| 703 | - if m.phase == phaseApproving && len(m.pending) > 0 { | |
| 805 | + switch { | |
| 806 | + case m.phase == phaseApproving && len(m.pending) > 0: | |
| 704 | 807 | b.WriteString(m.approvalView() + "\n") |
| 705 | - } else { | |
| 808 | + case m.phase == phaseEffort: | |
| 809 | + b.WriteString(m.effortView() + "\n") | |
| 810 | + case m.phase == phaseModel: | |
| 811 | + b.WriteString(m.modelView() + "\n") | |
| 812 | + default: | |
| 706 | 813 | b.WriteString(m.inputBox() + "\n") |
| 814 | + if v := m.menuView(); v != "" { | |
| 815 | + b.WriteString(v + "\n") | |
| 816 | + } | |
| 707 | 817 | } |
| 708 | 818 | b.WriteString(m.status() + "\n") |
| 709 | 819 | b.WriteString(m.modeLine()) |
| @@ -711,9 +821,9 @@ func (m *model) bottom() string { | ||
| 711 | 821 | } |
| 712 | 822 | |
| 713 | 823 | // activity is the line above the prompt: what the agent is doing on the left, |
| 714 | - // the effort setting on the right. The effort pill names the command that | |
| 715 | - // changes it, because a setting you can see and cannot reach is worse than one | |
| 716 | - // you cannot see. | |
| 824 | + // the effort setting on the right. The pill names /effort rather than /setup: | |
| 825 | + // it reports the level this session is running at, and the command that | |
| 826 | + // changes that is the one worth naming beside it. | |
| 717 | 827 | func (m *model) activity() string { |
| 718 | 828 | var left string |
| 719 | 829 | switch m.phase { |
| @@ -736,7 +846,7 @@ func (m *model) activity() string { | ||
| 736 | 846 | |
| 737 | 847 | right := "" |
| 738 | 848 | if m.opts.Effort != "" { |
| 739 | - right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /setup") | |
| 849 | + right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort") | |
| 740 | 850 | } |
| 741 | 851 | return spread(left, right, m.width) |
| 742 | 852 | } |
| @@ -762,7 +872,7 @@ func (m *model) inputBox() string { | ||
| 762 | 872 | func (m *model) status() string { |
| 763 | 873 | parts := []string{ |
| 764 | 874 | styName.Render(username()), |
| 765 | - styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)), | |
| 875 | + styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model())), | |
| 766 | 876 | } |
| 767 | 877 | |
| 768 | 878 | if pill, ok := m.contextPill(); ok { |
| @@ -779,8 +889,8 @@ func (m *model) status() string { | ||
| 779 | 889 | } |
| 780 | 890 | io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens)) |
| 781 | 891 | parts = append(parts, styDim.Render(io)) |
| 782 | - if cost, known := u.Cost(m.opts.Client.Model); known { | |
| 783 | - parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost))) | |
| 892 | + if m.costKnown { | |
| 893 | + parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost))) | |
| 784 | 894 | } |
| 785 | 895 | } |
| 786 | 896 | return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width)) |
| @@ -874,6 +984,30 @@ func username() string { | ||
| 874 | 984 | return "you" |
| 875 | 985 | } |
| 876 | 986 | |
| 987 | + // addCost prices one piece of usage at the model in force and adds it to the | |
| 988 | + // running total, recording that those tokens have now been paid for. | |
| 989 | + func (m *model) addCost(u llm.Usage) { | |
| 990 | + if u == (llm.Usage{}) { | |
| 991 | + return | |
| 992 | + } | |
| 993 | + m.priced = addUsage(m.priced, u) | |
| 994 | + if c, known := u.Cost(m.opts.Client.Model()); known { | |
| 995 | + m.cost += c | |
| 996 | + m.costKnown = true | |
| 997 | + } | |
| 998 | + } | |
| 999 | + | |
| 1000 | + // subUsage is a minus b, floored at zero per field: totals only ever grow, and | |
| 1001 | + // a negative remainder would mean charging backwards. | |
| 1002 | + func subUsage(a, b llm.Usage) llm.Usage { | |
| 1003 | + return llm.Usage{ | |
| 1004 | + InputTokens: maxInt(0, a.InputTokens-b.InputTokens), | |
| 1005 | + OutputTokens: maxInt(0, a.OutputTokens-b.OutputTokens), | |
| 1006 | + CacheReadInputTokens: maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens), | |
| 1007 | + CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens), | |
| 1008 | + } | |
| 1009 | + } | |
| 1010 | + | |
| 877 | 1011 | func addUsage(a, b llm.Usage) llm.Usage { |
| 878 | 1012 | a.InputTokens += b.InputTokens |
| 879 | 1013 | a.OutputTokens += b.OutputTokens |
| @@ -0,0 +1,265 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "strings" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "github.com/charmbracelet/bubbles/textarea" | |
| 10 | + tea "github.com/charmbracelet/bubbletea" | |
| 11 | + "github.com/charmbracelet/lipgloss" | |
| 12 | + | |
| 13 | + "harness/internal/llm" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // modelFetchTimeout bounds the wait. Someone is sitting in front of a picker | |
| 17 | + // that says it is asking; a minute of that is not a picker, it is a hang. | |
| 18 | + const modelFetchTimeout = 20 * time.Second | |
| 19 | + | |
| 20 | + // modelsMsg carries the answer to GET /v1/models back into the UI. | |
| 21 | + type modelsMsg struct { | |
| 22 | + models []llm.Model | |
| 23 | + err error | |
| 24 | + } | |
| 25 | + | |
| 26 | + // fetchModels asks the endpoint what it serves. It runs off the UI goroutine | |
| 27 | + // like any other command, and reports failure as a value rather than by | |
| 28 | + // leaving the picker waiting forever. | |
| 29 | + func fetchModels(c *llm.Client) tea.Cmd { | |
| 30 | + return func() tea.Msg { | |
| 31 | + ctx, cancel := context.WithTimeout(context.Background(), modelFetchTimeout) | |
| 32 | + defer cancel() | |
| 33 | + models, err := c.ListModels(ctx) | |
| 34 | + return modelsMsg{models: models, err: err} | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + // openModelPicker puts the picker up and starts asking. | |
| 39 | + func (m *model) openModelPicker() tea.Cmd { | |
| 40 | + m.phase = phaseModel | |
| 41 | + m.models, m.modelIdx, m.modelsLoading = nil, 0, true | |
| 42 | + m.closeMenu() | |
| 43 | + return tea.Batch(m.sync(), m.spin.Tick, fetchModels(m.opts.Client)) | |
| 44 | + } | |
| 45 | + | |
| 46 | + // onModels lands the answer. An answer nobody is waiting for any more is | |
| 47 | + // dropped: the picker was escaped, and reopening it over whatever the user | |
| 48 | + // went on to do would be the UI arguing with them. | |
| 49 | + func (m *model) onModels(msg modelsMsg) tea.Cmd { | |
| 50 | + if m.phase != phaseModel { | |
| 51 | + return nil | |
| 52 | + } | |
| 53 | + m.modelsLoading = false | |
| 54 | + m.phase = phaseIdle | |
| 55 | + | |
| 56 | + switch { | |
| 57 | + case msg.err != nil: | |
| 58 | + m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " + | |
| 59 | + msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works") | |
| 60 | + case len(msg.models) == 0: | |
| 61 | + m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works") | |
| 62 | + default: | |
| 63 | + m.phase = phaseModel | |
| 64 | + m.models = msg.models | |
| 65 | + m.modelIdx = modelIndex(msg.models, m.opts.Client.Model()) | |
| 66 | + } | |
| 67 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // modelIndex finds the model in force, so the picker opens where the session | |
| 71 | + // already is rather than at the top of a list. | |
| 72 | + func modelIndex(models []llm.Model, current string) int { | |
| 73 | + for i, mo := range models { | |
| 74 | + if mo.ID == current { | |
| 75 | + return i | |
| 76 | + } | |
| 77 | + } | |
| 78 | + return 0 | |
| 79 | + } | |
| 80 | + | |
| 81 | + // onModelKey drives the picker. While the fetch is in flight there is nothing | |
| 82 | + // to move through, so only the ways out are live. | |
| 83 | + func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 84 | + key := msg.String() | |
| 85 | + if key == "esc" || key == "ctrl+c" { | |
| 86 | + m.phase = phaseIdle | |
| 87 | + m.models, m.modelsLoading = nil, false | |
| 88 | + return m, tea.Batch(m.sync(), textarea.Blink) | |
| 89 | + } | |
| 90 | + if m.modelsLoading || len(m.models) == 0 { | |
| 91 | + return m, nil | |
| 92 | + } | |
| 93 | + | |
| 94 | + last := len(m.models) - 1 | |
| 95 | + switch key { | |
| 96 | + case "up", "ctrl+p": | |
| 97 | + m.modelIdx = maxInt(0, m.modelIdx-1) | |
| 98 | + case "down", "ctrl+n": | |
| 99 | + m.modelIdx = minInt(last, m.modelIdx+1) | |
| 100 | + case "home": | |
| 101 | + m.modelIdx = 0 | |
| 102 | + case "end": | |
| 103 | + m.modelIdx = last | |
| 104 | + case "enter", "tab": | |
| 105 | + id := m.models[m.modelIdx].ID | |
| 106 | + m.phase = phaseIdle | |
| 107 | + m.models = nil | |
| 108 | + m.applyModel(id) | |
| 109 | + return m, tea.Batch(m.sync(), textarea.Blink) | |
| 110 | + default: | |
| 111 | + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { | |
| 112 | + if i := int(key[0] - '1'); i <= last { | |
| 113 | + m.modelIdx = i | |
| 114 | + } | |
| 115 | + } | |
| 116 | + } | |
| 117 | + return m, m.sync() | |
| 118 | + } | |
| 119 | + | |
| 120 | + // useModel switches the client. The session keeps its history: the | |
| 121 | + // conversation so far is replayed to the new model on the next turn, which is | |
| 122 | + // the point of switching mid-session. | |
| 123 | + // | |
| 124 | + // It is separate from applyModel because the setup wizard changes several | |
| 125 | + // settings at once and says so in one line, where the picker changes this one | |
| 126 | + // and announces it. (setModel is taken: that is the /model command.) | |
| 127 | + func (m *model) useModel(id string) { | |
| 128 | + if id == "" { | |
| 129 | + return | |
| 130 | + } | |
| 131 | + m.opts.Client.SetModel(id) | |
| 132 | + m.refreshSplash() | |
| 133 | + } | |
| 134 | + | |
| 135 | + // applyModel switches the client and says so. | |
| 136 | + func (m *model) applyModel(id string) { | |
| 137 | + m.useModel(id) | |
| 138 | + m.tr.addNotice("model: " + prettyModel(id) + " (" + id + ") — from the next turn on") | |
| 139 | + } | |
| 140 | + | |
| 141 | + // modelView is the picker: a heading, where the list came from, and a row per | |
| 142 | + // model with what it is called, its id, and what it costs. | |
| 143 | + func (m *model) modelView() string { | |
| 144 | + width := maxInt(1, m.width) | |
| 145 | + head := " " + styHeading.Render("Select model") | |
| 146 | + from := styDim.Render("from " + endpointLabel(m.opts.Client)) | |
| 147 | + | |
| 148 | + if m.modelsLoading { | |
| 149 | + return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width) | |
| 150 | + } | |
| 151 | + | |
| 152 | + rows := m.modelRows() | |
| 153 | + shown := rows | |
| 154 | + if len(m.models) > rows { | |
| 155 | + shown = maxInt(1, rows-1) | |
| 156 | + } | |
| 157 | + start := 0 | |
| 158 | + if m.modelIdx >= shown { | |
| 159 | + start = m.modelIdx - shown + 1 | |
| 160 | + } | |
| 161 | + end := minInt(len(m.models), start+shown) | |
| 162 | + | |
| 163 | + // One column for the names, so the ids line up under each other. | |
| 164 | + names := make([]string, len(m.models)) | |
| 165 | + col := 0 | |
| 166 | + for i, mo := range m.models { | |
| 167 | + names[i] = mo.DisplayName | |
| 168 | + if names[i] == "" { | |
| 169 | + names[i] = prettyModel(mo.ID) | |
| 170 | + } | |
| 171 | + if i >= start && i < end { | |
| 172 | + col = maxInt(col, lipgloss.Width(names[i])) | |
| 173 | + } | |
| 174 | + } | |
| 175 | + col = minInt(col, maxInt(10, width/3)) | |
| 176 | + | |
| 177 | + var b strings.Builder | |
| 178 | + b.WriteString(head + "\n") | |
| 179 | + b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter apply · esc cancel"), width) + "\n") | |
| 180 | + for i := start; i < end; i++ { | |
| 181 | + mo := m.models[i] | |
| 182 | + marker, name := " ", styDim.Render(pad(names[i], col)) | |
| 183 | + if i == m.modelIdx { | |
| 184 | + marker, name = styKey.Render("❯ "), styKey.Render(pad(names[i], col)) | |
| 185 | + } | |
| 186 | + // An endpoint that gave no display name leaves the id as the name; | |
| 187 | + // repeating it in the detail column says nothing twice. | |
| 188 | + detail := "" | |
| 189 | + if names[i] != mo.ID { | |
| 190 | + detail = mo.ID | |
| 191 | + } | |
| 192 | + if mo.MaxInputTokens > 0 { | |
| 193 | + if detail != "" { | |
| 194 | + detail += " · " | |
| 195 | + } | |
| 196 | + detail += human(mo.MaxInputTokens) + " in" | |
| 197 | + } | |
| 198 | + if p, ok := llm.PriceOf(mo.ID); ok { | |
| 199 | + if detail != "" { | |
| 200 | + detail += " · " | |
| 201 | + } | |
| 202 | + detail += fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok) | |
| 203 | + } | |
| 204 | + line := marker + fmt.Sprintf("%d. ", i+1) + name + " " + styFaint.Render(detail) | |
| 205 | + if mo.ID == m.opts.Client.Model() { | |
| 206 | + line += styOK.Render(" ✓") | |
| 207 | + } | |
| 208 | + b.WriteString(truncateStyled(" "+line, width) + "\n") | |
| 209 | + } | |
| 210 | + if n := len(m.models) - (end - start); n > 0 { | |
| 211 | + b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n") | |
| 212 | + } | |
| 213 | + return strings.TrimRight(b.String(), "\n") | |
| 214 | + } | |
| 215 | + | |
| 216 | + // modelRows is how many model rows fit: the same arithmetic the completion | |
| 217 | + // menu does, minus the heading and the line saying where the list came from. | |
| 218 | + func (m *model) modelRows() int { | |
| 219 | + room := m.height - 6 | |
| 220 | + return maxInt(1, minInt(modelMaxRows, room)) | |
| 221 | + } | |
| 222 | + | |
| 223 | + // modelMaxRows caps the list. Anthropic serves a couple of dozen models and | |
| 224 | + // nobody scrolls a picker looking for the twenty-fourth. | |
| 225 | + const modelMaxRows = 10 | |
| 226 | + | |
| 227 | + // ---- describing a model ------------------------------------------------- | |
| 228 | + // | |
| 229 | + // The picker and the setup wizard show the same models and must say the same | |
| 230 | + // things about them, so what a model is called and what it costs is decided | |
| 231 | + // once, here. | |
| 232 | + | |
| 233 | + // pricedModels is the list to fall back on when the endpoint will not answer | |
| 234 | + // GET /v1/models. The picker treats that as an error and says so — there is a | |
| 235 | + // session to go back to. The wizard cannot: it is the first run, and a model | |
| 236 | + // has to be chosen before there is anything to go back to. | |
| 237 | + func pricedModels() []llm.Model { | |
| 238 | + ids := llm.PricedModels() | |
| 239 | + out := make([]llm.Model, 0, len(ids)) | |
| 240 | + for _, id := range ids { | |
| 241 | + out = append(out, llm.Model{ID: id, DisplayName: prettyModel(id)}) | |
| 242 | + } | |
| 243 | + return out | |
| 244 | + } | |
| 245 | + | |
| 246 | + // priceLabel is a model's list pricing, or "" when this build cannot price it. | |
| 247 | + // A blank says "not known"; a zero would say "free", which is worse than | |
| 248 | + // saying nothing. | |
| 249 | + func priceLabel(id string) string { | |
| 250 | + p, ok := llm.PriceOf(id) | |
| 251 | + if !ok { | |
| 252 | + return "" | |
| 253 | + } | |
| 254 | + return fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok) | |
| 255 | + } | |
| 256 | + | |
| 257 | + // modelDetail is the line under a highlighted model: its id, and its context | |
| 258 | + // window when the endpoint reported one. | |
| 259 | + func modelDetail(m llm.Model) string { | |
| 260 | + detail := m.ID | |
| 261 | + if m.MaxInputTokens > 0 { | |
| 262 | + detail += " · " + human(m.MaxInputTokens) + " token context" | |
| 263 | + } | |
| 264 | + return detail | |
| 265 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.