Vectrum
Sign In

tui: /settings, and a config that knows when setup is done

The config file has held the defaults since the wizard was built, but
the only way to change one of them was to sit through the whole
interview again — four questions to move the theme one row. And "first
run" was spelled as "no file exists", which is not the same statement:
a config written by hand, or by a build with fewer questions in it, is a
file, and nobody had ever been asked anything.

So the file now says so itself. The wizard stamps "setup": "done" when
the last question is answered, and that marker, not the file's
existence, is what suppresses the wizard on startup. Anything else in
that field is dropped on read, because a marker this build did not write
is not one it can trust. A config that cannot be parsed still suppresses
it, as before: the wizard's answers would be saved over settings that
are probably still in there.

/settings is the same four settings without the interview. One row
each, ←→ cycles a row, and the change is applied to the running session
as you move — the theme repaints under the panel, the effort pill above
the prompt changes — so there is nothing left to confirm on the way out
and esc only decides about the file. A panel that was only read leaves
it alone, and so does a value cycled back to where it started.

Only the rows actually touched are written back. The panel shows the
session rather than the file, because /effort, shift+tab and -yolo all
change a setting without the file hearing about it and a panel that
disagreed with the status bar two lines below it would be worse than
useless — but reading all four back off the session would write "accept
everything" into the config of someone who passed -yolo once, on the
command line, for one run.

The panel does not stamp the setup marker. It means the questions were
all asked and answered, and this screen does not ask them: someone who
has only ever changed their theme has not been through setup and is
still offered it.

The model row is the one that cannot cycle, because its choices come
from the endpoint rather than from this build, so enter hands over to
the /model picker and the picker lands back on the panel. Every other
row is built from the same option lists the wizard's steps are made of,
so the two screens cannot come to disagree about what a theme is called
or what an effort level means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 00e7e95commitc23aec0

Showing 12 changed files with 951 additions and 22 deletions.

+47−7README.mdView File
@@ -137,7 +137,7 @@ to six lines.
137137 | `ctrl+c` | interrupt the turn; again when idle to exit |
138138
139139 Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
140- `/mouse`, `/setup`, `/skills`, and one per discovered skill.
140+ `/mouse`, `/settings`, `/setup`, `/skills`, and one per discovered skill.
141141
142142 ### The mouse
143143
@@ -270,8 +270,8 @@ force shows above the prompt on the right. A terminal too narrow for five
270270 labels gets `◀ high ▶` and the same keys rather than a track with the words
271271 sliced in half.
272272
273- `/model` and `/effort` change the session. `/setup` changes what the next
274- session starts with — see [Setup](#setup).
273+ `/model` and `/effort` change the session. `/settings` and `/setup` change what
274+ the next session starts with — see [Setup](#setup).
275275
276276 `shift+tab` cycles between asking before every mutating call and accepting them
277277 all, which is `-yolo` reached from the keyboard. The mode line says which is in
@@ -293,6 +293,10 @@ cannot.
293293
294294 The first run on a terminal opens a full-screen wizard instead of the chat: the
295295 mark assembles, the name lights up, and it asks four questions one at a time.
296+ "First run" means the config file does not say `"setup": "done"` — the marker
297+ the wizard stamps when the last question is answered — rather than that no file
298+ exists, so a config written by hand, or by a build with fewer questions in it,
299+ still gets asked the ones it is missing.
296300
297301 ```
298302 harness setup step 1 of 4
@@ -338,7 +342,8 @@ honoured):
338342 "model": "claude-opus-5",
339343 "effort": "high",
340344 "approvals": "ask",
341- "theme": "ember"
345+ "theme": "ember",
346+ "setup": "done"
342347 }
343348 ```
344349
@@ -360,9 +365,44 @@ model than it started. A config file that cannot be parsed is reported and then
360365 ignored, and is never overwritten until you finish the wizard.
361366
362367 `/model` and `/effort` change the running session and say "from the next turn
363- on"; they do not write to the file. The wizard is the thing that decides what
364- the *next* session starts with, which is why it is a separate screen rather
365- than a fourth picker.
368+ on"; they do not write to the file. The two screens that do are the wizard and
369+ `/settings`.
370+
371+ ### /settings
372+
373+ `/settings` is the same four settings without the interview: one row each,
374+ changed in place, applied as you move.
375+
376+ ```
377+ Settings ↑↓ row · ←→ change · esc done
378+ ❯ Model Opus 5 $5 / $25 per Mtok
379+ Effort high default
380+ Approvals Ask me first recommended
381+ Theme ember
382+ claude-opus-5 · enter to pick from anthropic
383+ saved to ~/.config/harness/config.json
384+ ```
385+
386+ `←→` cycles a row and applies it there and then — the theme repaints under the
387+ panel, the way it previews in the wizard — and `esc` closes and writes the
388+ file. There is no cancel, because there is nothing left to confirm: the session
389+ already has every change. A panel that was only read leaves the file alone, and
390+ so does a value cycled back to where it started.
391+
392+ The rows come from the same option lists the wizard's steps are built from, so
393+ neither screen can invent a name for a theme or a sentence about an effort
394+ level the other does not use. The model row is the exception: its choices come
395+ from the endpoint rather than from this build, so `enter` hands over to the
396+ `/model` picker and lands back here once you have chosen.
397+
398+ What the panel shows is the *session*, not the file — `/effort`, `shift+tab`
399+ and `-yolo` all change a setting without the file hearing about it, and a panel
400+ that disagreed with the status bar two lines below it would be worse than
401+ useless. Only the rows actually touched are written back, which is what keeps a
402+ `-yolo` run from quietly saving "accept everything" as the default for every
403+ run after it. And the setup marker is *not* stamped here: changing a theme is
404+ not answering the setup questions, so someone who has only ever used this panel
405+ is still offered the wizard.
366406
367407 The whole thing is `↑↓` and `enter`, `esc` to go back. Animations collapse to
368408 their final frame under `NO_COLOR`, a `dumb` terminal, or a window under 60×20,
+9−6cmd/harness/main.goView File
@@ -68,7 +68,7 @@ func run() error {
6868 // Saved preferences sit under the flags and over the built-in defaults. A
6969 // config that cannot be read is reported and then ignored: the harness
7070 // still starts, and the wizard can write a good one over it.
71- cfg, haveConfig, cfgErr := config.Load()
71+ cfg, _, cfgErr := config.Load()
7272
7373 // Held as a slice because the Claude Code fallback below may have to
7474 // rebuild the client with one more option, and replaying the same list is
@@ -265,12 +265,15 @@ func run() error {
265265
266266 Banner: banner,
267267
268- // No config on disk is what "first run" means, and it is the only
269- // thing that opens the wizard unasked. A flag-only invocation is
270- // still a first run: the flags say what this run does, not what
271- // the next one should default to.
268+ // A config that does not say the setup was finished is what
269+ // "first run" means, and it is the only thing that opens the
270+ // wizard unasked. A flag-only invocation is still a first run:
271+ // the flags say what this run does, not what the next one should
272+ // default to. A config we could not read is not a first run
273+ // either — the wizard's answers would be saved over settings that
274+ // are probably still in there.
272275 Config: cfg,
273- FirstRun: !haveConfig && cfgErr == nil,
276+ FirstRun: !cfg.Done() && cfgErr == nil,
274277 // Applying the answers is the TUI's own job; persisting them is
275278 // the only part that belongs out here.
276279 SaveConfig: config.Save,
+22−0internal/config/config.goView File
@@ -32,6 +32,11 @@ const (
3232 ApprovalsAcceptAll = "accept-all"
3333 )
3434
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+
3540 // Themes are the TUI palettes, in the order the wizard offers them. The names
3641 // live here rather than in the tui package so that reading a config never
3742 // depends on a terminal front end being linked in — the eval runner has none.
@@ -51,8 +56,20 @@ type Config struct {
5156 Approvals string `json:"approvals,omitempty"`
5257 // Theme is one of Themes.
5358 Theme string `json:"theme,omitempty"`
59+ // Setup is SetupDone once the questions have been answered to the end.
60+ //
61+ // It is what decides whether the wizard opens by itself, in place of the
62+ // older test of whether the file exists at all. The difference shows up on
63+ // a config that exists but was never finished — written by hand, or by a
64+ // build that had fewer questions in it — where "the file is there" would
65+ // say the setup was done and this says who says so.
66+ Setup string `json:"setup,omitempty"`
5467 }
5568
69+ // Done reports whether the setup has been completed. It is the only thing
70+ // that suppresses the wizard on startup.
71+ func (c Config) Done() bool { return c.Setup == SetupDone }
72+
5673 // Path is where the config lives: $XDG_CONFIG_HOME/harness/config.json, or
5774 // ~/.config/harness/config.json when XDG_CONFIG_HOME is unset.
5875 func Path() (string, error) {
@@ -107,6 +124,11 @@ func sanitize(c Config) Config {
107124 if !ValidTheme(c.Theme) {
108125 c.Theme = ""
109126 }
127+ // Anything other than the one word means the marker was not written by a
128+ // wizard that finished, so it does not count as one.
129+ if c.Setup != SetupDone {
130+ c.Setup = ""
131+ }
110132 return c
111133 }
112134
+50−0internal/config/config_test.goView File
@@ -152,3 +152,53 @@ func TestFlagsOverrideTheConfig(t *testing.T) {
152152 t.Errorf("two empties produced %q", got)
153153 }
154154 }
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+ }
+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: "clear", desc: "empty the transcript"},
3031 {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"},
+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
@@ -524,6 +538,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
524538 if m.phase == phaseModel {
525539 return m.onModelKey(msg)
526540 }
541+ if m.phase == phaseSettings {
542+ return m.onSettingsKey(msg)
543+ }
527544
528545 // An open menu owns the keys that drive it — including enter and esc,
529546 // which mean something else the rest of the time.
@@ -688,6 +705,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
688705 }
689706 return m, m.sync()
690707
708+ case line == "/settings":
709+ return m, m.openSettings()
710+
691711 case line == "/setup":
692712 if m.phase == phaseRunning {
693713 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+ }
+352−0internal/tui/settings_test.goaddedView File
@@ -0,0 +1,352 @@
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+ }
+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