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:
@@ -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
|
||||
|
||||
+34
-2
@@ -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 {
|
||||
|
||||
@@ -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"}`
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user