Vectrum
Sign In

setup: a full-screen wizard for the defaults, and saved config

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>
Jjonicommitted 1 parent f04f191commit2e61c4b

Showing 21 changed files with 3042 additions and 139 deletions.

+70−2README.mdView File
@@ -79,7 +79,7 @@ to six lines.
7979 | `esc` | interrupt the turn |
8080 | `ctrl+c` | interrupt the turn; again when idle to exit |
8181
82- Commands: `/allowed`, `/clear`, `/help`, `/exit`, `/skills`.
82+ Commands: `/allowed`, `/clear`, `/help`, `/setup`, `/skills`, `/exit`.
8383
8484 `shift+tab` cycles between asking before every mutating call and accepting them
8585 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
9797 consume the same `agent.Event` stream, so neither can show something the other
9898 cannot.
9999
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+
100167 ### Driving it without an API key
101168
102169 `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
237304 internal/agent turn loop, conversation state, compaction, sessions
238305 internal/tools the tool registry and the tools
239306 internal/skills discovery and parsing of SKILL.md
307+ internal/config saved defaults: model, effort, approvals, theme
240308 internal/approval allow / deny / ask, with shell analysis
241309 internal/metrics JSONL event log
242310 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
244312 evals task suite and runner
245313 ```
246314
+55−6cmd/harness/main.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "harness/internal/agent"
1818 "harness/internal/approval"
19+ "harness/internal/config"
1920 "harness/internal/llm"
2021 "harness/internal/metrics"
2122 "harness/internal/skills"
@@ -62,8 +63,13 @@ func run() error {
6263 return err
6364 }
6465
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+
6571 client := llm.New(
66- llm.WithModel(*model),
72+ llm.WithModel(config.Or(*model, cfg.Model)),
6773 llm.WithBaseURL(*baseURL),
6874 llm.WithAPIKey(*apiKey),
6975 llm.WithAuthToken(*authToken),
@@ -72,6 +78,12 @@ func run() error {
7278 return errNoCredentials
7379 }
7480
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+
7587 session := time.Now().UTC().Format("20060102T150405Z")
7688 log, err := metrics.Open(filepath.Join(root, *logPath), session)
7789 if err != nil {
@@ -95,6 +107,10 @@ func run() error {
95107 fmt.Fprintln(os.Stderr, "harness: "+msg)
96108 }
97109
110+ if cfgErr != nil {
111+ notice("%v — using defaults; /setup will write a fresh one", cfgErr)
112+ }
113+
98114 if !client.IsDefaultEndpoint() {
99115 // Worth saying out loud: the prompt and every file the agent reads go
100116 // to this endpoint, not to Anthropic.
@@ -141,14 +157,14 @@ func run() error {
141157 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
142158 approver := &ui.Approver{In: in, Out: os.Stdout}
143159 policy := approval.New(approver.Prompt)
144- policy.YOLO = *yolo
145- if *yolo && !useTUI {
160+ policy.YOLO = acceptAll
161+ if acceptAll && !useTUI {
146162 // The TUI says this itself, in the banner.
147163 fmt.Fprintln(os.Stderr, "harness: -yolo — no approval prompts. Use a throwaway clone or a container.")
148164 }
149165 a := agent.New(client, reg, state, log, agent.Options{
150166 MaxTurns: *maxTurns,
151- Effort: *effort,
167+ Effort: effortSetting,
152168 ContextWindow: *contextWindow,
153169 CompactAt: *compactAt,
154170 NoCache: *noCache,
@@ -181,14 +197,22 @@ func run() error {
181197 SessionID: session,
182198 ToolNames: toolNames(reg),
183199 Verbose: *verbose,
184- YOLO: *yolo,
200+ YOLO: acceptAll,
185201 Skills: found,
186202
187203 Version: version,
188- Effort: *effort,
204+ Effort: effortSetting,
189205 ContextWindow: *contextWindow,
190206
191207 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),
192216 })
193217 }
194218
@@ -231,6 +255,31 @@ func run() error {
231255 }
232256 }
233257
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+
234283 // once runs a single prompt, prints the session totals, and saves the session.
235284 func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir, id, root string, client *llm.Client, prompt string) error {
236285 turnCtx, endTurn := beginTurn(ctx)
+9−0internal/agent/loop.goView File
@@ -144,6 +144,15 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
144144 // State exposes the conversation for callers that need to inspect or persist it.
145145 func (a *Agent) State() *State { return a.state }
146146
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+
147156 func (a *Agent) emit(ev Event) {
148157 if a.Observe != nil {
149158 a.Observe(ev)
+177−0internal/config/config.goaddedView File
@@ -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+ }
+149−0internal/config/config_test.goaddedView File
@@ -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+ }
+15−9internal/llm/client.goView File
@@ -122,6 +122,20 @@ func (c *Client) IsDefaultEndpoint() bool { return c.BaseURL == DefaultBaseURL }
122122 // HasCredentials reports whether the client can authenticate.
123123 func (c *Client) HasCredentials() bool { return c.apiKey != "" || c.authToken != "" }
124124
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+
125139 // APIError is a non-2xx response.
126140 type APIError struct {
127141 StatusCode int
@@ -193,15 +207,7 @@ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
193207 return nil, err
194208 }
195209 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)
205211
206212 httpResp, err := c.HTTP.Do(httpReq)
207213 if err != nil {
+120−0internal/llm/models.goaddedView File
@@ -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) }
+114−0internal/llm/models_test.goaddedView File
@@ -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+ }
+65−0internal/llm/name.goaddedView File
@@ -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+ }
+20−0internal/llm/pricing.goView File
@@ -1,5 +1,10 @@
11 package llm
22
3+ import (
4+ "fmt"
5+ "strconv"
6+ )
7+
38 // Price is per-million-token list pricing.
49 type Price struct {
510 InputPerMTok float64
@@ -36,3 +41,18 @@ func (u Usage) Cost(model string) (float64, bool) {
3641 float64(u.CacheReadInputTokens)*p.InputPerMTok*cacheReadMultiplier +
3742 float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
3843 }
44+
45+ // 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+ }
+1−7internal/llm/stream.goView File
@@ -85,13 +85,7 @@ func (c *Client) streamAttempt(ctx context.Context, body []byte, onEvent func(St
8585 }
8686 httpReq.Header.Set("content-type", "application/json")
8787 httpReq.Header.Set("accept", "text/event-stream")
88- 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)
9589
9690 httpResp, err := c.HTTP.Do(httpReq)
9791 if err != nil {
+230−0internal/tui/anim.goaddedView File
@@ -0,0 +1,230 @@
1+ package tui
2+
3+ import (
4+ "fmt"
5+ "math"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/charmbracelet/x/ansi"
12+ )
13+
14+ // Animation primitives for the setup wizard.
15+ //
16+ // All of it is string arithmetic on a fixed frame budget: there is no
17+ // goroutine, no clock beyond Bubble Tea's tick, and every animation is a pure
18+ // function of an integer frame number. That is what makes the animations
19+ // testable — a test asks for frame n and gets the same thing the terminal
20+ // would draw — and what makes them skippable, since jumping to the last frame
21+ // is just passing a bigger number.
22+
23+ // frameInterval is one animation frame. About 30fps: fast enough that a wipe
24+ // reads as motion, slow enough that a terminal over ssh keeps up.
25+ const frameInterval = 33 * time.Millisecond
26+
27+ // ease is the smoothstep curve, on [0,1]. Linear motion in a terminal reads as
28+ // mechanical; easing in and out reads as something sliding to a stop.
29+ func ease(t float64) float64 {
30+ t = clamp01(t)
31+ return t * t * (3 - 2*t)
32+ }
33+
34+ // easeOut decelerates only. It is for things that arrive — a panel coming to
35+ // rest — where an ease-in at the start reads as lag.
36+ func easeOut(t float64) float64 {
37+ t = clamp01(t)
38+ return 1 - (1-t)*(1-t)
39+ }
40+
41+ func clamp01(t float64) float64 {
42+ switch {
43+ case t < 0:
44+ return 0
45+ case t > 1:
46+ return 1
47+ }
48+ return t
49+ }
50+
51+ // progress turns a frame number into a 0..1 position over n frames. A zero or
52+ // negative budget is already finished, which is how the reduced-motion path
53+ // collapses every animation to its final frame without a special case at each
54+ // call site.
55+ func progress(frame, n int) float64 {
56+ if n <= 0 {
57+ return 1
58+ }
59+ return clamp01(float64(frame) / float64(n))
60+ }
61+
62+ // blend mixes two adaptive colours, channel by channel, on both the light and
63+ // the dark variant. It is what lets a letter ignite from faint to the accent
64+ // rather than snapping between the two.
65+ func blend(a, b lipgloss.AdaptiveColor, t float64) lipgloss.AdaptiveColor {
66+ return lipgloss.AdaptiveColor{
67+ Light: blendHex(a.Light, b.Light, t),
68+ Dark: blendHex(a.Dark, b.Dark, t),
69+ }
70+ }
71+
72+ // blendHex interpolates two "#rrggbb" strings. Anything it cannot parse comes
73+ // back as one of the endpoints rather than as a broken colour string, because
74+ // a malformed colour is rendered by lipgloss as no colour at all — which would
75+ // show up as a hole in the animation rather than as an error anyone could act
76+ // on.
77+ func blendHex(a, b string, t float64) string {
78+ ar, ag, ab, aok := parseHex(a)
79+ br, bg, bb, bok := parseHex(b)
80+ if !aok || !bok {
81+ if t < 0.5 {
82+ return a
83+ }
84+ return b
85+ }
86+ t = clamp01(t)
87+ lerp := func(x, y int) int { return int(math.Round(float64(x) + (float64(y)-float64(x))*t)) }
88+ return fmt.Sprintf("#%02x%02x%02x", lerp(ar, br), lerp(ag, bg), lerp(ab, bb))
89+ }
90+
91+ func parseHex(s string) (r, g, b int, ok bool) {
92+ s = strings.TrimPrefix(s, "#")
93+ if len(s) != 6 {
94+ return 0, 0, 0, false
95+ }
96+ v, err := strconv.ParseUint(s, 16, 32)
97+ if err != nil {
98+ return 0, 0, 0, false
99+ }
100+ return int(v >> 16 & 0xff), int(v >> 8 & 0xff), int(v & 0xff), true
101+ }
102+
103+ // triangle is a 0..1 triangle wave over period frames, for anything that
104+ // breathes. Triangle rather than sine because the ends are what you notice — a
105+ // sine lingers at both extremes and reads as a stutter at this frame rate.
106+ // (The name pulse is taken by the spinner.)
107+ func triangle(frame, period int) float64 {
108+ if period <= 1 {
109+ return 1
110+ }
111+ p := frame % period
112+ half := period / 2
113+ if p < half {
114+ return float64(p) / float64(half)
115+ }
116+ return 1 - float64(p-half)/float64(period-half)
117+ }
118+
119+ // slide pushes next in from the right while prev leaves to the left, as one
120+ // strip of film moving across the frame.
121+ //
122+ // Both panels are cut with an ANSI-aware slicer, so a line's colours survive
123+ // being cut in half. They are padded to a common height and width first: a
124+ // ragged panel would otherwise shear, with each line starting its slide from
125+ // wherever its own text happened to end.
126+ func slide(prev, next string, width, height int, t float64) string {
127+ width, height = maxInt(width, 1), maxInt(height, 1)
128+ off := int(math.Round(ease(t) * float64(width)))
129+
130+ p := padPanel(prev, width, height)
131+ n := padPanel(next, width, height)
132+
133+ out := make([]string, height)
134+ for i := range out {
135+ switch {
136+ case off <= 0:
137+ out[i] = p[i]
138+ case off >= width:
139+ out[i] = n[i]
140+ default:
141+ // The tail of the outgoing panel, then the head of the incoming
142+ // one: together exactly one screen wide.
143+ out[i] = ansi.Cut(p[i], off, width) + ansi.Cut(n[i], 0, off)
144+ }
145+ }
146+ return strings.Join(out, "\n")
147+ }
148+
149+ // padPanel squares a rendered panel off to width by height, so it can be cut
150+ // at any column without the cut landing past the end of a short line.
151+ func padPanel(s string, width, height int) []string {
152+ lines := strings.Split(s, "\n")
153+ if len(lines) > height {
154+ lines = lines[:height]
155+ }
156+ out := make([]string, height)
157+ for i := range out {
158+ line := ""
159+ if i < len(lines) {
160+ line = lines[i]
161+ }
162+ if w := lipgloss.Width(line); w < width {
163+ line += strings.Repeat(" ", width-w)
164+ } else if w > width {
165+ line = ansi.Truncate(line, width, "")
166+ }
167+ out[i] = line
168+ }
169+ return out
170+ }
171+
172+ // reveal returns the first n display cells of s, for a typewriter. It counts
173+ // cells rather than bytes so a multi-byte character is never half-drawn.
174+ func reveal(s string, n int) string {
175+ if n <= 0 {
176+ return ""
177+ }
178+ if lipgloss.Width(s) <= n {
179+ return s
180+ }
181+ return ansi.Cut(s, 0, n)
182+ }
183+
184+ // sweep renders a bar filled to t, in the accent, with the leading cell
185+ // brightened so the fill has a head rather than an edge.
186+ func sweep(width int, t float64, fill lipgloss.AdaptiveColor) string {
187+ width = maxInt(width, 1)
188+ done := int(math.Round(clamp01(t) * float64(width)))
189+
190+ var b strings.Builder
191+ for i := 0; i < width; i++ {
192+ switch {
193+ case i < done-1:
194+ b.WriteString(lipgloss.NewStyle().Foreground(fill).Render("━"))
195+ case i == done-1:
196+ // The head is the accent lifted toward the text colour: a moving
197+ // highlight, which is what makes the bar look like it is being
198+ // drawn rather than growing.
199+ b.WriteString(lipgloss.NewStyle().Foreground(blend(fill, colText, 0.45)).Bold(true).Render("━"))
200+ default:
201+ b.WriteString(styFaint.Render("─"))
202+ }
203+ }
204+ return b.String()
205+ }
206+
207+ // ignite renders text letter by letter, each one fading from `from` to `to` as
208+ // a wave passes over it. The wave is a few cells wide, so the word lights up
209+ // as a sweep rather than as a row of independent blinks.
210+ func ignite(text string, from, to lipgloss.AdaptiveColor, t float64, bold bool) string {
211+ runes := []rune(text)
212+ if len(runes) == 0 {
213+ return ""
214+ }
215+ // The head travels past the end of the word by the width of the wave, so
216+ // the last letter finishes rather than stopping mid-fade.
217+ const wave = 4.0
218+ head := clamp01(t) * (float64(len(runes)) + wave)
219+
220+ var b strings.Builder
221+ for i, r := range runes {
222+ lit := clamp01((head - float64(i)) / wave)
223+ st := lipgloss.NewStyle().Foreground(blend(from, to, lit))
224+ if bold && lit > 0.5 {
225+ st = st.Bold(true)
226+ }
227+ b.WriteString(st.Render(string(r)))
228+ }
229+ return b.String()
230+ }
+229−0internal/tui/anim_test.goaddedView File
@@ -0,0 +1,229 @@
1+ package tui
2+
3+ import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/muesli/termenv"
9+ )
10+
11+ // A slide is only correct if every intermediate frame is exactly as wide as
12+ // the screen. One cell out and the terminal reflows mid-animation, which looks
13+ // like the whole UI tearing.
14+ func TestSlideKeepsEveryFrameTheSameSize(t *testing.T) {
15+ forceColour(t)
16+
17+ prev := styCoral.Render("aaaaaaaaaa") + "\n" + styDim.Render("bbbb")
18+ next := styOK.Render("cccc") + "\n" + styErr.Render("dddddddddddddddd")
19+
20+ const width, height = 20, 4
21+ for frame := 0; frame <= slideFrames; frame++ {
22+ got := slide(prev, next, width, height, progress(frame, slideFrames))
23+ lines := strings.Split(got, "\n")
24+ if len(lines) != height {
25+ t.Fatalf("frame %d is %d lines, want %d", frame, len(lines), height)
26+ }
27+ for i, line := range lines {
28+ if w := lipgloss.Width(line); w != width {
29+ t.Errorf("frame %d line %d is %d cells, want %d", frame, i, w, width)
30+ }
31+ }
32+ }
33+ }
34+
35+ // The ends have to be exact: a slide that does not start on the old screen or
36+ // finish on the new one shows a jump at one end or the other.
37+ func TestSlideEndsAreTheTwoPanels(t *testing.T) {
38+ prev, next := "old ", "new "
39+ if got := slide(prev, next, 4, 1, 0); got != prev {
40+ t.Errorf("at t=0 the slide shows %q, want the outgoing panel", got)
41+ }
42+ if got := slide(prev, next, 4, 1, 1); got != next {
43+ t.Errorf("at t=1 the slide shows %q, want the incoming panel", got)
44+ }
45+ }
46+
47+ // A ragged panel — short lines, or more lines than fit — must be squared off
48+ // rather than sheared, or each row slides from a different place.
49+ func TestSlideSquaresOffRaggedPanels(t *testing.T) {
50+ got := slide("a\nbbbbbbbb\n", "x", 4, 2, 0.5)
51+ for i, line := range strings.Split(got, "\n") {
52+ if w := lipgloss.Width(line); w != 4 {
53+ t.Errorf("line %d is %d cells, want 4", i, w)
54+ }
55+ }
56+ }
57+
58+ func TestBlendHexInterpolates(t *testing.T) {
59+ cases := []struct {
60+ a, b string
61+ t float64
62+ want string
63+ }{
64+ {"#000000", "#ffffff", 0, "#000000"},
65+ {"#000000", "#ffffff", 1, "#ffffff"},
66+ {"#000000", "#ffffff", 0.5, "#808080"},
67+ {"#ff0000", "#0000ff", 0.5, "#800080"},
68+ // Out of range is clamped rather than extrapolated into nonsense.
69+ {"#000000", "#ffffff", 2, "#ffffff"},
70+ {"#000000", "#ffffff", -1, "#000000"},
71+ }
72+ for _, c := range cases {
73+ if got := blendHex(c.a, c.b, c.t); got != c.want {
74+ t.Errorf("blendHex(%s, %s, %v) = %s, want %s", c.a, c.b, c.t, got, c.want)
75+ }
76+ }
77+ }
78+
79+ // An unparseable colour must degrade to one of the endpoints. lipgloss renders
80+ // a malformed colour as no colour at all, which would show up as a hole in the
81+ // animation rather than as anything anyone could debug.
82+ func TestBlendHexSurvivesAColourItCannotParse(t *testing.T) {
83+ for _, c := range []struct{ a, b string }{
84+ {"not-a-colour", "#ffffff"},
85+ {"#000000", "13"},
86+ {"", ""},
87+ } {
88+ if got := blendHex(c.a, c.b, 0.2); got != c.a {
89+ t.Errorf("blendHex(%q, %q, 0.2) = %q, want %q", c.a, c.b, got, c.a)
90+ }
91+ if got := blendHex(c.a, c.b, 0.8); got != c.b {
92+ t.Errorf("blendHex(%q, %q, 0.8) = %q, want %q", c.a, c.b, got, c.b)
93+ }
94+ }
95+ }
96+
97+ // progress with no frames to run is already finished. That is the single
98+ // branch every animation's reduced-motion path goes through.
99+ func TestProgressWithNoBudgetIsComplete(t *testing.T) {
100+ if got := progress(0, 0); got != 1 {
101+ t.Errorf("progress(0, 0) = %v, want 1", got)
102+ }
103+ if got := progress(5, 10); got != 0.5 {
104+ t.Errorf("progress(5, 10) = %v, want 0.5", got)
105+ }
106+ if got := progress(99, 10); got != 1 {
107+ t.Errorf("progress past the end = %v, want 1", got)
108+ }
109+ }
110+
111+ func TestEaseHitsItsEndpoints(t *testing.T) {
112+ for _, f := range []func(float64) float64{ease, easeOut} {
113+ if got := f(0); got != 0 {
114+ t.Errorf("ease(0) = %v, want 0", got)
115+ }
116+ if got := f(1); got != 1 {
117+ t.Errorf("ease(1) = %v, want 1", got)
118+ }
119+ }
120+ // Smoothstep is symmetric about the middle; easeOut is not, and is ahead
121+ // of linear there — which is what "decelerates" means.
122+ if got := ease(0.5); got != 0.5 {
123+ t.Errorf("ease(0.5) = %v, want 0.5", got)
124+ }
125+ if easeOut(0.5) <= 0.5 {
126+ t.Errorf("easeOut(0.5) = %v, want more than linear", easeOut(0.5))
127+ }
128+ }
129+
130+ // The triangle wave has to actually reach both ends, or a breathing cursor
131+ // either never lights up or never dims.
132+ func TestTriangleReachesBothEnds(t *testing.T) {
133+ var lo, hi float64 = 1, 0
134+ for f := 0; f < breathPeriod; f++ {
135+ v := triangle(f, breathPeriod)
136+ if v < 0 || v > 1 {
137+ t.Fatalf("triangle(%d) = %v, outside [0,1]", f, v)
138+ }
139+ lo, hi = minFloat(lo, v), maxFloat(hi, v)
140+ }
141+ if lo > 0.01 {
142+ t.Errorf("the wave never dims below %v", lo)
143+ }
144+ if hi < 0.99 {
145+ t.Errorf("the wave never brightens past %v", hi)
146+ }
147+ }
148+
149+ func TestSweepFillsLeftToRight(t *testing.T) {
150+ forceColour(t)
151+ for _, tc := range []struct{ t, wantFilled float64 }{{0, 0}, {0.5, 10}, {1, 20}} {
152+ got := sweep(20, tc.t, colCoral)
153+ if w := lipgloss.Width(got); w != 20 {
154+ t.Errorf("t=%v: the bar is %d cells, want 20", tc.t, w)
155+ }
156+ if n := strings.Count(stripANSI(got), "━"); float64(n) != tc.wantFilled {
157+ t.Errorf("t=%v: %d cells filled, want %v", tc.t, n, tc.wantFilled)
158+ }
159+ }
160+ }
161+
162+ // reveal is the typewriter. It counts cells, so a multi-byte character is
163+ // either drawn or not drawn, never halved.
164+ func TestRevealCountsCellsNotBytes(t *testing.T) {
165+ const s = "héllo wörld"
166+ for n := 0; n <= lipgloss.Width(s); n++ {
167+ got := reveal(s, n)
168+ if w := lipgloss.Width(got); w != n {
169+ t.Errorf("reveal(%d) is %d cells: %q", n, w, got)
170+ }
171+ }
172+ if got := reveal(s, 99); got != s {
173+ t.Errorf("revealing past the end gave %q", got)
174+ }
175+ }
176+
177+ // ignite has to finish: at t=1 every letter is fully in the target colour, or
178+ // the title settles a shade off and stays there.
179+ func TestIgniteFinishesEveryLetter(t *testing.T) {
180+ forceColour(t)
181+
182+ // At t=1 every letter must be painted the target colour exactly: the same
183+ // sequence lipgloss emits for a plain render in it.
184+ lit := ignite("harness", colFaint, colCoral, 1, false)
185+ want := termenv.TrueColor.Color(colCoral.Dark).Sequence(false)
186+ if n := strings.Count(lit, want); n != len("harness") {
187+ t.Errorf("%d of %d letters reached the target colour at t=1", n, len("harness"))
188+ }
189+
190+ if got := stripANSI(lit); got != "harness" {
191+ t.Errorf("ignite changed the text to %q", got)
192+ }
193+ if got := stripANSI(ignite("harness", colFaint, colCoral, 0, false)); got != "harness" {
194+ t.Errorf("at t=0 the text is %q, want it present but unlit", got)
195+ }
196+ }
197+
198+ // stripANSI removes escape sequences, for assertions about what was written
199+ // rather than how it was painted.
200+ func stripANSI(s string) string {
201+ var out strings.Builder
202+ for i := 0; i < len(s); {
203+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
204+ j := i + 2
205+ for j < len(s) && s[j] != 'm' {
206+ j++
207+ }
208+ i = j + 1
209+ continue
210+ }
211+ out.WriteByte(s[i])
212+ i++
213+ }
214+ return out.String()
215+ }
216+
217+ func minFloat(a, b float64) float64 {
218+ if a < b {
219+ return a
220+ }
221+ return b
222+ }
223+
224+ func maxFloat(a, b float64) float64 {
225+ if a > b {
226+ return a
227+ }
228+ return b
229+ }
+4−59internal/tui/format.goView File
@@ -5,7 +5,6 @@ import (
55 "net/url"
66 "os"
77 "strings"
8- "unicode"
98
109 "harness/internal/llm"
1110 )
@@ -21,43 +20,10 @@ func human(n int) string {
2120 return fmt.Sprint(n)
2221 }
2322
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) }
6127
6228 // endpointLabel names where the prompts are going. The default endpoint gets
6329 // a short name; anything else shows its host, because "somewhere that is not
@@ -89,27 +55,6 @@ func camel(name string) string {
8955 return strings.Join(parts, "")
9056 }
9157
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-
11358 func maxInt(a, b int) int {
11459 if a > b {
11560 return a
+100−4internal/tui/model.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "harness/internal/agent"
1818 "harness/internal/approval"
19+ "harness/internal/config"
1920 "harness/internal/llm"
2021 "harness/internal/skills"
2122 )
@@ -45,6 +46,15 @@ type Options struct {
4546 // Skills are the instruction sets discovered at startup, used to resolve
4647 // /<slug> and to answer /skills.
4748 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
4858 }
4959
5060 type phase int
@@ -100,6 +110,10 @@ type model struct {
100110 // say so while the agent unwinds.
101111 interrupting bool
102112
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+
103117 quitting bool
104118 err error
105119 }
@@ -124,6 +138,11 @@ var verbs = []string{
124138 // tests drive the same wiring the program does; a hand-built model with a
125139 // zero-valued textarea panics on the first resize.
126140 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+
127146 ta := textarea.New()
128147 ta.Placeholder = "ask for a change, or /help"
129148 ta.Prompt = "› "
@@ -185,7 +204,57 @@ func Run(ctx context.Context, opts Options) error {
185204 return nil
186205 }
187206
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+ }
189258
190259 // runTurn drives one prompt on its own goroutine. Events reach the UI through
191260 // 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 {
207276 }
208277
209278 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+
210294 switch msg := msg.(type) {
211295
212296 case tea.WindowSizeMsg:
@@ -449,6 +533,13 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
449533 }
450534 return m, m.sync()
451535
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+
452543 case line == "/skills":
453544 if len(m.opts.Skills) == 0 {
454545 m.tr.addNotice("no skills found in ~/" + skills.Dirname + " or ./" + skills.Dirname)
@@ -576,7 +667,7 @@ func matches(keys []string, key string) bool {
576667
577668 // ---- view ---------------------------------------------------------------
578669
579- const helpText = `commands: /allowed /clear /help /skills /exit
670+ const helpText = `commands: /allowed /clear /help /setup /skills /exit
580671 keys: enter send · ctrl+j newline · shift+tab cycle approval mode
581672 ctrl+r toggle full tool output · pgup/pgdn scroll
582673 esc or ctrl+c interrupt a turn · ctrl+c again to exit`
@@ -588,6 +679,9 @@ func (m *model) View() string {
588679 if m.quitting {
589680 return ""
590681 }
682+ if m.wiz != nil {
683+ return m.wiz.View()
684+ }
591685
592686 // The bottom chrome changes height — the prompt box grows, the approval
593687 // box is taller than either — so the viewport is sized against what was
@@ -617,7 +711,9 @@ func (m *model) bottom() string {
617711 }
618712
619713 // 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.
621717 func (m *model) activity() string {
622718 var left string
623719 switch m.phase {
@@ -640,7 +736,7 @@ func (m *model) activity() string {
640736
641737 right := ""
642738 if m.opts.Effort != "" {
643- right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /effort")
739+ right = styDim.Render("● "+m.opts.Effort) + styFaint.Render(" · /setup")
644740 }
645741 return spread(left, right, m.width)
646742 }
+131−0internal/tui/model_test.goView File
@@ -8,6 +8,7 @@ import (
88 tea "github.com/charmbracelet/bubbletea"
99
1010 "harness/internal/approval"
11+ "harness/internal/config"
1112 "harness/internal/llm"
1213 "harness/internal/skills"
1314 )
@@ -417,3 +418,133 @@ func TestViewShowsApprovalOptionsConditionally(t *testing.T) {
417418 t.Error("opaque command offered an option that would be a lie")
418419 }
419420 }
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+ }
+47−52internal/tui/styles.goView File
@@ -8,70 +8,65 @@ package tui
88
99 import "github.com/charmbracelet/lipgloss"
1010
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.
1619 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
2730 )
2831
2932 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
3841
3942 // The transcript's grammar: every turn of events opens with a dot, and
4043 // 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
4952
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
5457
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
5962
6063 // 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
7068
7169 // The approval box is the one place the UI is allowed to shout: it is the
7270 // 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
7772 )
+203−0internal/tui/theme.goaddedView File
@@ -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+ }
+548−0internal/tui/wizard.goaddedView File
@@ -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+ }
+357−0internal/tui/wizard_test.goaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.