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 .
|
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:
|
Use `--yolo` only when you want agentu to write files or run shell commands:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+34
-2
@@ -145,7 +145,7 @@ var slashCommands = []slashCommand{
|
|||||||
{Name: "/new", Description: "new session"},
|
{Name: "/new", Description: "new session"},
|
||||||
{Name: "/status", Description: "show changed files"},
|
{Name: "/status", Description: "show changed files"},
|
||||||
{Name: "/diff", Description: "show unstaged diff"},
|
{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 {
|
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.status = "ready"
|
||||||
m.refreshViewport(true)
|
m.refreshViewport(true)
|
||||||
return *m, nil
|
return *m, nil
|
||||||
case "/model", "/compact":
|
case "/model", "/compact", "/theme":
|
||||||
return m.runLocalCommand(input)
|
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) {
|
func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
|
||||||
|
if strings.HasPrefix(input, "/theme") {
|
||||||
|
return m.handleThemeCommand(input)
|
||||||
|
}
|
||||||
output, err := m.handleLocalCommand(input)
|
output, err := m.handleLocalCommand(input)
|
||||||
if err == errOpenPicker {
|
if err == errOpenPicker {
|
||||||
m.openSessionPicker()
|
m.openSessionPicker()
|
||||||
@@ -1114,6 +1117,35 @@ func (m *model) runLocalCommand(input string) (tea.Model, tea.Cmd) {
|
|||||||
return *m, nil
|
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) {
|
func (m model) handleLocalCommand(input string) (string, error) {
|
||||||
fields := strings.Fields(input)
|
fields := strings.Fields(input)
|
||||||
if len(fields) == 0 {
|
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) {
|
func TestCleanToolLog(t *testing.T) {
|
||||||
got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n")
|
got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n")
|
||||||
want := `shell_run {"command":"pwd"}`
|
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 {
|
func (t theme) styles() styles {
|
||||||
return styles{
|
return styles{
|
||||||
App: lipgloss.NewStyle().
|
App: appStyle(t),
|
||||||
Foreground(lipgloss.Color(t.Text)),
|
|
||||||
|
|
||||||
Muted: lipgloss.NewStyle().
|
Muted: lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(t.Muted)),
|
Foreground(lipgloss.Color(t.Muted)),
|
||||||
|
|||||||
Reference in New Issue
Block a user