package tui import ( "context" "errors" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "codey/internal/approval" "codey/internal/config" "codey/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) } } // The web-search row is the one setting a running session cannot adopt: the // tool set was built at startup. Cycling it must still be saveable, and must // say that it will not mean anything until the next start. func TestSettingsWebSearchRow(t *testing.T) { m := newTestModel(t) m.opts.Config = config.Config{WebSearch: config.WebSearchNone} m.openSettings() m.setIdx = setWebSearchRow if got := m.settingValue(setWebSearchRow); got != config.WebSearchNone { t.Fatalf("the row opened on %q, want %q", got, config.WebSearchNone) } if strings.Contains(m.settingDetail(setWebSearchRow), "next start") { t.Error("an unchanged row warns about a restart it does not need") } m.cycleSetting(setWebSearchRow, 1) if got := m.settingValue(setWebSearchRow); got == config.WebSearchNone { t.Fatal("cycling did not move off the current value") } if !strings.Contains(m.settingDetail(setWebSearchRow), "next start") { t.Error("a changed row does not say when it takes effect") } if !m.settingTouched(setWebSearchRow) { t.Fatal("a cycled row was not counted as touched") } cfg := m.settingsConfig() if cfg.WebSearch != m.settingValue(setWebSearchRow) { t.Errorf("saved %q, want %q", cfg.WebSearch, m.settingValue(setWebSearchRow)) } if !strings.Contains(settingsSummary(cfg), "web search ") { t.Errorf("the summary does not mention web access: %q", settingsSummary(cfg)) } } // The two rows the session cannot take on mid-conversation. Both behave like // the web-search row: cycling edits what will be saved and says when it will // mean something, rather than pretending to change a tool set that was built // before the first prompt. func TestSettingsDiagnosticsRow(t *testing.T) { m := newTestModel(t) m.opts.Config = config.Config{Diagnostics: config.DiagnosticsOn} m.opts.Diagnostics = config.DiagnosticsOn m.openSettings() m.setIdx = setDiagnosticsRow if got := m.settingValue(setDiagnosticsRow); got != config.DiagnosticsOn { t.Fatalf("the row opened on %q, want %q", got, config.DiagnosticsOn) } if strings.Contains(m.settingDetail(setDiagnosticsRow), "next start") { t.Error("an unchanged row warns about a restart it does not need") } m.cycleSetting(setDiagnosticsRow, 1) if got := m.settingValue(setDiagnosticsRow); got != config.DiagnosticsOff { t.Fatalf("cycling landed on %q, want %q", got, config.DiagnosticsOff) } if !strings.Contains(m.settingDetail(setDiagnosticsRow), "next start") { t.Error("a changed row does not say when it takes effect") } cfg := m.settingsConfig() if cfg.Diagnostics != config.DiagnosticsOff { t.Errorf("saved %q, want %q", cfg.Diagnostics, config.DiagnosticsOff) } if !strings.Contains(settingsSummary(cfg), "no diagnostics") { t.Errorf("the summary does not mention what was turned off: %q", settingsSummary(cfg)) } } func TestSettingsClaudeSkillsRow(t *testing.T) { m := newTestModel(t) m.opts.Config = config.Config{ClaudeSkills: config.ClaudeSkillsOff} m.opts.ClaudeSkillsMode = config.ClaudeSkillsOff m.openSettings() m.setIdx = setClaudeSkillsRow if got := m.settingValue(setClaudeSkillsRow); got != config.ClaudeSkillsOff { t.Fatalf("the row opened on %q, want %q", got, config.ClaudeSkillsOff) } m.cycleSetting(setClaudeSkillsRow, 1) if got := m.settingValue(setClaudeSkillsRow); got != config.ClaudeSkillsUse { t.Fatalf("cycling landed on %q, want %q", got, config.ClaudeSkillsUse) } if !strings.Contains(m.settingDetail(setClaudeSkillsRow), "next start") { t.Error("a changed row does not say when it takes effect") } cfg := m.settingsConfig() if cfg.ClaudeSkills != config.ClaudeSkillsUse { t.Errorf("saved %q, want %q", cfg.ClaudeSkills, config.ClaudeSkillsUse) } if !strings.Contains(settingsSummary(cfg), "claude skills use") { t.Errorf("the summary does not mention the change: %q", settingsSummary(cfg)) } } // Every row has a label wide enough not to collide with its value. The panel // measures the column, and this is what says it still does. func TestSettingsLabelsDoNotRunIntoTheirValues(t *testing.T) { m := newTestModel(t) m.openSettings() m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) for _, line := range strings.Split(m.settingsView(), "\n") { for _, label := range settingLabels { if i := strings.Index(line, label); i >= 0 { rest := line[i+len(label):] if rest != "" && !strings.HasPrefix(rest, " ") { t.Errorf("%q runs straight into its value: %q", label, line) } } } } }