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