Vectrum
Sign In

Merge master: compaction meets the settings panel

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>
Jjonicommitted 2 parents 74cbf71 c23aec0commit93ba06d

Showing 12 changed files with 984 additions and 29 deletions.

Merge commit — showing changes against the first parent.

+54−11README.mdView File
@@ -171,7 +171,8 @@ compaction stops being optional, and it can be turned off altogether: see
171171 | `ctrl+c` | interrupt the turn; again when idle to exit |
172172
173173 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.
175176
176177 ### The mouse
177178
@@ -304,8 +305,8 @@ force shows above the prompt on the right. A terminal too narrow for five
304305 labels gets `◀ high ▶` and the same keys rather than a track with the words
305306 sliced in half.
306307
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).
309310
310311 `shift+tab` cycles between asking before every mutating call and accepting them
311312 all, which is `-yolo` reached from the keyboard. The mode line says which is in
@@ -327,6 +328,10 @@ cannot.
327328
328329 The first run on a terminal opens a full-screen wizard instead of the chat: the
329330 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.
330335
331336 ```
332337 harness setup step 1 of 4
@@ -373,14 +378,17 @@ honoured):
373378 "effort": "high",
374379 "approvals": "ask",
375380 "theme": "ember",
376- "auto_compact": "on"
381+ "auto_compact": "on",
382+ "setup": "done"
377383 }
378384 ```
379385
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,
384392 naming `/compact` and `/clear` as the ways on. The prompt is refused *before* it
385393 joins the history, so compacting and asking again sends it once rather than
386394 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
408416 ignored, and is never overwritten until you finish the wizard.
409417
410418 `/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.
414457
415458 The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to
416459 their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20,
+9−6cmd/harness/main.goView File
@@ -70,7 +70,7 @@ func run() error {
7070 // Saved preferences sit under the flags and over the built-in defaults. A
7171 // config that cannot be read is reported and then ignored: the harness
7272 // still starts, and the wizard can write a good one over it.
73- cfg, haveConfig, cfgErr := config.Load()
73+ cfg, _, cfgErr := config.Load()
7474
7575 // Held as a slice because the Claude Code fallback below may have to
7676 // rebuild the client with one more option, and replaying the same list is
@@ -272,12 +272,15 @@ func run() error {
272272
273273 Banner: banner,
274274
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.
279282 Config: cfg,
280- FirstRun: !haveConfig && cfgErr == nil,
283+ FirstRun: !cfg.Done() && cfgErr == nil,
281284 // Applying the answers is the TUI's own job; persisting them is
282285 // the only part that belongs out here.
283286 SaveConfig: config.Save,
+25−3internal/config/config.goView File
@@ -32,9 +32,14 @@ const (
3232 ApprovalsAcceptAll = "accept-all"
3333 )
3434
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".
3843 const (
3944 AutoCompactOn = "on"
4045 AutoCompactOff = "off"
@@ -63,12 +68,24 @@ type Config struct {
6368 // config written before this setting existed must not quietly start
6469 // stopping the agent at a full window.
6570 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"`
6679 }
6780
6881 // AutoCompactEnabled reports whether the conversation should fold itself when
6982 // the window fills. Only an explicit "off" turns it off.
7083 func (c Config) AutoCompactEnabled() bool { return c.AutoCompact != AutoCompactOff }
7184
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+
7289 // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or
7390 // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset.
7491 func Path() (string, error) {
@@ -126,6 +143,11 @@ func sanitize(c Config) Config {
126143 if c.AutoCompact != AutoCompactOn && c.AutoCompact != AutoCompactOff {
127144 c.AutoCompact = ""
128145 }
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+ }
129151 return c
130152 }
131153
+50−0internal/config/config_test.goView File
@@ -153,6 +153,56 @@ func TestFlagsOverrideTheConfig(t *testing.T) {
153153 }
154154 }
155155
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+
156206 func TestAutoCompactRoundTripsAndSanitizes(t *testing.T) {
157207 isolate(t)
158208 for _, v := range []string{AutoCompactOn, AutoCompactOff} {
+1−0internal/tui/commands.goView File
@@ -25,6 +25,7 @@ var builtins = []command{
2525 {name: "skills", desc: "the instruction sets found at startup"},
2626 {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
2727 {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"},
2829 {name: "setup", desc: "the full-screen wizard for the saved defaults"},
2930 {name: "compact", desc: "fold the conversation into a summary now"},
3031 {name: "clear", desc: "empty the transcript"},
+20−0internal/tui/model.goView File
@@ -70,6 +70,9 @@ const (
7070 // phaseModel is the model picker, which starts on a wait: the list comes
7171 // from the endpoint.
7272 phaseModel
73+ // phaseSettings is the settings panel, where the saved defaults are
74+ // changed in place.
75+ phaseSettings
7376 )
7477
7578 // inputMaxLines caps how far the prompt box grows before it scrolls. Past
@@ -127,6 +130,17 @@ type model struct {
127130 // effortIdx is where the marker sits while the effort slider is up.
128131 effortIdx int
129132
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+
130144 // The model picker: what the endpoint offered, where the cursor is, and
131145 // whether we are still waiting to be told.
132146 models []llm.Model
@@ -540,6 +554,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
540554 if m.phase == phaseModel {
541555 return m.onModelKey(msg)
542556 }
557+ if m.phase == phaseSettings {
558+ return m.onSettingsKey(msg)
559+ }
543560
544561 // An open menu owns the keys that drive it — including enter and esc,
545562 // which mean something else the rest of the time.
@@ -719,6 +736,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
719736 }
720737 return m, m.sync()
721738
739+ case line == "/settings":
740+ return m, m.openSettings()
741+
722742 case line == "/setup":
723743 if m.phase == phaseRunning {
724744 m.tr.addError("/setup would change the model mid-turn — interrupt first")
+4−4internal/tui/model_picker.goView File
@@ -51,16 +51,16 @@ func (m *model) onModels(msg modelsMsg) tea.Cmd {
5151 return nil
5252 }
5353 m.modelsLoading = false
54- m.phase = phaseIdle
5554
5655 switch {
5756 case msg.err != nil:
57+ m.phase = m.backFromPicker()
5858 m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " +
5959 msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works")
6060 case len(msg.models) == 0:
61+ m.phase = m.backFromPicker()
6162 m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works")
6263 default:
63- m.phase = phaseModel
6464 m.models = msg.models
6565 m.modelIdx = modelIndex(msg.models, m.opts.Client.Model())
6666 }
@@ -83,7 +83,7 @@ func modelIndex(models []llm.Model, current string) int {
8383 func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8484 key := msg.String()
8585 if key == "esc" || key == "ctrl+c" {
86- m.phase = phaseIdle
86+ m.phase = m.backFromPicker()
8787 m.models, m.modelsLoading = nil, false
8888 return m, tea.Batch(m.sync(), textarea.Blink)
8989 }
@@ -103,7 +103,7 @@ func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
103103 m.modelIdx = last
104104 case "enter", "tab":
105105 id := m.models[m.modelIdx].ID
106- m.phase = phaseIdle
106+ m.phase = m.backFromPicker()
107107 m.models = nil
108108 m.applyModel(id)
109109 return m, tea.Batch(m.sync(), textarea.Blink)
+25−1internal/tui/mouse.goView File
@@ -36,6 +36,7 @@ const (
3636 secApproval
3737 secEffort
3838 secModel
39+ secSettings
3940 )
4041
4142 // section is one piece of the bottom chrome and what it rendered to. The
@@ -58,6 +59,8 @@ func (m *model) layout() []section {
5859 secs = append(secs, section{secEffort, m.effortView()})
5960 case m.phase == phaseModel:
6061 secs = append(secs, section{secModel, m.modelView()})
62+ case m.phase == phaseSettings:
63+ secs = append(secs, section{secSettings, m.settingsView()})
6164 default:
6265 secs = append(secs, section{secInput, m.inputBox()})
6366 if v := m.menuView(); v != "" {
@@ -255,6 +258,12 @@ func (m *model) onChromeWheel(kind sectionKind, msg tea.MouseMsg) tea.Cmd {
255258 case secEffort:
256259 m.effortIdx = clampIdx(m.effortIdx+step(up), len(llm.EffortLevels))
257260 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
258267 }
259268 var cmd tea.Cmd
260269 m.vp, cmd = m.vp.Update(msg)
@@ -284,7 +293,7 @@ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd)
284293 if i := start + row - modelHeadRows; row >= modelHeadRows && i < end {
285294 m.modelIdx = i
286295 id := m.models[i].ID
287- m.phase, m.models = phaseIdle, nil
296+ m.phase, m.models = m.backFromPicker(), nil
288297 m.applyModel(id)
289298 return m, tea.Batch(m.sync(), textarea.Blink)
290299 }
@@ -312,6 +321,21 @@ func (m *model) onChromeClick(kind sectionKind, row, x int) (tea.Model, tea.Cmd)
312321 }
313322 return m, nil
314323
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+
315339 case secMode:
316340 return m, m.cycleMode()
317341 }
+375−0internal/tui/settings.goaddedView File
@@ -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+ }
+375−0internal/tui/settings_test.goaddedView File
@@ -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+ }
+12−0internal/tui/wizard.goView File
@@ -428,6 +428,14 @@ func (w *wizard) current() string {
428428 }
429429
430430 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 {
431439 for i, o := range opts {
432440 if o.value == value {
433441 return i
@@ -436,8 +444,12 @@ func (w *wizard) indexOf(opts []wizOption, value string) int {
436444 return 0
437445 }
438446
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.
439450 func (w *wizard) finish() {
440451 cfg := w.cfg
452+ cfg.Setup = config.SetupDone
441453 w.result = &cfg
442454 }
443455
+34−4internal/tui/wizard_test.goView File
@@ -118,8 +118,32 @@ func TestWizardPreselectsWhatIsAlreadyConfigured(t *testing.T) {
118118 if w.result == nil {
119119 t.Fatal("four enters did not finish the wizard")
120120 }
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)
123147 }
124148 }
125149
@@ -377,8 +401,14 @@ func TestSetupIsInTheCommandCatalogue(t *testing.T) {
377401 if !strings.Contains(m.helpText(), "/setup") {
378402 t.Error("/help does not list /setup")
379403 }
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)
382412 }
383413 }
384414