The harness had no persisted settings at all: model and effort came from flags every run, and the status bar advertised a /effort command that was never written. A first run now opens a wizard instead of the chat and asks four questions — model, effort, approvals, theme — one screen at a time. internal/config is the file behind it, under XDG. Nothing in it is required and none of it wins against a flag: precedence is flag, then config, then built-in default. A saved accept-all is honoured, but only -yolo can turn approvals off from nothing, because a file should not quietly grant the agent more than the command line did. A config that cannot be parsed is reported and ignored rather than overwritten. The model list comes from GET /v1/models, which most gateways do not serve; that is the expected case, not the error case, so it falls back to the models this build can price and says so. custom… takes any id. The theme step previews by applying the palette for real — a swatch tells you nothing about what a transcript looks like in it — and cancelling puts the old one back. styles.go becomes a projection of a palette rather than literals; the roles and every call site are unchanged. Animations are pure functions of an integer frame, which is what makes them testable and skippable: a test asks for frame n, and skipping is passing a bigger number. They collapse to their final frame under NO_COLOR, a dumb terminal, or a window under 60x20. PrettyModel moves to llm so the wizard's list and the status bar cannot disagree about what a model is called, and the three request paths now share one setHeaders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 21 changed files with 3042 additions and 139 deletions.
| @@ -79,7 +79,7 @@ to six lines. | ||
| 79 | 79 | | `esc` | interrupt the turn | |
| 80 | 80 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 81 | 81 | |
| 82 | - Commands: `/allowed`, `/clear`, `/help`, `/exit`, `/skills`. | |
| 82 | + Commands: `/allowed`, `/clear`, `/help`, `/setup`, `/skills`, `/exit`. | |
| 83 | 83 | |
| 84 | 84 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 85 | 85 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -97,6 +97,73 @@ The TUI is skipped automatically when stdin or stdout is not a terminal, and by | ||
| 97 | 97 | consume the same `agent.Event` stream, so neither can show something the other |
| 98 | 98 | cannot. |
| 99 | 99 | |
| 100 | + ## Setup | |
| 101 | + | |
| 102 | + The first run on a terminal opens a full-screen wizard instead of the chat: the | |
| 103 | + mark assembles, the name lights up, and it asks four questions one at a time. | |
| 104 | + | |
| 105 | + ``` | |
| 106 | + harness setup step 1 of 4 | |
| 107 | + ◆━━━━━━━━━━━━━━━◇───────────────◇───────────────◇ | |
| 108 | + | |
| 109 | + Which model should the agent use? | |
| 110 | + | |
| 111 | + ▌ ◆ Opus 5 $5 / $25 per Mtok | |
| 112 | + ◇ Sonnet 5 $2 / $10 per Mtok | |
| 113 | + ◇ Haiku 4.5 $1 / $5 per Mtok | |
| 114 | + ◇ custom… | |
| 115 | + | |
| 116 | + claude-opus-5 | |
| 117 | + | |
| 118 | + ↑↓ move enter next esc back ctrl+c cancel | |
| 119 | + ``` | |
| 120 | + | |
| 121 | + | Question | Answers | | |
| 122 | + | --- | --- | | |
| 123 | + | Model | whatever `GET /v1/models` reports, priced from what this build knows, plus `custom…` for any id | | |
| 124 | + | Effort | `low` `medium` `high` `xhigh` `max` | | |
| 125 | + | Approvals | ask before every mutating call, or accept them all | | |
| 126 | + | Theme | `ember` `mint` `iris` `mono` | | |
| 127 | + | |
| 128 | + The model list is fetched when the wizard opens. Against a gateway that does | |
| 129 | + not serve a catalogue — the common case — it falls back to the models this | |
| 130 | + build can price and says so rather than pretending the short list is the whole | |
| 131 | + truth. `custom…` is there because a gateway may serve something no build has | |
| 132 | + heard of. | |
| 133 | + | |
| 134 | + The theme step previews by applying the palette for real: reading a swatch | |
| 135 | + tells you nothing about what a transcript looks like in it. Cancelling puts the | |
| 136 | + old one back. | |
| 137 | + | |
| 138 | + Answers land in `~/.config/harness/config.json` (`XDG_CONFIG_HOME` is | |
| 139 | + honoured): | |
| 140 | + | |
| 141 | + ```json | |
| 142 | + { | |
| 143 | + "version": 1, | |
| 144 | + "model": "claude-opus-5", | |
| 145 | + "effort": "high", | |
| 146 | + "approvals": "ask", | |
| 147 | + "theme": "ember" | |
| 148 | + } | |
| 149 | + ``` | |
| 150 | + | |
| 151 | + Nothing in that file is required, and none of it wins an argument with a flag: | |
| 152 | + precedence is **flag, then config, then the built-in default**, so | |
| 153 | + `-model claude-sonnet-5` is still a one-run override. `accept-all` saved as a | |
| 154 | + default is honoured, but `-yolo` can only turn approvals off, never on — a file | |
| 155 | + should not be able to quietly grant the agent more than the command line did. | |
| 156 | + | |
| 157 | + `/setup` reopens the wizard at any time and applies the answers to the running | |
| 158 | + session. It is refused mid-turn: changing the model while a turn is in flight | |
| 159 | + would finish it on a different model than it started on. A config file that | |
| 160 | + cannot be parsed is reported and then ignored, and is never overwritten until | |
| 161 | + you finish the wizard. | |
| 162 | + | |
| 163 | + The whole thing is one screen at a time, `↑↓` and `enter`, `esc` to go back. | |
| 164 | + Animations collapse to their final frame under `NO_COLOR`, a `dumb` terminal, | |
| 165 | + or a window under 60×20, and any key skips the one playing. | |
| 166 | + | |
| 100 | 167 | ### Driving it without an API key |
| 101 | 168 | |
| 102 | 169 | `internal/tui/testdata/mockapi` is a fake Messages API that streams a canned |
| @@ -237,10 +304,11 @@ internal/llm Messages API: types, HTTP, SSE, pricing | ||
| 237 | 304 | internal/agent turn loop, conversation state, compaction, sessions |
| 238 | 305 | internal/tools the tool registry and the tools |
| 239 | 306 | internal/skills discovery and parsing of SKILL.md |
| 307 | + internal/config saved defaults: model, effort, approvals, theme | |
| 240 | 308 | internal/approval allow / deny / ask, with shell analysis |
| 241 | 309 | internal/metrics JSONL event log |
| 242 | 310 | internal/ui line-based terminal rendering |
| 243 | - internal/tui full-screen Bubble Tea front end | |
| 311 | + internal/tui full-screen Bubble Tea front end, and the setup wizard | |
| 244 | 312 | evals task suite and runner |
| 245 | 313 | ``` |
| 246 | 314 | |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "harness/internal/agent" |
| 18 | 18 | "harness/internal/approval" |
| 19 | + "harness/internal/config" | |
| 19 | 20 | "harness/internal/llm" |
| 20 | 21 | "harness/internal/metrics" |
| 21 | 22 | "harness/internal/skills" |
| @@ -62,8 +63,13 @@ func run() error { | ||
| 62 | 63 | return err |
| 63 | 64 | } |
| 64 | 65 | |
| 66 | + // Saved preferences sit under the flags and over the built-in defaults. A | |
| 67 | + // config that cannot be read is reported and then ignored: the harness | |
| 68 | + // still starts, and the wizard can write a good one over it. | |
| 69 | + cfg, haveConfig, cfgErr := config.Load() | |
| 70 | + | |
| 65 | 71 | client := llm.New( |
| 66 | - llm.WithModel(*model), | |
| 72 | + llm.WithModel(config.Or(*model, cfg.Model)), | |
| 67 | 73 | llm.WithBaseURL(*baseURL), |
| 68 | 74 | llm.WithAPIKey(*apiKey), |
| 69 | 75 | llm.WithAuthToken(*authToken), |
| @@ -72,6 +78,12 @@ func run() error { | ||
| 72 | 78 | return errNoCredentials |
| 73 | 79 | } |
| 74 | 80 | |
| 81 | + effortSetting := config.Or(*effort, cfg.Effort) | |
| 82 | + // An accept-all default is a decision about what the agent may do | |
| 83 | + // unattended, so -yolo can turn it on but a config can only be the default | |
| 84 | + // for a session where the flag said nothing. | |
| 85 | + acceptAll := *yolo || cfg.Approvals == config.ApprovalsAcceptAll | |
| 86 | + | |
| 75 | 87 | session := time.Now().UTC().Format("20060102T150405Z") |
| 76 | 88 | log, err := metrics.Open(filepath.Join(root, *logPath), session) |
| 77 | 89 | if err != nil { |
| @@ -95,6 +107,10 @@ func run() error { | ||
| 95 | 107 | fmt.Fprintln(os.Stderr, "harness: "+msg) |
| 96 | 108 | } |
| 97 | 109 | |
| 110 | + if cfgErr != nil { | |
| 111 | + notice("%v — using defaults; /setup will write a fresh one", cfgErr) | |
| 112 | + } | |
| 113 | + | |
| 98 | 114 | if !client.IsDefaultEndpoint() { |
| 99 | 115 | // Worth saying out loud: the prompt and every file the agent reads go |
| 100 | 116 | // to this endpoint, not to Anthropic. |
| @@ -141,14 +157,14 @@ func run() error { | ||
| 141 | 157 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 142 | 158 | approver := &ui.Approver{In: in, Out: os.Stdout} |
| 143 | 159 | policy := approval.New(approver.Prompt) |
| 144 | - policy.YOLO = *yolo | |
| 145 | - if *yolo && !useTUI { | |
| 160 | + policy.YOLO = acceptAll | |
| 161 | + if acceptAll && !useTUI { | |
| 146 | 162 | // The TUI says this itself, in the banner. |
| 147 | 163 | fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.") |
| 148 | 164 | } |
| 149 | 165 | a := agent.New(client, reg, state, log, agent.Options{ |
| 150 | 166 | MaxTurns: *maxTurns, |
| 151 | - Effort: *effort, | |
| 167 | + Effort: effortSetting, | |
| 152 | 168 | ContextWindow: *contextWindow, |
| 153 | 169 | CompactAt: *compactAt, |
| 154 | 170 | NoCache: *noCache, |
| @@ -181,14 +197,22 @@ func run() error { | ||
| 181 | 197 | SessionID: session, |
| 182 | 198 | ToolNames: toolNames(reg), |
| 183 | 199 | Verbose: *verbose, |
| 184 | - YOLO: *yolo, | |
| 200 | + YOLO: acceptAll, | |
| 185 | 201 | Skills: found, |
| 186 | 202 | |
| 187 | 203 | Version: version, |
| 188 | - Effort: *effort, | |
| 204 | + Effort: effortSetting, | |
| 189 | 205 | ContextWindow: *contextWindow, |
| 190 | 206 | |
| 191 | 207 | Banner: banner, |
| 208 | + | |
| 209 | + // No config on disk is what "first run" means, and it is the only | |
| 210 | + // thing that opens the wizard unasked. A flag-only invocation is | |
| 211 | + // still a first run: the flags say what this run does, not what | |
| 212 | + // the next one should default to. | |
| 213 | + Config: cfg, | |
| 214 | + FirstRun: !haveConfig && cfgErr == nil, | |
| 215 | + OnConfig: applyConfig(a, client, policy), | |
| 192 | 216 | }) |
| 193 | 217 | } |
| 194 | 218 | |
| @@ -231,6 +255,31 @@ func run() error { | ||
| 231 | 255 | } |
| 232 | 256 | } |
| 233 | 257 | |
| 258 | + // applyConfig makes a finished wizard's answers true for the running process | |
| 259 | + // and then writes them down, returning the one line the transcript should say | |
| 260 | + // about it. | |
| 261 | + // | |
| 262 | + // Applying before saving is deliberate: a config directory that cannot be | |
| 263 | + // written should still leave you with the session you just asked for, and a | |
| 264 | + // warning rather than a wizard that silently did nothing. | |
| 265 | + func applyConfig(a *agent.Agent, client *llm.Client, policy *approval.Policy) func(config.Config) string { | |
| 266 | + return func(cfg config.Config) string { | |
| 267 | + client.Model = cfg.Model | |
| 268 | + a.SetEffort(cfg.Effort) | |
| 269 | + policy.YOLO = cfg.Approvals == config.ApprovalsAcceptAll | |
| 270 | + | |
| 271 | + if err := config.Save(cfg); err != nil { | |
| 272 | + return fmt.Sprintf("settings are in force for this session, but saving them failed: %v", err) | |
| 273 | + } | |
| 274 | + note := fmt.Sprintf("setup saved — %s · effort %s · %s · %s theme", | |
| 275 | + llm.PrettyModel(cfg.Model), cfg.Effort, cfg.Approvals, cfg.Theme) | |
| 276 | + if policy.YOLO { | |
| 277 | + note += "\naccept-all is on — tool calls run without asking." | |
| 278 | + } | |
| 279 | + return note | |
| 280 | + } | |
| 281 | + } | |
| 282 | + | |
| 234 | 283 | // once runs a single prompt, prints the session totals, and saves the session. |
| 235 | 284 | func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error { |
| 236 | 285 | turnCtx, endTurn := beginTurn(ctx) |
| @@ -144,6 +144,15 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log | ||
| 144 | 144 | // State exposes the conversation for callers that need to inspect or persist it. |
| 145 | 145 | func (a *Agent) State() *State { return a.state } |
| 146 | 146 | |
| 147 | + // SetEffort changes the reasoning effort for subsequent turns. The setup | |
| 148 | + // wizard needs it because the effort is read when each request is built, so a | |
| 149 | + // value chosen mid-session would otherwise not take until a restart. | |
| 150 | + // | |
| 151 | + // It is safe only between turns: the caller is the Bubble Tea update loop, | |
| 152 | + // which cannot run while a turn is in flight on its own goroutine, and /setup | |
| 153 | + // is refused while one is. | |
| 154 | + func (a *Agent) SetEffort(effort string) { a.opts.Effort = effort } | |
| 155 | + | |
| 147 | 156 | func (a *Agent) emit(ev Event) { |
| 148 | 157 | if a.Observe != nil { |
| 149 | 158 | a.Observe(ev) |
| @@ -0,0 +1,177 @@ | ||
| 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 | + | |
| 18 | + // Filename is the config's name inside the harness config directory. | |
| 19 | + const Filename = "config.json" | |
| 20 | + | |
| 21 | + // Version is stamped on every write. It exists so a future change of shape | |
| 22 | + // has something to branch on rather than guessing from which keys are present. | |
| 23 | + const Version = 1 | |
| 24 | + | |
| 25 | + // Approval modes, as written to the file. They are spelled out rather than | |
| 26 | + // stored as a bool, because "approvals: false" reads as if approvals were | |
| 27 | + // broken rather than as if they were turned off deliberately. | |
| 28 | + const ( | |
| 29 | + ApprovalsAsk = "ask" | |
| 30 | + ApprovalsAcceptAll = "accept-all" | |
| 31 | + ) | |
| 32 | + | |
| 33 | + // Efforts are the values the API's output_config.effort accepts, in the order | |
| 34 | + // the wizard offers them. | |
| 35 | + var Efforts = []string{"low", "medium", "high", "xhigh", "max"} | |
| 36 | + | |
| 37 | + // Themes are the TUI palettes, in the order the wizard offers them. The names | |
| 38 | + // live here rather than in the tui package so that reading a config never | |
| 39 | + // depends on a terminal front end being linked in — the eval runner has none. | |
| 40 | + // The tui package maps each name to actual colours. | |
| 41 | + var Themes = []string{"ember", "mint", "iris", "mono"} | |
| 42 | + | |
| 43 | + // Config is what the wizard saves. Every field is optional: an empty one means | |
| 44 | + // "no preference", which falls through to the flag, the environment, or the | |
| 45 | + // built-in default, in that order. | |
| 46 | + type Config struct { | |
| 47 | + Version int `json:"version"` | |
| 48 | + // Model is an API model id, not a display name. | |
| 49 | + Model string `json:"model,omitempty"` | |
| 50 | + // Effort is one of Efforts. | |
| 51 | + Effort string `json:"effort,omitempty"` | |
| 52 | + // Approvals is ApprovalsAsk or ApprovalsAcceptAll. | |
| 53 | + Approvals string `json:"approvals,omitempty"` | |
| 54 | + // Theme is one of Themes. | |
| 55 | + Theme string `json:"theme,omitempty"` | |
| 56 | + } | |
| 57 | + | |
| 58 | + // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or | |
| 59 | + // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset. | |
| 60 | + func Path() (string, error) { | |
| 61 | + dir, err := os.UserConfigDir() | |
| 62 | + if err != nil { | |
| 63 | + return "", fmt.Errorf("config: no config directory: %w", err) | |
| 64 | + } | |
| 65 | + return filepath.Join(dir, "harness", Filename), nil | |
| 66 | + } | |
| 67 | + | |
| 68 | + // Load reads the config. The boolean reports whether a usable one was found; | |
| 69 | + // a missing file is not an error, because that is exactly what the first run | |
| 70 | + // looks like and the wizard is the answer to it. | |
| 71 | + // | |
| 72 | + // A file that exists but cannot be read or parsed comes back as an error with | |
| 73 | + // found=false. The caller is expected to carry on with defaults and say so: | |
| 74 | + // what it must not do is treat the file as absent, because the next Save would | |
| 75 | + // then overwrite settings the user still has. | |
| 76 | + func Load() (Config, bool, error) { | |
| 77 | + path, err := Path() | |
| 78 | + if err != nil { | |
| 79 | + return Config{}, false, err | |
| 80 | + } | |
| 81 | + raw, err := os.ReadFile(path) | |
| 82 | + if errors.Is(err, fs.ErrNotExist) { | |
| 83 | + return Config{}, false, nil | |
| 84 | + } | |
| 85 | + if err != nil { | |
| 86 | + return Config{}, false, fmt.Errorf("config: reading %s: %w", path, err) | |
| 87 | + } | |
| 88 | + | |
| 89 | + var cfg Config | |
| 90 | + if err := json.Unmarshal(raw, &cfg); err != nil { | |
| 91 | + return Config{}, false, fmt.Errorf("config: %s is not valid JSON: %w", path, err) | |
| 92 | + } | |
| 93 | + return sanitize(cfg), true, nil | |
| 94 | + } | |
| 95 | + | |
| 96 | + // sanitize drops values this build does not recognise. A config written by a | |
| 97 | + // newer harness, or edited by hand, should degrade to the default for that | |
| 98 | + // field rather than be forwarded to the API, which would reject the request | |
| 99 | + // with an error pointing at nothing the user can see. | |
| 100 | + func sanitize(c Config) Config { | |
| 101 | + if !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 | + // ValidEffort reports whether e is one of the levels the API accepts. | |
| 114 | + func ValidEffort(e string) bool { return contains(Efforts, e) } | |
| 115 | + | |
| 116 | + // ValidTheme reports whether name is a palette this build knows. | |
| 117 | + func ValidTheme(name string) bool { return contains(Themes, name) } | |
| 118 | + | |
| 119 | + func contains(set []string, v string) bool { | |
| 120 | + for _, s := range set { | |
| 121 | + if s == v { | |
| 122 | + return true | |
| 123 | + } | |
| 124 | + } | |
| 125 | + return false | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Save writes the config, creating the directory if it is not there yet. | |
| 129 | + // | |
| 130 | + // The write goes to a temporary file that is then renamed over the target, so | |
| 131 | + // an interrupted or failed write leaves the previous config intact instead of | |
| 132 | + // truncating it to nothing. | |
| 133 | + func Save(c Config) error { | |
| 134 | + path, err := Path() | |
| 135 | + if err != nil { | |
| 136 | + return err | |
| 137 | + } | |
| 138 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 139 | + return fmt.Errorf("config: creating %s: %w", filepath.Dir(path), err) | |
| 140 | + } | |
| 141 | + | |
| 142 | + c.Version = Version | |
| 143 | + body, err := json.MarshalIndent(c, "", " ") | |
| 144 | + if err != nil { | |
| 145 | + return fmt.Errorf("config: encoding: %w", err) | |
| 146 | + } | |
| 147 | + body = append(body, '\n') | |
| 148 | + | |
| 149 | + tmp, err := os.CreateTemp(filepath.Dir(path), Filename+".*") | |
| 150 | + if err != nil { | |
| 151 | + return fmt.Errorf("config: %w", err) | |
| 152 | + } | |
| 153 | + defer os.Remove(tmp.Name()) // no-op once the rename below succeeds | |
| 154 | + if _, err := tmp.Write(body); err != nil { | |
| 155 | + tmp.Close() | |
| 156 | + return fmt.Errorf("config: writing %s: %w", tmp.Name(), err) | |
| 157 | + } | |
| 158 | + if err := tmp.Close(); err != nil { | |
| 159 | + return fmt.Errorf("config: writing %s: %w", tmp.Name(), err) | |
| 160 | + } | |
| 161 | + if err := os.Rename(tmp.Name(), path); err != nil { | |
| 162 | + return fmt.Errorf("config: replacing %s: %w", path, err) | |
| 163 | + } | |
| 164 | + return nil | |
| 165 | + } | |
| 166 | + | |
| 167 | + // Or returns the first non-empty of its arguments. It is how precedence is | |
| 168 | + // spelled at the call sites: Or(flag, cfg.Model) is "the flag, else what was | |
| 169 | + // configured, else whatever the client defaults to". | |
| 170 | + func Or(vals ...string) string { | |
| 171 | + for _, v := range vals { | |
| 172 | + if v != "" { | |
| 173 | + return v | |
| 174 | + } | |
| 175 | + } | |
| 176 | + return "" | |
| 177 | + } | |
| @@ -0,0 +1,149 @@ | ||
| 1 | + package config | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + ) | |
| 9 | + | |
| 10 | + // isolate points the config at a temp directory, so a test never reads or | |
| 11 | + // writes the developer's real ~/.config/harness. | |
| 12 | + func isolate(t *testing.T) string { | |
| 13 | + t.Helper() | |
| 14 | + dir := t.TempDir() | |
| 15 | + t.Setenv("XDG_CONFIG_HOME", dir) | |
| 16 | + return filepath.Join(dir, "harness", Filename) | |
| 17 | + } | |
| 18 | + | |
| 19 | + func TestLoadReportsMissingWithoutError(t *testing.T) { | |
| 20 | + isolate(t) | |
| 21 | + | |
| 22 | + cfg, found, err := Load() | |
| 23 | + if err != nil { | |
| 24 | + t.Fatalf("a missing config is the first-run signal, not an error: %v", err) | |
| 25 | + } | |
| 26 | + if found { | |
| 27 | + t.Error("reported a config that does not exist") | |
| 28 | + } | |
| 29 | + if cfg != (Config{}) { | |
| 30 | + t.Errorf("a missing config should come back zeroed, got %+v", cfg) | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + func TestSaveThenLoadRoundTrips(t *testing.T) { | |
| 35 | + path := isolate(t) | |
| 36 | + | |
| 37 | + want := Config{Model: "claude-sonnet-5", Effort: "high", Approvals: ApprovalsAcceptAll, Theme: "iris"} | |
| 38 | + if err := Save(want); err != nil { | |
| 39 | + t.Fatalf("Save: %v", err) | |
| 40 | + } | |
| 41 | + if _, err := os.Stat(path); err != nil { | |
| 42 | + t.Fatalf("Save did not write %s: %v", path, err) | |
| 43 | + } | |
| 44 | + | |
| 45 | + got, found, err := Load() | |
| 46 | + if err != nil { | |
| 47 | + t.Fatalf("Load: %v", err) | |
| 48 | + } | |
| 49 | + if !found { | |
| 50 | + t.Fatal("Load did not find the config Save just wrote") | |
| 51 | + } | |
| 52 | + want.Version = Version // stamped on write | |
| 53 | + if got != want { | |
| 54 | + t.Errorf("round trip changed the config:\n got %+v\nwant %+v", got, want) | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + // A file we cannot parse must not be silently replaced: the user's settings | |
| 59 | + // are still in there, and the next Save would destroy them without the error | |
| 60 | + // ever being shown. | |
| 61 | + func TestLoadRejectsMalformedWithoutDiscardingIt(t *testing.T) { | |
| 62 | + path := isolate(t) | |
| 63 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 64 | + t.Fatal(err) | |
| 65 | + } | |
| 66 | + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { | |
| 67 | + t.Fatal(err) | |
| 68 | + } | |
| 69 | + | |
| 70 | + cfg, found, err := Load() | |
| 71 | + if err == nil { | |
| 72 | + t.Fatal("a malformed config loaded cleanly") | |
| 73 | + } | |
| 74 | + if found { | |
| 75 | + t.Error("a malformed config was reported as usable") | |
| 76 | + } | |
| 77 | + if cfg != (Config{}) { | |
| 78 | + t.Errorf("a malformed config produced values: %+v", cfg) | |
| 79 | + } | |
| 80 | + if b, _ := os.ReadFile(path); string(b) != "{not json" { | |
| 81 | + t.Errorf("Load rewrote the file it could not parse: %q", b) | |
| 82 | + } | |
| 83 | + if !strings.Contains(err.Error(), path) { | |
| 84 | + t.Errorf("the error does not say which file to fix: %v", err) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + func TestSaveCreatesTheDirectory(t *testing.T) { | |
| 89 | + path := isolate(t) | |
| 90 | + if err := Save(Config{Model: "claude-opus-5"}); err != nil { | |
| 91 | + t.Fatalf("Save into a directory that does not exist yet: %v", err) | |
| 92 | + } | |
| 93 | + if _, err := os.Stat(path); err != nil { | |
| 94 | + t.Fatal(err) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + // Unknown values are how a config written by a newer build, or edited by | |
| 99 | + // hand, reaches an older one. They are dropped rather than passed through to | |
| 100 | + // the API, which would fail the request with something unhelpful. | |
| 101 | + func TestLoadDropsValuesItDoesNotRecognise(t *testing.T) { | |
| 102 | + path := isolate(t) | |
| 103 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 104 | + t.Fatal(err) | |
| 105 | + } | |
| 106 | + body := `{"version":1,"model":"claude-opus-5","effort":"extreme","approvals":"sometimes","theme":"neon"}` | |
| 107 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 108 | + t.Fatal(err) | |
| 109 | + } | |
| 110 | + | |
| 111 | + cfg, found, err := Load() | |
| 112 | + if err != nil || !found { | |
| 113 | + t.Fatalf("Load(found=%v): %v", found, err) | |
| 114 | + } | |
| 115 | + if cfg.Model != "claude-opus-5" { | |
| 116 | + t.Errorf("a recognised field was dropped: %q", cfg.Model) | |
| 117 | + } | |
| 118 | + if cfg.Effort != "" || cfg.Approvals != "" || cfg.Theme != "" { | |
| 119 | + t.Errorf("unrecognised values survived: %+v", cfg) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + func TestValidEffort(t *testing.T) { | |
| 124 | + for _, e := range Efforts { | |
| 125 | + if !ValidEffort(e) { | |
| 126 | + t.Errorf("%q is in Efforts but does not validate", e) | |
| 127 | + } | |
| 128 | + } | |
| 129 | + for _, e := range []string{"", "extreme", "High", "none"} { | |
| 130 | + if ValidEffort(e) { | |
| 131 | + t.Errorf("%q validated", e) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + // The config is only ever a default: a flag says what this run should do, and | |
| 137 | + // it has to win. | |
| 138 | + func TestFlagsOverrideTheConfig(t *testing.T) { | |
| 139 | + cfg := Config{Model: "claude-sonnet-5", Effort: "low"} | |
| 140 | + if got := Or("claude-opus-5", cfg.Model); got != "claude-opus-5" { | |
| 141 | + t.Errorf("the flag lost to the config: %q", got) | |
| 142 | + } | |
| 143 | + if got := Or("", cfg.Model); got != "claude-sonnet-5" { | |
| 144 | + t.Errorf("an unset flag did not fall through to the config: %q", got) | |
| 145 | + } | |
| 146 | + if got := Or("", ""); got != "" { | |
| 147 | + t.Errorf("two empties produced %q", got) | |
| 148 | + } | |
| 149 | + } | |
| @@ -122,6 +122,20 @@ func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL } | ||
| 122 | 122 | // HasCredentials reports whether the client can authenticate. |
| 123 | 123 | func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" } |
| 124 | 124 | |
| 125 | + // setHeaders applies the version and the credential. Every request the client | |
| 126 | + // makes goes through here, so a change to how the harness authenticates cannot | |
| 127 | + // reach one endpoint and miss another. | |
| 128 | + func (c *Client) setHeaders(r *http.Request) { | |
| 129 | + r.Header.Set("anthropic-version", apiVersion) | |
| 130 | + if c.apiKey != "" { | |
| 131 | + r.Header.Set("x-api-key", c.apiKey) | |
| 132 | + return | |
| 133 | + } | |
| 134 | + // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. | |
| 135 | + r.Header.Set("authorization", "Bearer "+c.authToken) | |
| 136 | + r.Header.Set("anthropic-beta", oauthBeta) | |
| 137 | + } | |
| 138 | + | |
| 125 | 139 | // APIError is a non-2xx response. |
| 126 | 140 | type APIError struct { |
| 127 | 141 | StatusCode int |
| @@ -193,15 +207,7 @@ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) { | ||
| 193 | 207 | return nil, err |
| 194 | 208 | } |
| 195 | 209 | httpReq.Header.Set("content-type", "application/json") |
| 196 | - httpReq.Header.Set("anthropic-version", apiVersion) | |
| 197 | - switch { | |
| 198 | - case c.apiKey != "": | |
| 199 | - httpReq.Header.Set("x-api-key", c.apiKey) | |
| 200 | - default: | |
| 201 | - // OAuth tokens go on Authorization, not x-api-key, and need the beta flag. | |
| 202 | - httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 203 | - httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 204 | - } | |
| 210 | + c.setHeaders(httpReq) | |
| 205 | 211 | |
| 206 | 212 | httpResp, err := c.HTTP.Do(httpReq) |
| 207 | 213 | if err != nil { |
| @@ -0,0 +1,120 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "sort" | |
| 10 | + "time" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // Model is one entry in the catalogue at GET /v1/models. | |
| 14 | + type Model struct { | |
| 15 | + ID string `json:"id"` | |
| 16 | + DisplayName string `json:"display_name"` | |
| 17 | + Deprecated bool `json:"deprecated"` | |
| 18 | + } | |
| 19 | + | |
| 20 | + // catalogueTimeout is deliberately short. This runs while a person is looking | |
| 21 | + // at a wizard that cannot show them a list yet, and a gateway that black-holes | |
| 22 | + // the request must not hold the setup open for the client's ten-minute | |
| 23 | + // message timeout. | |
| 24 | + const catalogueTimeout = 10 * time.Second | |
| 25 | + | |
| 26 | + // ListModels reads the model catalogue. | |
| 27 | + // | |
| 28 | + // It is best-effort by design: plenty of endpoints this harness can be pointed | |
| 29 | + // at speak /v1/messages and nothing else, so every caller is expected to have | |
| 30 | + // a fallback — KnownModels — rather than to treat an error here as fatal. | |
| 31 | + func (c *Client) ListModels(ctx context.Context) ([]Model, error) { | |
| 32 | + if !c.HasCredentials() { | |
| 33 | + return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`") | |
| 34 | + } | |
| 35 | + ctx, cancel := context.WithTimeout(ctx, catalogueTimeout) | |
| 36 | + defer cancel() | |
| 37 | + | |
| 38 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/models?limit=100", nil) | |
| 39 | + if err != nil { | |
| 40 | + return nil, err | |
| 41 | + } | |
| 42 | + c.setHeaders(req) | |
| 43 | + | |
| 44 | + resp, err := c.HTTP.Do(req) | |
| 45 | + if err != nil { | |
| 46 | + return nil, fmt.Errorf("llm: listing models: %w", err) | |
| 47 | + } | |
| 48 | + defer resp.Body.Close() | |
| 49 | + | |
| 50 | + // Capped: an endpoint that answers an unknown route with a whole HTML | |
| 51 | + // page should not be read into memory in full to be thrown away. | |
| 52 | + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) | |
| 53 | + if err != nil { | |
| 54 | + return nil, fmt.Errorf("llm: listing models: %w", err) | |
| 55 | + } | |
| 56 | + if resp.StatusCode != http.StatusOK { | |
| 57 | + return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw) | |
| 58 | + } | |
| 59 | + | |
| 60 | + var out struct { | |
| 61 | + Data []Model `json:"data"` | |
| 62 | + } | |
| 63 | + if err := json.Unmarshal(raw, &out); err != nil { | |
| 64 | + return nil, fmt.Errorf("llm: %s did not return a model list: %w", c.BaseURL, err) | |
| 65 | + } | |
| 66 | + | |
| 67 | + models := make([]Model, 0, len(out.Data)) | |
| 68 | + for _, m := range out.Data { | |
| 69 | + // A deprecated model still answers requests, which is exactly why it | |
| 70 | + // should not be offered as a fresh default. | |
| 71 | + if m.ID == "" || m.Deprecated { | |
| 72 | + continue | |
| 73 | + } | |
| 74 | + if m.DisplayName == "" { | |
| 75 | + m.DisplayName = m.ID | |
| 76 | + } | |
| 77 | + models = append(models, m) | |
| 78 | + } | |
| 79 | + if len(models) == 0 { | |
| 80 | + return nil, fmt.Errorf("llm: %s returned no models", c.BaseURL) | |
| 81 | + } | |
| 82 | + return models, nil | |
| 83 | + } | |
| 84 | + | |
| 85 | + // KnownModels is the fallback catalogue: what this build knows how to price. | |
| 86 | + // | |
| 87 | + // Pricing is the honest basis for the list. A model the harness cannot price | |
| 88 | + // is one whose cost would silently read as blank in the status bar, so the set | |
| 89 | + // it can price is exactly the set it can fully describe. | |
| 90 | + func KnownModels() []Model { | |
| 91 | + out := make([]Model, 0, len(prices)) | |
| 92 | + for id := range prices { | |
| 93 | + out = append(out, Model{ID: id, DisplayName: displayName(id)}) | |
| 94 | + } | |
| 95 | + // Map iteration is random; the wizard needs the same order every time. | |
| 96 | + sort.Slice(out, func(i, j int) bool { | |
| 97 | + ri, rj := familyRank(out[i].ID), familyRank(out[j].ID) | |
| 98 | + if ri != rj { | |
| 99 | + return ri < rj | |
| 100 | + } | |
| 101 | + return out[i].ID > out[j].ID // newer version first within a family | |
| 102 | + }) | |
| 103 | + return out | |
| 104 | + } | |
| 105 | + | |
| 106 | + // familyRank orders the families the way someone choosing between them thinks | |
| 107 | + // about it: the flagship first, the cheap one last. | |
| 108 | + func familyRank(id string) int { | |
| 109 | + for i, fam := range []string{"claude-opus", "claude-sonnet", "claude-haiku", "claude-fable"} { | |
| 110 | + if len(id) >= len(fam) && id[:len(fam)] == fam { | |
| 111 | + return i | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return 99 | |
| 115 | + } | |
| 116 | + | |
| 117 | + // displayName stands in for the API's display_name on a model we only know | |
| 118 | + // statically. It is derived rather than tabulated: a table would be one more | |
| 119 | + // place to forget to update when a model is added to prices. | |
| 120 | + func displayName(id string) string { return "Claude " + PrettyModel(id) } | |
| @@ -0,0 +1,114 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "net/http" | |
| 6 | + "net/http/httptest" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + func TestListModelsReadsTheCatalogue(t *testing.T) { | |
| 12 | + var gotPath, gotKey string | |
| 13 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 14 | + gotPath, gotKey = r.URL.Path, r.Header.Get("x-api-key") | |
| 15 | + w.Header().Set("content-type", "application/json") | |
| 16 | + w.Write([]byte(`{"data":[ | |
| 17 | + {"id":"claude-opus-5","display_name":"Claude Opus 5","type":"model"}, | |
| 18 | + {"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","type":"model"} | |
| 19 | + ],"has_more":false}`)) | |
| 20 | + })) | |
| 21 | + defer srv.Close() | |
| 22 | + | |
| 23 | + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) | |
| 24 | + got, err := c.ListModels(context.Background()) | |
| 25 | + if err != nil { | |
| 26 | + t.Fatalf("ListModels: %v", err) | |
| 27 | + } | |
| 28 | + if gotPath != "/v1/models" { | |
| 29 | + t.Errorf("asked %s, want /v1/models", gotPath) | |
| 30 | + } | |
| 31 | + if gotKey != "k" { | |
| 32 | + t.Errorf("the catalogue request was not authenticated: x-api-key = %q", gotKey) | |
| 33 | + } | |
| 34 | + if len(got) != 2 { | |
| 35 | + t.Fatalf("got %d models, want 2: %+v", len(got), got) | |
| 36 | + } | |
| 37 | + if got[0].ID != "claude-opus-5" || got[0].DisplayName != "Claude Opus 5" { | |
| 38 | + t.Errorf("first model = %+v", got[0]) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + | |
| 42 | + // Pointed at a gateway that does not serve the catalogue, the call has to fail | |
| 43 | + // in a way the caller can fall back from — not hang, and not panic on a body | |
| 44 | + // that is HTML. | |
| 45 | + func TestListModelsFailsCleanlyOnAGatewayWithoutIt(t *testing.T) { | |
| 46 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 47 | + w.WriteHeader(http.StatusNotFound) | |
| 48 | + w.Write([]byte("<html>no such route</html>")) | |
| 49 | + })) | |
| 50 | + defer srv.Close() | |
| 51 | + | |
| 52 | + c := New(WithBaseURL(srv.URL), WithAPIKey("k")) | |
| 53 | + if _, err := c.ListModels(context.Background()); err == nil { | |
| 54 | + t.Fatal("a 404 catalogue came back as success") | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + func TestListModelsRefusesWithoutCredentials(t *testing.T) { | |
| 59 | + c := New(WithBaseURL("http://127.0.0.1:1")) | |
| 60 | + c.apiKey, c.authToken = "", "" | |
| 61 | + _, err := c.ListModels(context.Background()) | |
| 62 | + if err == nil { | |
| 63 | + t.Fatal("listed models with no credentials") | |
| 64 | + } | |
| 65 | + if !strings.Contains(err.Error(), "credentials") { | |
| 66 | + t.Errorf("the error does not name the cause: %v", err) | |
| 67 | + } | |
| 68 | + } | |
| 69 | + | |
| 70 | + // Deprecated models are still returned by the API. Offering one as a default | |
| 71 | + // in a fresh setup is a trap, so they are filtered out. | |
| 72 | + func TestListModelsDropsDeprecatedOnes(t *testing.T) { | |
| 73 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 74 | + w.Write([]byte(`{"data":[ | |
| 75 | + {"id":"claude-opus-5","display_name":"Claude Opus 5"}, | |
| 76 | + {"id":"claude-2.1","display_name":"Claude 2.1","deprecated":true} | |
| 77 | + ]}`)) | |
| 78 | + })) | |
| 79 | + defer srv.Close() | |
| 80 | + | |
| 81 | + got, err := New(WithBaseURL(srv.URL), WithAPIKey("k")).ListModels(context.Background()) | |
| 82 | + if err != nil { | |
| 83 | + t.Fatal(err) | |
| 84 | + } | |
| 85 | + for _, m := range got { | |
| 86 | + if m.ID == "claude-2.1" { | |
| 87 | + t.Error("a deprecated model was offered") | |
| 88 | + } | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + // The fallback is what the wizard shows when the catalogue cannot be reached, | |
| 93 | + // so it has to stand on its own: every entry priced, and the default present. | |
| 94 | + func TestKnownModelsIsAUsableFallback(t *testing.T) { | |
| 95 | + known := KnownModels() | |
| 96 | + if len(known) == 0 { | |
| 97 | + t.Fatal("no fallback models") | |
| 98 | + } | |
| 99 | + var hasDefault bool | |
| 100 | + for _, m := range known { | |
| 101 | + if m.ID == DefaultModel { | |
| 102 | + hasDefault = true | |
| 103 | + } | |
| 104 | + if _, ok := prices[m.ID]; !ok { | |
| 105 | + t.Errorf("%s is offered but has no pricing", m.ID) | |
| 106 | + } | |
| 107 | + if m.DisplayName == "" { | |
| 108 | + t.Errorf("%s has no display name", m.ID) | |
| 109 | + } | |
| 110 | + } | |
| 111 | + if !hasDefault { | |
| 112 | + t.Errorf("the fallback list does not include the default model %s", DefaultModel) | |
| 113 | + } | |
| 114 | + } | |
| @@ -0,0 +1,65 @@ | ||
| 1 | + package llm | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + "unicode" | |
| 6 | + ) | |
| 7 | + | |
| 8 | + // PrettyModel turns an API model id into the name a person uses for it: | |
| 9 | + // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes | |
| 10 | + // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which | |
| 11 | + // matters because this also runs against gateways serving models we have | |
| 12 | + // never heard of. | |
| 13 | + func PrettyModel(id string) string { | |
| 14 | + s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest") | |
| 15 | + // Anything bracketed is a variant tag, not part of the name: opus-5[1m]. | |
| 16 | + tag := "" | |
| 17 | + if i := strings.IndexByte(s, '['); i >= 0 { | |
| 18 | + tag, s = s[i:], s[:i] | |
| 19 | + } | |
| 20 | + parts := strings.Split(s, "-") | |
| 21 | + if len(parts) < 2 { | |
| 22 | + return id | |
| 23 | + } | |
| 24 | + // A trailing eight-digit date stamp is a build, not a version. | |
| 25 | + if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) { | |
| 26 | + parts = parts[:len(parts)-1] | |
| 27 | + } | |
| 28 | + family, rest := parts[0], parts[1:] | |
| 29 | + if family == "" || !isAlpha(family) { | |
| 30 | + return id | |
| 31 | + } | |
| 32 | + name := strings.ToUpper(family[:1]) + family[1:] | |
| 33 | + var version []string | |
| 34 | + for _, p := range rest { | |
| 35 | + if !allDigits(p) { | |
| 36 | + return id | |
| 37 | + } | |
| 38 | + version = append(version, p) | |
| 39 | + } | |
| 40 | + if len(version) > 0 { | |
| 41 | + name += " " + strings.Join(version, ".") | |
| 42 | + } | |
| 43 | + return name + tag | |
| 44 | + } | |
| 45 | + | |
| 46 | + func allDigits(s string) bool { | |
| 47 | + if s == "" { | |
| 48 | + return false | |
| 49 | + } | |
| 50 | + for _, r := range s { | |
| 51 | + if !unicode.IsDigit(r) { | |
| 52 | + return false | |
| 53 | + } | |
| 54 | + } | |
| 55 | + return true | |
| 56 | + } | |
| 57 | + | |
| 58 | + func isAlpha(s string) bool { | |
| 59 | + for _, r := range s { | |
| 60 | + if !unicode.IsLetter(r) { | |
| 61 | + return false | |
| 62 | + } | |
| 63 | + } | |
| 64 | + return s != "" | |
| 65 | + } | |
| @@ -1,5 +1,10 @@ | ||
| 1 | 1 | package llm |
| 2 | 2 | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "strconv" | |
| 6 | + ) | |
| 7 | + | |
| 3 | 8 | // Price is per-million-token list pricing. |
| 4 | 9 | type Price struct { |
| 5 | 10 | InputPerMTok float64 |
| @@ -36,3 +41,18 @@ 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 | + // PriceLabel renders a model's list pricing as the wizard shows it: | |
| 46 | + // "$5 / $25 per Mtok", input then output. A model with no known pricing gets | |
| 47 | + // an empty label rather than a zero, because "$0" beside a frontier model is | |
| 48 | + // a worse answer than saying nothing. | |
| 49 | + func PriceLabel(model string) string { | |
| 50 | + p, ok := prices[model] | |
| 51 | + if !ok { | |
| 52 | + return "" | |
| 53 | + } | |
| 54 | + // Shortest representation that round-trips: the whole-dollar rates read | |
| 55 | + // as "$5" rather than "$5.00", and a fractional one keeps its decimals. | |
| 56 | + num := func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } | |
| 57 | + return fmt.Sprintf("$%s / $%s per Mtok", num(p.InputPerMTok), num(p.OutputPerMTok)) | |
| 58 | + } | |
| @@ -85,13 +85,7 @@ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(St | ||
| 85 | 85 | } |
| 86 | 86 | httpReq.Header.Set("content-type", "application/json") |
| 87 | 87 | httpReq.Header.Set("accept", "text/event-stream") |
| 88 | - httpReq.Header.Set("anthropic-version", apiVersion) | |
| 89 | - if c.apiKey != "" { | |
| 90 | - httpReq.Header.Set("x-api-key", c.apiKey) | |
| 91 | - } else { | |
| 92 | - httpReq.Header.Set("authorization", "Bearer "+c.authToken) | |
| 93 | - httpReq.Header.Set("anthropic-beta", oauthBeta) | |
| 94 | - } | |
| 88 | + c.setHeaders(httpReq) | |
| 95 | 89 | |
| 96 | 90 | httpResp, err := c.HTTP.Do(httpReq) |
| 97 | 91 | if err != nil { |
| @@ -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 | + } | |
| @@ -5,7 +5,6 @@ import ( | ||
| 5 | 5 | "net/url" |
| 6 | 6 | "os" |
| 7 | 7 | "strings" |
| 8 | - "unicode" | |
| 9 | 8 | |
| 10 | 9 | "harness/internal/llm" |
| 11 | 10 | ) |
| @@ -21,43 +20,10 @@ func human(n int) string { | ||
| 21 | 20 | return fmt.Sprint(n) |
| 22 | 21 | } |
| 23 | 22 | |
| 24 | - // prettyModel turns an API model id into the name a person uses for it: | |
| 25 | - // claude-opus-5 becomes "Opus 5", claude-haiku-4-5-20251001 becomes | |
| 26 | - // "Haiku 4.5". An id that does not fit the shape is returned unchanged, which | |
| 27 | - // matters because this also runs against gateways serving models we have | |
| 28 | - // never heard of. | |
| 29 | - func prettyModel(id string) string { | |
| 30 | - s := strings.TrimSuffix(strings.TrimPrefix(id, "claude-"), "-latest") | |
| 31 | - // Anything bracketed is a variant tag, not part of the name: opus-5[1m]. | |
| 32 | - tag := "" | |
| 33 | - if i := strings.IndexByte(s, '['); i >= 0 { | |
| 34 | - tag, s = s[i:], s[:i] | |
| 35 | - } | |
| 36 | - parts := strings.Split(s, "-") | |
| 37 | - if len(parts) < 2 { | |
| 38 | - return id | |
| 39 | - } | |
| 40 | - // A trailing eight-digit date stamp is a build, not a version. | |
| 41 | - if last := parts[len(parts)-1]; len(last) == 8 && allDigits(last) { | |
| 42 | - parts = parts[:len(parts)-1] | |
| 43 | - } | |
| 44 | - family, rest := parts[0], parts[1:] | |
| 45 | - if family == "" || !isAlpha(family) { | |
| 46 | - return id | |
| 47 | - } | |
| 48 | - name := strings.ToUpper(family[:1]) + family[1:] | |
| 49 | - var version []string | |
| 50 | - for _, p := range rest { | |
| 51 | - if !allDigits(p) { | |
| 52 | - return id | |
| 53 | - } | |
| 54 | - version = append(version, p) | |
| 55 | - } | |
| 56 | - if len(version) > 0 { | |
| 57 | - name += " " + strings.Join(version, ".") | |
| 58 | - } | |
| 59 | - return name + tag | |
| 60 | - } | |
| 23 | + // prettyModel names a model the way a person does. The transform itself lives | |
| 24 | + // in llm, next to the ids it is about, so the wizard's list and the status bar | |
| 25 | + // cannot disagree about what a model is called. | |
| 26 | + func prettyModel(id string) string { return llm.PrettyModel(id) } | |
| 61 | 27 | |
| 62 | 28 | // endpointLabel names where the prompts are going. The default endpoint gets |
| 63 | 29 | // a short name; anything else shows its host, because "somewhere that is not |
| @@ -89,27 +55,6 @@ func camel(name string) string { | ||
| 89 | 55 | return strings.Join(parts, "") |
| 90 | 56 | } |
| 91 | 57 | |
| 92 | - func allDigits(s string) bool { | |
| 93 | - if s == "" { | |
| 94 | - return false | |
| 95 | - } | |
| 96 | - for _, r := range s { | |
| 97 | - if !unicode.IsDigit(r) { | |
| 98 | - return false | |
| 99 | - } | |
| 100 | - } | |
| 101 | - return true | |
| 102 | - } | |
| 103 | - | |
| 104 | - func isAlpha(s string) bool { | |
| 105 | - for _, r := range s { | |
| 106 | - if !unicode.IsLetter(r) { | |
| 107 | - return false | |
| 108 | - } | |
| 109 | - } | |
| 110 | - return s != "" | |
| 111 | - } | |
| 112 | - | |
| 113 | 58 | func maxInt(a, b int) int { |
| 114 | 59 | if a > b { |
| 115 | 60 | return a |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "harness/internal/agent" |
| 18 | 18 | "harness/internal/approval" |
| 19 | + "harness/internal/config" | |
| 19 | 20 | "harness/internal/llm" |
| 20 | 21 | "harness/internal/skills" |
| 21 | 22 | ) |
| @@ -45,6 +46,15 @@ type Options struct { | ||
| 45 | 46 | // Skills are the instruction sets discovered at startup, used to resolve |
| 46 | 47 | // /<slug> and to answer /skills. |
| 47 | 48 | Skills []skills.Skill |
| 49 | + | |
| 50 | + // Config is what was loaded from disk, and FirstRun says there was nothing | |
| 51 | + // to load — which is what opens the setup wizard unprompted. | |
| 52 | + Config config.Config | |
| 53 | + FirstRun bool | |
| 54 | + // OnConfig applies a finished wizard's answers to the running program and | |
| 55 | + // saves them. It returns what to say in the transcript, which is how the | |
| 56 | + // TUI reports a save that failed without knowing anything about files. | |
| 57 | + OnConfig func(config.Config) string | |
| 48 | 58 | } |
| 49 | 59 | |
| 50 | 60 | type phase int |
| @@ -100,6 +110,10 @@ type model struct { | ||
| 100 | 110 | // say so while the agent unwinds. |
| 101 | 111 | interrupting bool |
| 102 | 112 | |
| 113 | + // wiz is the setup wizard. While it is non-nil it owns the screen and | |
| 114 | + // every key, and this model does nothing but hold its place. | |
| 115 | + wiz *wizard | |
| 116 | + | |
| 103 | 117 | quitting bool |
| 104 | 118 | err error |
| 105 | 119 | } |
| @@ -124,6 +138,11 @@ var verbs = []string{ | ||
| 124 | 138 | // tests drive the same wiring the program does; a hand-built model with a |
| 125 | 139 | // zero-valued textarea panics on the first resize. |
| 126 | 140 | func newModel(ctx context.Context, opts Options) *model { |
| 141 | + // The palette has to be in force before the first style is read, which is | |
| 142 | + // the textarea's below. Everything afterwards reads the theme through the | |
| 143 | + // package-level styles, so this is the only place it has to happen. | |
| 144 | + applyTheme(resolveTheme(opts.Config)) | |
| 145 | + | |
| 127 | 146 | ta := textarea.New() |
| 128 | 147 | ta.Placeholder = "ask for a change, or /help" |
| 129 | 148 | ta.Prompt = "› " |
| @@ -185,7 +204,57 @@ func Run(ctx context.Context, opts Options) error { | ||
| 185 | 204 | return nil |
| 186 | 205 | } |
| 187 | 206 | |
| 188 | - func (m *model) Init() tea.Cmd { return textarea.Blink } | |
| 207 | + func (m *model) Init() tea.Cmd { | |
| 208 | + if m.opts.FirstRun { | |
| 209 | + return tea.Batch(textarea.Blink, m.openWizard()) | |
| 210 | + } | |
| 211 | + return textarea.Blink | |
| 212 | + } | |
| 213 | + | |
| 214 | + // openWizard hands the screen to the setup wizard. The chat model keeps its | |
| 215 | + // state untouched underneath: nothing about a running turn is disturbed, | |
| 216 | + // which is why /setup is safe to answer at any time the agent is idle. | |
| 217 | + func (m *model) openWizard() tea.Cmd { | |
| 218 | + m.wiz = newWizard(m.opts.Client, m.opts.Config) | |
| 219 | + cmds := []tea.Cmd{m.wiz.Init()} | |
| 220 | + if m.ready { | |
| 221 | + // The wizard has never seen a resize, so it is told the size the | |
| 222 | + // model already knows rather than rendering into a zero-sized screen | |
| 223 | + // until the terminal happens to send another one. | |
| 224 | + cmds = append(cmds, func() tea.Msg { | |
| 225 | + return tea.WindowSizeMsg{Width: m.width, Height: m.height} | |
| 226 | + }) | |
| 227 | + } | |
| 228 | + return tea.Batch(cmds...) | |
| 229 | + } | |
| 230 | + | |
| 231 | + // closeWizard puts the chat back on screen, applying the answers if there | |
| 232 | + // were any. A cancelled wizard changes nothing at all. | |
| 233 | + func (m *model) closeWizard() tea.Cmd { | |
| 234 | + w := m.wiz | |
| 235 | + m.wiz = nil | |
| 236 | + | |
| 237 | + if w.result != nil { | |
| 238 | + m.opts.Config = *w.result | |
| 239 | + m.opts.Effort = w.result.Effort | |
| 240 | + if m.opts.OnConfig != nil { | |
| 241 | + if note := m.opts.OnConfig(*w.result); note != "" { | |
| 242 | + m.tr.addNotice(note) | |
| 243 | + } | |
| 244 | + } | |
| 245 | + } else { | |
| 246 | + m.tr.addNotice("setup cancelled — nothing was changed") | |
| 247 | + } | |
| 248 | + | |
| 249 | + // The welcome block names the model, which the wizard may have just | |
| 250 | + // changed, and every entry was laid out in the old palette. | |
| 251 | + if len(m.tr.entries) > 0 && m.tr.entries[0].kind == entryRaw { | |
| 252 | + m.tr.entries[0].text = splash(m.opts, m.width) | |
| 253 | + } | |
| 254 | + m.tr.dirty = true | |
| 255 | + m.input.Focus() | |
| 256 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 257 | + } | |
| 189 | 258 | |
| 190 | 259 | // runTurn drives one prompt on its own goroutine. Events reach the UI through |
| 191 | 260 | // the bridge while this runs; the command's return value ends the turn. |
| @@ -207,6 +276,21 @@ func (m *model) runTurn(prompt string) tea.Cmd { | ||
| 207 | 276 | } |
| 208 | 277 | |
| 209 | 278 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 279 | + // The wizard owns the screen while it is open. A resize still reaches the | |
| 280 | + // chat model underneath, so that what is revealed when the wizard closes | |
| 281 | + // is laid out for the terminal as it is now. | |
| 282 | + if m.wiz != nil { | |
| 283 | + var cmds []tea.Cmd | |
| 284 | + if size, ok := msg.(tea.WindowSizeMsg); ok { | |
| 285 | + cmds = append(cmds, m.resize(size.Width, size.Height)) | |
| 286 | + } | |
| 287 | + cmds = append(cmds, m.wiz.Update(msg)) | |
| 288 | + if m.wiz.result != nil || m.wiz.cancelled { | |
| 289 | + cmds = append(cmds, m.closeWizard()) | |
| 290 | + } | |
| 291 | + return m, tea.Batch(cmds...) | |
| 292 | + } | |
| 293 | + | |
| 210 | 294 | switch msg := msg.(type) { |
| 211 | 295 | |
| 212 | 296 | case tea.WindowSizeMsg: |
| @@ -449,6 +533,13 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 449 | 533 | } |
| 450 | 534 | return m, m.sync() |
| 451 | 535 | |
| 536 | + case line == "/setup": | |
| 537 | + if m.phase == phaseRunning { | |
| 538 | + m.tr.addError("/setup would change the model mid-turn — interrupt first") | |
| 539 | + return m, m.sync() | |
| 540 | + } | |
| 541 | + return m, m.openWizard() | |
| 542 | + | |
| 452 | 543 | case line == "/skills": |
| 453 | 544 | if len(m.opts.Skills) == 0 { |
| 454 | 545 | m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname) |
| @@ -576,7 +667,7 @@ func matches(keys []string, key string) bool { | ||
| 576 | 667 | |
| 577 | 668 | // ---- view --------------------------------------------------------------- |
| 578 | 669 | |
| 579 | - const helpText = `commands: /allowed /clear /help /skills /exit | |
| 670 | + const helpText = `commands: /allowed /clear /help /setup /skills /exit | |
| 580 | 671 | keys: enter send · ctrl+j newline · shift+tab cycle approval mode |
| 581 | 672 | ctrl+r toggle full tool output · pgup/pgdn scroll |
| 582 | 673 | esc or ctrl+c interrupt a turn · ctrl+c again to exit` |
| @@ -588,6 +679,9 @@ func (m *model) View() string { | ||
| 588 | 679 | if m.quitting { |
| 589 | 680 | return "" |
| 590 | 681 | } |
| 682 | + if m.wiz != nil { | |
| 683 | + return m.wiz.View() | |
| 684 | + } | |
| 591 | 685 | |
| 592 | 686 | // The bottom chrome changes height — the prompt box grows, the approval |
| 593 | 687 | // box is taller than either — so the viewport is sized against what was |
| @@ -617,7 +711,9 @@ func (m *model) bottom() string { | ||
| 617 | 711 | } |
| 618 | 712 | |
| 619 | 713 | // activity is the line above the prompt: what the agent is doing on the left, |
| 620 | - // the effort setting on the right. | |
| 714 | + // the effort setting on the right. The effort pill names the command that | |
| 715 | + // changes it, because a setting you can see and cannot reach is worse than one | |
| 716 | + // you cannot see. | |
| 621 | 717 | func (m *model) activity() string { |
| 622 | 718 | var left string |
| 623 | 719 | switch m.phase { |
| @@ -640,7 +736,7 @@ func (m *model) activity() string { | ||
| 640 | 736 | |
| 641 | 737 | right := "" |
| 642 | 738 | if m.opts.Effort != "" { |
| 643 | - right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort") | |
| 739 | + right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /setup") | |
| 644 | 740 | } |
| 645 | 741 | return spread(left, right, m.width) |
| 646 | 742 | } |
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | tea "github.com/charmbracelet/bubbletea" |
| 9 | 9 | |
| 10 | 10 | "harness/internal/approval" |
| 11 | + "harness/internal/config" | |
| 11 | 12 | "harness/internal/llm" |
| 12 | 13 | "harness/internal/skills" |
| 13 | 14 | ) |
| @@ -417,3 +418,133 @@ func TestViewShowsApprovalOptionsConditionally(t *testing.T) { | ||
| 417 | 418 | t.Error("opaque command offered an option that would be a lie") |
| 418 | 419 | } |
| 419 | 420 | } |
| 421 | + | |
| 422 | + // ---- the setup wizard, from the chat model's side ------------------------ | |
| 423 | + | |
| 424 | + // A first run opens the wizard before anything else is on screen. That is the | |
| 425 | + // whole point of the FirstRun flag: nobody should have to know /setup exists | |
| 426 | + // to be asked the questions once. | |
| 427 | + func TestFirstRunOpensTheWizard(t *testing.T) { | |
| 428 | + m := newModel(context.Background(), Options{ | |
| 429 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 430 | + Root: "/tmp/repo", | |
| 431 | + Policy: approval.New(nil), | |
| 432 | + FirstRun: true, | |
| 433 | + }) | |
| 434 | + m.Init() | |
| 435 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) | |
| 436 | + | |
| 437 | + if m.wiz == nil { | |
| 438 | + t.Fatal("a first run did not open the wizard") | |
| 439 | + } | |
| 440 | + if !strings.Contains(m.View(), "harness") { | |
| 441 | + t.Error("the wizard is open but its screen is not what View returns") | |
| 442 | + } | |
| 443 | + } | |
| 444 | + | |
| 445 | + func TestASecondRunDoesNotOpenTheWizard(t *testing.T) { | |
| 446 | + m := newTestModel(t) | |
| 447 | + m.Init() | |
| 448 | + if m.wiz != nil { | |
| 449 | + t.Error("the wizard opened without a first run") | |
| 450 | + } | |
| 451 | + } | |
| 452 | + | |
| 453 | + // While the wizard is up it owns every key: a keystroke meant for it must not | |
| 454 | + // also reach the prompt underneath. | |
| 455 | + func TestWizardSwallowsKeysMeantForIt(t *testing.T) { | |
| 456 | + m := newTestModel(t) | |
| 457 | + m.Update(key("/setup")) | |
| 458 | + m.submit("/setup") | |
| 459 | + if m.wiz == nil { | |
| 460 | + t.Fatal("/setup did not open the wizard") | |
| 461 | + } | |
| 462 | + | |
| 463 | + before := m.input.Value() | |
| 464 | + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) | |
| 465 | + if m.input.Value() != before { | |
| 466 | + t.Errorf("a key reached the prompt through the wizard: %q", m.input.Value()) | |
| 467 | + } | |
| 468 | + } | |
| 469 | + | |
| 470 | + // Finishing hands the answers to OnConfig — the only path by which anything is | |
| 471 | + // saved or applied — and puts the chat back. | |
| 472 | + func TestFinishingTheWizardAppliesAndCloses(t *testing.T) { | |
| 473 | + var got *config.Config | |
| 474 | + m := newModel(context.Background(), Options{ | |
| 475 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 476 | + Root: "/tmp/repo", | |
| 477 | + Policy: approval.New(nil), | |
| 478 | + OnConfig: func(c config.Config) string { | |
| 479 | + got = &c | |
| 480 | + return "saved it" | |
| 481 | + }, | |
| 482 | + }) | |
| 483 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) | |
| 484 | + m.submit("/setup") | |
| 485 | + m.Update(wizModels{models: llm.KnownModels()}) | |
| 486 | + | |
| 487 | + for i := 0; i < 400 && m.wiz != nil; i++ { | |
| 488 | + m.Update(key("enter")) | |
| 489 | + m.Update(wizTick{}) | |
| 490 | + } | |
| 491 | + if m.wiz != nil { | |
| 492 | + t.Fatal("the wizard never closed") | |
| 493 | + } | |
| 494 | + if got == nil { | |
| 495 | + t.Fatal("OnConfig was never called") | |
| 496 | + } | |
| 497 | + if got.Model == "" || got.Effort == "" { | |
| 498 | + t.Errorf("OnConfig got an incomplete config: %+v", *got) | |
| 499 | + } | |
| 500 | + if m.opts.Effort != got.Effort { | |
| 501 | + t.Errorf("the status bar still reports effort %q, want %q", m.opts.Effort, got.Effort) | |
| 502 | + } | |
| 503 | + if !strings.Contains(m.tr.render(), "saved it") { | |
| 504 | + t.Error("what OnConfig reported never reached the transcript") | |
| 505 | + } | |
| 506 | + } | |
| 507 | + | |
| 508 | + // Cancelling must change nothing, and must say so rather than leaving the user | |
| 509 | + // wondering whether it took. | |
| 510 | + func TestCancellingTheWizardChangesNothing(t *testing.T) { | |
| 511 | + called := false | |
| 512 | + m := newModel(context.Background(), Options{ | |
| 513 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 514 | + Root: "/tmp/repo", | |
| 515 | + Policy: approval.New(nil), | |
| 516 | + Config: config.Config{Effort: "low"}, | |
| 517 | + OnConfig: func(config.Config) string { called = true; return "" }, | |
| 518 | + }) | |
| 519 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) | |
| 520 | + m.submit("/setup") | |
| 521 | + m.Update(key("ctrl+c")) | |
| 522 | + | |
| 523 | + if m.wiz != nil { | |
| 524 | + t.Fatal("ctrl+c did not close the wizard") | |
| 525 | + } | |
| 526 | + if called { | |
| 527 | + t.Error("a cancelled wizard still applied its answers") | |
| 528 | + } | |
| 529 | + if m.quitting { | |
| 530 | + t.Error("ctrl+c in the wizard quit the whole program") | |
| 531 | + } | |
| 532 | + if !strings.Contains(m.tr.render(), "cancelled") { | |
| 533 | + t.Error("cancelling said nothing") | |
| 534 | + } | |
| 535 | + } | |
| 536 | + | |
| 537 | + // Changing the model mid-turn would mean the turn finishes on a different | |
| 538 | + // model than it started on, and the session log would be wrong about which. | |
| 539 | + func TestSetupIsRefusedWhileTheAgentIsWorking(t *testing.T) { | |
| 540 | + m := newTestModel(t) | |
| 541 | + m.phase = phaseRunning | |
| 542 | + m.submit("/setup") | |
| 543 | + | |
| 544 | + if m.wiz != nil { | |
| 545 | + t.Error("/setup opened the wizard mid-turn") | |
| 546 | + } | |
| 547 | + if !strings.Contains(m.tr.render(), "interrupt first") { | |
| 548 | + t.Error("/setup was refused without saying why") | |
| 549 | + } | |
| 550 | + } | |
| @@ -8,70 +8,65 @@ package tui | ||
| 8 | 8 | |
| 9 | 9 | import "github.com/charmbracelet/lipgloss" |
| 10 | 10 | |
| 11 | - // The palette is warm rather than neutral: one coral accent carries the | |
| 12 | - // identity and everything else is a grey at some distance from the text | |
| 13 | - // colour. Every colour is adaptive, because a palette tuned only for a dark | |
| 14 | - // terminal is the usual way a hand-rolled TUI becomes unreadable on a light | |
| 15 | - // one. | |
| 11 | + // The palette is a role, not a colour. Every value below is assigned by | |
| 12 | + // applyTheme in theme.go from the palette in force, so a style here can be | |
| 13 | + // read anywhere in the package without knowing which theme is on. | |
| 14 | + // | |
| 15 | + // The roles stay what they always were: one accent carries the identity and | |
| 16 | + // everything else is a grey at some distance from the text colour. Every | |
| 17 | + // colour is adaptive, because a palette tuned only for a dark terminal is the | |
| 18 | + // usual way a hand-rolled TUI becomes unreadable on a light one. | |
| 16 | 19 | var ( |
| 17 | - colCoral = lipgloss.AdaptiveColor{Light: "#b4532f", Dark: "#d97757"} | |
| 18 | - colText = lipgloss.AdaptiveColor{Light: "#1c1b1a", Dark: "#e8e6e3"} | |
| 19 | - colUser = lipgloss.AdaptiveColor{Light: "#4b4945", Dark: "#b3ada4"} | |
| 20 | - colDim = lipgloss.AdaptiveColor{Light: "#6f6a66", Dark: "#8a847d"} | |
| 21 | - colFaint = lipgloss.AdaptiveColor{Light: "#c9c4be", Dark: "#3f3b38"} | |
| 22 | - colOK = lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#5dbb7a"} | |
| 23 | - colErr = lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#eb6f6f"} | |
| 24 | - colWarn = lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"} | |
| 25 | - colBlue = lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"} | |
| 26 | - colPillBg = lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"} | |
| 20 | + colCoral lipgloss.AdaptiveColor // the accent, whatever hue the theme gives it | |
| 21 | + colText lipgloss.AdaptiveColor | |
| 22 | + colUser lipgloss.AdaptiveColor | |
| 23 | + colDim lipgloss.AdaptiveColor | |
| 24 | + colFaint lipgloss.AdaptiveColor | |
| 25 | + colOK lipgloss.AdaptiveColor | |
| 26 | + colErr lipgloss.AdaptiveColor | |
| 27 | + colWarn lipgloss.AdaptiveColor | |
| 28 | + colBlue lipgloss.AdaptiveColor | |
| 29 | + colPillBg lipgloss.AdaptiveColor | |
| 27 | 30 | ) |
| 28 | 31 | |
| 29 | 32 | var ( |
| 30 | - styText = lipgloss.NewStyle().Foreground(colText) | |
| 31 | - styDim = lipgloss.NewStyle().Foreground(colDim) | |
| 32 | - styFaint = lipgloss.NewStyle().Foreground(colFaint) | |
| 33 | - styCoral = lipgloss.NewStyle().Foreground(colCoral) | |
| 34 | - styBold = lipgloss.NewStyle().Bold(true) | |
| 35 | - styErr = lipgloss.NewStyle().Foreground(colErr) | |
| 36 | - styWarn = lipgloss.NewStyle().Foreground(colWarn) | |
| 37 | - styOK = lipgloss.NewStyle().Foreground(colOK) | |
| 33 | + styText lipgloss.Style | |
| 34 | + styDim lipgloss.Style | |
| 35 | + styFaint lipgloss.Style | |
| 36 | + styCoral lipgloss.Style | |
| 37 | + styBold lipgloss.Style | |
| 38 | + styErr lipgloss.Style | |
| 39 | + styWarn lipgloss.Style | |
| 40 | + styOK lipgloss.Style | |
| 38 | 41 | |
| 39 | 42 | // The transcript's grammar: every turn of events opens with a dot, and |
| 40 | 43 | // anything hanging off it is indented under that dot. |
| 41 | - styUserMark = lipgloss.NewStyle().Foreground(colDim) | |
| 42 | - styUserText = lipgloss.NewStyle().Foreground(colUser) | |
| 43 | - styToolDot = lipgloss.NewStyle().Foreground(colCoral) | |
| 44 | - styTextDot = lipgloss.NewStyle().Foreground(colText) | |
| 45 | - styErrDot = lipgloss.NewStyle().Foreground(colErr) | |
| 46 | - styToolName = lipgloss.NewStyle().Foreground(colText).Bold(true) | |
| 47 | - styToolArgs = lipgloss.NewStyle().Foreground(colDim) | |
| 48 | - styResult = lipgloss.NewStyle().Foreground(colDim) | |
| 44 | + styUserMark lipgloss.Style | |
| 45 | + styUserText lipgloss.Style | |
| 46 | + styToolDot lipgloss.Style | |
| 47 | + styTextDot lipgloss.Style | |
| 48 | + styErrDot lipgloss.Style | |
| 49 | + styToolName lipgloss.Style | |
| 50 | + styToolArgs lipgloss.Style | |
| 51 | + styResult lipgloss.Style | |
| 49 | 52 | |
| 50 | - styDiffAdd = lipgloss.NewStyle().Foreground(colOK) | |
| 51 | - styDiffDel = lipgloss.NewStyle().Foreground(colErr) | |
| 52 | - styDiffHunk = lipgloss.NewStyle().Foreground(colCoral) | |
| 53 | - styDiffHead = lipgloss.NewStyle().Foreground(colDim).Bold(true) | |
| 53 | + styDiffAdd lipgloss.Style | |
| 54 | + styDiffDel lipgloss.Style | |
| 55 | + styDiffHunk lipgloss.Style | |
| 56 | + styDiffHead lipgloss.Style | |
| 54 | 57 | |
| 55 | - styName = lipgloss.NewStyle().Foreground(colBlue).Background(colPillBg).Padding(0, 1) | |
| 56 | - styPill = lipgloss.NewStyle().Foreground(colDim).Background(colPillBg).Padding(0, 1) | |
| 57 | - styKey = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 58 | - styHeading = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 58 | + styName lipgloss.Style | |
| 59 | + styPill lipgloss.Style | |
| 60 | + styKey lipgloss.Style | |
| 61 | + styHeading lipgloss.Style | |
| 59 | 62 | |
| 60 | 63 | // The input frame is the only always-present chrome, so it carries the |
| 61 | - // mode: coral when it is yours to type into, faint while the agent has it. | |
| 62 | - styBoxFocus = lipgloss.NewStyle(). | |
| 63 | - Border(lipgloss.RoundedBorder()). | |
| 64 | - BorderForeground(colCoral). | |
| 65 | - Padding(0, 1) | |
| 66 | - styBoxBlur = lipgloss.NewStyle(). | |
| 67 | - Border(lipgloss.RoundedBorder()). | |
| 68 | - BorderForeground(colFaint). | |
| 69 | - Padding(0, 1) | |
| 64 | + // mode: the accent when it is yours to type into, faint while the agent | |
| 65 | + // has it. | |
| 66 | + styBoxFocus lipgloss.Style | |
| 67 | + styBoxBlur lipgloss.Style | |
| 70 | 68 | |
| 71 | 69 | // The approval box is the one place the UI is allowed to shout: it is the |
| 72 | 70 | // only moment a human has to decide something. |
| 73 | - styModal = lipgloss.NewStyle(). | |
| 74 | - Border(lipgloss.RoundedBorder()). | |
| 75 | - BorderForeground(colCoral). | |
| 76 | - Padding(0, 1) | |
| 71 | + styModal lipgloss.Style | |
| 77 | 72 | ) |
| @@ -0,0 +1,203 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "github.com/charmbracelet/lipgloss" | |
| 5 | + | |
| 6 | + "harness/internal/config" | |
| 7 | + ) | |
| 8 | + | |
| 9 | + // A theme is a palette, not a set of styles: the styles in styles.go are | |
| 10 | + // derived from it, so a theme decides colour and nothing about layout. Adding | |
| 11 | + // one is adding an entry here and a name to config.Themes. | |
| 12 | + // | |
| 13 | + // Every theme names all ten roles rather than inheriting the missing ones from | |
| 14 | + // a base. Inheritance would make a half-specified theme render as a mix of two | |
| 15 | + // palettes, which is the one outcome worse than an ugly theme. | |
| 16 | + type theme struct { | |
| 17 | + name string | |
| 18 | + // blurb is what the wizard says about it, in the wizard's own voice. | |
| 19 | + blurb string | |
| 20 | + | |
| 21 | + accent lipgloss.AdaptiveColor // the identity colour: one per theme | |
| 22 | + text lipgloss.AdaptiveColor | |
| 23 | + user lipgloss.AdaptiveColor | |
| 24 | + dim lipgloss.AdaptiveColor | |
| 25 | + faint lipgloss.AdaptiveColor | |
| 26 | + ok lipgloss.AdaptiveColor | |
| 27 | + err lipgloss.AdaptiveColor | |
| 28 | + warn lipgloss.AdaptiveColor | |
| 29 | + info lipgloss.AdaptiveColor | |
| 30 | + pillBg lipgloss.AdaptiveColor | |
| 31 | + } | |
| 32 | + | |
| 33 | + // themes are in the order the wizard offers them; ember is first because it | |
| 34 | + // is the default. | |
| 35 | + // | |
| 36 | + // The greys differ per theme rather than being shared: a neutral grey beside a | |
| 37 | + // warm accent reads as slightly blue, and beside a cool one as slightly | |
| 38 | + // brown. Tinting each theme's greys toward its own accent is what keeps the | |
| 39 | + // palette reading as one decision instead of two. | |
| 40 | + var themes = []theme{{ | |
| 41 | + name: "ember", | |
| 42 | + blurb: "Warm coral on a neutral ground. The original.", | |
| 43 | + accent: lipgloss.AdaptiveColor{Light: "#b4532f", Dark: "#d97757"}, | |
| 44 | + text: lipgloss.AdaptiveColor{Light: "#1c1b1a", Dark: "#e8e6e3"}, | |
| 45 | + user: lipgloss.AdaptiveColor{Light: "#4b4945", Dark: "#b3ada4"}, | |
| 46 | + dim: lipgloss.AdaptiveColor{Light: "#6f6a66", Dark: "#8a847d"}, | |
| 47 | + faint: lipgloss.AdaptiveColor{Light: "#c9c4be", Dark: "#3f3b38"}, | |
| 48 | + ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#5dbb7a"}, | |
| 49 | + err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#eb6f6f"}, | |
| 50 | + warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"}, | |
| 51 | + info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"}, | |
| 52 | + pillBg: lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"}, | |
| 53 | + }, { | |
| 54 | + name: "mint", | |
| 55 | + blurb: "Cool green. Easiest on a bright screen.", | |
| 56 | + accent: lipgloss.AdaptiveColor{Light: "#12796a", Dark: "#4ec9b0"}, | |
| 57 | + text: lipgloss.AdaptiveColor{Light: "#16201e", Dark: "#e3ebe8"}, | |
| 58 | + user: lipgloss.AdaptiveColor{Light: "#42504d", Dark: "#a8b8b4"}, | |
| 59 | + dim: lipgloss.AdaptiveColor{Light: "#65736f", Dark: "#7d8d89"}, | |
| 60 | + faint: lipgloss.AdaptiveColor{Light: "#c0cbc8", Dark: "#36403e"}, | |
| 61 | + ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#7fd48f"}, | |
| 62 | + err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#f07a7a"}, | |
| 63 | + warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e8bc6a"}, | |
| 64 | + info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#79a8d8"}, | |
| 65 | + pillBg: lipgloss.AdaptiveColor{Light: "#e4ecea", Dark: "#232b29"}, | |
| 66 | + }, { | |
| 67 | + name: "iris", | |
| 68 | + blurb: "Violet and slate. Quiet under low light.", | |
| 69 | + accent: lipgloss.AdaptiveColor{Light: "#6a4ac2", Dark: "#b18cf0"}, | |
| 70 | + text: lipgloss.AdaptiveColor{Light: "#1b1a22", Dark: "#e7e4ee"}, | |
| 71 | + user: lipgloss.AdaptiveColor{Light: "#4a4756", Dark: "#b0aac0"}, | |
| 72 | + dim: lipgloss.AdaptiveColor{Light: "#6d6980", Dark: "#88829c"}, | |
| 73 | + faint: lipgloss.AdaptiveColor{Light: "#c8c4d4", Dark: "#3c3848"}, | |
| 74 | + ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#79c98f"}, | |
| 75 | + err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#f07a90"}, | |
| 76 | + warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e2b062"}, | |
| 77 | + info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#86a8ec"}, | |
| 78 | + pillBg: lipgloss.AdaptiveColor{Light: "#eae7f2", Dark: "#282434"}, | |
| 79 | + }, { | |
| 80 | + name: "mono", | |
| 81 | + blurb: "No accent hue. For screens and eyes that want none.", | |
| 82 | + // The accent is a contrast step rather than a hue, so the identity is | |
| 83 | + // carried by weight alone. The four status colours stay coloured even | |
| 84 | + // here: the context gauge and an error are the two things that must not | |
| 85 | + // need reading to be noticed. | |
| 86 | + accent: lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"}, | |
| 87 | + text: lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#e6e6e6"}, | |
| 88 | + user: lipgloss.AdaptiveColor{Light: "#4a4a4a", Dark: "#b0b0b0"}, | |
| 89 | + dim: lipgloss.AdaptiveColor{Light: "#6e6e6e", Dark: "#8a8a8a"}, | |
| 90 | + faint: lipgloss.AdaptiveColor{Light: "#c6c6c6", Dark: "#3c3c3c"}, | |
| 91 | + ok: lipgloss.AdaptiveColor{Light: "#3f6f4d", Dark: "#8fb89a"}, | |
| 92 | + err: lipgloss.AdaptiveColor{Light: "#9c3b35", Dark: "#d99a9a"}, | |
| 93 | + warn: lipgloss.AdaptiveColor{Light: "#7a6030", Dark: "#c8b083"}, | |
| 94 | + info: lipgloss.AdaptiveColor{Light: "#4a5f7a", Dark: "#9aaec8"}, | |
| 95 | + pillBg: lipgloss.AdaptiveColor{Light: "#e9e9e9", Dark: "#2a2a2a"}, | |
| 96 | + }} | |
| 97 | + | |
| 98 | + // defaultTheme is what an unconfigured harness looks like, and the fallback | |
| 99 | + // for a theme name this build does not have. | |
| 100 | + const defaultTheme = "ember" | |
| 101 | + | |
| 102 | + // lookupTheme finds a theme by name, falling back to the default. It never | |
| 103 | + // fails: a config naming a theme from a newer build should cost you the | |
| 104 | + // colours you picked, not the ability to start. | |
| 105 | + func lookupTheme(name string) theme { | |
| 106 | + for _, t := range themes { | |
| 107 | + if t.name == name { | |
| 108 | + return t | |
| 109 | + } | |
| 110 | + } | |
| 111 | + for _, t := range themes { | |
| 112 | + if t.name == defaultTheme { | |
| 113 | + return t | |
| 114 | + } | |
| 115 | + } | |
| 116 | + return themes[0] | |
| 117 | + } | |
| 118 | + | |
| 119 | + // currentTheme is the palette in force. The wizard previews a theme by | |
| 120 | + // applying it for real, so this changes while the program runs. | |
| 121 | + var currentTheme = lookupTheme(defaultTheme) | |
| 122 | + | |
| 123 | + func init() { applyTheme(defaultTheme) } | |
| 124 | + | |
| 125 | + // applyTheme rebuilds every style from the named palette. | |
| 126 | + // | |
| 127 | + // The styles are package-level variables rather than fields on the model, so | |
| 128 | + // this is global mutable state — a deliberate trade. The alternative is | |
| 129 | + // threading a palette through every render function in the package, including | |
| 130 | + // the ones lipgloss calls for us, to serve a program that draws exactly one | |
| 131 | + // screen at a time. It is reassigned only from the Bubble Tea update loop, | |
| 132 | + // which is single-threaded, so the styles a frame reads are never mid-swap. | |
| 133 | + func applyTheme(name string) { | |
| 134 | + t := lookupTheme(name) | |
| 135 | + currentTheme = t | |
| 136 | + | |
| 137 | + colCoral, colText, colUser = t.accent, t.text, t.user | |
| 138 | + colDim, colFaint = t.dim, t.faint | |
| 139 | + colOK, colErr, colWarn, colBlue, colPillBg = t.ok, t.err, t.warn, t.info, t.pillBg | |
| 140 | + | |
| 141 | + styText = lipgloss.NewStyle().Foreground(colText) | |
| 142 | + styDim = lipgloss.NewStyle().Foreground(colDim) | |
| 143 | + styFaint = lipgloss.NewStyle().Foreground(colFaint) | |
| 144 | + styCoral = lipgloss.NewStyle().Foreground(colCoral) | |
| 145 | + styBold = lipgloss.NewStyle().Bold(true) | |
| 146 | + styErr = lipgloss.NewStyle().Foreground(colErr) | |
| 147 | + styWarn = lipgloss.NewStyle().Foreground(colWarn) | |
| 148 | + styOK = lipgloss.NewStyle().Foreground(colOK) | |
| 149 | + | |
| 150 | + styUserMark = lipgloss.NewStyle().Foreground(colDim) | |
| 151 | + styUserText = lipgloss.NewStyle().Foreground(colUser) | |
| 152 | + styToolDot = lipgloss.NewStyle().Foreground(colCoral) | |
| 153 | + styTextDot = lipgloss.NewStyle().Foreground(colText) | |
| 154 | + styErrDot = lipgloss.NewStyle().Foreground(colErr) | |
| 155 | + styToolName = lipgloss.NewStyle().Foreground(colText).Bold(true) | |
| 156 | + styToolArgs = lipgloss.NewStyle().Foreground(colDim) | |
| 157 | + styResult = lipgloss.NewStyle().Foreground(colDim) | |
| 158 | + | |
| 159 | + styDiffAdd = lipgloss.NewStyle().Foreground(colOK) | |
| 160 | + styDiffDel = lipgloss.NewStyle().Foreground(colErr) | |
| 161 | + styDiffHunk = lipgloss.NewStyle().Foreground(colCoral) | |
| 162 | + styDiffHead = lipgloss.NewStyle().Foreground(colDim).Bold(true) | |
| 163 | + | |
| 164 | + styName = lipgloss.NewStyle().Foreground(colBlue).Background(colPillBg).Padding(0, 1) | |
| 165 | + styPill = lipgloss.NewStyle().Foreground(colDim).Background(colPillBg).Padding(0, 1) | |
| 166 | + styKey = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 167 | + styHeading = lipgloss.NewStyle().Foreground(colCoral).Bold(true) | |
| 168 | + | |
| 169 | + styBoxFocus = lipgloss.NewStyle(). | |
| 170 | + Border(lipgloss.RoundedBorder()). | |
| 171 | + BorderForeground(colCoral). | |
| 172 | + Padding(0, 1) | |
| 173 | + styBoxBlur = lipgloss.NewStyle(). | |
| 174 | + Border(lipgloss.RoundedBorder()). | |
| 175 | + BorderForeground(colFaint). | |
| 176 | + Padding(0, 1) | |
| 177 | + | |
| 178 | + styModal = lipgloss.NewStyle(). | |
| 179 | + Border(lipgloss.RoundedBorder()). | |
| 180 | + BorderForeground(colCoral). | |
| 181 | + Padding(0, 1) | |
| 182 | + } | |
| 183 | + | |
| 184 | + // themeNames reports the themes in offer order. It is derived from themes so | |
| 185 | + // the wizard cannot drift out of step with what exists, and checked against | |
| 186 | + // config.Themes by a test so the two lists cannot disagree either. | |
| 187 | + func themeNames() []string { | |
| 188 | + out := make([]string, 0, len(themes)) | |
| 189 | + for _, t := range themes { | |
| 190 | + out = append(out, t.name) | |
| 191 | + } | |
| 192 | + return out | |
| 193 | + } | |
| 194 | + | |
| 195 | + // resolveTheme picks the theme to start in: what was configured, else the | |
| 196 | + // default. It is here rather than in config because config deliberately knows | |
| 197 | + // only the names. | |
| 198 | + func resolveTheme(cfg config.Config) string { | |
| 199 | + if config.ValidTheme(cfg.Theme) { | |
| 200 | + return cfg.Theme | |
| 201 | + } | |
| 202 | + return defaultTheme | |
| 203 | + } | |
| @@ -0,0 +1,548 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "strings" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "github.com/charmbracelet/bubbles/textinput" | |
| 10 | + tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | |
| 12 | + "harness/internal/config" | |
| 13 | + "harness/internal/llm" | |
| 14 | + ) | |
| 15 | + | |
| 16 | + // The setup wizard: a full-screen, one-question-at-a-time interface that owns | |
| 17 | + // the whole terminal while it is open. This file is its state — what has been | |
| 18 | + // answered, what is animating, what a key means. wizard_view.go draws it. | |
| 19 | + // | |
| 20 | + // It is a separate model rather than another phase of the chat model. The | |
| 21 | + // chat model is a state machine about a running agent — turns, approvals, | |
| 22 | + // interrupts — and none of that is true while the wizard is up. Keeping them | |
| 23 | + // apart means the wizard cannot leave the chat in a half-state, and the chat's | |
| 24 | + // key handling cannot fire under a screen it was never written for. | |
| 25 | + | |
| 26 | + type wizStep int | |
| 27 | + | |
| 28 | + const ( | |
| 29 | + stepIntro wizStep = iota | |
| 30 | + stepModel | |
| 31 | + stepEffort | |
| 32 | + stepApprovals | |
| 33 | + stepTheme | |
| 34 | + stepDone | |
| 35 | + ) | |
| 36 | + | |
| 37 | + // railSteps is how many of the steps are questions, which is what the progress | |
| 38 | + // rail counts. The intro and the closing animation are not positions on it. | |
| 39 | + const railSteps = 4 | |
| 40 | + | |
| 41 | + // Frame budgets. They are counts rather than durations so that every animation | |
| 42 | + // is addressable by frame number in a test. | |
| 43 | + const ( | |
| 44 | + introFrames = 40 | |
| 45 | + slideFrames = 9 | |
| 46 | + doneFrames = 46 | |
| 47 | + // breathPeriod is how long the selected row takes to go dim and bright | |
| 48 | + // again. Slow: a marker that blinks at you competes with the text it is | |
| 49 | + // meant to be pointing at. | |
| 50 | + breathPeriod = 44 | |
| 51 | + ) | |
| 52 | + | |
| 53 | + type animKind int | |
| 54 | + | |
| 55 | + const ( | |
| 56 | + animNone animKind = iota | |
| 57 | + animIntro | |
| 58 | + animSlide | |
| 59 | + animDone | |
| 60 | + ) | |
| 61 | + | |
| 62 | + // wizOption is one answer on offer. label is what it is, note is the fact that | |
| 63 | + // decides between them — a price, a default — and desc is the sentence shown | |
| 64 | + // only for the highlighted row, so the list stays scannable. | |
| 65 | + type wizOption struct { | |
| 66 | + value string | |
| 67 | + label string | |
| 68 | + note string | |
| 69 | + desc string | |
| 70 | + } | |
| 71 | + | |
| 72 | + type wizard struct { | |
| 73 | + width, height int | |
| 74 | + | |
| 75 | + step wizStep | |
| 76 | + cursor int | |
| 77 | + // chosen remembers the cursor per step, so stepping back lands on what you | |
| 78 | + // picked rather than at the top of the list. | |
| 79 | + chosen map[wizStep]int | |
| 80 | + | |
| 81 | + cfg config.Config | |
| 82 | + client *llm.Client | |
| 83 | + | |
| 84 | + // The catalogue, and what to say if it could not be had. | |
| 85 | + models []llm.Model | |
| 86 | + modelsErr error | |
| 87 | + loading bool | |
| 88 | + | |
| 89 | + // custom is the "type any model id" entry, for gateways serving models | |
| 90 | + // this build has never heard of. | |
| 91 | + custom textinput.Model | |
| 92 | + typing bool | |
| 93 | + | |
| 94 | + // Animation state. frame counts from the start of the current animation. | |
| 95 | + anim animKind | |
| 96 | + frame int | |
| 97 | + from string | |
| 98 | + // railFrom is where the progress rail was when the current slide began. | |
| 99 | + railFrom float64 | |
| 100 | + // reduced collapses every animation to its final frame. | |
| 101 | + reduced bool | |
| 102 | + | |
| 103 | + // enteredTheme is the palette in force when the wizard opened. Cancelling | |
| 104 | + // puts it back, because the theme step previews by applying for real. | |
| 105 | + enteredTheme string | |
| 106 | + | |
| 107 | + // result is set once the answers are final; cancelled once they are | |
| 108 | + // abandoned. The parent model polls both after each update. | |
| 109 | + result *config.Config | |
| 110 | + cancelled bool | |
| 111 | + } | |
| 112 | + | |
| 113 | + // newWizard builds the wizard, preselecting whatever is already in force so | |
| 114 | + // that pressing enter four times is a no-op rather than a reset. | |
| 115 | + func newWizard(client *llm.Client, cur config.Config) *wizard { | |
| 116 | + ti := textinput.New() | |
| 117 | + ti.Placeholder = "model id, e.g. claude-sonnet-5" | |
| 118 | + ti.Prompt = "› " | |
| 119 | + ti.CharLimit = 200 | |
| 120 | + | |
| 121 | + w := &wizard{ | |
| 122 | + client: client, | |
| 123 | + cfg: cur, | |
| 124 | + chosen: map[wizStep]int{}, | |
| 125 | + custom: ti, | |
| 126 | + loading: client != nil, | |
| 127 | + anim: animIntro, | |
| 128 | + enteredTheme: currentTheme.name, | |
| 129 | + reduced: reducedMotion(), | |
| 130 | + } | |
| 131 | + if w.cfg.Theme == "" { | |
| 132 | + w.cfg.Theme = currentTheme.name | |
| 133 | + } | |
| 134 | + if w.cfg.Approvals == "" { | |
| 135 | + w.cfg.Approvals = config.ApprovalsAsk | |
| 136 | + } | |
| 137 | + return w | |
| 138 | + } | |
| 139 | + | |
| 140 | + // reducedMotion reports whether this terminal should be spared the animations. | |
| 141 | + // | |
| 142 | + // NO_COLOR is honoured because an animation built out of colour transitions is | |
| 143 | + // mostly noise without them, and a terminal that cannot report its type is | |
| 144 | + // assumed to be something being scripted or logged rather than watched. | |
| 145 | + func reducedMotion() bool { | |
| 146 | + if os.Getenv("NO_COLOR") != "" { | |
| 147 | + return true | |
| 148 | + } | |
| 149 | + switch os.Getenv("TERM") { | |
| 150 | + case "", "dumb": | |
| 151 | + return true | |
| 152 | + } | |
| 153 | + return false | |
| 154 | + } | |
| 155 | + | |
| 156 | + // tooSmall reports whether the terminal cannot hold the wizard's layout. The | |
| 157 | + // animations are dropped before the content is: a cramped but readable | |
| 158 | + // question beats a beautifully animated one that does not fit. | |
| 159 | + func (w *wizard) tooSmall() bool { return w.width < 60 || w.height < 20 } | |
| 160 | + | |
| 161 | + func (w *wizard) still() bool { return w.reduced || w.tooSmall() } | |
| 162 | + | |
| 163 | + // ---- messages ----------------------------------------------------------- | |
| 164 | + | |
| 165 | + type wizTick struct{} | |
| 166 | + | |
| 167 | + type wizModels struct { | |
| 168 | + models []llm.Model | |
| 169 | + err error | |
| 170 | + } | |
| 171 | + | |
| 172 | + func wizTickCmd() tea.Cmd { | |
| 173 | + return tea.Tick(frameInterval, func(time.Time) tea.Msg { return wizTick{} }) | |
| 174 | + } | |
| 175 | + | |
| 176 | + // fetchModels asks the endpoint what it serves. The error is carried in the | |
| 177 | + // message rather than surfaced as a failure: the wizard has a fallback list | |
| 178 | + // and must stay usable against an endpoint with no catalogue at all. | |
| 179 | + func fetchModels(c *llm.Client) tea.Cmd { | |
| 180 | + if c == nil { | |
| 181 | + return nil | |
| 182 | + } | |
| 183 | + return func() tea.Msg { | |
| 184 | + models, err := c.ListModels(context.Background()) | |
| 185 | + return wizModels{models: models, err: err} | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + func (w *wizard) Init() tea.Cmd { | |
| 190 | + cmds := []tea.Cmd{fetchModels(w.client)} | |
| 191 | + if !w.still() { | |
| 192 | + cmds = append(cmds, wizTickCmd()) | |
| 193 | + } else { | |
| 194 | + // Nothing will advance the intro, so it is already over. | |
| 195 | + w.anim, w.frame = animNone, introFrames | |
| 196 | + } | |
| 197 | + return tea.Batch(cmds...) | |
| 198 | + } | |
| 199 | + | |
| 200 | + // ---- update ------------------------------------------------------------- | |
| 201 | + | |
| 202 | + func (w *wizard) Update(msg tea.Msg) tea.Cmd { | |
| 203 | + switch msg := msg.(type) { | |
| 204 | + case tea.WindowSizeMsg: | |
| 205 | + w.width, w.height = msg.Width, msg.Height | |
| 206 | + w.custom.Width = maxInt(20, w.cardWidth()-8) | |
| 207 | + return nil | |
| 208 | + | |
| 209 | + case wizModels: | |
| 210 | + w.loading = false | |
| 211 | + w.models, w.modelsErr = msg.models, msg.err | |
| 212 | + // The catalogue is what the model step's rows are made of, so the | |
| 213 | + // preselection can only be resolved now. It is resolved against the | |
| 214 | + // model options specifically: the step on screen when this lands is | |
| 215 | + // usually still the intro, and asking that step for its options would | |
| 216 | + // preselect row zero of a list nobody is looking at. | |
| 217 | + w.chosen[stepModel] = w.indexOf(w.modelOptions(), w.cfg.Model) | |
| 218 | + if w.step == stepModel { | |
| 219 | + w.cursor = w.chosen[stepModel] | |
| 220 | + } | |
| 221 | + return nil | |
| 222 | + | |
| 223 | + case wizTick: | |
| 224 | + return w.tick() | |
| 225 | + | |
| 226 | + case tea.KeyMsg: | |
| 227 | + return w.onKey(msg) | |
| 228 | + } | |
| 229 | + return nil | |
| 230 | + } | |
| 231 | + | |
| 232 | + // tick advances the clock. It keeps scheduling frames for as long as the | |
| 233 | + // wizard is open, not only while something is animating, because the selected | |
| 234 | + // row breathes on every step — that is a redraw of one screen at 30fps for the | |
| 235 | + // minute somebody spends here, and it stops the moment the wizard closes. | |
| 236 | + func (w *wizard) tick() tea.Cmd { | |
| 237 | + if w.still() { | |
| 238 | + return nil | |
| 239 | + } | |
| 240 | + w.frame++ | |
| 241 | + | |
| 242 | + switch w.anim { | |
| 243 | + case animIntro: | |
| 244 | + if w.frame >= introFrames { | |
| 245 | + // The intro's last beat is an invitation to press a key, which | |
| 246 | + // breathes on its own; the animation proper is over. | |
| 247 | + w.anim = animNone | |
| 248 | + } | |
| 249 | + case animSlide: | |
| 250 | + if w.frame >= slideFrames { | |
| 251 | + w.anim, w.from = animNone, "" | |
| 252 | + } | |
| 253 | + case animDone: | |
| 254 | + if w.frame >= doneFrames { | |
| 255 | + w.finish() | |
| 256 | + return nil | |
| 257 | + } | |
| 258 | + } | |
| 259 | + return wizTickCmd() | |
| 260 | + } | |
| 261 | + | |
| 262 | + func (w *wizard) onKey(msg tea.KeyMsg) tea.Cmd { | |
| 263 | + key := msg.String() | |
| 264 | + | |
| 265 | + if key == "ctrl+c" { | |
| 266 | + w.cancel() | |
| 267 | + return nil | |
| 268 | + } | |
| 269 | + | |
| 270 | + // A key during an animation skips to the end of it rather than being | |
| 271 | + // swallowed. Nobody should have to wait out a flourish twice. | |
| 272 | + if w.anim != animNone && w.anim != animSlide { | |
| 273 | + if w.anim == animDone { | |
| 274 | + w.finish() | |
| 275 | + return nil | |
| 276 | + } | |
| 277 | + w.anim, w.frame = animNone, introFrames | |
| 278 | + return nil | |
| 279 | + } | |
| 280 | + | |
| 281 | + if w.typing { | |
| 282 | + return w.onTypingKey(msg, key) | |
| 283 | + } | |
| 284 | + | |
| 285 | + switch key { | |
| 286 | + case "up", "k", "shift+tab": | |
| 287 | + w.move(-1) | |
| 288 | + return nil | |
| 289 | + case "down", "j", "tab": | |
| 290 | + w.move(1) | |
| 291 | + return nil | |
| 292 | + | |
| 293 | + case "enter", "right", "l", " ": | |
| 294 | + return w.advance() | |
| 295 | + | |
| 296 | + case "esc", "left", "h": | |
| 297 | + return w.back() | |
| 298 | + } | |
| 299 | + return nil | |
| 300 | + } | |
| 301 | + | |
| 302 | + func (w *wizard) onTypingKey(msg tea.KeyMsg, key string) tea.Cmd { | |
| 303 | + switch key { | |
| 304 | + case "enter": | |
| 305 | + id := strings.TrimSpace(w.custom.Value()) | |
| 306 | + if id == "" { | |
| 307 | + return nil | |
| 308 | + } | |
| 309 | + w.typing = false | |
| 310 | + w.custom.Blur() | |
| 311 | + w.cfg.Model = id | |
| 312 | + return w.goTo(stepEffort) | |
| 313 | + case "esc": | |
| 314 | + w.typing = false | |
| 315 | + w.custom.Blur() | |
| 316 | + return nil | |
| 317 | + } | |
| 318 | + var cmd tea.Cmd | |
| 319 | + w.custom, cmd = w.custom.Update(msg) | |
| 320 | + return cmd | |
| 321 | + } | |
| 322 | + | |
| 323 | + func (w *wizard) move(d int) { | |
| 324 | + opts := w.options() | |
| 325 | + if len(opts) == 0 { | |
| 326 | + return | |
| 327 | + } | |
| 328 | + w.cursor = (w.cursor + d + len(opts)) % len(opts) | |
| 329 | + w.chosen[w.step] = w.cursor | |
| 330 | + | |
| 331 | + // The theme step previews by applying the palette for real: reading a | |
| 332 | + // swatch tells you nothing about what a transcript looks like in it. | |
| 333 | + if w.step == stepTheme { | |
| 334 | + applyTheme(opts[w.cursor].value) | |
| 335 | + } | |
| 336 | + } | |
| 337 | + | |
| 338 | + // advance records the answer on the current step and moves to the next one. | |
| 339 | + func (w *wizard) advance() tea.Cmd { | |
| 340 | + switch w.step { | |
| 341 | + case stepIntro: | |
| 342 | + return w.goTo(stepModel) | |
| 343 | + | |
| 344 | + case stepModel: | |
| 345 | + opts := w.options() | |
| 346 | + if len(opts) == 0 { | |
| 347 | + return nil // the catalogue has not landed yet | |
| 348 | + } | |
| 349 | + if opts[w.cursor].value == customModel { | |
| 350 | + w.typing = true | |
| 351 | + w.custom.SetValue("") | |
| 352 | + return w.custom.Focus() | |
| 353 | + } | |
| 354 | + w.cfg.Model = opts[w.cursor].value | |
| 355 | + return w.goTo(stepEffort) | |
| 356 | + | |
| 357 | + case stepEffort: | |
| 358 | + w.cfg.Effort = w.options()[w.cursor].value | |
| 359 | + return w.goTo(stepApprovals) | |
| 360 | + | |
| 361 | + case stepApprovals: | |
| 362 | + w.cfg.Approvals = w.options()[w.cursor].value | |
| 363 | + return w.goTo(stepTheme) | |
| 364 | + | |
| 365 | + case stepTheme: | |
| 366 | + w.cfg.Theme = w.options()[w.cursor].value | |
| 367 | + return w.goTo(stepDone) | |
| 368 | + } | |
| 369 | + return nil | |
| 370 | + } | |
| 371 | + | |
| 372 | + func (w *wizard) back() tea.Cmd { | |
| 373 | + switch w.step { | |
| 374 | + case stepIntro: | |
| 375 | + // Nothing has been chosen yet, so leaving is free. | |
| 376 | + w.cancel() | |
| 377 | + return nil | |
| 378 | + case stepModel: | |
| 379 | + return w.goTo(stepIntro) | |
| 380 | + case stepEffort: | |
| 381 | + return w.goTo(stepModel) | |
| 382 | + case stepApprovals: | |
| 383 | + return w.goTo(stepEffort) | |
| 384 | + case stepTheme: | |
| 385 | + return w.goTo(stepApprovals) | |
| 386 | + } | |
| 387 | + return nil | |
| 388 | + } | |
| 389 | + | |
| 390 | + // goTo starts the transition to a step, capturing the screen being left so it | |
| 391 | + // can be slid out from under the new one. | |
| 392 | + func (w *wizard) goTo(next wizStep) tea.Cmd { | |
| 393 | + if w.still() { | |
| 394 | + w.step = next | |
| 395 | + w.restoreCursor() | |
| 396 | + if next == stepDone { | |
| 397 | + w.finish() | |
| 398 | + } | |
| 399 | + return nil | |
| 400 | + } | |
| 401 | + | |
| 402 | + w.from = w.screen() | |
| 403 | + w.railFrom = w.railPos() | |
| 404 | + w.step = next | |
| 405 | + w.restoreCursor() | |
| 406 | + | |
| 407 | + if next == stepDone { | |
| 408 | + w.anim, w.frame = animDone, 0 | |
| 409 | + } else { | |
| 410 | + w.anim, w.frame = animSlide, 0 | |
| 411 | + } | |
| 412 | + return wizTickCmd() | |
| 413 | + } | |
| 414 | + | |
| 415 | + // restoreCursor puts the highlight back on what is already chosen for the step | |
| 416 | + // being entered. | |
| 417 | + func (w *wizard) restoreCursor() { | |
| 418 | + opts := w.options() | |
| 419 | + if len(opts) == 0 { | |
| 420 | + w.cursor = 0 | |
| 421 | + return | |
| 422 | + } | |
| 423 | + if i, ok := w.chosen[w.step]; ok && i < len(opts) { | |
| 424 | + w.cursor = i | |
| 425 | + return | |
| 426 | + } | |
| 427 | + w.cursor = w.indexOf(opts, w.current()) | |
| 428 | + w.chosen[w.step] = w.cursor | |
| 429 | + } | |
| 430 | + | |
| 431 | + // current is the configured value for the step being shown, used to preselect. | |
| 432 | + func (w *wizard) current() string { | |
| 433 | + switch w.step { | |
| 434 | + case stepModel: | |
| 435 | + return w.cfg.Model | |
| 436 | + case stepEffort: | |
| 437 | + return w.cfg.Effort | |
| 438 | + case stepApprovals: | |
| 439 | + return w.cfg.Approvals | |
| 440 | + case stepTheme: | |
| 441 | + return w.cfg.Theme | |
| 442 | + } | |
| 443 | + return "" | |
| 444 | + } | |
| 445 | + | |
| 446 | + func (w *wizard) indexOf(opts []wizOption, value string) int { | |
| 447 | + for i, o := range opts { | |
| 448 | + if o.value == value { | |
| 449 | + return i | |
| 450 | + } | |
| 451 | + } | |
| 452 | + return 0 | |
| 453 | + } | |
| 454 | + | |
| 455 | + func (w *wizard) finish() { | |
| 456 | + cfg := w.cfg | |
| 457 | + w.result = &cfg | |
| 458 | + } | |
| 459 | + | |
| 460 | + // cancel abandons the wizard and undoes the one change it makes as you go. | |
| 461 | + func (w *wizard) cancel() { | |
| 462 | + applyTheme(w.enteredTheme) | |
| 463 | + w.cancelled = true | |
| 464 | + } | |
| 465 | + | |
| 466 | + // ---- options ------------------------------------------------------------ | |
| 467 | + | |
| 468 | + // customModel is the sentinel for the "type any model id" row. It is not a | |
| 469 | + // valid model id, so it can never be mistaken for one. | |
| 470 | + const customModel = "\x00custom" | |
| 471 | + | |
| 472 | + func (w *wizard) options() []wizOption { | |
| 473 | + switch w.step { | |
| 474 | + case stepModel: | |
| 475 | + return w.modelOptions() | |
| 476 | + case stepEffort: | |
| 477 | + return effortOptions() | |
| 478 | + case stepApprovals: | |
| 479 | + return approvalOptions() | |
| 480 | + case stepTheme: | |
| 481 | + return themeOptions() | |
| 482 | + } | |
| 483 | + return nil | |
| 484 | + } | |
| 485 | + | |
| 486 | + func (w *wizard) modelOptions() []wizOption { | |
| 487 | + if w.loading { | |
| 488 | + return nil | |
| 489 | + } | |
| 490 | + models := w.models | |
| 491 | + if len(models) == 0 { | |
| 492 | + models = llm.KnownModels() | |
| 493 | + } | |
| 494 | + out := make([]wizOption, 0, len(models)+1) | |
| 495 | + for _, m := range models { | |
| 496 | + out = append(out, wizOption{ | |
| 497 | + value: m.ID, | |
| 498 | + label: llm.PrettyModel(m.ID), | |
| 499 | + note: llm.PriceLabel(m.ID), | |
| 500 | + desc: m.ID, | |
| 501 | + }) | |
| 502 | + } | |
| 503 | + return append(out, wizOption{ | |
| 504 | + value: customModel, | |
| 505 | + label: "custom…", | |
| 506 | + desc: "type any model id, for a gateway serving something else", | |
| 507 | + }) | |
| 508 | + } | |
| 509 | + | |
| 510 | + func effortOptions() []wizOption { | |
| 511 | + desc := map[string]string{ | |
| 512 | + "low": "Answers sooner and spends less. Good for small, well-specified edits.", | |
| 513 | + "medium": "A middle setting, for work that needs a little thinking but not much.", | |
| 514 | + "high": "What the API does if you say nothing. The right default for most work.", | |
| 515 | + "xhigh": "Thinks longer before acting. Worth it on unfamiliar code.", | |
| 516 | + "max": "As much deliberation as the model will do. Slowest and priciest.", | |
| 517 | + } | |
| 518 | + out := make([]wizOption, 0, len(config.Efforts)) | |
| 519 | + for _, e := range config.Efforts { | |
| 520 | + o := wizOption{value: e, label: e, desc: desc[e]} | |
| 521 | + if e == "high" { | |
| 522 | + o.note = "default" | |
| 523 | + } | |
| 524 | + out = append(out, o) | |
| 525 | + } | |
| 526 | + return out | |
| 527 | + } | |
| 528 | + | |
| 529 | + func approvalOptions() []wizOption { | |
| 530 | + return []wizOption{{ | |
| 531 | + value: config.ApprovalsAsk, | |
| 532 | + label: "Ask me first", | |
| 533 | + note: "recommended", | |
| 534 | + desc: "bash, edit_file and write_file stop and wait for you. Reads run unattended.", | |
| 535 | + }, { | |
| 536 | + value: config.ApprovalsAcceptAll, | |
| 537 | + label: "Accept everything", | |
| 538 | + desc: "tool calls run without asking. Only against a throwaway clone or a container.", | |
| 539 | + }} | |
| 540 | + } | |
| 541 | + | |
| 542 | + func themeOptions() []wizOption { | |
| 543 | + out := make([]wizOption, 0, len(themes)) | |
| 544 | + for _, t := range themes { | |
| 545 | + out = append(out, wizOption{value: t.name, label: t.name, desc: t.blurb}) | |
| 546 | + } | |
| 547 | + return out | |
| 548 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.