diff --git a/README.md b/README.md index fe25b7e..ffb836d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ export AGENTU_API_KEY='your-api-key' go run . ``` +Use dark mode when your terminal is dark: + +```sh +go run . --theme dark +``` + Use `--yolo` only when you want agentu to write files or run shell commands: ```sh diff --git a/internal/tui/app.go b/internal/tui/app.go index f122595..db7cc10 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -145,7 +145,7 @@ var slashCommands = []slashCommand{ {Name: "/new", Description: "new session"}, {Name: "/status", Description: "show changed files"}, {Name: "/diff", Description: "show unstaged diff"}, - {Name: "/test", Description: "run project tests"}, + {Name: "/theme", Description: "switch theme light|dark"}, } func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { @@ -389,7 +389,7 @@ func (m *model) submit() (tea.Model, tea.Cmd) { m.status = "ready" m.refreshViewport(true) return *m, nil - case "/model", "/compact": + case "/model", "/compact", "/theme": return m.runLocalCommand(input) } @@ -1090,6 +1090,9 @@ func fitLine(text string, width int) string { } func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { + if strings.HasPrefix(input, "/theme") { + return m.handleThemeCommand(input) + } output, err := m.handleLocalCommand(input) if err == errOpenPicker { m.openSessionPicker() @@ -1114,6 +1117,35 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) { return *m, nil } +func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) { + fields := strings.Fields(input) + if len(fields) < 2 { + m.input.SetValue("") + m.afterInputChanged() + m.messages = append(m.messages, message{ + role: roleSystem, content: "theme: " + string(m.theme.Mode) + "\nusage: /theme light|dark", + }) + m.refreshViewport(true) + return *m, nil + } + mode, err := ParseThemeMode(fields[1]) + if err != nil { + m.input.SetValue("") + m.afterInputChanged() + m.messages = append(m.messages, message{role: roleError, content: err.Error()}) + m.refreshViewport(true) + return *m, nil + } + m.theme = themeForMode(mode) + m.styles = m.theme.styles() + applyTextareaTheme(&m.input, m.theme) + m.input.SetValue("") + m.afterInputChanged() + m.messages = append(m.messages, message{role: roleSystem, content: "theme: " + string(mode)}) + m.refreshViewport(true) + return *m, nil +} + func (m model) handleLocalCommand(input string) (string, error) { fields := strings.Fields(input) if len(fields) == 0 { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 666f6e3..1a68a9f 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -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"}` diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 7640dd2..6f53588 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -130,10 +130,17 @@ func darkTheme() theme { } } +func appStyle(t theme) lipgloss.Style { + style := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Text)) + if t.Mode == ThemeDark { + style = style.Background(lipgloss.Color(t.Background)) + } + return style +} + func (t theme) styles() styles { return styles{ - App: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)), + App: appStyle(t), Muted: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)),