Skip to content

feat(tui): remember the last used model across restarts #1424

Description

@dennisonbertram

Work type

Feature slice

Change class

User-facing feature

Why this matters

The TUI forgets which model you chose. Pick a model with /model, quit, restart, and you are back on the server's default — so the choice has to be re-made every session. Owner request: "enable the cli to remember the last used model so when we restart we're using that one."

This is not a missing store. harnesscli already persists user choices to ~/.config/harnesscli/config.json — starred models, gateway, API keys, command history, and the selected theme all survive a restart. The model is the conspicuous omission, and it is the one users change most often.

Root of it: newTUIConfig (cmd/harnesscli/main.go:562) never sets Model, so TUIConfig.Model is always "" and m.selectedModel starts empty. The model then comes from whatever the daemon defaults to.

Protected path

Entry point: go-code / harnesscli --tui.

Current path that must keep working: newTUIConfig builds TUIConfig -> tui.New(cfg) sets selectedModel: cfg.Model (model.go:501) and modelSwitcher = modelswitcher.New(cfg.Model) -> the constructor then loads harnessconfig.Load() and applies starred models, gateway, API keys, and history (model.go:512-518) -> /model opens the switcher -> ModelSelectedMsg (model.go:5186) sets selectedModel, selectedProvider, selectedReasoningEffort, rebuilds the switcher, reapplies the context window, and refreshes the status bar -> runs are submitted with Model: m.selectedModel (model.go:2103, :4241).

Must not change: an empty selectedModel still means "let the daemon choose", which is what happens on a first run with nothing remembered. The context-window recalculation on model change (issue #1306) must still run. Starred models, gateway, API keys, history, and theme must keep loading exactly as they do.

Acceptance contract

  1. Selecting a model in the TUI persists it, along with its provider and reasoning effort, which are chosen at the same moment and are meaningless apart from it.
  2. On the next start, the TUI opens on that model without any user action.
  3. A remembered model is applied to the same fields a live selection sets, so the status bar, the switcher's current-selection highlight, the context window, and submitted runs all agree.
  4. An explicitly requested model takes precedence over the remembered one. Remembering must never override an instruction.
  5. Nothing remembered, or a corrupt/unreadable config, degrades to today's behavior — daemon default, no error shown. Losing this preference is never worth a broken start.
  6. No new storage location, no new file format, no new env var. The existing harnessconfig store gains fields.

Current architecture and search evidence

  • cmd/harnesscli/config/config.go:10Config with StarredModels, Gateway, APIKeys, HistoryEntries, Theme; Load/Save against ~/.config/harnesscli/config.json, returning an empty Config when the file is absent.
  • cmd/harnesscli/tui/config_persist.go:15persistConfigField(mutate func(*harnessconfig.Config)) error, load-mutate-save. Five existing call sites (model.go:3949, :4013, :5225, :5264, :5385) — the established idiom for exactly this.
  • cmd/harnesscli/tui/model.go:512-518 — where persisted values are already applied during construction. The natural place to apply a remembered model.
  • cmd/harnesscli/tui/model.go:5186ModelSelectedMsg, the single point where a model choice is made. The natural place to persist.
  • cmd/harnesscli/main.go:562newTUIConfig, which omits Model.
  • cmd/harnesscli/main.go:149flags.String("model", "", "model override for this run"). Default "", so "empty means no override" is already the codebase's convention and gives the precedence rule for free.

Searched: grep -rn "selectedModel" cmd/harnesscli/tui/model.go, grep -rn "persistConfigField", grep -rn "StarredModels", grep -n "TUIConfig{" -A 16 cmd/harnesscli/main.go. No existing model persistence anywhere — grep -rln "lastUsed|last_used|persistState|SaveState" returns nothing.

Note found while searching, worth stating: runTUI never receives the -model flag, so harnesscli --tui -model X silently ignores -model today. That is a separate defect. This slice must not make it worse, and its precedence guard should be written so the flag wins as soon as it is wired.

Cross-surface impact map

Callers and data flow: harnessconfig.Config gains fields; two touch points in tui/model.go (apply at construction, persist on selection). No server, protocol, or provider change.

Config/env/defaults: ~/.config/harnesscli/config.json gains model, provider, reasoning_effort, all omitempty. Older files without them load fine; the file stays readable by an older binary, which ignores unknown fields.

API/CLI/wire formats/tools: none. Runs already carry Model; they will simply carry a remembered value rather than an empty one.

Persistence/schema/cache: additive JSON fields only. No migration. A hand-edited or corrupt file already degrades to an empty Config via the existing Load error path.

Concurrency/lifecycle: persistConfigField does load-mutate-save on the Bubble Tea update goroutine, as the five existing call sites do. There is an atomic-write test (cmd/harnesscli/config/atomic_test.go), so the write path is already covered.

Security/auth/permissions/privacy: a model ID and provider name are not secrets, and this file already stores API keys, so its permissions are already the sensitive question — unchanged here. Nothing new is written that was not already user-visible in the UI.

TUI/web/macOS/other clients: TUI only. macapp and the streaming (non-TUI) path are untouched — notably, a remembered model must NOT leak into non-TUI harnesscli runs, which have their own -model handling.

Provider/model/tool catalog: read-only. A remembered model may no longer exist or may belong to a provider whose key has been removed; the UI already handles an unavailable model with a redirect (#1404), and that path must be exercised rather than assumed.

Deployment/observability/runbooks: none.

Compatibility: additive.

Existing tests/fixtures: cmd/harnesscli/config/config_test.go and atomic_test.go; TUI tests that construct a Model and may now pick up a developer's real ~/.config/harnesscli/config.json if the constructor reads it unconditionally — the existing harnessconfig.Load() call already has this property, so the tests either tolerate it or must be checked.

Documentation: website/docs/cli/ if it documents the config file; docs/logs/engineering-log.md.

Product and UX contract

Entry and exit: unchanged commands. The difference is which model is active on start.

States:

Copy: none needed for the happy path.

Accessibility/motion/responsive: no new UI.

Real interaction QA path: pick a non-default model with /model, confirm the status bar shows it, quit, restart, confirm the status bar shows the same model without touching anything, and submit a run to confirm the request actually carries it — the status bar agreeing is not proof the run does.

In scope

  • Model, Provider, ReasoningEffort fields on harnessconfig.Config.
  • Persist all three on ModelSelectedMsg via persistConfigField.
  • Apply them at construction when no model was explicitly requested, setting the same fields a live selection sets so the status bar, switcher, and submitted runs agree.
  • Tests, docs, engineering-log entry.

Out of scope

  • Wiring -model through runTUI into TUIConfig. It is a real defect and deserves its own issue; this slice only ensures the precedence guard is correct so that fix drops in cleanly.
  • Per-project (as opposed to per-user) model memory. Not asked for, and it needs a decision about which layer wins.
  • Remembering any other run setting (permissions, plan mode, profile).
  • The non-TUI streaming path.

Coordination and dependencies

None blocking. Sits on top of #1415/#1420, which touched the same file but not these lines.

Test-first plan

  1. TestConfigRoundTripsModelSelection in cmd/harnesscli/config — a Config with model, provider, and reasoning effort survives Save then Load. Red today: the fields do not exist.
  2. TestModelSelectionIsPersisted in tui — dispatch ModelSelectedMsg and assert the stored config now holds all three values. Must use a redirected config path (or the existing test seam) so it never writes to a developer's real ~/.config. Red today: nothing is written.
  3. TestRememberedModelAppliedAtStartup in tui — with a stored model and an empty TUIConfig.Model, the constructed model has selectedModel, selectedProvider, and selectedReasoningEffort set from the store. Red today: selectedModel is "".
  4. TestExplicitModelBeatsRememberedModel — with a stored model AND a non-empty TUIConfig.Model, the explicit value wins and the stored one is not applied. Red today: vacuous, since nothing is ever applied; it becomes the guard that keeps precedence right when -model is wired.

False-positive controls: assert that a missing stored model leaves selectedModel empty (so "remembering" cannot be faked by defaulting to something), and keep the existing starred/gateway/history/theme loading assertions green so the new field does not disturb the shared load path.

Verification plan

  • Red: run all four before implementing; record output.
  • Green: go test ./cmd/harnesscli/... -race.
  • Full regression: go test ./cmd/... ./internal/....
  • Real path, required — a passing unit test does not prove the model survives a real restart: scripts/install.sh, launch the TUI, /model to a non-default model, confirm the status bar, quit, restart, confirm the status bar again without interacting, then submit a run and confirm from the run's recorded model (go-code show <run-id>) that the request actually used it. Attach that output.
  • Adjacent: confirm a non-TUI harnesscli run is unaffected, and confirm the config file still round-trips its other fields by inspecting it before and after.

Rollout and rollback

Single PR to main; picked up on the next scripts/install.sh or Homebrew --HEAD. The new JSON fields are additive and ignored by older binaries, so downgrade is safe. Rollback is reverting the commit; a stale model key left in the file is inert.

Rollback trigger: a remembered model overriding an explicit instruction, or a start failing because of a stored value.

Documentation and handoff

  • website/docs/cli/ — if the persisted-config file is documented, add the new fields and the precedence rule.
  • docs/logs/engineering-log.md — the change, and the note that -model is currently ignored by the TUI, so that defect is recorded where the next person will find it.

Definition of done

  • Four tests written first and observed failing
  • Model, provider, and reasoning effort persisted together on selection
  • Applied at startup so status bar, switcher, context window, and submitted runs agree
  • Explicit request beats remembered value
  • Missing or corrupt config degrades silently to the daemon default
  • Unavailable remembered model uses the existing redirect path rather than failing
  • Tests never write to a developer's real ~/.config/harnesscli/config.json
  • go test ./cmd/harnesscli/... -race green; full regression green
  • Real restart proven, including the model recorded on an actual run
  • Engineering log updated, including the -model gap

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions