Both sides added a key to the config and a row to the command list, in the same three places. Nothing disagreed — /settings and /compact are unrelated — so the resolution is to keep both everywhere: setup and auto_compact side by side in Config and in sanitize, both sets of tests, both commands in the README's list. The one thing worth checking rather than merging was whether the panel can lose the new key. It cannot: settingsConfig starts from the config as loaded and overwrites only the rows that were touched, so a field it has no row for comes through whatever you do in it. That is true by construction today and easy to break later, so it now has a test, as the wizard already did for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 12 changed files with 984 additions and 29 deletions.
| @@ -171,7 +171,8 @@ compaction stops being optional, and it can be turned off altogether: see | ||
| 171 | 171 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 172 | 172 | |
| 173 | 173 | Commands: `/allowed`, `/clear`, `/compact`, `/effort`, `/exit`, `/help`, |
| 174 | - `/model`, `/mouse`, `/setup`, `/skills`, and one per discovered skill. | |
| 174 | + `/model`, `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered | |
| 175 | + skill. | |
| 175 | 176 | |
| 176 | 177 | ### The mouse |
| 177 | 178 | |
| @@ -304,8 +305,8 @@ force shows above the prompt on the right. A terminal too narrow for five | ||
| 304 | 305 | labels gets `◀ high ▶` and the same keys rather than a track with the words |
| 305 | 306 | sliced in half. |
| 306 | 307 | |
| 307 | - `/model` and `/effort` change the session. `/setup` changes what the next | |
| 308 | - session starts with — see [Setup](#setup). | |
| 308 | + `/model` and `/effort` change the session. `/settings` and `/setup` change what | |
| 309 | + the next session starts with — see [Setup](#setup). | |
| 309 | 310 | |
| 310 | 311 | `shift+tab` cycles between asking before every mutating call and accepting them |
| 311 | 312 | all, which is `-yolo` reached from the keyboard. The mode line says which is in |
| @@ -327,6 +328,10 @@ cannot. | ||
| 327 | 328 | |
| 328 | 329 | The first run on a terminal opens a full-screen wizard instead of the chat: the |
| 329 | 330 | mark assembles, the name lights up, and it asks four questions one at a time. |
| 331 | + "First run" means the config file does not say `"setup": "done"` — the marker | |
| 332 | + the wizard stamps when the last question is answered — rather than that no file | |
| 333 | + exists, so a config written by hand, or by a build with fewer questions in it, | |
| 334 | + still gets asked the ones it is missing. | |
| 330 | 335 | |
| 331 | 336 | ``` |
| 332 | 337 | harness setup step 1 of 4 |
| @@ -373,14 +378,17 @@ honoured): | ||
| 373 | 378 | "effort": "high", |
| 374 | 379 | "approvals": "ask", |
| 375 | 380 | "theme": "ember", |
| 376 | - "auto_compact": "on" | |
| 381 | + "auto_compact": "on", | |
| 382 | + "setup": "done" | |
| 377 | 383 | } |
| 378 | 384 | ``` |
| 379 | 385 | |
| 380 | - `auto_compact` is the one key the wizard does not ask about — set it by hand, or | |
| 381 | - pass `-no-auto-compact` for a single run. Set to `"off"`, the conversation is | |
| 382 | - never folded on its own: the window becomes a wall rather than a trigger, and at | |
| 383 | - `-compact-floor` the turn stops before sending a request that would not fit, | |
| 386 | + `auto_compact` is the one key neither the wizard nor `/settings` offers a row | |
| 387 | + for — set it by hand, or pass `-no-auto-compact` for a single run. Both screens | |
| 388 | + write the whole file but only touch what they asked about, so it survives them. | |
| 389 | + Set to `"off"`, the conversation is never folded on its own: the window becomes | |
| 390 | + a wall rather than a trigger, and at `-compact-floor` the turn stops before | |
| 391 | + sending a request that would not fit, | |
| 384 | 392 | naming `/compact` and `/clear` as the ways on. The prompt is refused *before* it |
| 385 | 393 | joins the history, so compacting and asking again sends it once rather than |
| 386 | 394 | twice. `/compact` keeps working throughout — the setting governs the automatic |
| @@ -408,9 +416,44 @@ model than it started. A config file that cannot be parsed is reported and then | ||
| 408 | 416 | ignored, and is never overwritten until you finish the wizard. |
| 409 | 417 | |
| 410 | 418 | `/model` and `/effort` change the running session and say "from the next turn |
| 411 | - on"; they do not write to the file. The wizard is the thing that decides what | |
| 412 | - the *next* session starts with, which is why it is a separate screen rather | |
| 413 | - than a fourth picker. | |
| 419 | + on"; they do not write to the file. The two screens that do are the wizard and | |
| 420 | + `/settings`. | |
| 421 | + | |
| 422 | + ### /settings | |
| 423 | + | |
| 424 | + `/settings` is the same four settings without the interview: one row each, | |
| 425 | + changed in place, applied as you move. | |
| 426 | + | |
| 427 | + ``` | |
| 428 | + Settings ↑↓ row · ←→ change · esc done | |
| 429 | + ❯ Model Opus 5 $5 / $25 per Mtok | |
| 430 | + Effort high default | |
| 431 | + Approvals Ask me first recommended | |
| 432 | + Theme ember | |
| 433 | + claude-opus-5 · enter to pick from anthropic | |
| 434 | + saved to ~/.config/harness/config.json | |
| 435 | + ``` | |
| 436 | + | |
| 437 | + `←→` cycles a row and applies it there and then — the theme repaints under the | |
| 438 | + panel, the way it previews in the wizard — and `esc` closes and writes the | |
| 439 | + file. There is no cancel, because there is nothing left to confirm: the session | |
| 440 | + already has every change. A panel that was only read leaves the file alone, and | |
| 441 | + so does a value cycled back to where it started. | |
| 442 | + | |
| 443 | + The rows come from the same option lists the wizard's steps are built from, so | |
| 444 | + neither screen can invent a name for a theme or a sentence about an effort | |
| 445 | + level the other does not use. The model row is the exception: its choices come | |
| 446 | + from the endpoint rather than from this build, so `enter` hands over to the | |
| 447 | + `/model` picker and lands back here once you have chosen. | |
| 448 | + | |
| 449 | + What the panel shows is the *session*, not the file — `/effort`, `shift+tab` | |
| 450 | + and `-yolo` all change a setting without the file hearing about it, and a panel | |
| 451 | + that disagreed with the status bar two lines below it would be worse than | |
| 452 | + useless. Only the rows actually touched are written back, which is what keeps a | |
| 453 | + `-yolo` run from quietly saving "accept everything" as the default for every | |
| 454 | + run after it. And the setup marker is *not* stamped here: changing a theme is | |
| 455 | + not answering the setup questions, so someone who has only ever used this panel | |
| 456 | + is still offered the wizard. | |
| 414 | 457 | |
| 415 | 458 | The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to |
| 416 | 459 | their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20, |
| @@ -70,7 +70,7 @@ func run() error { | ||
| 70 | 70 | // Saved preferences sit under the flags and over the built-in defaults. A |
| 71 | 71 | // config that cannot be read is reported and then ignored: the harness |
| 72 | 72 | // still starts, and the wizard can write a good one over it. |
| 73 | - cfg, haveConfig, cfgErr := config.Load() | |
| 73 | + cfg, _, cfgErr := config.Load() | |
| 74 | 74 | |
| 75 | 75 | // Held as a slice because the Claude Code fallback below may have to |
| 76 | 76 | // rebuild the client with one more option, and replaying the same list is |
| @@ -272,12 +272,15 @@ func run() error { | ||
| 272 | 272 | |
| 273 | 273 | Banner: banner, |
| 274 | 274 | |
| 275 | - // No config on disk is what "first run" means, and it is the only | |
| 276 | - // thing that opens the wizard unasked. A flag-only invocation is | |
| 277 | - // still a first run: the flags say what this run does, not what | |
| 278 | - // the next one should default to. | |
| 275 | + // A config that does not say the setup was finished is what | |
| 276 | + // "first run" means, and it is the only thing that opens the | |
| 277 | + // wizard unasked. A flag-only invocation is still a first run: | |
| 278 | + // the flags say what this run does, not what the next one should | |
| 279 | + // default to. A config we could not read is not a first run | |
| 280 | + // either — the wizard's answers would be saved over settings that | |
| 281 | + // are probably still in there. | |
| 279 | 282 | Config: cfg, |
| 280 | - FirstRun: !haveConfig && cfgErr == nil, | |
| 283 | + FirstRun: !cfg.Done() && cfgErr == nil, | |
| 281 | 284 | // Applying the answers is the TUI's own job; persisting them is |
| 282 | 285 | // the only part that belongs out here. |
| 283 | 286 | SaveConfig: config.Save, |
| @@ -32,9 +32,14 @@ const ( | ||
| 32 | 32 | ApprovalsAcceptAll = "accept-all" |
| 33 | 33 | ) |
| 34 | 34 | |
| 35 | - // Auto-compaction settings, as written to the file. Spelled out rather than | |
| 36 | - // stored as a bool for the same reason the approval modes are: an empty value | |
| 37 | - // has to mean "no preference", and a bool cannot tell that apart from "off". | |
| 35 | + // SetupDone is what the Setup field says once the wizard has been all the way | |
| 36 | + // through. It is a word rather than a bool for the same reason the approval | |
| 37 | + // modes are: a file that reads "setup": false looks like something failed. | |
| 38 | + const SetupDone = "done" | |
| 39 | + | |
| 40 | + // Auto-compaction settings, as written to the file. Spelled out for the same | |
| 41 | + // reason again: an empty value has to mean "no preference", and a bool cannot | |
| 42 | + // tell that apart from "off". | |
| 38 | 43 | const ( |
| 39 | 44 | AutoCompactOn = "on" |
| 40 | 45 | AutoCompactOff = "off" |
| @@ -63,12 +68,24 @@ type Config struct { | ||
| 63 | 68 | // config written before this setting existed must not quietly start |
| 64 | 69 | // stopping the agent at a full window. |
| 65 | 70 | AutoCompact string `json:"auto_compact,omitempty"` |
| 71 | + // Setup is SetupDone once the questions have been answered to the end. | |
| 72 | + // | |
| 73 | + // It is what decides whether the wizard opens by itself, in place of the | |
| 74 | + // older test of whether the file exists at all. The difference shows up on | |
| 75 | + // a config that exists but was never finished — written by hand, or by a | |
| 76 | + // build that had fewer questions in it — where "the file is there" would | |
| 77 | + // say the setup was done and this says who says so. | |
| 78 | + Setup string `json:"setup,omitempty"` | |
| 66 | 79 | } |
| 67 | 80 | |
| 68 | 81 | // AutoCompactEnabled reports whether the conversation should fold itself when |
| 69 | 82 | // the window fills. Only an explicit "off" turns it off. |
| 70 | 83 | func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff } |
| 71 | 84 | |
| 85 | + // Done reports whether the setup has been completed. It is the only thing | |
| 86 | + // that suppresses the wizard on startup. | |
| 87 | + func (c Config) Done() bool { return c.Setup == SetupDone } | |
| 88 | + | |
| 72 | 89 | // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or |
| 73 | 90 | // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset. |
| 74 | 91 | func Path() (string, error) { |
| @@ -126,6 +143,11 @@ func sanitize(c Config) Config { | ||
| 126 | 143 | if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff { |
| 127 | 144 | c.AutoCompact = "" |
| 128 | 145 | } |
| 146 | + // Anything other than the one word means the marker was not written by a | |
| 147 | + // wizard that finished, so it does not count as one. | |
| 148 | + if c.Setup != SetupDone { | |
| 149 | + c.Setup = "" | |
| 150 | + } | |
| 129 | 151 | return c |
| 130 | 152 | } |
| 131 | 153 | |
| @@ -153,6 +153,56 @@ func TestFlagsOverrideTheConfig(t *testing.T) { | ||
| 153 | 153 | } |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | + // The marker is what suppresses the wizard, so it has to survive the file | |
| 157 | + // unchanged — and a config without it has to keep reading as unfinished. | |
| 158 | + func TestSetupMarkerRoundTrips(t *testing.T) { | |
| 159 | + isolate(t) | |
| 160 | + | |
| 161 | + if (Config{Model: "claude-opus-5"}).Done() { | |
| 162 | + t.Error("a config with no marker reported the setup as done") | |
| 163 | + } | |
| 164 | + | |
| 165 | + want := Config{Model: "claude-opus-5", Theme: "mono", Setup: SetupDone} | |
| 166 | + if err := Save(want); err != nil { | |
| 167 | + t.Fatal(err) | |
| 168 | + } | |
| 169 | + got, found, err := Load() | |
| 170 | + if err != nil || !found { | |
| 171 | + t.Fatalf("Load(found=%v): %v", found, err) | |
| 172 | + } | |
| 173 | + if !got.Done() { | |
| 174 | + t.Errorf("the setup marker did not survive the round trip: %+v", got) | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + // A config that exists but was never finished is the case the marker exists | |
| 179 | + // for: the file is there, and the wizard still has questions to ask. | |
| 180 | + func TestAnUnfinishedConfigIsNotDone(t *testing.T) { | |
| 181 | + path := isolate(t) | |
| 182 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 183 | + t.Fatal(err) | |
| 184 | + } | |
| 185 | + // A marker of the wrong type — "setup": true, the obvious wrong guess for | |
| 186 | + // someone editing by hand — is not in this list: that is a parse failure | |
| 187 | + // for the whole file, which Load already reports rather than papering over. | |
| 188 | + for _, body := range []string{ | |
| 189 | + `{"version":1,"theme":"mint"}`, | |
| 190 | + `{"version":1,"theme":"mint","setup":"maybe"}`, | |
| 191 | + `{"version":1,"theme":"mint","setup":""}`, | |
| 192 | + } { | |
| 193 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 194 | + t.Fatal(err) | |
| 195 | + } | |
| 196 | + cfg, _, _ := Load() | |
| 197 | + if cfg.Done() { | |
| 198 | + t.Errorf("%s reported the setup as done", body) | |
| 199 | + } | |
| 200 | + if cfg.Theme != "mint" { | |
| 201 | + t.Errorf("%s lost the settings it did have: %+v", body, cfg) | |
| 202 | + } | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 156 | 206 | func TestAutoCompactRoundTripsAndSanitizes(t *testing.T) { |
| 157 | 207 | isolate(t) |
| 158 | 208 | for _, v := range []string{AutoCompactOn, AutoCompactOff} { |
| @@ -25,6 +25,7 @@ var builtins = []command{ | ||
| 25 | 25 | {name: "skills", desc: "the instruction sets found at startup"}, |
| 26 | 26 | {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, |
| 27 | 27 | {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, |
| 28 | + {name: "settings", desc: "change the saved defaults, a row at a time"}, | |
| 28 | 29 | {name: "setup", desc: "the full-screen wizard for the saved defaults"}, |
| 29 | 30 | {name: "compact", desc: "fold the conversation into a summary now"}, |
| 30 | 31 | {name: "clear", desc: "empty the transcript"}, |
| @@ -70,6 +70,9 @@ const ( | ||
| 70 | 70 | // phaseModel is the model picker, which starts on a wait: the list comes |
| 71 | 71 | // from the endpoint. |
| 72 | 72 | phaseModel |
| 73 | + // phaseSettings is the settings panel, where the saved defaults are | |
| 74 | + // changed in place. | |
| 75 | + phaseSettings | |
| 73 | 76 | ) |
| 74 | 77 | |
| 75 | 78 | // inputMaxLines caps how far the prompt box grows before it scrolls. Past |
| @@ -127,6 +130,17 @@ type model struct { | ||
| 127 | 130 | // effortIdx is where the marker sits while the effort slider is up. |
| 128 | 131 | effortIdx int |
| 129 | 132 | |
| 133 | + // The settings panel. setIdx is the row under the cursor and setTouched | |
| 134 | + // is which rows were actually changed, which is what gets written back — | |
| 135 | + // see settingsConfig. setFrom records that the model picker was opened | |
| 136 | + // from the panel, so it knows to return there, and setModelWas is the | |
| 137 | + // model the panel opened on, which is how a trip through the picker is | |
| 138 | + // noticed without the two having to agree on an order. | |
| 139 | + setIdx settingRow | |
| 140 | + setTouched [numSettingRows]bool | |
| 141 | + setFrom bool | |
| 142 | + setModelWas string | |
| 143 | + | |
| 130 | 144 | // The model picker: what the endpoint offered, where the cursor is, and |
| 131 | 145 | // whether we are still waiting to be told. |
| 132 | 146 | models []llm.Model |
| @@ -540,6 +554,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 540 | 554 | if m.phase == phaseModel { |
| 541 | 555 | return m.onModelKey(msg) |
| 542 | 556 | } |
| 557 | + if m.phase == phaseSettings { | |
| 558 | + return m.onSettingsKey(msg) | |
| 559 | + } | |
| 543 | 560 | |
| 544 | 561 | // An open menu owns the keys that drive it — including enter and esc, |
| 545 | 562 | // which mean something else the rest of the time. |
| @@ -719,6 +736,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 719 | 736 | } |
| 720 | 737 | return m, m.sync() |
| 721 | 738 | |
| 739 | + case line == "/settings": | |
| 740 | + return m, m.openSettings() | |
| 741 | + | |
| 722 | 742 | case line == "/setup": |
| 723 | 743 | if m.phase == phaseRunning { |
| 724 | 744 | m.tr.addError("/setup would change the model mid-turn — interrupt first") |
| @@ -51,16 +51,16 @@ func (m *model) onModels(msg modelsMsg) tea.Cmd { | ||
| 51 | 51 | return nil |
| 52 | 52 | } |
| 53 | 53 | m.modelsLoading = false |
| 54 | - m.phase = phaseIdle | |
| 55 | 54 | |
| 56 | 55 | switch { |
| 57 | 56 | case msg.err != nil: |
| 57 | + m.phase = m.backFromPicker() | |
| 58 | 58 | m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " + |
| 59 | 59 | msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works") |
| 60 | 60 | case len(msg.models) == 0: |
| 61 | + m.phase = m.backFromPicker() | |
| 61 | 62 | m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works") |
| 62 | 63 | default: |
| 63 | - m.phase = phaseModel | |
| 64 | 64 | m.models = msg.models |
| 65 | 65 | m.modelIdx = modelIndex(msg.models, m.opts.Client.Model()) |
| 66 | 66 | } |
| @@ -83,7 +83,7 @@ func modelIndex(models []llm.Model, current string) int { | ||
| 83 | 83 | func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
| 84 | 84 | key := msg.String() |
| 85 | 85 | if key == "esc" || key == "ctrl+c" { |
| 86 | - m.phase = phaseIdle | |
| 86 | + m.phase = m.backFromPicker() | |
| 87 | 87 | m.models, m.modelsLoading = nil, false |
| 88 | 88 | return m, tea.Batch(m.sync(), textarea.Blink) |
| 89 | 89 | } |
| @@ -103,7 +103,7 @@ func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 103 | 103 | m.modelIdx = last |
| 104 | 104 | case "enter", "tab": |
| 105 | 105 | id := m.models[m.modelIdx].ID |
| 106 | - m.phase = phaseIdle | |
| 106 | + m.phase = m.backFromPicker() | |
| 107 | 107 | m.models = nil |
| 108 | 108 | m.applyModel(id) |
| 109 | 109 | return m, tea.Batch(m.sync(), textarea.Blink) |
| @@ -36,6 +36,7 @@ const ( | ||
| 36 | 36 | secApproval |
| 37 | 37 | secEffort |
| 38 | 38 | secModel |
| 39 | + secSettings | |
| 39 | 40 | ) |
| 40 | 41 | |
| 41 | 42 | // section is one piece of the bottom chrome and what it rendered to. The |
| @@ -58,6 +59,8 @@ func (m *model) layout() []section { | ||
| 58 | 59 | secs = append(secs, section{secEffort, m.effortView()}) |
| 59 | 60 | case m.phase == phaseModel: |
| 60 | 61 | secs = append(secs, section{secModel, m.modelView()}) |
| 62 | + case m.phase == phaseSettings: | |
| 63 | + secs = append(secs, section{secSettings, m.settingsView()}) | |
| 61 | 64 | default: |
| 62 | 65 | secs = append(secs, section{secInput, m.inputBox()}) |
| 63 | 66 | if v := m.menuView(); v != "" { |
| @@ -255,6 +258,12 @@ func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd { | ||
| 255 | 258 | case secEffort: |
| 256 | 259 | m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels)) |
| 257 | 260 | return nil |
| 261 | + case secSettings: | |
| 262 | + // The wheel moves between rows rather than changing a value: a roll | |
| 263 | + // that silently turned approvals off would be the worst possible | |
| 264 | + // thing for it to do. | |
| 265 | + m.setIdx = settingRow(clampIdx(int(m.setIdx)+step(up), int(numSettingRows))) | |
| 266 | + return nil | |
| 258 | 267 | } |
| 259 | 268 | var cmd tea.Cmd |
| 260 | 269 | m.vp, cmd = m.vp.Update(msg) |
| @@ -284,7 +293,7 @@ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd) | ||
| 284 | 293 | if i := start + row - modelHeadRows; row >= modelHeadRows && i < end { |
| 285 | 294 | m.modelIdx = i |
| 286 | 295 | id := m.models[i].ID |
| 287 | - m.phase, m.models = phaseIdle, nil | |
| 296 | + m.phase, m.models = m.backFromPicker(), nil | |
| 288 | 297 | m.applyModel(id) |
| 289 | 298 | return m, tea.Batch(m.sync(), textarea.Blink) |
| 290 | 299 | } |
| @@ -312,6 +321,21 @@ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd) | ||
| 312 | 321 | } |
| 313 | 322 | return m, nil |
| 314 | 323 | |
| 324 | + case secSettings: | |
| 325 | + // A click on a row is a choice, like everywhere else in the chrome: | |
| 326 | + // it moves the cursor there and does what enter would have done. | |
| 327 | + i := row - settingsHeadRows | |
| 328 | + if i < 0 || i >= int(numSettingRows) { | |
| 329 | + return m, nil | |
| 330 | + } | |
| 331 | + m.setIdx = settingRow(i) | |
| 332 | + if m.setIdx == setModelRow { | |
| 333 | + m.setFrom = true | |
| 334 | + return m, m.openModelPicker() | |
| 335 | + } | |
| 336 | + m.cycleSetting(m.setIdx, 1) | |
| 337 | + return m, m.sync() | |
| 338 | + | |
| 315 | 339 | case secMode: |
| 316 | 340 | return m, m.cycleMode() |
| 317 | 341 | } |
| @@ -0,0 +1,375 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "strings" | |
| 5 | + | |
| 6 | + "github.com/charmbracelet/bubbles/textarea" | |
| 7 | + tea "github.com/charmbracelet/bubbletea" | |
| 8 | + "github.com/charmbracelet/lipgloss" | |
| 9 | + | |
| 10 | + "harness/internal/config" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + // The settings panel: /settings. One row per thing the config file holds, | |
| 14 | + // changed in place and applied as you move, which is what separates it from | |
| 15 | + // the setup wizard. The wizard is an interview — it asks every question once, | |
| 16 | + // in order, and is how the file comes to exist. This is the amendment: open | |
| 17 | + // it, change the one thing, leave. | |
| 18 | + // | |
| 19 | + // Everything it offers comes from the same option lists the wizard's steps are | |
| 20 | + // built from, so the two screens cannot come to disagree about what a theme is | |
| 21 | + // called or what an effort level means. | |
| 22 | + | |
| 23 | + type settingRow int | |
| 24 | + | |
| 25 | + const ( | |
| 26 | + setModelRow settingRow = iota | |
| 27 | + setEffortRow | |
| 28 | + setApprovalsRow | |
| 29 | + setThemeRow | |
| 30 | + numSettingRows | |
| 31 | + ) | |
| 32 | + | |
| 33 | + var settingLabels = [numSettingRows]string{"Model", "Effort", "Approvals", "Theme"} | |
| 34 | + | |
| 35 | + // settingsHeadRows is how many rows sit above the list: the heading, which | |
| 36 | + // carries the keys on its right. | |
| 37 | + const settingsHeadRows = 1 | |
| 38 | + | |
| 39 | + // openSettings puts the panel up, on the row most likely to be wanted. | |
| 40 | + func (m *model) openSettings() tea.Cmd { | |
| 41 | + m.phase = phaseSettings | |
| 42 | + m.setIdx = setModelRow | |
| 43 | + m.setTouched = [numSettingRows]bool{} | |
| 44 | + m.setModelWas = m.modelInForce() | |
| 45 | + // A theme change repaints everything, including whatever was marked. | |
| 46 | + m.clearSelection() | |
| 47 | + m.closeMenu() | |
| 48 | + return m.sync() | |
| 49 | + } | |
| 50 | + | |
| 51 | + // onSettingsKey drives the panel. Like the other full-width pieces of chrome | |
| 52 | + // it answers every key while it is up: there is no prompt behind it to type | |
| 53 | + // into, so nothing falls through. | |
| 54 | + func (m *model) onSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 55 | + switch key := msg.String(); key { | |
| 56 | + case "esc", "ctrl+c": | |
| 57 | + return m, m.closeSettings() | |
| 58 | + | |
| 59 | + case "up", "k", "ctrl+p", "shift+tab": | |
| 60 | + m.moveSettingRow(-1) | |
| 61 | + | |
| 62 | + case "down", "j", "ctrl+n", "tab": | |
| 63 | + m.moveSettingRow(1) | |
| 64 | + | |
| 65 | + case "left", "h": | |
| 66 | + m.cycleSetting(m.setIdx, -1) | |
| 67 | + | |
| 68 | + case "right", "l", " ": | |
| 69 | + m.cycleSetting(m.setIdx, 1) | |
| 70 | + | |
| 71 | + case "enter": | |
| 72 | + // The model row is the one setting whose choices this build does not | |
| 73 | + // know: they come from whatever the endpoint serves. So enter hands | |
| 74 | + // over to the picker that asks it, rather than cycling a list that | |
| 75 | + // could not name the model a gateway is actually running. | |
| 76 | + if m.setIdx == setModelRow { | |
| 77 | + m.setFrom = true | |
| 78 | + return m, m.openModelPicker() | |
| 79 | + } | |
| 80 | + m.cycleSetting(m.setIdx, 1) | |
| 81 | + | |
| 82 | + default: | |
| 83 | + // A digit jumps to a row, the way it picks a model or an effort level | |
| 84 | + // in the other two panels. | |
| 85 | + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { | |
| 86 | + if i := int(key[0] - '1'); i < int(numSettingRows) { | |
| 87 | + m.setIdx = settingRow(i) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + } | |
| 91 | + return m, m.sync() | |
| 92 | + } | |
| 93 | + | |
| 94 | + // moveSettingRow walks the list. It stops at the ends rather than wrapping: | |
| 95 | + // the rows are a list to read, not a dial, and the values wrap already. | |
| 96 | + func (m *model) moveSettingRow(d int) { | |
| 97 | + m.setIdx = settingRow(clampIdx(int(m.setIdx)+d, int(numSettingRows))) | |
| 98 | + } | |
| 99 | + | |
| 100 | + // closeSettings puts the prompt back and saves, if there is anything to save. | |
| 101 | + // | |
| 102 | + // There is no cancel: every change has already been applied to the running | |
| 103 | + // session as it was made, which is the point of a panel you watch the theme | |
| 104 | + // change under. Leaving is therefore only ever about the file. | |
| 105 | + func (m *model) closeSettings() tea.Cmd { | |
| 106 | + m.phase = phaseIdle | |
| 107 | + m.input.Focus() | |
| 108 | + themed := m.setTouched[setThemeRow] | |
| 109 | + cfg := m.settingsConfig() | |
| 110 | + m.setTouched = [numSettingRows]bool{} | |
| 111 | + | |
| 112 | + if cfg == m.opts.Config { | |
| 113 | + // Nothing was changed, or it was changed back. Leaving must not | |
| 114 | + // rewrite the file, and it is not worth a line of scrollback either. | |
| 115 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 116 | + } | |
| 117 | + m.opts.Config = cfg | |
| 118 | + | |
| 119 | + // The setup marker is deliberately not stamped here. It means "the | |
| 120 | + // questions were all asked and answered", and this panel does not ask | |
| 121 | + // them: someone who has only ever changed their theme has not been | |
| 122 | + // through setup, and should still be offered it. | |
| 123 | + note := "settings saved: " + settingsSummary(cfg) | |
| 124 | + if m.opts.SaveConfig != nil { | |
| 125 | + if err := m.opts.SaveConfig(cfg); err != nil { | |
| 126 | + // The settings are already in force; only the file failed. | |
| 127 | + note = "settings: " + settingsSummary(cfg) + | |
| 128 | + "\nthese are in force for this session, but saving them failed: " + err.Error() | |
| 129 | + } | |
| 130 | + } | |
| 131 | + m.tr.addNotice(note) | |
| 132 | + if themed { | |
| 133 | + // Every entry was laid out in the old palette. | |
| 134 | + m.refreshSplash() | |
| 135 | + } | |
| 136 | + return tea.Batch(m.sync(), textarea.Blink) | |
| 137 | + } | |
| 138 | + | |
| 139 | + // settingsConfig is the config as the panel would save it: what was loaded, | |
| 140 | + // with the rows that were actually touched replaced by what is now in force. | |
| 141 | + // | |
| 142 | + // Only the touched rows, rather than all four, because the panel shows the | |
| 143 | + // session and the session is not always the file. -yolo turns accept-all on | |
| 144 | + // for one run; a panel that read it back off the policy would quietly write | |
| 145 | + // "run everything without asking" into the config of someone who asked for it | |
| 146 | + // once, on the command line, for one session. | |
| 147 | + func (m *model) settingsConfig() config.Config { | |
| 148 | + cfg := m.opts.Config | |
| 149 | + for r := settingRow(0); r < numSettingRows; r++ { | |
| 150 | + if !m.settingTouched(r) { | |
| 151 | + continue | |
| 152 | + } | |
| 153 | + v := m.settingValue(r) | |
| 154 | + switch r { | |
| 155 | + case setModelRow: | |
| 156 | + cfg.Model = v | |
| 157 | + case setEffortRow: | |
| 158 | + cfg.Effort = v | |
| 159 | + case setApprovalsRow: | |
| 160 | + cfg.Approvals = v | |
| 161 | + case setThemeRow: | |
| 162 | + cfg.Theme = v | |
| 163 | + } | |
| 164 | + } | |
| 165 | + return cfg | |
| 166 | + } | |
| 167 | + | |
| 168 | + func settingsSummary(cfg config.Config) string { | |
| 169 | + return prettyModel(cfg.Model) + " · effort " + config.Or(cfg.Effort, defaultEffort) + | |
| 170 | + " · approvals " + config.Or(cfg.Approvals, config.ApprovalsAsk) + | |
| 171 | + " · " + config.Or(cfg.Theme, currentTheme.name) + " theme" | |
| 172 | + } | |
| 173 | + | |
| 174 | + // settingTouched reports whether a row was changed while the panel has been | |
| 175 | + // open, which is the only thing that gets written back. | |
| 176 | + func (m *model) settingTouched(r settingRow) bool { | |
| 177 | + if r == setModelRow { | |
| 178 | + // The model is changed through the picker the panel hands over to, so | |
| 179 | + // there is no keystroke here to have recorded. What it was when the | |
| 180 | + // panel opened is the whole test, and asking at this point rather than | |
| 181 | + // as the picker closes keeps it out of the order those two do their | |
| 182 | + // work in. | |
| 183 | + return m.modelInForce() != m.setModelWas | |
| 184 | + } | |
| 185 | + return m.setTouched[r] | |
| 186 | + } | |
| 187 | + | |
| 188 | + // settingValue is what a row is set to right now. It reads the session rather | |
| 189 | + // than the config, because the session is what the panel is showing: /effort, | |
| 190 | + // shift+tab and -yolo all change a setting without the file hearing about it, | |
| 191 | + // and a panel that disagreed with the status bar would be worse than useless. | |
| 192 | + func (m *model) settingValue(r settingRow) string { | |
| 193 | + switch r { | |
| 194 | + case setModelRow: | |
| 195 | + return m.modelInForce() | |
| 196 | + case setEffortRow: | |
| 197 | + if m.opts.Effort == "" { | |
| 198 | + return defaultEffort | |
| 199 | + } | |
| 200 | + return m.opts.Effort | |
| 201 | + case setApprovalsRow: | |
| 202 | + if m.opts.Policy != nil && m.opts.Policy.YOLO { | |
| 203 | + return config.ApprovalsAcceptAll | |
| 204 | + } | |
| 205 | + return config.ApprovalsAsk | |
| 206 | + case setThemeRow: | |
| 207 | + return currentTheme.name | |
| 208 | + } | |
| 209 | + return "" | |
| 210 | + } | |
| 211 | + | |
| 212 | + func (m *model) modelInForce() string { | |
| 213 | + if m.opts.Client == nil { | |
| 214 | + return "" | |
| 215 | + } | |
| 216 | + return m.opts.Client.Model() | |
| 217 | + } | |
| 218 | + | |
| 219 | + // settingOptions is what a row cycles through. The model row has none: its | |
| 220 | + // choices come from the endpoint, through the picker. | |
| 221 | + func settingOptions(r settingRow) []wizOption { | |
| 222 | + switch r { | |
| 223 | + case setEffortRow: | |
| 224 | + return effortOptions() | |
| 225 | + case setApprovalsRow: | |
| 226 | + return approvalOptions() | |
| 227 | + case setThemeRow: | |
| 228 | + return themeOptions() | |
| 229 | + } | |
| 230 | + return nil | |
| 231 | + } | |
| 232 | + | |
| 233 | + // cycleSetting moves a row to its next value and applies it there and then. | |
| 234 | + // It wraps: these are short lists, and a row that stops at one end reads as a | |
| 235 | + // row that has stopped working. | |
| 236 | + func (m *model) cycleSetting(r settingRow, d int) { | |
| 237 | + opts := settingOptions(r) | |
| 238 | + if len(opts) == 0 { | |
| 239 | + return | |
| 240 | + } | |
| 241 | + i := (optionIndex(opts, m.settingValue(r)) + d + len(opts)) % len(opts) | |
| 242 | + m.useSetting(r, opts[i].value) | |
| 243 | + } | |
| 244 | + | |
| 245 | + // useSetting applies a value to the running session. It goes through the same | |
| 246 | + // setters /effort, shift+tab and the wizard use, so a setting changed here and | |
| 247 | + // the same setting changed from the prompt cannot come out different. | |
| 248 | + // | |
| 249 | + // The quiet setters are the ones used, not the announcing ones: the panel is | |
| 250 | + // on screen showing the value, and cycling past four themes should not cost | |
| 251 | + // four lines of transcript. | |
| 252 | + func (m *model) useSetting(r settingRow, v string) { | |
| 253 | + switch r { | |
| 254 | + case setEffortRow: | |
| 255 | + m.useEffort(v) | |
| 256 | + case setApprovalsRow: | |
| 257 | + if m.opts.Policy != nil { | |
| 258 | + m.opts.Policy.YOLO = v == config.ApprovalsAcceptAll | |
| 259 | + } | |
| 260 | + case setThemeRow: | |
| 261 | + applyTheme(v) | |
| 262 | + } | |
| 263 | + m.setTouched[r] = true | |
| 264 | + } | |
| 265 | + | |
| 266 | + // backFromPicker is where the model picker goes when it is done: back to the | |
| 267 | + // settings panel when that is what opened it, and to the prompt otherwise. | |
| 268 | + func (m *model) backFromPicker() phase { | |
| 269 | + if !m.setFrom { | |
| 270 | + return phaseIdle | |
| 271 | + } | |
| 272 | + m.setFrom = false | |
| 273 | + return phaseSettings | |
| 274 | + } | |
| 275 | + | |
| 276 | + // ---- view --------------------------------------------------------------- | |
| 277 | + | |
| 278 | + // settingsView is the panel: a heading with the keys, a row per setting, a | |
| 279 | + // sentence about the row under the cursor, and where all of it is saved. | |
| 280 | + // | |
| 281 | + // Settings ↑↓ row · ←→ change · esc done | |
| 282 | + // ❯ Model Claude Sonnet 5 $3 / $15 per Mtok | |
| 283 | + // Effort high default | |
| 284 | + // Approvals Ask me first recommended | |
| 285 | + // Theme ember | |
| 286 | + // the model every turn is sent to · enter to pick from api.anthropic.com | |
| 287 | + // saved to ~/.config/harness/config.json | |
| 288 | + func (m *model) settingsView() string { | |
| 289 | + width := maxInt(1, m.width) | |
| 290 | + | |
| 291 | + var b strings.Builder | |
| 292 | + b.WriteString(spread(" "+styHeading.Render("Settings"), | |
| 293 | + styFaint.Render("↑↓ row · ←→ change · esc done"), width)) | |
| 294 | + | |
| 295 | + // Wide enough for the longest label and a gap after it, so the values line | |
| 296 | + // up in a column of their own rather than against the widest word. | |
| 297 | + const labelCol = 12 | |
| 298 | + valueCol := 0 | |
| 299 | + values := make([]string, numSettingRows) | |
| 300 | + for r := settingRow(0); r < numSettingRows; r++ { | |
| 301 | + values[r] = m.settingLabel(r) | |
| 302 | + valueCol = maxInt(valueCol, lipgloss.Width(values[r])) | |
| 303 | + } | |
| 304 | + valueCol = minInt(valueCol, maxInt(10, width/3)) | |
| 305 | + | |
| 306 | + for r := settingRow(0); r < numSettingRows; r++ { | |
| 307 | + marker, style := " ", styDim | |
| 308 | + if r == m.setIdx { | |
| 309 | + marker, style = styKey.Render("❯ "), styKey | |
| 310 | + } | |
| 311 | + line := marker + styFaint.Render(pad(settingLabels[r], labelCol)) + | |
| 312 | + style.Render(pad(values[r], valueCol)) + " " + styFaint.Render(m.settingNote(r)) | |
| 313 | + b.WriteString("\n" + truncateStyled(" "+line, width)) | |
| 314 | + } | |
| 315 | + | |
| 316 | + b.WriteString("\n" + truncateStyled(" "+styDim.Render(m.settingDetail(m.setIdx)), width)) | |
| 317 | + // Where it lands is on screen because "where did that go?" is the first | |
| 318 | + // question a panel that saves by itself has to answer. It is the same | |
| 319 | + // label the wizard signs off with. | |
| 320 | + b.WriteString("\n" + truncateStyled(" "+styFaint.Render("saved to "+configPathLabel()), width)) | |
| 321 | + return b.String() | |
| 322 | + } | |
| 323 | + | |
| 324 | + // settingLabel is how a row's value is spelled on screen: the option's own | |
| 325 | + // label where there is one, so the panel and the wizard say the same words. | |
| 326 | + func (m *model) settingLabel(r settingRow) string { | |
| 327 | + v := m.settingValue(r) | |
| 328 | + if r == setModelRow { | |
| 329 | + return prettyModel(v) | |
| 330 | + } | |
| 331 | + for _, o := range settingOptions(r) { | |
| 332 | + if o.value == v { | |
| 333 | + return o.label | |
| 334 | + } | |
| 335 | + } | |
| 336 | + return v | |
| 337 | + } | |
| 338 | + | |
| 339 | + // settingNote is the short fact in the right-hand column — a price, which | |
| 340 | + // level the API would have picked anyway — kept to the width of a glance. | |
| 341 | + func (m *model) settingNote(r settingRow) string { | |
| 342 | + if r == setModelRow { | |
| 343 | + return priceLabel(m.settingValue(r)) | |
| 344 | + } | |
| 345 | + v := m.settingValue(r) | |
| 346 | + for _, o := range settingOptions(r) { | |
| 347 | + if o.value == v { | |
| 348 | + return o.note | |
| 349 | + } | |
| 350 | + } | |
| 351 | + return "" | |
| 352 | + } | |
| 353 | + | |
| 354 | + // settingDetail is the sentence about the row under the cursor. It is one line | |
| 355 | + // for the whole panel rather than one per row, so the list stays scannable — | |
| 356 | + // the same trade the wizard makes. | |
| 357 | + func (m *model) settingDetail(r settingRow) string { | |
| 358 | + if r == setModelRow { | |
| 359 | + detail := m.settingValue(r) | |
| 360 | + if detail == "" { | |
| 361 | + detail = "no model set" | |
| 362 | + } | |
| 363 | + if m.opts.Client != nil { | |
| 364 | + detail += " · enter to pick from " + endpointLabel(m.opts.Client) | |
| 365 | + } | |
| 366 | + return detail | |
| 367 | + } | |
| 368 | + v := m.settingValue(r) | |
| 369 | + for _, o := range settingOptions(r) { | |
| 370 | + if o.value == v { | |
| 371 | + return o.desc | |
| 372 | + } | |
| 373 | + } | |
| 374 | + return "" | |
| 375 | + } | |
| @@ -0,0 +1,375 @@ | ||
| 1 | + package tui | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + tea "github.com/charmbracelet/bubbletea" | |
| 10 | + "github.com/charmbracelet/lipgloss" | |
| 11 | + | |
| 12 | + "harness/internal/approval" | |
| 13 | + "harness/internal/config" | |
| 14 | + "harness/internal/llm" | |
| 15 | + ) | |
| 16 | + | |
| 17 | + // newSettingsModel builds a chat model with a config already on disk and a | |
| 18 | + // SaveConfig that records rather than writes. The returned pointer is nil | |
| 19 | + // until the panel saves something, which is what most of these tests are | |
| 20 | + // about. | |
| 21 | + func newSettingsModel(t *testing.T, cur config.Config) (*model, **config.Config) { | |
| 22 | + t.Helper() | |
| 23 | + t.Cleanup(func() { applyTheme(defaultTheme) }) | |
| 24 | + | |
| 25 | + var saved *config.Config | |
| 26 | + m := newModel(context.Background(), Options{ | |
| 27 | + Client: llm.New(llm.WithAPIKey("x")), | |
| 28 | + Root: "/tmp/repo", | |
| 29 | + Policy: approval.New(nil), | |
| 30 | + Config: cur, | |
| 31 | + SaveConfig: func(c config.Config) error { | |
| 32 | + saved = &c | |
| 33 | + return nil | |
| 34 | + }, | |
| 35 | + }) | |
| 36 | + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) | |
| 37 | + return m, &saved | |
| 38 | + } | |
| 39 | + | |
| 40 | + // open puts the panel up the way a person does. | |
| 41 | + func openSettingsPanel(t *testing.T, m *model) { | |
| 42 | + t.Helper() | |
| 43 | + m.submit("/settings") | |
| 44 | + if m.phase != phaseSettings { | |
| 45 | + t.Fatalf("/settings left the model in phase %v", m.phase) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + // The walk the panel exists for: change one thing, leave, and find it both in | |
| 50 | + // force and in the file. | |
| 51 | + func TestSettingsChangeAppliesAndSaves(t *testing.T) { | |
| 52 | + m, saved := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember", Setup: config.SetupDone}) | |
| 53 | + openSettingsPanel(t, m) | |
| 54 | + | |
| 55 | + m.Update(key("down")) // → effort | |
| 56 | + if m.setIdx != setEffortRow { | |
| 57 | + t.Fatalf("down from the model row landed on %v", m.setIdx) | |
| 58 | + } | |
| 59 | + m.Update(key("right")) | |
| 60 | + | |
| 61 | + want := m.opts.Effort | |
| 62 | + if want == "high" { | |
| 63 | + t.Fatal("cycling the effort row did not change the level in force") | |
| 64 | + } | |
| 65 | + m.Update(key("esc")) | |
| 66 | + | |
| 67 | + if m.phase != phaseIdle { | |
| 68 | + t.Fatalf("esc did not close the panel: phase %v", m.phase) | |
| 69 | + } | |
| 70 | + if *saved == nil { | |
| 71 | + t.Fatal("leaving the panel saved nothing") | |
| 72 | + } | |
| 73 | + if (*saved).Effort != want { | |
| 74 | + t.Errorf("saved effort %q, want the %q that is in force", (*saved).Effort, want) | |
| 75 | + } | |
| 76 | + if !(*saved).Done() { | |
| 77 | + t.Error("the panel dropped the setup marker that was already in the file") | |
| 78 | + } | |
| 79 | + if !strings.Contains(m.tr.render(), "settings saved") { | |
| 80 | + t.Error("the change was never reported in the transcript") | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + // A theme is applied as you move onto it, not when you leave: the whole point | |
| 85 | + // of a panel over a wizard step is watching the transcript change colour. | |
| 86 | + func TestSettingsThemeAppliesWhileYouCycle(t *testing.T) { | |
| 87 | + m, saved := newSettingsModel(t, config.Config{Theme: "ember"}) | |
| 88 | + openSettingsPanel(t, m) | |
| 89 | + | |
| 90 | + m.setIdx = setThemeRow | |
| 91 | + m.Update(key("right")) | |
| 92 | + if currentTheme.name == "ember" { | |
| 93 | + t.Fatal("cycling the theme row did not repaint") | |
| 94 | + } | |
| 95 | + previewed := currentTheme.name | |
| 96 | + | |
| 97 | + m.Update(key("esc")) | |
| 98 | + if currentTheme.name != previewed { | |
| 99 | + t.Errorf("leaving the panel undid the theme: %q, want %q", currentTheme.name, previewed) | |
| 100 | + } | |
| 101 | + if *saved == nil || (*saved).Theme != previewed { | |
| 102 | + t.Errorf("the previewed theme was not saved: %+v", *saved) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + // Browsing is not editing. Opening the panel, walking every row and leaving | |
| 107 | + // must not rewrite the file — and must not say it did. | |
| 108 | + func TestSettingsSavesNothingWhenNothingChanged(t *testing.T) { | |
| 109 | + m, saved := newSettingsModel(t, config.Config{Effort: "low", Theme: "mint", Setup: config.SetupDone}) | |
| 110 | + openSettingsPanel(t, m) | |
| 111 | + | |
| 112 | + for i := 0; i < int(numSettingRows)+2; i++ { | |
| 113 | + m.Update(key("down")) | |
| 114 | + } | |
| 115 | + for i := 0; i < int(numSettingRows)+2; i++ { | |
| 116 | + m.Update(key("up")) | |
| 117 | + } | |
| 118 | + m.Update(key("esc")) | |
| 119 | + | |
| 120 | + if *saved != nil { | |
| 121 | + t.Errorf("walking the panel rewrote the config: %+v", *saved) | |
| 122 | + } | |
| 123 | + if strings.Contains(m.tr.render(), "settings saved") { | |
| 124 | + t.Error("nothing changed, but the panel said it saved") | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + // A value cycled back to where it started is not a change either. | |
| 129 | + func TestSettingsSavesNothingWhenCycledBack(t *testing.T) { | |
| 130 | + m, saved := newSettingsModel(t, config.Config{Theme: "ember", Setup: config.SetupDone}) | |
| 131 | + openSettingsPanel(t, m) | |
| 132 | + | |
| 133 | + m.setIdx = setThemeRow | |
| 134 | + for range themeOptions() { | |
| 135 | + m.Update(key("right")) | |
| 136 | + } | |
| 137 | + if currentTheme.name != "ember" { | |
| 138 | + t.Fatalf("a full cycle did not come back to ember: %q", currentTheme.name) | |
| 139 | + } | |
| 140 | + m.Update(key("esc")) | |
| 141 | + if *saved != nil { | |
| 142 | + t.Errorf("a value cycled back to itself was still written: %+v", *saved) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + // -yolo is a decision about one session, taken on the command line. Somebody | |
| 147 | + // who opens the panel on such a session to change their theme must not find | |
| 148 | + // "run everything without asking" written into their config afterwards. | |
| 149 | + func TestSettingsDoesNotPersistAnApprovalModeItWasNotAsked(t *testing.T) { | |
| 150 | + m, saved := newSettingsModel(t, config.Config{Theme: "ember", Approvals: config.ApprovalsAsk}) | |
| 151 | + m.opts.Policy.YOLO = true // as -yolo leaves it | |
| 152 | + openSettingsPanel(t, m) | |
| 153 | + | |
| 154 | + m.setIdx = setThemeRow | |
| 155 | + m.Update(key("right")) | |
| 156 | + m.Update(key("esc")) | |
| 157 | + | |
| 158 | + if *saved == nil { | |
| 159 | + t.Fatal("the theme change was not saved") | |
| 160 | + } | |
| 161 | + if (*saved).Approvals != config.ApprovalsAsk { | |
| 162 | + t.Errorf("the session's -yolo was written to the config: %q", (*saved).Approvals) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + | |
| 166 | + // Changing approvals in the panel does take, both in the file and in the | |
| 167 | + // policy the agent actually consults. | |
| 168 | + func TestSettingsApprovalsReachThePolicy(t *testing.T) { | |
| 169 | + m, saved := newSettingsModel(t, config.Config{Approvals: config.ApprovalsAsk, Setup: config.SetupDone}) | |
| 170 | + openSettingsPanel(t, m) | |
| 171 | + | |
| 172 | + m.setIdx = setApprovalsRow | |
| 173 | + m.Update(key("right")) | |
| 174 | + if !m.opts.Policy.YOLO { | |
| 175 | + t.Fatal("cycling to accept-all did not reach the policy") | |
| 176 | + } | |
| 177 | + m.Update(key("esc")) | |
| 178 | + if *saved == nil || (*saved).Approvals != config.ApprovalsAcceptAll { | |
| 179 | + t.Errorf("accept-all was not saved: %+v", *saved) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + // The panel shows the session, not the file: /effort five minutes ago is what | |
| 184 | + // the effort row has to say, or the two disagree in front of the user. | |
| 185 | + func TestSettingsShowsWhatTheSessionIsRunning(t *testing.T) { | |
| 186 | + m, _ := newSettingsModel(t, config.Config{Effort: "low", Model: "claude-opus-5"}) | |
| 187 | + m.useEffort("max") | |
| 188 | + m.opts.Client.SetModel("claude-haiku-4-5") | |
| 189 | + openSettingsPanel(t, m) | |
| 190 | + | |
| 191 | + view := m.settingsView() | |
| 192 | + for _, want := range []string{"max", prettyModel("claude-haiku-4-5")} { | |
| 193 | + if !strings.Contains(view, want) { | |
| 194 | + t.Errorf("the panel does not report %q:\n%s", want, view) | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + // The model row is the one that cannot cycle: its choices come from the | |
| 200 | + // endpoint, so enter hands over to the picker and the picker comes back here. | |
| 201 | + func TestSettingsModelRowOpensThePickerAndReturns(t *testing.T) { | |
| 202 | + m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone}) | |
| 203 | + openSettingsPanel(t, m) | |
| 204 | + | |
| 205 | + m.Update(key("right")) // the model row does not cycle | |
| 206 | + if m.opts.Client.Model() != "claude-opus-5" { | |
| 207 | + t.Errorf("the model row cycled to %q", m.opts.Client.Model()) | |
| 208 | + } | |
| 209 | + | |
| 210 | + m.Update(key("enter")) | |
| 211 | + if m.phase != phaseModel { | |
| 212 | + t.Fatalf("enter on the model row left the model in phase %v", m.phase) | |
| 213 | + } | |
| 214 | + m.Update(modelsMsg{models: []llm.Model{ | |
| 215 | + {ID: "claude-opus-5", DisplayName: "Claude Opus 5"}, | |
| 216 | + {ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"}, | |
| 217 | + }}) | |
| 218 | + m.Update(key("down")) | |
| 219 | + m.Update(key("enter")) | |
| 220 | + | |
| 221 | + if m.phase != phaseSettings { | |
| 222 | + t.Fatalf("the picker did not come back to the panel: phase %v", m.phase) | |
| 223 | + } | |
| 224 | + if m.opts.Client.Model() != "claude-sonnet-5" { | |
| 225 | + t.Fatalf("the picked model did not take: %q", m.opts.Client.Model()) | |
| 226 | + } | |
| 227 | + m.Update(key("esc")) | |
| 228 | + if *saved == nil || (*saved).Model != "claude-sonnet-5" { | |
| 229 | + t.Errorf("the picked model was not saved: %+v", *saved) | |
| 230 | + } | |
| 231 | + } | |
| 232 | + | |
| 233 | + // Escaping the picker is not choosing a model, and must leave the panel with | |
| 234 | + // nothing to save. | |
| 235 | + func TestSettingsPickerEscapeReturnsWithoutChanging(t *testing.T) { | |
| 236 | + m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone}) | |
| 237 | + openSettingsPanel(t, m) | |
| 238 | + | |
| 239 | + m.Update(key("enter")) | |
| 240 | + m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}, {ID: "claude-sonnet-5"}}}) | |
| 241 | + m.Update(key("esc")) | |
| 242 | + if m.phase != phaseSettings { | |
| 243 | + t.Fatalf("esc in the picker did not return to the panel: phase %v", m.phase) | |
| 244 | + } | |
| 245 | + m.Update(key("esc")) | |
| 246 | + if *saved != nil { | |
| 247 | + t.Errorf("an escaped picker still wrote the config: %+v", *saved) | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + // /model opened from the prompt has nothing to do with the panel and must | |
| 252 | + // still land back at the prompt. | |
| 253 | + func TestModelPickerStillReturnsToThePrompt(t *testing.T) { | |
| 254 | + m, _ := newSettingsModel(t, config.Config{Model: "claude-opus-5"}) | |
| 255 | + m.submit("/model") | |
| 256 | + m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}}}) | |
| 257 | + m.Update(key("enter")) | |
| 258 | + if m.phase != phaseIdle { | |
| 259 | + t.Errorf("the picker returned to phase %v rather than the prompt", m.phase) | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + // The settings are already in force by the time the file is written, so a | |
| 264 | + // failed write is a warning, not a lost change. | |
| 265 | + func TestSettingsReportsAFailedSave(t *testing.T) { | |
| 266 | + m, _ := newSettingsModel(t, config.Config{Theme: "ember"}) | |
| 267 | + m.opts.SaveConfig = func(config.Config) error { return errors.New("disk is full") } | |
| 268 | + openSettingsPanel(t, m) | |
| 269 | + | |
| 270 | + m.setIdx = setThemeRow | |
| 271 | + m.Update(key("right")) | |
| 272 | + applied := currentTheme.name | |
| 273 | + m.Update(key("esc")) | |
| 274 | + | |
| 275 | + if currentTheme.name != applied { | |
| 276 | + t.Error("a failed save undid the change it failed to record") | |
| 277 | + } | |
| 278 | + if !strings.Contains(m.tr.render(), "disk is full") { | |
| 279 | + t.Errorf("a failed save was not reported:\n%s", m.tr.render()) | |
| 280 | + } | |
| 281 | + } | |
| 282 | + | |
| 283 | + // The panel is not the wizard: changing a theme is not answering the setup | |
| 284 | + // questions, so somebody who has never seen the wizard still gets it. | |
| 285 | + func TestSettingsDoesNotMarkTheSetupDone(t *testing.T) { | |
| 286 | + m, saved := newSettingsModel(t, config.Config{}) | |
| 287 | + openSettingsPanel(t, m) | |
| 288 | + | |
| 289 | + m.setIdx = setThemeRow | |
| 290 | + m.Update(key("right")) | |
| 291 | + m.Update(key("esc")) | |
| 292 | + | |
| 293 | + if *saved == nil { | |
| 294 | + t.Fatal("the theme change was not saved") | |
| 295 | + } | |
| 296 | + if (*saved).Done() { | |
| 297 | + t.Error("the settings panel claimed the setup was finished") | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + // A click on a row is a choice, the way it is everywhere else in the chrome. | |
| 302 | + func TestSettingsClickPicksTheRowUnderThePointer(t *testing.T) { | |
| 303 | + m, _ := newSettingsModel(t, config.Config{Theme: "ember"}) | |
| 304 | + openSettingsPanel(t, m) | |
| 305 | + | |
| 306 | + secs := m.layout() | |
| 307 | + top := maxInt(1, m.height-rowsOf(secs)) | |
| 308 | + // The rows start under the heading, and the panel is the second section. | |
| 309 | + row := top + lipgloss.Height(secs[0].text) + settingsHeadRows + int(setThemeRow) | |
| 310 | + m.Update(tea.MouseMsg{X: 4, Y: row, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) | |
| 311 | + | |
| 312 | + if m.setIdx != setThemeRow { | |
| 313 | + t.Fatalf("the click landed on row %v, want the theme row", m.setIdx) | |
| 314 | + } | |
| 315 | + if currentTheme.name == "ember" { | |
| 316 | + t.Error("clicking a row did not change its value") | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + // Every row has to be spelled out, or the panel is a list of blanks. | |
| 321 | + func TestSettingsViewNamesEveryRow(t *testing.T) { | |
| 322 | + m, _ := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember"}) | |
| 323 | + openSettingsPanel(t, m) | |
| 324 | + | |
| 325 | + view := m.settingsView() | |
| 326 | + for r := settingRow(0); r < numSettingRows; r++ { | |
| 327 | + if !strings.Contains(view, settingLabels[r]) { | |
| 328 | + t.Errorf("the panel does not name the %s row:\n%s", settingLabels[r], view) | |
| 329 | + } | |
| 330 | + if m.settingLabel(r) == "" { | |
| 331 | + t.Errorf("the %s row has no value to show", settingLabels[r]) | |
| 332 | + } | |
| 333 | + } | |
| 334 | + if !strings.Contains(view, "saved to") { | |
| 335 | + t.Error("the panel does not say where it saves") | |
| 336 | + } | |
| 337 | + } | |
| 338 | + | |
| 339 | + // A narrow terminal must not panic the renderer, the way every other piece of | |
| 340 | + // chrome is expected to survive one. | |
| 341 | + func TestSettingsViewSurvivesANarrowScreen(t *testing.T) { | |
| 342 | + m, _ := newSettingsModel(t, config.Config{}) | |
| 343 | + openSettingsPanel(t, m) | |
| 344 | + for _, w := range []int{1, 8, 20, 40} { | |
| 345 | + m.Update(tea.WindowSizeMsg{Width: w, Height: 20}) | |
| 346 | + for _, line := range strings.Split(m.settingsView(), "\n") { | |
| 347 | + if lipgloss.Width(line) > w { | |
| 348 | + t.Errorf("width %d: a line is %d wide: %q", w, lipgloss.Width(line), line) | |
| 349 | + } | |
| 350 | + } | |
| 351 | + } | |
| 352 | + } | |
| 353 | + | |
| 354 | + // The panel shows four rows but writes the whole config, so a setting it has | |
| 355 | + // no row for has to come through untouched — otherwise cycling the theme would | |
| 356 | + // quietly turn auto-compaction back on. | |
| 357 | + func TestSettingsKeepsSettingsItHasNoRowFor(t *testing.T) { | |
| 358 | + m, saved := newSettingsModel(t, config.Config{ | |
| 359 | + Theme: "ember", | |
| 360 | + Setup: config.SetupDone, | |
| 361 | + AutoCompact: config.AutoCompactOff, | |
| 362 | + }) | |
| 363 | + openSettingsPanel(t, m) | |
| 364 | + | |
| 365 | + m.setIdx = setThemeRow | |
| 366 | + m.Update(key("right")) | |
| 367 | + m.Update(key("esc")) | |
| 368 | + | |
| 369 | + if *saved == nil { | |
| 370 | + t.Fatal("changing the theme saved nothing") | |
| 371 | + } | |
| 372 | + if (*saved).AutoCompact != config.AutoCompactOff { | |
| 373 | + t.Errorf("auto_compact = %q, want it carried through untouched", (*saved).AutoCompact) | |
| 374 | + } | |
| 375 | + } | |
| @@ -428,6 +428,14 @@ func (w *wizard) current() string { | ||
| 428 | 428 | } |
| 429 | 429 | |
| 430 | 430 | func (w *wizard) indexOf(opts []wizOption, value string) int { |
| 431 | + return optionIndex(opts, value) | |
| 432 | + } | |
| 433 | + | |
| 434 | + // optionIndex is where a value sits in a list of options, or the top of the | |
| 435 | + // list when it is not in it at all — which is what preselecting a setting this | |
| 436 | + // build no longer offers has to fall back to. The settings panel cycles the | |
| 437 | + // same lists, so the lookup is shared rather than written twice. | |
| 438 | + func optionIndex(opts []wizOption, value string) int { | |
| 431 | 439 | for i, o := range opts { |
| 432 | 440 | if o.value == value { |
| 433 | 441 | return i |
| @@ -436,8 +444,12 @@ func (w *wizard) indexOf(opts []wizOption, value string) int { | ||
| 436 | 444 | return 0 |
| 437 | 445 | } |
| 438 | 446 | |
| 447 | + // finish seals the answers. The marker is stamped here rather than by | |
| 448 | + // whatever saves the file: it means "these questions were all asked and | |
| 449 | + // answered", which is a thing only the wizard is in a position to say. | |
| 439 | 450 | func (w *wizard) finish() { |
| 440 | 451 | cfg := w.cfg |
| 452 | + cfg.Setup = config.SetupDone | |
| 441 | 453 | w.result = &cfg |
| 442 | 454 | } |
| 443 | 455 | |
| @@ -118,8 +118,32 @@ func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) { | ||
| 118 | 118 | if w.result == nil { |
| 119 | 119 | t.Fatal("four enters did not finish the wizard") |
| 120 | 120 | } |
| 121 | - if *w.result != cur { | |
| 122 | - t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, cur) | |
| 121 | + want := cur | |
| 122 | + want.Setup = config.SetupDone // the one thing finishing does add | |
| 123 | + if *w.result != want { | |
| 124 | + t.Errorf("accepting every default changed the config:\n got %+v\nwant %+v", *w.result, want) | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Finishing is what the marker means, so it must come back on the result and | |
| 129 | + // not be something the wizard hands out before the last question is answered. | |
| 130 | + func TestWizardMarksTheSetupDoneOnlyWhenItFinishes(t *testing.T) { | |
| 131 | + w := newTestWizard(t, config.Config{}) | |
| 132 | + w.Update(key("enter")) // → model | |
| 133 | + w.Update(key("enter")) // → effort | |
| 134 | + if w.cfg.Setup != "" { | |
| 135 | + t.Errorf("the setup was marked done two questions in: %q", w.cfg.Setup) | |
| 136 | + } | |
| 137 | + | |
| 138 | + for i := 0; i < 5 && w.result == nil; i++ { | |
| 139 | + w.Update(key("enter")) | |
| 140 | + settle(w) | |
| 141 | + } | |
| 142 | + if w.result == nil { | |
| 143 | + t.Fatal("the wizard did not finish") | |
| 144 | + } | |
| 145 | + if !w.result.Done() { | |
| 146 | + t.Errorf("a finished wizard did not mark the setup done: %+v", *w.result) | |
| 123 | 147 | } |
| 124 | 148 | } |
| 125 | 149 | |
| @@ -377,8 +401,14 @@ func TestSetupIsInTheCommandCatalogue(t *testing.T) { | ||
| 377 | 401 | if !strings.Contains(m.helpText(), "/setup") { |
| 378 | 402 | t.Error("/help does not list /setup") |
| 379 | 403 | } |
| 380 | - if got := matchCommands(m.commands(), "set"); len(got) == 0 || got[0].name != "setup" { | |
| 381 | - t.Errorf("typing /set does not offer /setup first: %v", got) | |
| 404 | + // /set is a prefix of both, and the menu offers them in catalogue order: | |
| 405 | + // /settings first, because it is the one reached for more than once. One | |
| 406 | + // letter more is unambiguous, which is what has to keep working. | |
| 407 | + if got := matchCommands(m.commands(), "setu"); len(got) == 0 || got[0].name != "setup" { | |
| 408 | + t.Errorf("typing /setu does not offer /setup first: %v", got) | |
| 409 | + } | |
| 410 | + if got := matchCommands(m.commands(), "sett"); len(got) == 0 || got[0].name != "settings" { | |
| 411 | + t.Errorf("typing /sett does not offer /settings first: %v", got) | |
| 382 | 412 | } |
| 383 | 413 | } |
| 384 | 414 | |