feat(tui): implement dark mode with runtime /theme switching
- Paint dark background at app shell level via appStyle() helper - Add /theme slash command for runtime light/dark switching - Add comprehensive dark-mode style tests - Document --theme dark in README Quick Start
This commit is contained in:
@@ -604,6 +604,115 @@ func TestParseThemeMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func requireStyleColor(t *testing.T, name string, got lipgloss.TerminalColor, want string) {
|
||||
t.Helper()
|
||||
color, ok := got.(lipgloss.Color)
|
||||
if !ok {
|
||||
t.Fatalf("%s = %T, want lipgloss.Color", name, got)
|
||||
}
|
||||
if string(color) != want {
|
||||
t.Fatalf("%s = %q, want %q", name, string(color), want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireNoStyleColor(t *testing.T, name string, got lipgloss.TerminalColor) {
|
||||
t.Helper()
|
||||
if _, ok := got.(lipgloss.NoColor); !ok {
|
||||
t.Fatalf("%s should not paint a color, got %T", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkThemePaintsAppBackground(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
rendered := stripANSI(m.View())
|
||||
if !strings.Contains(rendered, "theme dark") {
|
||||
t.Fatalf("rendered view missing 'theme dark':\n%s", rendered)
|
||||
}
|
||||
if m.theme.Mode != ThemeDark {
|
||||
t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark)
|
||||
}
|
||||
want := darkTheme()
|
||||
requireStyleColor(t, "App.Background", m.styles.App.GetBackground(), want.Background)
|
||||
requireStyleColor(t, "App.Foreground", m.styles.App.GetForeground(), want.Text)
|
||||
}
|
||||
|
||||
func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m = next.(model)
|
||||
requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground())
|
||||
requireNoStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground())
|
||||
requireNoStyleColor(t, "AssistantMsg.Background", m.styles.AssistantMsg.GetBackground())
|
||||
requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground())
|
||||
m.running = true
|
||||
m.status = "thinking..."
|
||||
m.resize()
|
||||
if backgroundPattern.MatchString(m.activityView()) {
|
||||
t.Fatalf("activity view should not paint a background:\n%q", m.activityView())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkThemeStylesUseDarkPalette(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
|
||||
want := darkTheme()
|
||||
requireStyleColor(t, "Header.Foreground", m.styles.Header.GetForeground(), want.Title)
|
||||
requireStyleColor(t, "Muted.Foreground", m.styles.Muted.GetForeground(), want.Muted)
|
||||
requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt)
|
||||
requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text)
|
||||
requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted)
|
||||
}
|
||||
|
||||
func TestModelCommandShowsDarkTheme(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark})
|
||||
m.input.SetValue("/model")
|
||||
next, _ := m.submit()
|
||||
rendered := next.(model).renderMessages()
|
||||
if !strings.Contains(rendered, "theme: dark") {
|
||||
t.Fatalf("model command output missing 'theme: dark':\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) {
|
||||
m := newModel(context.Background(), nil, Options{ModelName: "test-model"})
|
||||
if m.theme.Mode != ThemeLight {
|
||||
t.Fatalf("initial theme = %q, want light", m.theme.Mode)
|
||||
}
|
||||
requireNoStyleColor(t, "initial App.Background", m.styles.App.GetBackground())
|
||||
|
||||
// Switch to dark
|
||||
m.input.SetValue("/theme dark")
|
||||
next, _ := m.submit()
|
||||
m = next.(model)
|
||||
if m.theme.Mode != ThemeDark {
|
||||
t.Fatalf("after /theme dark: Mode = %q, want dark", m.theme.Mode)
|
||||
}
|
||||
requireStyleColor(t, "dark App.Background", m.styles.App.GetBackground(), darkTheme().Background)
|
||||
last := m.messages[len(m.messages)-1]
|
||||
if !strings.Contains(last.content, "theme: dark") {
|
||||
t.Fatalf("expected confirmation message, got %q", last.content)
|
||||
}
|
||||
|
||||
// Switch back to light
|
||||
m.input.SetValue("/theme light")
|
||||
next, _ = m.submit()
|
||||
m = next.(model)
|
||||
if m.theme.Mode != ThemeLight {
|
||||
t.Fatalf("after /theme light: Mode = %q, want light", m.theme.Mode)
|
||||
}
|
||||
requireNoStyleColor(t, "restored App.Background", m.styles.App.GetBackground())
|
||||
|
||||
// No arg shows usage
|
||||
m.input.SetValue("/theme")
|
||||
next, _ = m.submit()
|
||||
m = next.(model)
|
||||
last = m.messages[len(m.messages)-1]
|
||||
if !strings.Contains(last.content, "usage: /theme") {
|
||||
t.Fatalf("expected usage hint, got %q", last.content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanToolLog(t *testing.T) {
|
||||
got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n")
|
||||
want := `shell_run {"command":"pwd"}`
|
||||
|
||||
Reference in New Issue
Block a user