package tui
import (
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"harness/internal/approval"
"harness/internal/config"
"harness/internal/llm"
)
// newSettingsModel builds a chat model with a config already on disk and a
// SaveConfig that records rather than writes. The returned pointer is nil
// until the panel saves something, which is what most of these tests are
// about.
func newSettingsModel(t *testing.T, cur config.Config) (*model, **config.Config) {
t.Helper()
t.Cleanup(func() { applyTheme(defaultTheme) })
var saved *config.Config
m := newModel(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: "/tmp/repo",
Policy: approval.New(nil),
Config: cur,
SaveConfig: func(c config.Config) error {
saved = &c
return nil
},
})
m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
return m, &saved
}
// open puts the panel up the way a person does.
func openSettingsPanel(t *testing.T, m *model) {
t.Helper()
m.submit("/settings")
if m.phase != phaseSettings {
t.Fatalf("/settings left the model in phase %v", m.phase)
}
}
// The walk the panel exists for: change one thing, leave, and find it both in
// force and in the file.
func TestSettingsChangeAppliesAndSaves(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember", Setup: config.SetupDone})
openSettingsPanel(t, m)
m.Update(key("down")) // → effort
if m.setIdx != setEffortRow {
t.Fatalf("down from the model row landed on %v", m.setIdx)
}
m.Update(key("right"))
want := m.opts.Effort
if want == "high" {
t.Fatal("cycling the effort row did not change the level in force")
}
m.Update(key("esc"))
if m.phase != phaseIdle {
t.Fatalf("esc did not close the panel: phase %v", m.phase)
}
if *saved == nil {
t.Fatal("leaving the panel saved nothing")
}
if (*saved).Effort != want {
t.Errorf("saved effort %q, want the %q that is in force", (*saved).Effort, want)
}
if !(*saved).Done() {
t.Error("the panel dropped the setup marker that was already in the file")
}
if !strings.Contains(m.tr.render(), "settings saved") {
t.Error("the change was never reported in the transcript")
}
}
// A theme is applied as you move onto it, not when you leave: the whole point
// of a panel over a wizard step is watching the transcript change colour.
func TestSettingsThemeAppliesWhileYouCycle(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Theme: "ember"})
openSettingsPanel(t, m)
m.setIdx = setThemeRow
m.Update(key("right"))
if currentTheme.name == "ember" {
t.Fatal("cycling the theme row did not repaint")
}
previewed := currentTheme.name
m.Update(key("esc"))
if currentTheme.name != previewed {
t.Errorf("leaving the panel undid the theme: %q, want %q", currentTheme.name, previewed)
}
if *saved == nil || (*saved).Theme != previewed {
t.Errorf("the previewed theme was not saved: %+v", *saved)
}
}
// Browsing is not editing. Opening the panel, walking every row and leaving
// must not rewrite the file — and must not say it did.
func TestSettingsSavesNothingWhenNothingChanged(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Effort: "low", Theme: "mint", Setup: config.SetupDone})
openSettingsPanel(t, m)
for i := 0; i < int(numSettingRows)+2; i++ {
m.Update(key("down"))
}
for i := 0; i < int(numSettingRows)+2; i++ {
m.Update(key("up"))
}
m.Update(key("esc"))
if *saved != nil {
t.Errorf("walking the panel rewrote the config: %+v", *saved)
}
if strings.Contains(m.tr.render(), "settings saved") {
t.Error("nothing changed, but the panel said it saved")
}
}
// A value cycled back to where it started is not a change either.
func TestSettingsSavesNothingWhenCycledBack(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Theme: "ember", Setup: config.SetupDone})
openSettingsPanel(t, m)
m.setIdx = setThemeRow
for range themeOptions() {
m.Update(key("right"))
}
if currentTheme.name != "ember" {
t.Fatalf("a full cycle did not come back to ember: %q", currentTheme.name)
}
m.Update(key("esc"))
if *saved != nil {
t.Errorf("a value cycled back to itself was still written: %+v", *saved)
}
}
// -yolo is a decision about one session, taken on the command line. Somebody
// who opens the panel on such a session to change their theme must not find
// "run everything without asking" written into their config afterwards.
func TestSettingsDoesNotPersistAnApprovalModeItWasNotAsked(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Theme: "ember", Approvals: config.ApprovalsAsk})
m.opts.Policy.YOLO = true // as -yolo leaves it
openSettingsPanel(t, m)
m.setIdx = setThemeRow
m.Update(key("right"))
m.Update(key("esc"))
if *saved == nil {
t.Fatal("the theme change was not saved")
}
if (*saved).Approvals != config.ApprovalsAsk {
t.Errorf("the session's -yolo was written to the config: %q", (*saved).Approvals)
}
}
// Changing approvals in the panel does take, both in the file and in the
// policy the agent actually consults.
func TestSettingsApprovalsReachThePolicy(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Approvals: config.ApprovalsAsk, Setup: config.SetupDone})
openSettingsPanel(t, m)
m.setIdx = setApprovalsRow
m.Update(key("right"))
if !m.opts.Policy.YOLO {
t.Fatal("cycling to accept-all did not reach the policy")
}
m.Update(key("esc"))
if *saved == nil || (*saved).Approvals != config.ApprovalsAcceptAll {
t.Errorf("accept-all was not saved: %+v", *saved)
}
}
// The panel shows the session, not the file: /effort five minutes ago is what
// the effort row has to say, or the two disagree in front of the user.
func TestSettingsShowsWhatTheSessionIsRunning(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{Effort: "low", Model: "claude-opus-5"})
m.useEffort("max")
m.opts.Client.SetModel("claude-haiku-4-5")
openSettingsPanel(t, m)
view := m.settingsView()
for _, want := range []string{"max", prettyModel("claude-haiku-4-5")} {
if !strings.Contains(view, want) {
t.Errorf("the panel does not report %q:\n%s", want, view)
}
}
}
// The model row is the one that cannot cycle: its choices come from the
// endpoint, so enter hands over to the picker and the picker comes back here.
func TestSettingsModelRowOpensThePickerAndReturns(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone})
openSettingsPanel(t, m)
m.Update(key("right")) // the model row does not cycle
if m.opts.Client.Model() != "claude-opus-5" {
t.Errorf("the model row cycled to %q", m.opts.Client.Model())
}
m.Update(key("enter"))
if m.phase != phaseModel {
t.Fatalf("enter on the model row left the model in phase %v", m.phase)
}
m.Update(modelsMsg{models: []llm.Model{
{ID: "claude-opus-5", DisplayName: "Claude Opus 5"},
{ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
}})
m.Update(key("down"))
m.Update(key("enter"))
if m.phase != phaseSettings {
t.Fatalf("the picker did not come back to the panel: phase %v", m.phase)
}
if m.opts.Client.Model() != "claude-sonnet-5" {
t.Fatalf("the picked model did not take: %q", m.opts.Client.Model())
}
m.Update(key("esc"))
if *saved == nil || (*saved).Model != "claude-sonnet-5" {
t.Errorf("the picked model was not saved: %+v", *saved)
}
}
// Escaping the picker is not choosing a model, and must leave the panel with
// nothing to save.
func TestSettingsPickerEscapeReturnsWithoutChanging(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{Model: "claude-opus-5", Setup: config.SetupDone})
openSettingsPanel(t, m)
m.Update(key("enter"))
m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}, {ID: "claude-sonnet-5"}}})
m.Update(key("esc"))
if m.phase != phaseSettings {
t.Fatalf("esc in the picker did not return to the panel: phase %v", m.phase)
}
m.Update(key("esc"))
if *saved != nil {
t.Errorf("an escaped picker still wrote the config: %+v", *saved)
}
}
// /model opened from the prompt has nothing to do with the panel and must
// still land back at the prompt.
func TestModelPickerStillReturnsToThePrompt(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{Model: "claude-opus-5"})
m.submit("/model")
m.Update(modelsMsg{models: []llm.Model{{ID: "claude-opus-5"}}})
m.Update(key("enter"))
if m.phase != phaseIdle {
t.Errorf("the picker returned to phase %v rather than the prompt", m.phase)
}
}
// The settings are already in force by the time the file is written, so a
// failed write is a warning, not a lost change.
func TestSettingsReportsAFailedSave(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{Theme: "ember"})
m.opts.SaveConfig = func(config.Config) error { return errors.New("disk is full") }
openSettingsPanel(t, m)
m.setIdx = setThemeRow
m.Update(key("right"))
applied := currentTheme.name
m.Update(key("esc"))
if currentTheme.name != applied {
t.Error("a failed save undid the change it failed to record")
}
if !strings.Contains(m.tr.render(), "disk is full") {
t.Errorf("a failed save was not reported:\n%s", m.tr.render())
}
}
// The panel is not the wizard: changing a theme is not answering the setup
// questions, so somebody who has never seen the wizard still gets it.
func TestSettingsDoesNotMarkTheSetupDone(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{})
openSettingsPanel(t, m)
m.setIdx = setThemeRow
m.Update(key("right"))
m.Update(key("esc"))
if *saved == nil {
t.Fatal("the theme change was not saved")
}
if (*saved).Done() {
t.Error("the settings panel claimed the setup was finished")
}
}
// A click on a row is a choice, the way it is everywhere else in the chrome.
func TestSettingsClickPicksTheRowUnderThePointer(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{Theme: "ember"})
openSettingsPanel(t, m)
secs := m.layout()
top := maxInt(1, m.height-rowsOf(secs))
// The rows start under the heading, and the panel is the second section.
row := top + lipgloss.Height(secs[0].text) + settingsHeadRows + int(setThemeRow)
m.Update(tea.MouseMsg{X: 4, Y: row, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft})
if m.setIdx != setThemeRow {
t.Fatalf("the click landed on row %v, want the theme row", m.setIdx)
}
if currentTheme.name == "ember" {
t.Error("clicking a row did not change its value")
}
}
// Every row has to be spelled out, or the panel is a list of blanks.
func TestSettingsViewNamesEveryRow(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{Effort: "high", Theme: "ember"})
openSettingsPanel(t, m)
view := m.settingsView()
for r := settingRow(0); r < numSettingRows; r++ {
if !strings.Contains(view, settingLabels[r]) {
t.Errorf("the panel does not name the %s row:\n%s", settingLabels[r], view)
}
if m.settingLabel(r) == "" {
t.Errorf("the %s row has no value to show", settingLabels[r])
}
}
if !strings.Contains(view, "saved to") {
t.Error("the panel does not say where it saves")
}
}
// A narrow terminal must not panic the renderer, the way every other piece of
// chrome is expected to survive one.
func TestSettingsViewSurvivesANarrowScreen(t *testing.T) {
m, _ := newSettingsModel(t, config.Config{})
openSettingsPanel(t, m)
for _, w := range []int{1, 8, 20, 40} {
m.Update(tea.WindowSizeMsg{Width: w, Height: 20})
for _, line := range strings.Split(m.settingsView(), "\n") {
if lipgloss.Width(line) > w {
t.Errorf("width %d: a line is %d wide: %q", w, lipgloss.Width(line), line)
}
}
}
}
// The panel shows four rows but writes the whole config, so a setting it has
// no row for has to come through untouched — otherwise cycling the theme would
// quietly turn auto-compaction back on.
func TestSettingsKeepsSettingsItHasNoRowFor(t *testing.T) {
m, saved := newSettingsModel(t, config.Config{
Theme: "ember",
Setup: config.SetupDone,
AutoCompact: config.AutoCompactOff,
})
openSettingsPanel(t, m)
m.setIdx = setThemeRow
m.Update(key("right"))
m.Update(key("esc"))
if *saved == nil {
t.Fatal("changing the theme saved nothing")
}
if (*saved).AutoCompact != config.AutoCompactOff {
t.Errorf("auto_compact = %q, want it carried through untouched", (*saved).AutoCompact)
}
}