diff --git a/.dockerignore b/.dockerignore index 4ceee3743..57a5f070e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,5 @@ !./**/*.css !./**/*.go !./**/*.txt -!/pkg/config/default-agent.yaml \ No newline at end of file +!/pkg/config/default-agent.yaml +!/pkg/tui/styles/themes/*.yaml \ No newline at end of file diff --git a/cmd/root/run.go b/cmd/root/run.go index 6a109603b..dd41cf477 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -22,6 +22,7 @@ import ( "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/teamloader" "github.com/docker/cagent/pkg/telemetry" + "github.com/docker/cagent/pkg/tui/styles" ) type runExecFlags struct { @@ -234,6 +235,11 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s } defer cleanup() + // Apply theme before TUI starts + if tui { + applyTheme() + } + if f.dryRun { out.Println("Dry run mode enabled. Agent initialized but will not execute.") return nil @@ -440,3 +446,21 @@ func (f *runExecFlags) handleRunMode(ctx context.Context, rt runtime.Runtime, se return runTUI(ctx, rt, sess, opts...) } + +// applyTheme applies the theme from user config, or the built-in default. +func applyTheme() { + // Resolve theme from user config > built-in default + themeRef := styles.DefaultThemeRef + if userSettings := config.GetUserSettings(); userSettings.Theme != "" { + themeRef = userSettings.Theme + } + + theme, err := styles.LoadTheme(themeRef) + if err != nil { + slog.Warn("Failed to load theme, using default", "theme", themeRef, "error", err) + theme = styles.DefaultTheme() + } + + styles.ApplyTheme(theme) + slog.Debug("Applied theme", "theme_ref", themeRef, "theme_name", theme.Name) +} diff --git a/docs/USAGE.md b/docs/USAGE.md index 7275a219e..8797235a4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -170,6 +170,7 @@ During TUI sessions, you can use special slash commands. Type `/` to see all ava | `/sessions` | Browse and load past sessions | | `/shell` | Start a shell | | `/star` | Toggle star on current session | +| `/theme` | Change the color theme (see [Theming](#theming)) | | `/think` | Toggle thinking/reasoning mode | | `/yolo` | Toggle automatic approval of tool calls | @@ -195,6 +196,89 @@ The `/model` command (or `ctrl+m`) allows you to change the AI model used by the To revert to the agent's default model, select the model marked with "(default)" in the picker. +#### Theming + +The TUI supports customizable color themes. You can create and use custom themes to personalize the appearance of the terminal interface. + +**Theme Configuration:** + +Your theme preference is saved globally in `~/.config/cagent/config.yaml` under `settings.theme`. If not set, the built-in default theme is used. + +**Creating Custom Themes:** + +Create theme files in `~/.cagent/themes/` as YAML files (`.yaml` or `.yml`). Theme files are **partial overrides** — you only need to specify the colors you want to change. Any omitted keys fall back to the built-in default theme values. + +```yaml +# ~/.cagent/themes/my-theme.yaml +name: "My Custom Theme" + +colors: + # Backgrounds + background: "#1a1a2e" + background_alt: "#16213e" + + # Text colors + text_bright: "#ffffff" + text_primary: "#e8e8e8" + text_secondary: "#b0b0b0" + text_muted: "#707070" + + # Accent colors + accent: "#4fc3f7" + brand: "#1d96f3" + + # Status colors + success: "#4caf50" + error: "#f44336" + warning: "#ff9800" + info: "#00bcd4" + +# Optional: Customize syntax highlighting colors +chroma: + comment: "#6a9955" + keyword: "#569cd6" + literal_string: "#ce9178" + +# Optional: Customize markdown rendering colors +markdown: + heading: "#4fc3f7" + link: "#569cd6" + code: "#ce9178" +``` + +**Applying Themes:** + +- **In user config** (`~/.config/cagent/config.yaml`): + ```yaml + settings: + theme: my-theme # References ~/.cagent/themes/my-theme.yaml + ``` + +- **At runtime**: Use the `/theme` command to open the theme picker and select from available themes. Your selection is automatically saved to user config and persists across sessions. + +**Hot Reload:** Custom theme files are automatically watched for changes. When you edit a user theme file (in `~/.cagent/themes/`), the changes are applied immediately without needing to restart cagent or re-select the theme. This makes it easy to customize themes while seeing changes in real-time. + + +> **Note:** All user themes are partial overrides applied on top of the `default` theme. If you want to customize a built-in theme, copy the full YAML from the [built-in themes on GitHub](https://github.com/docker/cagent/tree/main/pkg/tui/styles/themes) into `~/.cagent/themes/` and edit the copy. Otherwise, omitted values will use `default` colors, not the original theme's colors. + +**Built-in Themes:** + +The following themes are included: +- `default` — The built-in default theme with a dark color scheme +- `catppuccin-latte`, `catppuccin-mocha` — Catppuccin themes (light and dark) +- `dracula` — Dracula dark theme +- `gruvbox-dark`, `gruvbox-light` — Gruvbox themes +- `nord` — Nord dark theme +- `one-dark` — One Dark theme +- `solarized-dark` — Solarized dark theme +- `tokyo-night` — Tokyo Night dark theme + +**Available Color Keys:** + +Themes can customize colors in three sections: `colors`, `chroma` (syntax highlighting), and `markdown` (markdown rendering). + +See the [built-in themes on GitHub](https://github.com/docker/cagent/tree/main/pkg/tui/styles/themes) for complete examples. + ## 🔧 Configuration Reference ### Agent Properties diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index a20594e6e..43525b316 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -186,6 +186,16 @@ func builtInSessionCommands() []Item { return core.CmdHandler(messages.AttachFileMsg{FilePath: arg}) }, }, + { + ID: "settings.theme", + Label: "Theme", + SlashCommand: "/theme", + Description: "Change the color theme (saved globally)", + Category: "Settings", + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.OpenThemePickerMsg{}) + }, + }, } // Add speak command on supported platforms (macOS only) diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index d0a17b0f2..5eb566fc0 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -550,6 +550,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { e.keyboardEnhancementsSupported = msg.Flags != 0 e.configureNewlineKeybinding() return e, nil + case messages.ThemeChangedMsg: + e.textarea.SetStyles(styles.InputStyle) + return e, nil case tea.WindowSizeMsg: e.textarea.SetWidth(msg.Width - 2) return e, nil diff --git a/pkg/tui/components/markdown/fast_renderer.go b/pkg/tui/components/markdown/fast_renderer.go index 394290d14..a2f8fdd8a 100644 --- a/pkg/tui/components/markdown/fast_renderer.go +++ b/pkg/tui/components/markdown/fast_renderer.go @@ -160,9 +160,27 @@ type cachedStyles struct { var ( globalStyles *cachedStyles globalStylesOnce sync.Once + globalStylesMu sync.Mutex ) +// ResetStyles resets the cached markdown styles so they will be rebuilt on next use. +// Call this when the theme changes to pick up new colors. +func ResetStyles() { + globalStylesMu.Lock() + globalStyles = nil + globalStylesOnce = sync.Once{} + globalStylesMu.Unlock() + + // Also clear chroma syntax highlighting cache + chromaStyleCacheMu.Lock() + chromaStyleCache = make(map[chroma.TokenType]ansiStyle) + chromaStyleCacheMu.Unlock() +} + func getGlobalStyles() *cachedStyles { + globalStylesMu.Lock() + defer globalStylesMu.Unlock() + globalStylesOnce.Do(func() { mdStyle := styles.MarkdownStyle() @@ -207,7 +225,7 @@ func getGlobalStyles() *cachedStyles { buildAnsiStyle(headingLipStyles[5]), }, ansiBlockquote: buildAnsiStyle(blockquoteLipStyle), - ansiFootnote: buildAnsiStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)), + ansiFootnote: buildAnsiStyle(lipgloss.NewStyle().Foreground(styles.TextSecondary).Italic(true)), styleTaskTicked: mdStyle.Task.Ticked, styleTaskUntick: mdStyle.Task.Unticked, listIndent: int(mdStyle.List.LevelIndent), diff --git a/pkg/tui/components/markdown/fast_renderer_test.go b/pkg/tui/components/markdown/fast_renderer_test.go index efb8970f1..d573c989d 100644 --- a/pkg/tui/components/markdown/fast_renderer_test.go +++ b/pkg/tui/components/markdown/fast_renderer_test.go @@ -1061,9 +1061,11 @@ func TestInlineCodeRestoresBaseStyle(t *testing.T) { // - Resets between styled segments require.GreaterOrEqual(t, len(seqs), 3, "Should have at least 3 ANSI sequences") - // Verify that the base document style (color 252) appears somewhere (for text styling) + // Verify that text styling is applied (either ANSI 256 color or RGB) allSeqs := strings.Join(seqs, "") - assert.Contains(t, allSeqs, "38;5;252", "Should have document text color applied") + // Text color can be either "38;5;N" (256 color) or "38;2;R;G;B" (RGB) depending on theme + hasTextColor := strings.Contains(allSeqs, "38;5;") || strings.Contains(allSeqs, "38;2;") + assert.True(t, hasTextColor, "Should have text color applied (38;5; or 38;2;)") // Verify code style appears (RGB foreground and background) assert.Contains(t, allSeqs, "38;2;", "Code style should have RGB foreground") diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index f9e9c2047..655748d2d 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -188,6 +188,19 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { m.invalidateAllItems() return m, nil + case messages.ThemeChangedMsg: + // Theme changed - invalidate all render caches + m.invalidateAllItems() + editfile.InvalidateCaches() + for i, view := range m.views { + updatedView, cmd := view.Update(msg) + m.views[i] = updatedView + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return m, tea.Batch(cmds...) + case reasoningblock.BlockMsg: return m.forwardToReasoningBlock(msg.GetBlockID(), msg) diff --git a/pkg/tui/components/reasoningblock/reasoningblock.go b/pkg/tui/components/reasoningblock/reasoningblock.go index 743ef1b65..c96c155bf 100644 --- a/pkg/tui/components/reasoningblock/reasoningblock.go +++ b/pkg/tui/components/reasoningblock/reasoningblock.go @@ -17,6 +17,7 @@ import ( "github.com/docker/cagent/pkg/tui/components/markdown" "github.com/docker/cagent/pkg/tui/components/tool" "github.com/docker/cagent/pkg/tui/core/layout" + "github.com/docker/cagent/pkg/tui/messages" "github.com/docker/cagent/pkg/tui/service" "github.com/docker/cagent/pkg/tui/styles" "github.com/docker/cagent/pkg/tui/types" @@ -369,7 +370,11 @@ func (m *Model) Init() tea.Cmd { // Update handles messages. func (m *Model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { - if _, ok := msg.(animation.TickMsg); ok { + switch msg.(type) { + case messages.ThemeChangedMsg: + // Theme changed - invalidate cached rendering + m.cache = nil + case animation.TickMsg: // Compute fade levels based on elapsed time (tick-rate independent) m.computeFadeProgressAt(nowFunc()) // Unregister if no more fading tools (uses fadeProgress computed above) diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 99bc5305a..7297c8966 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -459,6 +459,31 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { delete(m.ragIndexing, k) } return m, nil + case messages.ThemeChangedMsg: + // Theme changed - recreate spinners with new colors + // The spinner pre-renders frames with colors, so we need to recreate it + var cmds []tea.Cmd + + // Recreate main spinner + wasActive := m.spinnerActive + if wasActive { + m.spinner.Stop() + } + m.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle) + if wasActive { + cmd := m.spinner.Init() + m.spinnerActive = true + cmds = append(cmds, cmd) + } + + // Recreate all RAG indexing spinners + for _, state := range m.ragIndexing { + state.spinner.Stop() + state.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle) + cmds = append(cmds, state.spinner.Init()) + } + + return m, tea.Batch(cmds...) default: var cmds []tea.Cmd diff --git a/pkg/tui/components/statusbar/statusbar.go b/pkg/tui/components/statusbar/statusbar.go index 4d5767c4a..5a2fdbb77 100644 --- a/pkg/tui/components/statusbar/statusbar.go +++ b/pkg/tui/components/statusbar/statusbar.go @@ -25,8 +25,7 @@ type StatusBar struct { // New creates a new StatusBar instance func New(help core.KeyMapHelp) StatusBar { return StatusBar{ - help: help, - cachedVersionText: styles.MutedStyle.Render("cagent " + version.Version), + help: help, } } @@ -58,8 +57,21 @@ func (s *StatusBar) formatHelpString(bindings []key.Binding) string { return strings.Join(helpParts, " ") } +// InvalidateCache clears all cached values. +// Call this when the theme changes to pick up new colors. +func (s *StatusBar) InvalidateCache() { + s.cachedHelpText = "" + s.cachedVersionText = "" + s.cachedBindingsLen = 0 +} + // View renders the status bar func (s *StatusBar) View() string { + // Regenerate version text if empty + if s.cachedVersionText == "" { + s.cachedVersionText = styles.MutedStyle.Render("cagent " + version.Version) + } + var helpText string if s.help != nil { help := s.help.Help() diff --git a/pkg/tui/components/tool/editfile/render.go b/pkg/tui/components/tool/editfile/render.go index f0a8c52cd..4b1c4e299 100644 --- a/pkg/tui/components/tool/editfile/render.go +++ b/pkg/tui/components/tool/editfile/render.go @@ -49,6 +49,16 @@ var ( lexerCacheMu sync.RWMutex ) +// InvalidateCaches clears all render caches. +// Call this when the theme changes to pick up new colors. +func InvalidateCaches() { + cacheMu.Lock() + for _, c := range cache { + c.renderCached = false + } + cacheMu.Unlock() +} + type chromaToken struct { Text string Style lipgloss.Style diff --git a/pkg/tui/dialog/cost.go b/pkg/tui/dialog/cost.go index b41441af9..e41879a60 100644 --- a/pkg/tui/dialog/cost.go +++ b/pkg/tui/dialog/cost.go @@ -202,17 +202,17 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { RenderTitle("Session Cost Details", contentWidth, styles.DialogTitleStyle), RenderSeparator(contentWidth), "", - sectionStyle.Render("Total"), + sectionStyle().Render("Total"), "", - accentStyle.Render(formatCost(data.total.cost)), + accentStyle().Render(formatCost(data.total.cost)), d.renderInputLine(data.total, true), - fmt.Sprintf("%s %s", labelStyle.Render("output:"), valueStyle.Render(formatTokenCount(data.total.OutputTokens))), + fmt.Sprintf("%s %s", labelStyle().Render("output:"), valueStyle().Render(formatTokenCount(data.total.OutputTokens))), "", } // By Model Section if len(data.models) > 0 { - lines = append(lines, sectionStyle.Render("By Model"), "") + lines = append(lines, sectionStyle().Render("By Model"), "") for _, m := range data.models { lines = append(lines, d.renderUsageLine(m)) } @@ -221,7 +221,7 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { // By Message Section if len(data.messages) > 0 { - lines = append(lines, sectionStyle.Render("By Message"), "") + lines = append(lines, sectionStyle().Render("By Message"), "") for _, m := range data.messages { lines = append(lines, d.renderUsageLine(m)) } @@ -235,9 +235,9 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { } func (d *costDialog) renderInputLine(u totalUsage, showBreakdown bool) string { - line := fmt.Sprintf("%s %s", labelStyle.Render("input:"), valueStyle.Render(formatTokenCount(u.totalInput()))) + line := fmt.Sprintf("%s %s", labelStyle().Render("input:"), valueStyle().Render(formatTokenCount(u.totalInput()))) if showBreakdown && (u.CachedInputTokens > 0 || u.CacheWriteTokens > 0) { - line += valueStyle.Render(fmt.Sprintf(" (%s new + %s cached + %s cache write)", + line += valueStyle().Render(fmt.Sprintf(" (%s new + %s cached + %s cache write)", formatTokenCount(u.InputTokens), formatTokenCount(u.CachedInputTokens), formatTokenCount(u.CacheWriteTokens))) @@ -247,12 +247,12 @@ func (d *costDialog) renderInputLine(u totalUsage, showBreakdown bool) string { func (d *costDialog) renderUsageLine(u totalUsage) string { return fmt.Sprintf("%s %s %s %s %s %s", - accentStyle.Render(padRight(formatCostPadded(u.cost))), - labelStyle.Render("input:"), - valueStyle.Render(padRight(formatTokenCount(u.totalInput()))), - labelStyle.Render("output:"), - valueStyle.Render(padRight(formatTokenCount(u.OutputTokens))), - accentStyle.Render(u.label)) + accentStyle().Render(padRight(formatCostPadded(u.cost))), + labelStyle().Render("input:"), + valueStyle().Render(padRight(formatTokenCount(u.totalInput()))), + labelStyle().Render("output:"), + valueStyle().Render(padRight(formatTokenCount(u.OutputTokens))), + accentStyle().Render(u.label)) } func (d *costDialog) applyScrolling(allLines []string, contentWidth, maxHeight int) string { @@ -323,13 +323,22 @@ func (d *costDialog) renderPlainText() string { return strings.Join(lines, "\n") } -// Styles -var ( - sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(styles.ColorTextSecondary)) - labelStyle = lipgloss.NewStyle().Bold(true) - valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(styles.ColorTextSecondary)) - accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(styles.ColorHighlight)) -) +// Style getters - use functions to pick up theme changes dynamically +func sectionStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(styles.TextSecondary) +} + +func labelStyle() lipgloss.Style { + return lipgloss.NewStyle().Bold(true) +} + +func valueStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.TextSecondary) +} + +func accentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(styles.Highlight) +} func formatCost(cost float64) string { if cost < 0.0001 { diff --git a/pkg/tui/dialog/dialog.go b/pkg/tui/dialog/dialog.go index 39f666720..d1f44df5b 100644 --- a/pkg/tui/dialog/dialog.go +++ b/pkg/tui/dialog/dialog.go @@ -5,6 +5,7 @@ import ( "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/core/layout" + "github.com/docker/cagent/pkg/tui/messages" ) // OpenDialogMsg is sent to open a new dialog @@ -65,6 +66,16 @@ func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } return d, tea.Batch(cmds...) + case messages.ThemeChangedMsg: + // Propagate theme change to all dialogs in the stack so they can invalidate caches + var cmds []tea.Cmd + for i := range d.dialogStack { + u, cmd := d.dialogStack[i].Update(msg) + d.dialogStack[i] = u.(Dialog) + cmds = append(cmds, cmd) + } + return d, tea.Batch(cmds...) + case OpenDialogMsg: return d.handleOpen(msg) diff --git a/pkg/tui/dialog/model_picker.go b/pkg/tui/dialog/model_picker.go index e595c1662..b916052ac 100644 --- a/pkg/tui/dialog/model_picker.go +++ b/pkg/tui/dialog/model_picker.go @@ -649,8 +649,7 @@ func (d *modelPickerDialog) View() string { // Show error message if present if d.errMsg != "" { - errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - contentBuilder.AddContent(errorStyle.Render("⚠ " + d.errMsg)) + contentBuilder.AddContent(styles.ErrorStyle.Render("⚠ " + d.errMsg)) } content := contentBuilder. diff --git a/pkg/tui/dialog/theme_picker.go b/pkg/tui/dialog/theme_picker.go new file mode 100644 index 000000000..8a72a17ba --- /dev/null +++ b/pkg/tui/dialog/theme_picker.go @@ -0,0 +1,719 @@ +package dialog + +import ( + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/cagent/pkg/tui/components/scrollbar" + "github.com/docker/cagent/pkg/tui/components/toolcommon" + "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" + "github.com/docker/cagent/pkg/tui/messages" + "github.com/docker/cagent/pkg/tui/styles" +) + +// ThemeChoice represents a selectable theme option +type ThemeChoice struct { + Ref string // Theme reference ("default" for built-in default) + Name string // Display name + IsCurrent bool // Currently active theme + IsDefault bool // Built-in default theme ("default") + IsBuiltin bool // Built-in theme shipped with cagent +} + +// themePickerDialog is a dialog for selecting a theme. +type themePickerDialog struct { + BaseDialog + textInput textinput.Model + themes []ThemeChoice + filtered []ThemeChoice + selected int + keyMap commandPaletteKeyMap + scrollbar *scrollbar.Model + needsScrollToSel bool + + // Double-click detection + lastClickTime time.Time + lastClickIndex int + + // Original theme for restoration on cancel + originalThemeRef string + + // Avoid re-applying the same preview repeatedly (e.g., during filtering) + lastPreviewRef string +} + +// NewThemePickerDialog creates a new theme picker dialog. +// originalThemeRef is the currently active theme ref (for restoration on cancel). +func NewThemePickerDialog(themes []ThemeChoice, originalThemeRef string) Dialog { + ti := textinput.New() + ti.Placeholder = "Type to search themes…" + ti.Focus() + ti.CharLimit = 100 + ti.SetWidth(50) + ti.SetStyles(styles.DialogInputStyle) + + // Sort themes: built-in first, then custom. Within each section: + // current first, then default, then alphabetically. + sortedThemes := make([]ThemeChoice, len(themes)) + copy(sortedThemes, themes) + sort.Slice(sortedThemes, func(i, j int) bool { + getPriority := func(t ThemeChoice) int { + if t.IsBuiltin { + return 0 + } + return 1 + } + pi, pj := getPriority(sortedThemes[i]), getPriority(sortedThemes[j]) + if pi != pj { + return pi < pj + } + if sortedThemes[i].IsCurrent != sortedThemes[j].IsCurrent { + return sortedThemes[i].IsCurrent + } + if sortedThemes[i].IsDefault != sortedThemes[j].IsDefault { + return sortedThemes[i].IsDefault + } + ni := strings.ToLower(sortedThemes[i].Name) + nj := strings.ToLower(sortedThemes[j].Name) + if ni != nj { + return ni < nj + } + return sortedThemes[i].Ref < sortedThemes[j].Ref + }) + + d := &themePickerDialog{ + textInput: ti, + themes: themes, + filtered: nil, + keyMap: defaultCommandPaletteKeyMap(), + scrollbar: scrollbar.New(), + originalThemeRef: originalThemeRef, + } + + d.themes = sortedThemes + d.filterThemes() + + // Find current theme and select it (if multiple are marked current, pick first) + for i, t := range d.filtered { + if t.IsCurrent { + d.selected = i + d.needsScrollToSel = true // Scroll to current selection on open + break + } + } + + // Initialize preview tracking to current selection (theme is already applied when dialog opens) + if d.selected >= 0 && d.selected < len(d.filtered) { + sel := d.filtered[d.selected] + d.lastPreviewRef = sel.Ref + } + + return d +} + +func (d *themePickerDialog) Init() tea.Cmd { + return textinput.Blink +} + +func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case messages.ThemeChangedMsg: + // Theme changed (preview/hot reload) - update textinput styles + d.textInput.SetStyles(styles.DialogInputStyle) + return d, nil + + case tea.PasteMsg: + // Forward paste to text input + var cmd tea.Cmd + d.textInput, cmd = d.textInput.Update(msg) + if selectionChanged := d.filterThemes(); selectionChanged { + d.needsScrollToSel = true + return d, tea.Batch(cmd, d.emitPreview()) + } + return d, cmd + + case tea.MouseClickMsg: + return d.handleMouseClick(msg) + + case tea.MouseMotionMsg: + return d.handleMouseMotion(msg) + + case tea.MouseReleaseMsg: + return d.handleMouseRelease(msg) + + case tea.MouseWheelMsg: + return d.handleMouseWheel(msg) + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + + switch { + case key.Matches(msg, d.keyMap.Escape): + // Restore original theme on cancel + return d, tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.ThemeCancelPreviewMsg{OriginalRef: d.originalThemeRef}), + ) + + case key.Matches(msg, d.keyMap.Up): + if d.selected > 0 { + d.selected-- + d.needsScrollToSel = true + cmd := d.emitPreview() + return d, cmd + } + return d, nil + + case key.Matches(msg, d.keyMap.Down): + if d.selected < len(d.filtered)-1 { + d.selected++ + d.needsScrollToSel = true + cmd := d.emitPreview() + return d, cmd + } + return d, nil + + case key.Matches(msg, d.keyMap.PageUp): + oldSelected := d.selected + d.selected -= d.pageSize() + if d.selected < 0 { + d.selected = 0 + } + d.needsScrollToSel = true + if d.selected != oldSelected { + cmd := d.emitPreview() + return d, cmd + } + return d, nil + + case key.Matches(msg, d.keyMap.PageDown): + oldSelected := d.selected + d.selected += d.pageSize() + if d.selected >= len(d.filtered) { + d.selected = max(0, len(d.filtered)-1) + } + d.needsScrollToSel = true + if d.selected != oldSelected { + cmd := d.emitPreview() + return d, cmd + } + return d, nil + + case key.Matches(msg, d.keyMap.Enter): + cmd := d.handleSelection() + return d, cmd + + default: + var cmd tea.Cmd + d.textInput, cmd = d.textInput.Update(msg) + if selectionChanged := d.filterThemes(); selectionChanged { + d.needsScrollToSel = true + return d, tea.Batch(cmd, d.emitPreview()) + } + return d, cmd + } + } + + return d, nil +} + +func (d *themePickerDialog) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd) { + // Check if click is on the scrollbar + if d.isMouseOnScrollbar(msg.X, msg.Y) { + sb, cmd := d.scrollbar.Update(msg) + d.scrollbar = sb + return d, cmd + } + + // Check if click is on a theme in the list + if msg.Button == tea.MouseLeft { + if themeIdx := d.mouseYToThemeIndex(msg.Y); themeIdx >= 0 { + now := time.Now() + + // Check for double-click + if themeIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { + d.selected = themeIdx + d.lastClickTime = time.Time{} + cmd := d.handleSelection() + return d, cmd + } + + // Single click: highlight and preview + oldSelected := d.selected + d.selected = themeIdx + d.lastClickTime = now + d.lastClickIndex = themeIdx + + // Emit preview if selection changed + if d.selected != oldSelected { + cmd := d.emitPreview() + return d, cmd + } + } + } + return d, nil +} + +func (d *themePickerDialog) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) { + if d.scrollbar.IsDragging() { + sb, cmd := d.scrollbar.Update(msg) + d.scrollbar = sb + return d, cmd + } + return d, nil +} + +func (d *themePickerDialog) handleMouseRelease(msg tea.MouseReleaseMsg) (layout.Model, tea.Cmd) { + if d.scrollbar.IsDragging() { + sb, cmd := d.scrollbar.Update(msg) + d.scrollbar = sb + return d, cmd + } + return d, nil +} + +func (d *themePickerDialog) handleMouseWheel(msg tea.MouseWheelMsg) (layout.Model, tea.Cmd) { + if !d.isMouseInDialog(msg.X, msg.Y) { + return d, nil + } + + buttonStr := msg.Button.String() + switch buttonStr { + case "wheelup": + d.scrollbar.ScrollUp() + d.scrollbar.ScrollUp() + case "wheeldown": + d.scrollbar.ScrollDown() + d.scrollbar.ScrollDown() + } + return d, nil +} + +func (d *themePickerDialog) isMouseInDialog(x, y int) bool { + dialogRow, dialogCol := d.Position() + dialogWidth, maxHeight, _ := d.dialogSize() + return x >= dialogCol && x < dialogCol+dialogWidth && + y >= dialogRow && y < dialogRow+maxHeight +} + +func (d *themePickerDialog) isMouseOnScrollbar(x, y int) bool { + dialogWidth, maxHeight, _ := d.dialogSize() + maxItems := maxHeight - pickerListVerticalOverhead + + // If the list fits, there is no scrollbar. + // Note: total lines include category separators (if any). + if d.totalLineCount() <= maxItems { + return false + } + + dialogRow, dialogCol := d.Position() + scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width + scrollbarY := dialogRow + pickerScrollbarYOffset + + return x >= scrollbarX && x < scrollbarX+scrollbar.Width && + y >= scrollbarY && y < scrollbarY+maxItems +} + +func (d *themePickerDialog) mouseYToThemeIndex(y int) int { + dialogRow, _ := d.Position() + _, maxHeight, _ := d.dialogSize() + maxItems := maxHeight - pickerListVerticalOverhead + + listStartY := dialogRow + pickerListStartOffset + listEndY := listStartY + maxItems + + if y < listStartY || y >= listEndY { + return -1 + } + + lineInView := y - listStartY + scrollOffset := d.scrollbar.GetScrollOffset() + actualLine := scrollOffset + lineInView + + return d.lineToThemeIndex(actualLine) +} + +func (d *themePickerDialog) handleSelection() tea.Cmd { + if d.selected >= 0 && d.selected < len(d.filtered) { + selected := d.filtered[d.selected] + return tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(messages.ChangeThemeMsg{ThemeRef: selected.Ref}), + ) + } + return nil +} + +// emitPreview requests a theme preview via an app-level message. +func (d *themePickerDialog) emitPreview() tea.Cmd { + if d.selected >= 0 && d.selected < len(d.filtered) { + selected := d.filtered[d.selected] + + // Skip if we're already previewing this exact selection. + if selected.Ref == d.lastPreviewRef { + return nil + } + d.lastPreviewRef = selected.Ref + + return core.CmdHandler(messages.ThemePreviewMsg{ + ThemeRef: selected.Ref, + OriginalRef: d.originalThemeRef, + }) + } + return nil +} + +const customThemesSeparatorLabel = "── Custom themes " + +func (d *themePickerDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { + // Match the model picker sizing for consistent UI. + dialogWidth = max(min(d.Width()*pickerWidthPercent/100, pickerMaxWidth), pickerMinWidth) + maxHeight = min(d.Height()*pickerHeightPercent/100, pickerMaxHeight) + contentWidth = dialogWidth - pickerDialogPadding - scrollbar.Width - pickerScrollbarGap + return dialogWidth, maxHeight, contentWidth +} + +func (d *themePickerDialog) View() string { + dialogWidth, maxHeight, contentWidth := d.dialogSize() + d.textInput.SetWidth(contentWidth) + maxItems := maxHeight - pickerListVerticalOverhead + + // Build all theme lines + var allLines []string + customSeparatorShown := false + + // Pre-compute which groups exist to decide on separators + hasBuiltinThemes := false + for _, t := range d.filtered { + if t.IsBuiltin { + hasBuiltinThemes = true + break + } + } + + for i, theme := range d.filtered { + // Add separator before first custom theme if there are built-in themes above. + if !theme.IsBuiltin && !customSeparatorShown { + if hasBuiltinThemes { + separatorLine := styles.MutedStyle.Render(customThemesSeparatorLabel + strings.Repeat("─", max(0, contentWidth-lipgloss.Width(customThemesSeparatorLabel)-2))) + allLines = append(allLines, separatorLine) + } + customSeparatorShown = true + } + + allLines = append(allLines, d.renderTheme(theme, i == d.selected, contentWidth)) + } + + totalLines := len(allLines) + visibleLines := maxItems + + // Update scrollbar dimensions + d.scrollbar.SetDimensions(visibleLines, totalLines) + + // Auto-scroll to selection when keyboard navigation occurred + if d.needsScrollToSel { + selectedLine := d.findSelectedLine(allLines) + scrollOffset := d.scrollbar.GetScrollOffset() + if selectedLine < scrollOffset { + d.scrollbar.SetScrollOffset(selectedLine) + } else if selectedLine >= scrollOffset+visibleLines { + d.scrollbar.SetScrollOffset(selectedLine - visibleLines + 1) + } + d.needsScrollToSel = false + } + + // Slice visible lines based on scroll offset + scrollOffset := d.scrollbar.GetScrollOffset() + visibleEnd := min(scrollOffset+visibleLines, totalLines) + visibleThemeLines := allLines[scrollOffset:visibleEnd] + + // Pad with empty lines if content is shorter than visible area + for len(visibleThemeLines) < visibleLines { + visibleThemeLines = append(visibleThemeLines, "") + } + + // Handle empty state + if len(d.filtered) == 0 { + visibleThemeLines = []string{"", styles.DialogContentStyle. + Italic(true). + Align(lipgloss.Center). + Width(contentWidth). + Render("No themes found")} + for len(visibleThemeLines) < visibleLines { + visibleThemeLines = append(visibleThemeLines, "") + } + } + + // Build theme list with fixed width + themeListStyle := lipgloss.NewStyle().Width(contentWidth) + var fixedWidthLines []string + for _, line := range visibleThemeLines { + fixedWidthLines = append(fixedWidthLines, themeListStyle.Render(line)) + } + themeListContent := strings.Join(fixedWidthLines, "\n") + + // Set scrollbar position for mouse hit testing + dialogRow, dialogCol := d.Position() + scrollbarX := dialogCol + dialogWidth - pickerScrollbarXInset - scrollbar.Width + scrollbarY := dialogRow + pickerScrollbarYOffset + d.scrollbar.SetPosition(scrollbarX, scrollbarY) + + // Get scrollbar view + scrollbarView := d.scrollbar.View() + + // Combine content with scrollbar + var scrollableContent string + gap := strings.Repeat(" ", pickerScrollbarGap) + if scrollbarView != "" { + scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, themeListContent, gap, scrollbarView) + } else { + scrollbarPlaceholder := strings.Repeat(" ", scrollbar.Width) + scrollableContent = lipgloss.JoinHorizontal(lipgloss.Top, themeListContent, gap, scrollbarPlaceholder) + } + + content := NewContent(contentWidth+pickerScrollbarGap+scrollbar.Width). + AddTitle("Select Theme"). + AddSpace(). + AddContent(d.textInput.View()). + AddSeparator(). + AddContent(scrollableContent). + AddSpace(). + AddHelpKeys("↑/↓", "navigate", "enter", "select", "esc", "cancel"). + Build() + + return styles.DialogStyle.Width(dialogWidth).Render(content) +} + +func (d *themePickerDialog) renderTheme(theme ThemeChoice, selected bool, maxWidth int) string { + nameStyle, descStyle := styles.PaletteUnselectedActionStyle, styles.PaletteUnselectedDescStyle + defaultBadgeStyle := styles.BadgeDefaultStyle + currentBadgeStyle := styles.BadgeCurrentStyle + if selected { + nameStyle, descStyle = styles.PaletteSelectedActionStyle, styles.PaletteSelectedDescStyle + defaultBadgeStyle = defaultBadgeStyle.Background(styles.MobyBlue) + currentBadgeStyle = currentBadgeStyle.Background(styles.MobyBlue) + } + + // Display name + displayName := theme.Name + + // Build description: for custom themes, show filename (without user: prefix) + // For built-in themes, don't show filename - just the name is enough + var desc string + if !theme.IsBuiltin { + // Custom theme - show filename for identification + baseRef := strings.TrimPrefix(theme.Ref, styles.UserThemePrefix) + desc = baseRef + } + + // Calculate badge widths - show all applicable badges + var badgeWidth int + if theme.IsCurrent { + badgeWidth += lipgloss.Width(" (current)") + } + if theme.IsDefault { + badgeWidth += lipgloss.Width(" (default)") + } + + separatorWidth := 0 + if desc != "" { + separatorWidth = lipgloss.Width(" • ") + } + + // Maximum width for name (leaving space for badges and description). + maxNameWidth := maxWidth - badgeWidth + if desc != "" { + minDescWidth := min(10, lipgloss.Width(desc)) + maxNameWidth = maxWidth - badgeWidth - separatorWidth - minDescWidth + } + + // Truncate name if needed. + if lipgloss.Width(displayName) > maxNameWidth { + displayName = toolcommon.TruncateText(displayName, maxNameWidth) + } + + // Build the name with colored badges - show all applicable badges. + // Order: name (current) (default) - most important context first. + var nameParts []string + nameParts = append(nameParts, nameStyle.Render(displayName)) + if theme.IsCurrent { + nameParts = append(nameParts, currentBadgeStyle.Render(" (current)")) + } + if theme.IsDefault { + nameParts = append(nameParts, defaultBadgeStyle.Render(" (default)")) + } + name := strings.Join(nameParts, "") + + if desc != "" { + nameWidth := lipgloss.Width(name) + remainingWidth := maxWidth - nameWidth - separatorWidth + if remainingWidth > 0 { + truncatedDesc := toolcommon.TruncateText(desc, remainingWidth) + return name + descStyle.Render(" • "+truncatedDesc) + } + } + + return name +} + +func (d *themePickerDialog) pageSize() int { + _, maxHeight, _ := d.dialogSize() + return max(1, maxHeight-pickerListVerticalOverhead) +} + +func (d *themePickerDialog) Position() (row, col int) { + dialogWidth, maxHeight, _ := d.dialogSize() + return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) +} + +func (d *themePickerDialog) filterThemes() (selectionChanged bool) { + query := strings.ToLower(strings.TrimSpace(d.textInput.Value())) + + // Remember current selection so filtering doesn't cause surprising jumps. + prevRef := "" + if d.selected >= 0 && d.selected < len(d.filtered) { + prevRef = d.filtered[d.selected].Ref + } + + d.filtered = nil + for _, theme := range d.themes { + if query == "" { + d.filtered = append(d.filtered, theme) + continue + } + + searchText := strings.ToLower(theme.Name + " " + theme.Ref) + if strings.Contains(searchText, query) { + d.filtered = append(d.filtered, theme) + } + } + + // Restore selection if possible; otherwise fall back to first item. + d.selected = 0 + if prevRef != "" { + for i, t := range d.filtered { + if t.Ref == prevRef { + d.selected = i + break + } + } + } + + // Reset scrollbar when filtering. + d.scrollbar.SetScrollOffset(0) + + // Determine if selection changed. + newRef := "" + if d.selected >= 0 && d.selected < len(d.filtered) { + newRef = d.filtered[d.selected].Ref + } + return newRef != prevRef +} + +// totalLineCount returns the total number of visible list lines, including category separators. +func (d *themePickerDialog) totalLineCount() int { + if len(d.filtered) == 0 { + return 0 + } + + hasBuiltinThemes := false + hasCustomThemes := false + for _, t := range d.filtered { + if t.IsBuiltin { + hasBuiltinThemes = true + } else { + hasCustomThemes = true + } + } + + sepCount := 0 + if hasCustomThemes && hasBuiltinThemes { + sepCount++ + } + + return len(d.filtered) + sepCount +} + +// lineToThemeIndex converts a line index (in the rendered list including separators) +// to a theme index in d.filtered. Returns -1 if the line is a separator. +func (d *themePickerDialog) lineToThemeIndex(lineIdx int) int { + hasBuiltinThemes := false + for _, t := range d.filtered { + if t.IsBuiltin { + hasBuiltinThemes = true + break + } + } + + currentLine := 0 + customSeparatorShown := false + + for i, theme := range d.filtered { + // Custom separator before first custom theme (if built-in themes exist above). + if !theme.IsBuiltin && !customSeparatorShown { + if hasBuiltinThemes { + if currentLine == lineIdx { + return -1 + } + currentLine++ + } + customSeparatorShown = true + } + + if currentLine == lineIdx { + return i + } + currentLine++ + } + + return -1 +} + +// findSelectedLine returns the line index (including separators) that corresponds to the selected theme. +func (d *themePickerDialog) findSelectedLine(allLines []string) int { + if d.selected < 0 || d.selected >= len(d.filtered) { + return 0 + } + + hasBuiltinThemes := false + for _, t := range d.filtered { + if t.IsBuiltin { + hasBuiltinThemes = true + break + } + } + + lineIndex := 0 + customSeparatorShown := false + + for i := range d.selected + 1 { + theme := d.filtered[i] + + if !theme.IsBuiltin && !customSeparatorShown { + if hasBuiltinThemes && i <= d.selected { + lineIndex++ + } + customSeparatorShown = true + } + + if i == d.selected { + return lineIndex + } + lineIndex++ + } + + return min(lineIndex, len(allLines)-1) +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index 02a4e0f6f..8e31b0c0c 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "strings" tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" @@ -20,11 +21,13 @@ import ( "github.com/docker/cagent/pkg/tui/messages" "github.com/docker/cagent/pkg/tui/page/chat" "github.com/docker/cagent/pkg/tui/service" + "github.com/docker/cagent/pkg/tui/styles" ) // Session management handlers func (a *appModel) handleNewSession() (tea.Model, tea.Cmd) { + // Theme is now global - no per-session theme reset needed a.application.NewSession() sess := a.application.Session() a.sessionState = service.NewSessionState(sess) @@ -32,7 +35,10 @@ func (a *appModel) handleNewSession() (tea.Model, tea.Cmd) { a.dialog = dialog.New() a.statusBar.SetHelp(a.chatPage) - return a, tea.Batch(a.Init(), a.handleWindowResize(a.wWidth, a.wHeight)) + return a, tea.Batch( + a.Init(), + a.handleWindowResize(a.wWidth, a.wHeight), + ) } func (a *appModel) handleOpenSessionBrowser() (tea.Model, tea.Cmd) { @@ -67,6 +73,8 @@ func (a *appModel) handleLoadSession(sessionID string) (tea.Model, tea.Cmd) { slog.Debug("Loaded session from store", "session_id", sessionID, "model_overrides", sess.AgentModelOverrides) + // Theme is now global - no per-session theme switching needed + // Cancel current session and replace with loaded one a.application.ReplaceSession(context.Background(), sess) a.sessionState = service.NewSessionState(sess) @@ -74,7 +82,10 @@ func (a *appModel) handleLoadSession(sessionID string) (tea.Model, tea.Cmd) { a.dialog = dialog.New() a.statusBar.SetHelp(a.chatPage) - return a, tea.Batch(a.Init(), a.handleWindowResize(a.wWidth, a.wHeight)) + return a, tea.Batch( + a.Init(), + a.handleWindowResize(a.wWidth, a.wHeight), + ) } func (a *appModel) handleToggleSessionStar(sessionID string) (tea.Model, tea.Cmd) { @@ -346,6 +357,101 @@ func (a *appModel) handleChangeModel(modelRef string) (tea.Model, tea.Cmd) { return a, notification.SuccessCmd(fmt.Sprintf("Model changed to %s", modelRef)) } +// Theme handlers + +func (a *appModel) handleOpenThemePicker() (tea.Model, tea.Cmd) { + // Get available themes + themeRefs, err := styles.ListThemeRefs() + if err != nil { + return a, notification.ErrorCmd(fmt.Sprintf("Failed to list themes: %v", err)) + } + + // Get the currently active global theme + currentTheme := styles.CurrentTheme() + currentRef := currentTheme.Ref + + // Build theme choices + var choices []dialog.ThemeChoice + + for _, ref := range themeRefs { + theme, loadErr := styles.LoadTheme(ref) + if loadErr != nil { + continue + } + + // Use YAML name, or filename as fallback + name := theme.Name + if name == "" { + name = strings.TrimPrefix(ref, styles.UserThemePrefix) + } + + choices = append(choices, dialog.ThemeChoice{ + Ref: ref, + Name: name, + IsCurrent: ref == currentRef, + IsDefault: ref == styles.DefaultThemeRef, + IsBuiltin: styles.IsBuiltinTheme(ref), + }) + } + + return a, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewThemePickerDialog(choices, currentRef), + }) +} + +func (a *appModel) handleChangeTheme(themeRef string) (tea.Model, tea.Cmd) { + // Load and apply the theme + theme, err := styles.LoadTheme(themeRef) + if err != nil { + return a, notification.ErrorCmd(fmt.Sprintf("Failed to load theme: %v", err)) + } + + styles.ApplyTheme(theme) + + // Invalidate caches synchronously + a.invalidateCachesForThemeChange() + + // Persist to user config (global setting) + if err := styles.SaveThemeToUserConfig(themeRef); err != nil { + slog.Warn("Failed to save theme to user config", "theme", themeRef, "error", err) + } + + return a, tea.Sequence( + notification.SuccessCmd(fmt.Sprintf("Theme changed to %s", theme.Name)), + core.CmdHandler(messages.ThemeChangedMsg{}), + ) +} + +// handleThemePreview applies a theme temporarily for live preview (without persisting). +func (a *appModel) handleThemePreview(themeRef string) (tea.Model, tea.Cmd) { + // Load and apply the theme (without persisting) + theme, err := styles.LoadTheme(themeRef) + if err != nil { + // Silently fail for preview - don't show error notification + return a, nil + } + + styles.ApplyTheme(theme) + + // Apply theme changed logic synchronously to ensure View() renders with updated styles + return a.applyThemeChanged() +} + +// handleThemeCancelPreview restores the original theme when the user cancels the theme picker. +func (a *appModel) handleThemeCancelPreview(originalRef string) (tea.Model, tea.Cmd) { + // Load and apply the original theme + theme, err := styles.LoadTheme(originalRef) + if err != nil { + // Fall back to default theme if original can't be loaded + theme = styles.DefaultTheme() + } + + styles.ApplyTheme(theme) + + // Apply theme changed logic (invalidates caches, updates watcher, forwards messages) + return a.applyThemeChanged() +} + // Speech-to-text handlers // speakTranscriptAndContinue is an internal message that carries a transcript delta diff --git a/pkg/tui/messages/messages.go b/pkg/tui/messages/messages.go index b6a38129d..5c6996961 100644 --- a/pkg/tui/messages/messages.go +++ b/pkg/tui/messages/messages.go @@ -57,4 +57,22 @@ type ( Content string // Full content sent to the agent (with file contents expanded) Attachments map[string]string // Map of filename to content for attachments } + + OpenThemePickerMsg struct{} // Open the theme picker dialog + ChangeThemeMsg struct { + ThemeRef string // Theme reference to apply + } + ThemePreviewMsg struct { + ThemeRef string // Theme reference to preview + OriginalRef string // Original theme to restore on cancel + } + ThemeCancelPreviewMsg struct { + OriginalRef string // Theme reference to restore + } + ThemeChangedMsg struct{} // Notifies components that the theme has changed (for cache invalidation) + // ThemeFileChangedMsg notifies TUI that the theme file was modified on disk (hot reload). + // The TUI should load and apply the theme on the main goroutine to avoid race conditions. + ThemeFileChangedMsg struct { + ThemeRef string // The theme ref that was modified + } ) diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 82ab915ce..b13d1602f 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -479,6 +479,43 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case msgtypes.ClearQueueMsg: return p.handleClearQueue() + case msgtypes.ThemeChangedMsg: + // Theme changed - forward to all child components to invalidate caches + var cmds []tea.Cmd + + model, cmd := p.messages.Update(msg) + p.messages = model.(messages.Model) + cmds = append(cmds, cmd) + + editorModel, editorCmd := p.editor.Update(msg) + p.editor = editorModel.(editor.Editor) + cmds = append(cmds, editorCmd) + + // Forward to sidebar to ensure it picks up new theme colors + sidebarModel, sidebarCmd := p.sidebar.Update(msg) + p.sidebar = sidebarModel.(sidebar.Model) + cmds = append(cmds, sidebarCmd) + + // Recreate spinners with new colors (they pre-render frames) + if p.working { + p.spinner.Stop() + p.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle) + cmds = append(cmds, p.spinner.Init()) + } else { + // Just recreate without reinitializing + p.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle) + } + + if p.pendingResponse { + p.pendingSpinner.Stop() + p.pendingSpinner = spinner.New(spinner.ModeBoth, styles.SpinnerDotsAccentStyle) + cmds = append(cmds, p.pendingSpinner.Init()) + } else { + p.pendingSpinner = spinner.New(spinner.ModeBoth, styles.SpinnerDotsAccentStyle) + } + + return p, tea.Batch(cmds...) + default: // Try to handle as a runtime event if handled, cmd := p.handleRuntimeEvent(msg); handled { diff --git a/pkg/tui/styles/composite.go b/pkg/tui/styles/composite.go index e94992dac..650dcf85c 100644 --- a/pkg/tui/styles/composite.go +++ b/pkg/tui/styles/composite.go @@ -20,6 +20,14 @@ var ( styleSeqCacheMu sync.RWMutex ) +// clearStyleSeqCache clears the style sequence cache. +// Called when the theme changes to ensure styles are re-computed with new colors. +func clearStyleSeqCache() { + styleSeqCacheMu.Lock() + styleSeqCache = make(map[string]string) + styleSeqCacheMu.Unlock() +} + // getStyleSeq returns the ANSI escape sequence for a style's colors only. // Results are cached for repeated calls with the same style. func getStyleSeq(style lipgloss.Style) string { diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 0bc7baaee..76620a328 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -1,6 +1,7 @@ package styles import ( + "image/color" "strings" "time" @@ -16,144 +17,70 @@ const ( defaultMargin = 2 ) -// Color hex values (used throughout the file) -const ( - // Primary colors - ColorWhite = "#E5F2FC" - ColorAccentBlue = "#7AA2F7" - ColorMutedBlue = "#8B95C1" - ColorMutedGray = "#808080" - ColorFadedGray = "#404550" // Very dim, close to background - for fade-out effects - ColorBackgroundAlt = "#24283B" - ColorBorderSecondary = "#6B75A8" - ColorTextPrimary = "#C0C0C0" - ColorTextSecondary = "#808080" - ColorSuccessGreen = "#9ECE6A" - ColorErrorRed = "#F7768E" - ColorWarningYellow = "#E0AF68" - ColorMobyBlue = "#1D63ED" - ColorDarkBlue = "#202a4b" - ColorErrorStrong = "#d74532" - ColorErrorDark = "#4a2523" - - // Spinner glow colors (transition from base blue towards white) - ColorSpinnerDim = "#9AB8F9" - ColorSpinnerBright = "#B8CFFB" - ColorSpinnerBrightest = "#D6E5FC" - - // Background colors - ColorBackground = "#1C1C22" - - // Status colors - ColorInfoCyan = "#7DCFFF" - ColorHighlight = "#98C379" - - // Diff colors - ColorDiffAddBg = "#20303B" - ColorDiffRemoveBg = "#3C2A2A" - - // Line number and UI element colors - ColorLineNumber = "#565F89" - ColorSeparator = "#414868" - - // Interactive element colors - ColorSelected = "#364A82" - - // AutoCompleteGhost colors - ColorSuggestionGhost = "#6B6B6B" - - // Tab colors - ColorTab = "#25252c" -) - -// Chroma syntax highlighting colors (Monokai theme) -const ( - ChromaErrorFgColor = "#F1F1F1" - ChromaSuccessColor = "#00D787" - ChromaErrorBgColor = "#F05B5B" - ChromaCommentColor = "#676767" - ChromaCommentPreprocColor = "#FF875F" - ChromaKeywordColor = "#00AAFF" - ChromaKeywordReservedColor = "#FF5FD2" - ChromaKeywordNamespaceColor = "#FF5F87" - ChromaKeywordTypeColor = "#6E6ED8" - ChromaOperatorColor = "#EF8080" - ChromaPunctuationColor = "#E8E8A8" - ChromaNameBuiltinColor = "#FF8EC7" - ChromaNameTagColor = "#B083EA" - ChromaNameAttributeColor = "#7A7AE6" - ChromaNameDecoratorColor = "#FFFF87" - ChromaLiteralNumberColor = "#6EEFC0" - ChromaLiteralStringColor = "#C69669" - ChromaLiteralStringEscapeColor = "#AFFFD7" - ChromaGenericDeletedColor = "#FD5B5B" - ChromaGenericSubheadingColor = "#777777" - ChromaBackgroundColor = "#373737" -) - -// ANSI color codes (8-bit color codes) -const ( - ANSIColor252 = "252" - ANSIColor39 = "39" - ANSIColor35 = "35" - ANSIColor212 = "212" - ANSIColor243 = "243" - ANSIColor244 = "244" -) - -// Tokyo Night-inspired Color Palette +// Color variables - initialized by ApplyTheme() before TUI starts. +// These are set from the theme's YAML values (see themes/default.yaml for defaults). var ( // Background colors - Background = lipgloss.Color(ColorBackground) - BackgroundAlt = lipgloss.Color(ColorBackgroundAlt) + Background color.Color + BackgroundAlt color.Color // Primary accent colors - White = lipgloss.Color(ColorWhite) - MobyBlue = lipgloss.Color(ColorMobyBlue) - Accent = lipgloss.Color(ColorAccentBlue) + White color.Color + MobyBlue color.Color + Accent color.Color - // Status colors - softer, more professional - Success = lipgloss.Color(ColorSuccessGreen) - Error = lipgloss.Color(ColorErrorRed) - Warning = lipgloss.Color(ColorWarningYellow) - Info = lipgloss.Color(ColorInfoCyan) - Highlight = lipgloss.Color(ColorHighlight) + // Status colors + Success color.Color + Error color.Color + Warning color.Color + Info color.Color + Highlight color.Color // Text hierarchy - TextPrimary = lipgloss.Color(ColorTextPrimary) - TextSecondary = lipgloss.Color(ColorTextSecondary) - TextMuted = lipgloss.Color(ColorMutedBlue) - TextMutedGray = lipgloss.Color(ColorMutedGray) + TextPrimary color.Color + TextSecondary color.Color + TextMuted color.Color + TextMutedGray color.Color // Border colors - BorderPrimary = lipgloss.Color(ColorAccentBlue) - BorderSecondary = lipgloss.Color(ColorBorderSecondary) - BorderMuted = lipgloss.Color(ColorBackgroundAlt) - BorderWarning = lipgloss.Color(ColorWarningYellow) + BorderPrimary color.Color + BorderSecondary color.Color + BorderMuted color.Color + BorderWarning color.Color - // Diff colors (matching glamour/markdown "dark" theme) - DiffAddBg = lipgloss.Color(ColorDiffAddBg) - DiffRemoveBg = lipgloss.Color(ColorDiffRemoveBg) - DiffAddFg = lipgloss.Color(ColorSuccessGreen) - DiffRemoveFg = lipgloss.Color(ColorErrorRed) + // Diff colors + DiffAddBg color.Color + DiffRemoveBg color.Color + DiffAddFg color.Color + DiffRemoveFg color.Color // UI element colors - LineNumber = lipgloss.Color(ColorLineNumber) - Separator = lipgloss.Color(ColorSeparator) + LineNumber color.Color + Separator color.Color // Interactive element colors - Selected = lipgloss.Color(ColorSelected) - SelectedFg = lipgloss.Color(ColorTextPrimary) - PlaceholderColor = lipgloss.Color(ColorMutedGray) + Selected color.Color + SelectedFg color.Color + PlaceholderColor color.Color // Badge colors - AgentBadgeFg = White - AgentBadgeBg = MobyBlue + AgentBadgeFg color.Color + AgentBadgeBg color.Color + BadgePurple color.Color + BadgeCyan color.Color + BadgeGreen color.Color + + // Error colors (extended) + ErrorStrong color.Color + ErrorDark color.Color + + // Additional muted colors + FadedGray color.Color // Tabs - TabBg = lipgloss.Color(ColorTab) - TabPrimaryFg = lipgloss.Color(ColorMutedGray) - TabAccentFg = lipgloss.Color(ColorHighlight) + TabBg color.Color + TabPrimaryFg color.Color + TabAccentFg color.Color ) // Base Styles @@ -176,7 +103,7 @@ var ( MutedStyle = BaseStyle.Foreground(TextMutedGray) SecondaryStyle = BaseStyle.Foreground(TextSecondary) BoldStyle = BaseStyle.Bold(true) - FadingStyle = NoStyle.Foreground(lipgloss.Color(ColorFadedGray)) // Very dim for fade-out animations + FadingStyle = NoStyle.Foreground(FadedGray) // Very dim for fade-out animations (rebuilt by ApplyTheme) ) // Status Styles @@ -207,6 +134,7 @@ var ( UserMessageStyle = BaseMessageStyle. BorderStyle(lipgloss.ThickBorder()). BorderForeground(BorderPrimary). + Foreground(TextPrimary). Background(BackgroundAlt). Bold(true) @@ -279,24 +207,17 @@ var ( TabTitleStyle = BaseStyle. Foreground(TabPrimaryFg) - TabStyle = TabPrimaryStyle. - Padding(1, 0) - TabPrimaryStyle = BaseStyle. Foreground(TextPrimary) + TabStyle = TabPrimaryStyle. + Padding(1, 0) + TabAccentStyle = BaseStyle. Foreground(TabAccentFg) ) -// Model selector Badge colors -const ( - ColorBadgePurple = "#B083EA" // Purple for alloy badge - ColorBadgeCyan = "#7DCFFF" // Cyan for default badge - ColorBadgeGreen = "#9ECE6A" // Green for current badge -) - -// Command Palette Styles +// Command Palette Styles - rebuilt by ApplyTheme() var ( PaletteCategoryStyle = BaseStyle. Bold(true). @@ -318,15 +239,15 @@ var ( Background(MobyBlue). Foreground(White) - // Badge styles for model picker + // Badge styles for model picker - use color vars set by ApplyTheme() BadgeAlloyStyle = BaseStyle. - Foreground(lipgloss.Color(ColorBadgePurple)) + Foreground(BadgePurple) BadgeDefaultStyle = BaseStyle. - Foreground(lipgloss.Color(ColorBadgeCyan)) + Foreground(BadgeCyan) BadgeCurrentStyle = BaseStyle. - Foreground(lipgloss.Color(ColorBadgeGreen)) + Foreground(BadgeGreen) ) // Star Styles for session browser and sidebar @@ -368,15 +289,15 @@ var ( Foreground(TextMutedGray) ToolErrorMessageStyle = BaseStyle. - Foreground(lipgloss.Color(ColorErrorStrong)) + Foreground(ErrorStrong) ToolName = ToolMessageStyle. Foreground(TextMutedGray). Padding(0, 1) ToolNameError = ToolName. - Foreground(lipgloss.Color(ColorErrorStrong)). - Background(lipgloss.Color(ColorErrorDark)) + Foreground(ErrorStrong). + Background(ErrorDark) ToolNameDim = ToolMessageStyle. Foreground(TextMutedGray). @@ -390,10 +311,10 @@ var ( Foreground(TextMutedGray) ToolErrorIcon = ToolCompletedIcon. - Background(lipgloss.Color(ColorErrorStrong)) + Background(ErrorStrong) ToolPendingIcon = ToolCompletedIcon. - Background(lipgloss.Color(ColorWarningYellow)) + Background(Warning) ToolCallArgs = ToolMessageStyle. Padding(0, 0, 0, 2) @@ -436,17 +357,19 @@ var ( EditorStyle = BaseStyle.Padding(1, 0, 0, 0) // SuggestionGhostStyle renders inline auto-complete hints in a muted tone. // Use a distinct grey so suggestion text is visually separate from the user's input. - SuggestionGhostStyle = BaseStyle.Foreground(lipgloss.Color(ColorSuggestionGhost)) + // NOTE: Rebuilt by ApplyTheme() using theme's suggestion_ghost color. + SuggestionGhostStyle = BaseStyle.Foreground(TextMutedGray) // SuggestionCursorStyle renders the first character of a suggestion inside the cursor. // Uses the same blue accent background as the normal cursor, with ghost-colored foreground text. - SuggestionCursorStyle = BaseStyle.Background(Accent).Foreground(lipgloss.Color(ColorSuggestionGhost)) + // NOTE: Rebuilt by ApplyTheme(). + SuggestionCursorStyle = BaseStyle.Background(Accent).Foreground(TextMutedGray) // Attachment banner styles - polished look with subtle border AttachmentBannerStyle = BaseStyle. Foreground(TextSecondary) AttachmentBadgeStyle = BaseStyle. - Foreground(lipgloss.Color(ColorInfoCyan)). + Foreground(Info). Bold(true) AttachmentSizeStyle = BaseStyle. @@ -454,7 +377,7 @@ var ( Italic(true) AttachmentIconStyle = BaseStyle. - Foreground(lipgloss.Color(ColorInfoCyan)) + Foreground(Info) ) // Scrollbar @@ -554,13 +477,13 @@ var ( Foreground(SelectedFg) ) -// Spinner Styles +// Spinner Styles - rebuilt by ApplyTheme() with actual spinner colors from theme var ( SpinnerDotsAccentStyle = BaseStyle.Foreground(Accent) SpinnerDotsHighlightStyle = BaseStyle.Foreground(TabAccentFg) - SpinnerTextBrightestStyle = BaseStyle.Foreground(lipgloss.Color(ColorSpinnerBrightest)) - SpinnerTextBrightStyle = BaseStyle.Foreground(lipgloss.Color(ColorSpinnerBright)) - SpinnerTextDimStyle = BaseStyle.Foreground(lipgloss.Color(ColorSpinnerDim)) + SpinnerTextBrightestStyle = BaseStyle.Foreground(Accent) + SpinnerTextBrightStyle = BaseStyle.Foreground(Accent) + SpinnerTextDimStyle = BaseStyle.Foreground(Accent) SpinnerTextDimmestStyle = BaseStyle.Foreground(Accent) ) @@ -626,28 +549,143 @@ func ChromaStyle() *chroma.Style { return style } +// MarkdownStyle returns the markdown style configuration, deriving colors from the current theme. func MarkdownStyle() ansi.StyleConfig { - h1Color := ColorAccentBlue - h2Color := ColorAccentBlue - h3Color := ColorAccentBlue - h4Color := ColorAccentBlue - h5Color := ColorAccentBlue - h6Color := ColorAccentBlue - linkColor := ColorAccentBlue - strongColor := ColorTextPrimary - codeColor := ColorTextPrimary - codeBgColor := ColorBackgroundAlt - blockquoteColor := ColorTextSecondary - listColor := ColorTextPrimary - hrColor := ColorBorderSecondary - codeBg := ColorBackgroundAlt + theme := CurrentTheme() + md := theme.Markdown + ch := theme.Chroma + colors := theme.Colors + + // Use theme markdown colors with fallbacks to theme.Colors + headingColor := md.Heading + if headingColor == "" { + headingColor = colors.Accent + } + linkColor := md.Link + if linkColor == "" { + linkColor = colors.Accent + } + strongColor := md.Strong + if strongColor == "" { + strongColor = colors.TextPrimary + } + codeColor := md.Code + if codeColor == "" { + codeColor = colors.TextPrimary + } + codeBgColor := md.CodeBg + if codeBgColor == "" { + codeBgColor = colors.BackgroundAlt + } + blockquoteColor := md.Blockquote + if blockquoteColor == "" { + blockquoteColor = colors.TextSecondary + } + listColor := md.List + if listColor == "" { + listColor = colors.TextPrimary + } + hrColor := md.HR + if hrColor == "" { + hrColor = colors.BorderSecondary + } + // Use text primary for document text + textColor := colors.TextPrimary + textSecondary := colors.TextSecondary + + // Chroma colors with fallbacks from default theme + defaults := DefaultTheme().Chroma + chromaComment := ch.Comment + if chromaComment == "" { + chromaComment = defaults.Comment + } + chromaCommentPreproc := ch.CommentPreproc + if chromaCommentPreproc == "" { + chromaCommentPreproc = defaults.CommentPreproc + } + chromaKeyword := ch.Keyword + if chromaKeyword == "" { + chromaKeyword = defaults.Keyword + } + chromaKeywordReserved := ch.KeywordReserved + if chromaKeywordReserved == "" { + chromaKeywordReserved = defaults.KeywordReserved + } + chromaKeywordNamespace := ch.KeywordNamespace + if chromaKeywordNamespace == "" { + chromaKeywordNamespace = defaults.KeywordNamespace + } + chromaKeywordType := ch.KeywordType + if chromaKeywordType == "" { + chromaKeywordType = defaults.KeywordType + } + chromaOperator := ch.Operator + if chromaOperator == "" { + chromaOperator = defaults.Operator + } + chromaPunctuation := ch.Punctuation + if chromaPunctuation == "" { + chromaPunctuation = defaults.Punctuation + } + chromaNameBuiltin := ch.NameBuiltin + if chromaNameBuiltin == "" { + chromaNameBuiltin = defaults.NameBuiltin + } + chromaNameTag := ch.NameTag + if chromaNameTag == "" { + chromaNameTag = defaults.NameTag + } + chromaNameAttribute := ch.NameAttribute + if chromaNameAttribute == "" { + chromaNameAttribute = defaults.NameAttribute + } + chromaNameDecorator := ch.NameDecorator + if chromaNameDecorator == "" { + chromaNameDecorator = defaults.NameDecorator + } + chromaLiteralNumber := ch.LiteralNumber + if chromaLiteralNumber == "" { + chromaLiteralNumber = defaults.LiteralNumber + } + chromaLiteralString := ch.LiteralString + if chromaLiteralString == "" { + chromaLiteralString = defaults.LiteralString + } + chromaLiteralStringEscape := ch.LiteralStringEscape + if chromaLiteralStringEscape == "" { + chromaLiteralStringEscape = defaults.LiteralStringEscape + } + chromaGenericDeleted := ch.GenericDeleted + if chromaGenericDeleted == "" { + chromaGenericDeleted = defaults.GenericDeleted + } + chromaGenericSubheading := ch.GenericSubheading + if chromaGenericSubheading == "" { + chromaGenericSubheading = defaults.GenericSubheading + } + chromaBackground := ch.Background + if chromaBackground == "" { + chromaBackground = colors.BackgroundAlt + } + chromaErrorFg := ch.ErrorFg + if chromaErrorFg == "" { + chromaErrorFg = defaults.ErrorFg + } + chromaErrorBg := ch.ErrorBg + if chromaErrorBg == "" { + chromaErrorBg = defaults.ErrorBg + } + chromaSuccess := ch.Success + if chromaSuccess == "" { + chromaSuccess = colors.Success + } customDarkStyle := ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockPrefix: "", BlockSuffix: "", - Color: stringPtr(ANSIColor252), + Color: &textColor, }, Margin: uintPtr(0), }, @@ -664,45 +702,45 @@ func MarkdownStyle() ansi.StyleConfig { Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockSuffix: "\n", - Color: stringPtr(ANSIColor39), + Color: &headingColor, Bold: boolPtr(true), }, }, H1: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "## ", - Color: &h1Color, + Color: &headingColor, Bold: boolPtr(true), }, }, H2: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "## ", - Color: &h2Color, + Color: &headingColor, }, }, H3: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "### ", - Color: &h3Color, + Color: &headingColor, }, }, H4: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "#### ", - Color: &h4Color, + Color: &headingColor, }, }, H5: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "##### ", - Color: &h5Color, + Color: &headingColor, }, }, H6: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "###### ", - Color: &h6Color, + Color: &headingColor, }, }, Strikethrough: ansi.StylePrimitive{ @@ -735,15 +773,15 @@ func MarkdownStyle() ansi.StyleConfig { Underline: boolPtr(true), }, LinkText: ansi.StylePrimitive{ - Color: stringPtr(ANSIColor35), + Color: &linkColor, Bold: boolPtr(true), }, Image: ansi.StylePrimitive{ - Color: stringPtr(ANSIColor212), + Color: &linkColor, Underline: boolPtr(true), }, ImageText: ansi.StylePrimitive{ - Color: stringPtr(ANSIColor243), + Color: &textSecondary, Format: "Image: {{.text}} →", }, Code: ansi.StyleBlock{ @@ -757,92 +795,92 @@ func MarkdownStyle() ansi.StyleConfig { CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(ANSIColor244), + Color: &textSecondary, }, Margin: uintPtr(defaultMargin), }, Theme: "monokai", Chroma: &ansi.Chroma{ Text: ansi.StylePrimitive{ - Color: stringPtr(ColorTextPrimary), + Color: &textColor, }, Error: ansi.StylePrimitive{ - Color: stringPtr(ChromaErrorFgColor), - BackgroundColor: stringPtr(ChromaErrorBgColor), + Color: &chromaErrorFg, + BackgroundColor: &chromaErrorBg, }, Comment: ansi.StylePrimitive{ - Color: stringPtr(ChromaCommentColor), + Color: &chromaComment, }, CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(ChromaCommentPreprocColor), + Color: &chromaCommentPreproc, }, Keyword: ansi.StylePrimitive{ - Color: stringPtr(ChromaKeywordColor), + Color: &chromaKeyword, }, KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(ChromaKeywordReservedColor), + Color: &chromaKeywordReserved, }, KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(ChromaKeywordNamespaceColor), + Color: &chromaKeywordNamespace, }, KeywordType: ansi.StylePrimitive{ - Color: stringPtr(ChromaKeywordTypeColor), + Color: &chromaKeywordType, }, Operator: ansi.StylePrimitive{ - Color: stringPtr(ChromaOperatorColor), + Color: &chromaOperator, }, Punctuation: ansi.StylePrimitive{ - Color: stringPtr(ChromaPunctuationColor), + Color: &chromaPunctuation, }, Name: ansi.StylePrimitive{ - Color: stringPtr(ColorTextPrimary), + Color: &textColor, }, NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(ChromaNameBuiltinColor), + Color: &chromaNameBuiltin, }, NameTag: ansi.StylePrimitive{ - Color: stringPtr(ChromaNameTagColor), + Color: &chromaNameTag, }, NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(ChromaNameAttributeColor), + Color: &chromaNameAttribute, }, NameClass: ansi.StylePrimitive{ - Color: stringPtr(ChromaErrorFgColor), + Color: &chromaErrorFg, Underline: boolPtr(true), Bold: boolPtr(true), }, NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(ChromaNameDecoratorColor), + Color: &chromaNameDecorator, }, NameFunction: ansi.StylePrimitive{ - Color: stringPtr(ChromaSuccessColor), + Color: &chromaSuccess, }, LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(ChromaLiteralNumberColor), + Color: &chromaLiteralNumber, }, LiteralString: ansi.StylePrimitive{ - Color: stringPtr(ChromaLiteralStringColor), + Color: &chromaLiteralString, }, LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(ChromaLiteralStringEscapeColor), + Color: &chromaLiteralStringEscape, }, GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(ChromaGenericDeletedColor), + Color: &chromaGenericDeleted, }, GenericEmph: ansi.StylePrimitive{ Italic: boolPtr(true), }, GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(ChromaSuccessColor), + Color: &chromaSuccess, }, GenericStrong: ansi.StylePrimitive{ Bold: boolPtr(true), }, GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(ChromaGenericSubheadingColor), + Color: &chromaGenericSubheading, }, Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr(ChromaBackgroundColor), + BackgroundColor: &chromaBackground, }, }, }, @@ -857,7 +895,7 @@ func MarkdownStyle() ansi.StyleConfig { } customDarkStyle.List.Color = &listColor - customDarkStyle.CodeBlock.BackgroundColor = &codeBg + customDarkStyle.CodeBlock.BackgroundColor = &codeBgColor return customDarkStyle } @@ -869,7 +907,3 @@ func uintPtr(u uint) *uint { func boolPtr(b bool) *bool { return &b } - -func stringPtr(s string) *string { - return &s -} diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go new file mode 100644 index 000000000..a71e4ef4d --- /dev/null +++ b/pkg/tui/styles/theme.go @@ -0,0 +1,1278 @@ +package styles + +import ( + "embed" + "fmt" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + "github.com/goccy/go-yaml" + + "github.com/docker/cagent/pkg/paths" + "github.com/docker/cagent/pkg/userconfig" +) + +//go:embed themes/*.yaml +var builtinThemes embed.FS + +// themeCacheEntry holds a cached theme with metadata for invalidation. +type themeCacheEntry struct { + theme *Theme + modTime time.Time // For user themes: file modTime; for built-in: zero value + path string // For user themes: file path; for built-in: empty +} + +var ( + themeCache = make(map[string]*themeCacheEntry) + themeCacheMu sync.RWMutex + + // builtinRefsCache caches the list of built-in theme refs (they never change at runtime) + builtinRefsCache []string + builtinRefsCacheOK bool + builtinRefsCacheMu sync.Mutex +) + +// InvalidateThemeCache clears the theme cache for a specific ref, or all if ref is empty. +// This is primarily for testing; the cache is mtime-aware so it auto-invalidates on file changes. +func InvalidateThemeCache(ref string) { + themeCacheMu.Lock() + defer themeCacheMu.Unlock() + if ref == "" { + themeCache = make(map[string]*themeCacheEntry) + } else { + delete(themeCache, ref) + } +} + +// DefaultThemeRef is the reference for the built-in default theme. +const DefaultThemeRef = "default" + +// ThemesDir returns the directory where user themes are stored. +func ThemesDir() string { + return filepath.Join(paths.GetDataDir(), "themes") +} + +// Theme represents a complete color theme for the TUI. +// All fields are optional; unset fields use the built-in defaults. +type Theme struct { + Version int `yaml:"version,omitempty"` + Name string `yaml:"name,omitempty"` + Ref string `yaml:"-"` // Set by loader, not from YAML + Colors ThemeColors `yaml:"colors,omitempty"` + Chroma ChromaColors `yaml:"chroma,omitempty"` + Markdown MarkdownTheme `yaml:"markdown,omitempty"` +} + +// ThemeColors contains all color definitions for the TUI. +// Use hex color strings (e.g., "#7AA2F7") or ANSI color numbers (e.g., "39"). +type ThemeColors struct { + // Text colors + TextBright string `yaml:"text_bright,omitempty"` // Bright/emphasized text + TextPrimary string `yaml:"text_primary,omitempty"` // Primary text + TextSecondary string `yaml:"text_secondary,omitempty"` // Secondary text + TextMuted string `yaml:"text_muted,omitempty"` // Muted/subtle text + TextFaint string `yaml:"text_faint,omitempty"` // Very faint text/decorations + + // Accent colors + Accent string `yaml:"accent,omitempty"` // Primary accent color + AccentMuted string `yaml:"accent_muted,omitempty"` // Muted accent color + + // Background colors + Background string `yaml:"background,omitempty"` // Main background + BackgroundAlt string `yaml:"background_alt,omitempty"` // Alternate background (cards, panels) + + // Border colors + BorderSecondary string `yaml:"border_secondary,omitempty"` + + // Status colors + Success string `yaml:"success,omitempty"` // Success/positive state + Error string `yaml:"error,omitempty"` // Error/negative state + Warning string `yaml:"warning,omitempty"` // Warning state + Info string `yaml:"info,omitempty"` // Info/neutral state + Highlight string `yaml:"highlight,omitempty"` // Highlighted elements + + // Brand colors + Brand string `yaml:"brand,omitempty"` // Primary brand color + BrandBg string `yaml:"brand_bg,omitempty"` // Brand background + + // Error-specific colors + ErrorStrong string `yaml:"error_strong,omitempty"` // Strong error emphasis + ErrorDark string `yaml:"error_dark,omitempty"` // Dark error background + + // Spinner colors + SpinnerDim string `yaml:"spinner_dim,omitempty"` + SpinnerBright string `yaml:"spinner_bright,omitempty"` + SpinnerBrightest string `yaml:"spinner_brightest,omitempty"` + + // Diff colors + DiffAddBg string `yaml:"diff_add_bg,omitempty"` + DiffRemoveBg string `yaml:"diff_remove_bg,omitempty"` + + // UI element colors + LineNumber string `yaml:"line_number,omitempty"` + Separator string `yaml:"separator,omitempty"` + Selected string `yaml:"selected,omitempty"` + SelectedFg string `yaml:"selected_fg,omitempty"` // Text on selected/brand backgrounds + SuggestionGhost string `yaml:"suggestion_ghost,omitempty"` + TabBg string `yaml:"tab_bg,omitempty"` + Placeholder string `yaml:"placeholder,omitempty"` + + // Badge colors + BadgeAccent string `yaml:"badge_accent,omitempty"` // Accent badge (e.g., purple highlights) + BadgeInfo string `yaml:"badge_info,omitempty"` // Info badge (e.g., cyan) + BadgeSuccess string `yaml:"badge_success,omitempty"` // Success badge (e.g., green) +} + +// ChromaColors contains syntax highlighting colors (for code blocks). +type ChromaColors struct { + ErrorFg string `yaml:"error_fg,omitempty"` + ErrorBg string `yaml:"error_bg,omitempty"` + Success string `yaml:"success,omitempty"` + Comment string `yaml:"comment,omitempty"` + CommentPreproc string `yaml:"comment_preproc,omitempty"` + Keyword string `yaml:"keyword,omitempty"` + KeywordReserved string `yaml:"keyword_reserved,omitempty"` + KeywordNamespace string `yaml:"keyword_namespace,omitempty"` + KeywordType string `yaml:"keyword_type,omitempty"` + Operator string `yaml:"operator,omitempty"` + Punctuation string `yaml:"punctuation,omitempty"` + NameBuiltin string `yaml:"name_builtin,omitempty"` + NameTag string `yaml:"name_tag,omitempty"` + NameAttribute string `yaml:"name_attribute,omitempty"` + NameDecorator string `yaml:"name_decorator,omitempty"` + LiteralNumber string `yaml:"literal_number,omitempty"` + LiteralString string `yaml:"literal_string,omitempty"` + LiteralStringEscape string `yaml:"literal_string_escape,omitempty"` + GenericDeleted string `yaml:"generic_deleted,omitempty"` + GenericSubheading string `yaml:"generic_subheading,omitempty"` + Background string `yaml:"background,omitempty"` +} + +// MarkdownTheme contains markdown-specific color overrides. +type MarkdownTheme struct { + Heading string `yaml:"heading,omitempty"` + Link string `yaml:"link,omitempty"` + Strong string `yaml:"strong,omitempty"` + Code string `yaml:"code,omitempty"` + CodeBg string `yaml:"code_bg,omitempty"` + Blockquote string `yaml:"blockquote,omitempty"` + List string `yaml:"list,omitempty"` + HR string `yaml:"hr,omitempty"` +} + +// cachedDefaultTheme holds the parsed default.yaml theme (loaded once). +var ( + cachedDefaultTheme *Theme + cachedDefaultThemeMu sync.Mutex +) + +// DefaultTheme returns the built-in default theme loaded from embedded default.yaml. +// The result is cached internally, but a copy is returned to prevent callers from +// accidentally modifying the cached theme. +func DefaultTheme() *Theme { + cachedDefaultThemeMu.Lock() + defer cachedDefaultThemeMu.Unlock() + + if cachedDefaultTheme == nil { + // Load default.yaml from embedded files + data, err := builtinThemes.ReadFile("themes/default.yaml") + if err != nil { + // This should never happen - default.yaml is embedded at compile time + panic(fmt.Sprintf("failed to read embedded default.yaml: %v", err)) + } + + var theme Theme + if err := yaml.Unmarshal(data, &theme); err != nil { + panic(fmt.Sprintf("failed to parse embedded default.yaml: %v", err)) + } + + theme.Ref = DefaultThemeRef + cachedDefaultTheme = &theme + } + + // Return a copy to prevent callers from modifying the cached theme + themeCopy := *cachedDefaultTheme + return &themeCopy +} + +// UserThemePrefix is used to distinguish user themes from built-in themes +// when they have the same base name. A ref like "user:nord" refers to the +// user's custom nord theme, while "nord" refers to the built-in. +const UserThemePrefix = "user:" + +// ListThemeRefs returns the list of available theme references. +// It includes all built-in themes (including "default") and user themes from ~/.cagent/themes/. +// User themes with names matching built-in themes are prefixed with "user:" to distinguish them. +// The "default" theme is always listed first for UX purposes. +func ListThemeRefs() ([]string, error) { + // Track built-in refs to detect conflicts with user themes + builtinSet := make(map[string]bool) + + // Start with default theme (listed first for UX) + refs := []string{DefaultThemeRef} + builtinSet[DefaultThemeRef] = true + + // Add built-in themes from embedded files (default.yaml will be skipped since already added) + builtinRefs, err := listBuiltinThemeRefs() + if err != nil { + return nil, fmt.Errorf("listing built-in themes: %w", err) + } + for _, ref := range builtinRefs { + if !builtinSet[ref] { + refs = append(refs, ref) + builtinSet[ref] = true + } + } + + // Add user themes from data directory + // If a user theme has the same name as a built-in, prefix it with "user:" + userRefs, err := listUserThemeRefs() + if err != nil { + return nil, fmt.Errorf("listing user themes: %w", err) + } + for _, ref := range userRefs { + if builtinSet[ref] { + // User theme has same name as built-in - use prefixed ref + refs = append(refs, UserThemePrefix+ref) + } else { + refs = append(refs, ref) + } + } + + return refs, nil +} + +// listBuiltinThemeRefs returns the list of built-in theme references from embedded files. +// Results are cached since built-in themes never change at runtime. +func listBuiltinThemeRefs() ([]string, error) { + builtinRefsCacheMu.Lock() + defer builtinRefsCacheMu.Unlock() + + if builtinRefsCacheOK { + return builtinRefsCache, nil + } + + var refs []string + + entries, err := builtinThemes.ReadDir("themes") + if err != nil { + return nil, fmt.Errorf("reading embedded themes directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Accept .yaml and .yml files + if strings.HasSuffix(name, ".yaml") { + refs = append(refs, strings.TrimSuffix(name, ".yaml")) + } else if strings.HasSuffix(name, ".yml") { + refs = append(refs, strings.TrimSuffix(name, ".yml")) + } + } + + builtinRefsCache = refs + builtinRefsCacheOK = true + return refs, nil +} + +// listUserThemeRefs returns the list of user theme references from ~/.cagent/themes/. +func listUserThemeRefs() ([]string, error) { + return listThemeRefsFrom(ThemesDir()) +} + +// UserThemeExists returns true if a user theme file exists for the given ref +// in the user themes directory (typically ~/.cagent/themes/). +// +// This handles the "user:" prefix - "user:nord" checks for ~/.cagent/themes/nord.yaml. +func UserThemeExists(ref string) bool { + if ref == "" { + return false + } + + // Strip user: prefix if present + baseRef := strings.TrimPrefix(ref, UserThemePrefix) + + if err := validateThemeRef(baseRef); err != nil { + return false + } + + dir := ThemesDir() + + // Try .yaml first, then .yml + if _, err := os.Stat(filepath.Join(dir, baseRef+".yaml")); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(dir, baseRef+".yml")); err == nil { + return true + } + + return false +} + +// SaveThemeToUserConfig persists the theme reference to the user config file. +// If themeRef equals DefaultThemeRef, the setting is cleared (empty string). +func SaveThemeToUserConfig(themeRef string) error { + cfg, err := userconfig.Load() + if err != nil { + return fmt.Errorf("loading user config: %w", err) + } + + if cfg.Settings == nil { + cfg.Settings = &userconfig.Settings{} + } + + // Clear the setting if using the default theme + if themeRef == DefaultThemeRef { + cfg.Settings.Theme = "" + } else { + cfg.Settings.Theme = themeRef + } + + if err := cfg.Save(); err != nil { + return fmt.Errorf("saving user config: %w", err) + } + + return nil +} + +// listThemeRefsFrom lists theme refs from a specific directory (for testing). +// It only returns theme refs found in the directory, without adding any defaults. +func listThemeRefsFrom(dir string) ([]string, error) { + var refs []string + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return refs, nil + } + return nil, fmt.Errorf("reading themes directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Accept .yaml and .yml files + if strings.HasSuffix(name, ".yaml") { + refs = append(refs, strings.TrimSuffix(name, ".yaml")) + } else if strings.HasSuffix(name, ".yml") { + refs = append(refs, strings.TrimSuffix(name, ".yml")) + } + } + + return refs, nil +} + +// LoadTheme loads a theme by reference with mtime-aware caching. +// If ref is "" (empty), this is an error - caller should resolve to DefaultThemeRef first. +// +// Refs starting with "user:" (e.g., "user:nord") explicitly load from user themes directory. +// Other refs load built-in themes first, falling back to user themes if no built-in exists. +// +// The cache is mtime-aware: user themes are re-parsed only when the file's modTime changes. +// If a user theme file exists but fails to parse, an error is returned (no silent fallback). +func LoadTheme(ref string) (*Theme, error) { + // Empty ref means "use default theme" - caller should resolve this to DefaultThemeRef + if ref == "" { + return nil, fmt.Errorf("cannot load theme with empty ref; use %q instead", DefaultThemeRef) + } + + // Check if this is an explicit user theme reference (user:name) + forceUserTheme := strings.HasPrefix(ref, UserThemePrefix) + baseRef := ref + if forceUserTheme { + baseRef = strings.TrimPrefix(ref, UserThemePrefix) + } + + // Validate the base ref - reject path traversal attempts + if err := validateThemeRef(baseRef); err != nil { + return nil, err + } + + // Determine if this should load from built-in or user themes + isBuiltin := !forceUserTheme && IsBuiltinTheme(baseRef) + + // For user themes, check if file exists and get modTime + var userThemePath string + var userModTime time.Time + if !isBuiltin { + userThemePath, userModTime = getUserThemeFileInfo(baseRef) + } + + // Check the cache (use the full ref as cache key to distinguish user:nord from nord) + themeCacheMu.RLock() + cached, hasCached := themeCache[ref] + themeCacheMu.RUnlock() + + if hasCached { + if isBuiltin { + // Built-in themes don't change at runtime, cache is always valid + return cached.theme, nil + } + // User theme: check if modTime matches + if cached.path == userThemePath && cached.modTime.Equal(userModTime) { + return cached.theme, nil + } + // modTime changed or path changed, need to reload + } + + // Load and cache the theme + var theme *Theme + var err error + var entry *themeCacheEntry + + switch { + case isBuiltin: + // Load built-in theme from embedded files + theme, err = loadBuiltinTheme(baseRef) + if err != nil { + return nil, err + } + entry = &themeCacheEntry{ + theme: theme, + modTime: time.Time{}, // Zero time for built-in themes + path: "", // Empty path for built-in themes + } + case userThemePath != "": + // User theme file exists - load it + theme, err = loadThemeFrom(baseRef, ThemesDir()) + if err != nil { + return nil, err + } + entry = &themeCacheEntry{ + theme: theme, + modTime: userModTime, + path: userThemePath, + } + default: + // Not a built-in and no user theme file exists + return nil, fmt.Errorf("theme %q not found", ref) + } + + // Store in cache (use full ref as key) + themeCacheMu.Lock() + themeCache[ref] = entry + themeCacheMu.Unlock() + + return theme, nil +} + +// getUserThemeFileInfo returns the path and modTime of a user theme file if it exists. +// Returns empty path and zero time if the file doesn't exist. +func getUserThemeFileInfo(ref string) (path string, modTime time.Time) { + dir := ThemesDir() + + // Try .yaml first, then .yml + yamlPath := filepath.Join(dir, ref+".yaml") + if info, err := os.Stat(yamlPath); err == nil { + return yamlPath, info.ModTime() + } + + ymlPath := filepath.Join(dir, ref+".yml") + if info, err := os.Stat(ymlPath); err == nil { + return ymlPath, info.ModTime() + } + + return "", time.Time{} +} + +// validateThemeRef validates a theme reference to prevent path traversal attacks. +func validateThemeRef(ref string) error { + if ref == "" || ref == DefaultThemeRef { + return nil // These are valid sentinel values + } + if strings.Contains(ref, "/") || strings.Contains(ref, "\\") || strings.Contains(ref, "..") { + return fmt.Errorf("invalid theme ref %q: must not contain path separators or traversal", ref) + } + return nil +} + +// loadBuiltinTheme loads a built-in theme from embedded files. +func loadBuiltinTheme(ref string) (*Theme, error) { + base := DefaultTheme() + + // Try .yaml first, then .yml + var data []byte + var err error + + yamlPath := "themes/" + ref + ".yaml" + ymlPath := "themes/" + ref + ".yml" + + data, err = builtinThemes.ReadFile(yamlPath) + if err != nil { + data, err = builtinThemes.ReadFile(ymlPath) + } + if err != nil { + return nil, fmt.Errorf("built-in theme %q not found", ref) + } + + var override Theme + if err := yaml.Unmarshal(data, &override); err != nil { + return nil, fmt.Errorf("parsing built-in theme %q: %w", ref, err) + } + + // Merge override onto base + merged := mergeTheme(base, &override) + merged.Ref = ref + if merged.Name == "" { + merged.Name = ref + } + + return merged, nil +} + +// IsBuiltinTheme returns true if the given theme reference is a built-in theme. +// Refs prefixed with "user:" are always considered user themes, not built-in. +func IsBuiltinTheme(ref string) bool { + // User-prefixed refs are explicitly user themes + if strings.HasPrefix(ref, UserThemePrefix) { + return false + } + + if ref == DefaultThemeRef { + return true + } + + builtinRefs, err := listBuiltinThemeRefs() + if err != nil { + return false + } + + for _, builtinRef := range builtinRefs { + if builtinRef == ref { + return true + } + } + return false +} + +// loadThemeFrom loads a theme from a specific directory (for testing). +// Returns an error if the theme file is not found. +func loadThemeFrom(ref, dir string) (*Theme, error) { + base := DefaultTheme() + + // Try .yaml first, then .yml + var data []byte + var err error + + yamlPath := filepath.Join(dir, ref+".yaml") + ymlPath := filepath.Join(dir, ref+".yml") + + data, err = os.ReadFile(yamlPath) + if os.IsNotExist(err) { + data, err = os.ReadFile(ymlPath) + } + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("theme %q not found in %s", ref, dir) + } + return nil, fmt.Errorf("reading theme file: %w", err) + } + + var override Theme + if err := yaml.Unmarshal(data, &override); err != nil { + return nil, fmt.Errorf("parsing theme %q: %w", ref, err) + } + + // Merge override onto base + merged := mergeTheme(base, &override) + merged.Ref = ref + if merged.Name == "" { + merged.Name = ref + } + + return merged, nil +} + +// mergeTheme merges override onto base, returning a new theme. +// Only non-empty fields in override replace base values. +func mergeTheme(base, override *Theme) *Theme { + result := *base + + if override.Version != 0 { + result.Version = override.Version + } + if override.Name != "" { + result.Name = override.Name + } + + // Merge colors + result.Colors = mergeColors(base.Colors, override.Colors) + result.Chroma = mergeChromaColors(base.Chroma, override.Chroma) + result.Markdown = mergeMarkdownTheme(base.Markdown, override.Markdown) + + return &result +} + +func mergeColors(base, override ThemeColors) ThemeColors { + result := base + // Text colors + if override.TextBright != "" { + result.TextBright = override.TextBright + } + if override.TextPrimary != "" { + result.TextPrimary = override.TextPrimary + } + if override.TextSecondary != "" { + result.TextSecondary = override.TextSecondary + } + if override.TextMuted != "" { + result.TextMuted = override.TextMuted + } + if override.TextFaint != "" { + result.TextFaint = override.TextFaint + } + // Accent colors + if override.Accent != "" { + result.Accent = override.Accent + } + if override.AccentMuted != "" { + result.AccentMuted = override.AccentMuted + } + // Background colors + if override.Background != "" { + result.Background = override.Background + } + if override.BackgroundAlt != "" { + result.BackgroundAlt = override.BackgroundAlt + } + // Border colors + if override.BorderSecondary != "" { + result.BorderSecondary = override.BorderSecondary + } + // Status colors + if override.Success != "" { + result.Success = override.Success + } + if override.Error != "" { + result.Error = override.Error + } + if override.Warning != "" { + result.Warning = override.Warning + } + if override.Info != "" { + result.Info = override.Info + } + if override.Highlight != "" { + result.Highlight = override.Highlight + } + // Brand colors + if override.Brand != "" { + result.Brand = override.Brand + } + if override.BrandBg != "" { + result.BrandBg = override.BrandBg + } + // Error colors + if override.ErrorStrong != "" { + result.ErrorStrong = override.ErrorStrong + } + if override.ErrorDark != "" { + result.ErrorDark = override.ErrorDark + } + // Spinner colors + if override.SpinnerDim != "" { + result.SpinnerDim = override.SpinnerDim + } + if override.SpinnerBright != "" { + result.SpinnerBright = override.SpinnerBright + } + if override.SpinnerBrightest != "" { + result.SpinnerBrightest = override.SpinnerBrightest + } + // Diff colors + if override.DiffAddBg != "" { + result.DiffAddBg = override.DiffAddBg + } + if override.DiffRemoveBg != "" { + result.DiffRemoveBg = override.DiffRemoveBg + } + // UI element colors + if override.LineNumber != "" { + result.LineNumber = override.LineNumber + } + if override.Separator != "" { + result.Separator = override.Separator + } + if override.Selected != "" { + result.Selected = override.Selected + } + if override.SelectedFg != "" { + result.SelectedFg = override.SelectedFg + } + if override.SuggestionGhost != "" { + result.SuggestionGhost = override.SuggestionGhost + } + if override.TabBg != "" { + result.TabBg = override.TabBg + } + if override.Placeholder != "" { + result.Placeholder = override.Placeholder + } + // Badge colors + if override.BadgeAccent != "" { + result.BadgeAccent = override.BadgeAccent + } + if override.BadgeInfo != "" { + result.BadgeInfo = override.BadgeInfo + } + if override.BadgeSuccess != "" { + result.BadgeSuccess = override.BadgeSuccess + } + return result +} + +func mergeChromaColors(base, override ChromaColors) ChromaColors { + result := base + if override.ErrorFg != "" { + result.ErrorFg = override.ErrorFg + } + if override.ErrorBg != "" { + result.ErrorBg = override.ErrorBg + } + if override.Success != "" { + result.Success = override.Success + } + if override.Comment != "" { + result.Comment = override.Comment + } + if override.CommentPreproc != "" { + result.CommentPreproc = override.CommentPreproc + } + if override.Keyword != "" { + result.Keyword = override.Keyword + } + if override.KeywordReserved != "" { + result.KeywordReserved = override.KeywordReserved + } + if override.KeywordNamespace != "" { + result.KeywordNamespace = override.KeywordNamespace + } + if override.KeywordType != "" { + result.KeywordType = override.KeywordType + } + if override.Operator != "" { + result.Operator = override.Operator + } + if override.Punctuation != "" { + result.Punctuation = override.Punctuation + } + if override.NameBuiltin != "" { + result.NameBuiltin = override.NameBuiltin + } + if override.NameTag != "" { + result.NameTag = override.NameTag + } + if override.NameAttribute != "" { + result.NameAttribute = override.NameAttribute + } + if override.NameDecorator != "" { + result.NameDecorator = override.NameDecorator + } + if override.LiteralNumber != "" { + result.LiteralNumber = override.LiteralNumber + } + if override.LiteralString != "" { + result.LiteralString = override.LiteralString + } + if override.LiteralStringEscape != "" { + result.LiteralStringEscape = override.LiteralStringEscape + } + if override.GenericDeleted != "" { + result.GenericDeleted = override.GenericDeleted + } + if override.GenericSubheading != "" { + result.GenericSubheading = override.GenericSubheading + } + if override.Background != "" { + result.Background = override.Background + } + return result +} + +func mergeMarkdownTheme(base, override MarkdownTheme) MarkdownTheme { + result := base + if override.Heading != "" { + result.Heading = override.Heading + } + if override.Link != "" { + result.Link = override.Link + } + if override.Strong != "" { + result.Strong = override.Strong + } + if override.Code != "" { + result.Code = override.Code + } + if override.CodeBg != "" { + result.CodeBg = override.CodeBg + } + if override.Blockquote != "" { + result.Blockquote = override.Blockquote + } + if override.List != "" { + result.List = override.List + } + if override.HR != "" { + result.HR = override.HR + } + return result +} + +// currentTheme stores the currently applied theme for reference. +var currentTheme atomic.Pointer[Theme] + +// CurrentTheme returns the currently applied theme, or the default if none applied. +func CurrentTheme() *Theme { + t := currentTheme.Load() + if t == nil { + return DefaultTheme() + } + return t +} + +// ApplyTheme applies the given theme to all style variables. +// This updates all exported color and style variables in the styles package. +// After calling this, send ThemeChangedMsg to invalidate all TUI caches. +func ApplyTheme(theme *Theme) { + if theme == nil { + theme = DefaultTheme() + } + + // Store current theme + currentTheme.Store(theme) + + // Update color variables + c := theme.Colors + // Background colors + Background = lipgloss.Color(c.Background) + BackgroundAlt = lipgloss.Color(c.BackgroundAlt) + // Text colors + White = lipgloss.Color(c.SelectedFg) + TextPrimary = lipgloss.Color(c.TextPrimary) + TextSecondary = lipgloss.Color(c.TextSecondary) + TextMuted = lipgloss.Color(c.AccentMuted) + TextMutedGray = lipgloss.Color(c.TextMuted) + // Accent & brand colors + Accent = lipgloss.Color(c.Accent) + MobyBlue = lipgloss.Color(c.Brand) + // Status colors + Success = lipgloss.Color(c.Success) + Error = lipgloss.Color(c.Error) + Warning = lipgloss.Color(c.Warning) + Info = lipgloss.Color(c.Info) + Highlight = lipgloss.Color(c.Highlight) + // Border colors + BorderPrimary = lipgloss.Color(c.Accent) + BorderSecondary = lipgloss.Color(c.BorderSecondary) + BorderMuted = lipgloss.Color(c.BackgroundAlt) + BorderWarning = lipgloss.Color(c.Warning) + // Diff colors + DiffAddBg = lipgloss.Color(c.DiffAddBg) + DiffRemoveBg = lipgloss.Color(c.DiffRemoveBg) + DiffAddFg = lipgloss.Color(c.Success) + DiffRemoveFg = lipgloss.Color(c.Error) + // UI element colors + LineNumber = lipgloss.Color(c.LineNumber) + Separator = lipgloss.Color(c.Separator) + Selected = lipgloss.Color(c.Selected) + SelectedFg = lipgloss.Color(c.TextPrimary) + PlaceholderColor = lipgloss.Color(c.Placeholder) + // Badge colors + AgentBadgeBg = MobyBlue + AgentBadgeFg = lipgloss.Color(bestForegroundHex( + c.Brand, + c.TextBright, + c.Background, + "#000000", + "#ffffff", + )) + BadgePurple = lipgloss.Color(c.BadgeAccent) + BadgeCyan = lipgloss.Color(c.BadgeInfo) + BadgeGreen = lipgloss.Color(c.BadgeSuccess) + // Error colors + ErrorStrong = lipgloss.Color(c.ErrorStrong) + ErrorDark = lipgloss.Color(c.ErrorDark) + // Other UI colors + FadedGray = lipgloss.Color(c.TextFaint) + TabBg = lipgloss.Color(c.TabBg) + TabPrimaryFg = lipgloss.Color(c.TextMuted) + TabAccentFg = lipgloss.Color(c.Highlight) + + // Rebuild all derived styles + rebuildStyles() + + // Clear style sequence cache (used by RenderComposite) + clearStyleSeqCache() +} + +// rebuildStyles rebuilds all derived lipgloss.Style variables from the current color values. +func rebuildStyles() { + // Base styles + BaseStyle = NoStyle.Foreground(TextPrimary) + AppStyle = BaseStyle.Padding(0, 1, 0, AppPaddingLeft) + + // Text styles + HighlightWhiteStyle = BaseStyle.Foreground(White).Bold(true) + MutedStyle = BaseStyle.Foreground(TextMutedGray) + SecondaryStyle = BaseStyle.Foreground(TextSecondary) + BoldStyle = BaseStyle.Bold(true) + FadingStyle = NoStyle.Foreground(FadedGray) + + // Status styles + SuccessStyle = BaseStyle.Foreground(Success) + ErrorStyle = BaseStyle.Foreground(Error) + WarningStyle = BaseStyle.Foreground(Warning) + InfoStyle = BaseStyle.Foreground(Info) + ActiveStyle = BaseStyle.Foreground(Success) + ToBeDoneStyle = BaseStyle.Foreground(TextPrimary) + InProgressStyle = BaseStyle.Foreground(Highlight) + CompletedStyle = BaseStyle.Foreground(TextMutedGray) + + // Layout styles + CenterStyle = BaseStyle.Align(lipgloss.Center, lipgloss.Center) + + // Border/message styles + BaseMessageStyle = BaseStyle. + Padding(1, 1). + BorderLeft(true). + BorderStyle(lipgloss.HiddenBorder()). + BorderForeground(BorderPrimary) + + UserMessageStyle = BaseMessageStyle. + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(BorderPrimary). + Foreground(TextPrimary). + Background(BackgroundAlt). + Bold(true) + + AssistantMessageStyle = BaseMessageStyle.Padding(0, 1) + + WelcomeMessageStyle = BaseMessageStyle. + BorderStyle(lipgloss.DoubleBorder()). + Bold(true) + + ErrorMessageStyle = BaseMessageStyle. + BorderStyle(lipgloss.ThickBorder()). + Foreground(Error) + + SelectedMessageStyle = AssistantMessageStyle. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(Success) + + // Dialog styles + DialogStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderSecondary). + Foreground(TextPrimary). + Padding(1, 2). + Align(lipgloss.Left) + + DialogWarningStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderWarning). + Foreground(TextPrimary). + Padding(1, 2). + Align(lipgloss.Left) + + DialogTitleStyle = BaseStyle. + Bold(true). + Foreground(TextSecondary). + Align(lipgloss.Center) + + DialogTitleWarningStyle = BaseStyle. + Bold(true). + Foreground(Warning). + Align(lipgloss.Center) + + DialogTitleInfoStyle = BaseStyle. + Bold(true). + Foreground(Info). + Align(lipgloss.Center) + + DialogContentStyle = BaseStyle.Foreground(TextPrimary) + + DialogSeparatorStyle = BaseStyle.Foreground(BorderMuted) + + DialogQuestionStyle = BaseStyle. + Bold(true). + Foreground(TextPrimary). + Align(lipgloss.Center) + + DialogOptionsStyle = BaseStyle. + Foreground(TextMuted). + Align(lipgloss.Center) + + DialogHelpStyle = BaseStyle. + Foreground(TextMuted). + Italic(true) + + TabTitleStyle = BaseStyle.Foreground(TabPrimaryFg) + TabPrimaryStyle = BaseStyle.Foreground(TextPrimary) + TabStyle = TabPrimaryStyle.Padding(1, 0) + TabAccentStyle = BaseStyle.Foreground(TabAccentFg) + + // Command palette styles + PaletteCategoryStyle = BaseStyle. + Bold(true). + Foreground(White). + MarginTop(1) + + PaletteUnselectedActionStyle = BaseStyle. + Foreground(TextPrimary). + Bold(true) + + PaletteSelectedActionStyle = PaletteUnselectedActionStyle. + Background(MobyBlue). + Foreground(White) + + PaletteUnselectedDescStyle = BaseStyle.Foreground(TextSecondary) + + PaletteSelectedDescStyle = PaletteUnselectedDescStyle. + Background(MobyBlue). + Foreground(White) + + // Badge styles + BadgeAlloyStyle = BaseStyle.Foreground(BadgePurple) + BadgeDefaultStyle = BaseStyle.Foreground(BadgeCyan) + BadgeCurrentStyle = BaseStyle.Foreground(BadgeGreen) + + // Star styles + StarredStyle = BaseStyle.Foreground(Success) + UnstarredStyle = BaseStyle.Foreground(TextMuted) + + // Diff styles + DiffAddStyle = BaseStyle.Background(DiffAddBg).Foreground(DiffAddFg) + DiffRemoveStyle = BaseStyle.Background(DiffRemoveBg).Foreground(DiffRemoveFg) + DiffUnchangedStyle = BaseStyle.Background(BackgroundAlt) + + // Syntax highlighting styles + LineNumberStyle = BaseStyle.Foreground(LineNumber).Background(BackgroundAlt) + SeparatorStyle = BaseStyle.Foreground(Separator).Background(BackgroundAlt) + + // Tool call styles + ToolMessageStyle = BaseStyle.Foreground(TextMutedGray) + ToolErrorMessageStyle = BaseStyle.Foreground(ErrorStrong) + ToolName = ToolMessageStyle.Foreground(TextMutedGray).Padding(0, 1) + ToolNameError = ToolName. + Foreground(ErrorStrong). + Background(ErrorDark) + ToolNameDim = ToolMessageStyle.Foreground(TextMutedGray).Italic(true) + ToolDescription = ToolMessageStyle.Foreground(TextPrimary) + ToolCompletedIcon = BaseStyle.MarginLeft(2).Foreground(TextMutedGray) + ToolErrorIcon = ToolCompletedIcon.Background(ErrorStrong) + ToolPendingIcon = ToolCompletedIcon.Background(Warning) + ToolCallArgs = ToolMessageStyle.Padding(0, 0, 0, 2) + ToolCallResult = ToolMessageStyle.Padding(0, 0, 0, 2) + + // Input styles + InputStyle = textarea.Styles{ + Focused: textarea.StyleState{ + Base: BaseStyle, + Placeholder: BaseStyle.Foreground(PlaceholderColor), + }, + Blurred: textarea.StyleState{ + Base: BaseStyle, + Placeholder: BaseStyle.Foreground(PlaceholderColor), + }, + Cursor: textarea.CursorStyle{ + Color: Accent, + }, + } + + DialogInputStyle = textinput.Styles{ + Focused: textinput.StyleState{ + Text: BaseStyle, + Placeholder: BaseStyle.Foreground(PlaceholderColor), + }, + Blurred: textinput.StyleState{ + Text: BaseStyle, + Placeholder: BaseStyle.Foreground(PlaceholderColor), + }, + Cursor: textinput.CursorStyle{ + Color: Accent, + }, + } + + EditorStyle = BaseStyle.Padding(1, 0, 0, 0) + SuggestionGhostStyle = BaseStyle.Foreground(lipgloss.Color(CurrentTheme().Colors.SuggestionGhost)) + SuggestionCursorStyle = BaseStyle.Background(Accent).Foreground(lipgloss.Color(CurrentTheme().Colors.SuggestionGhost)) + + // Attachment styles + AttachmentBannerStyle = BaseStyle.Foreground(TextSecondary) + AttachmentBadgeStyle = BaseStyle.Foreground(Info).Bold(true) + AttachmentSizeStyle = BaseStyle.Foreground(TextMuted).Italic(true) + AttachmentIconStyle = BaseStyle.Foreground(Info) + + // Scrollbar styles + TrackStyle = lipgloss.NewStyle().Foreground(BorderSecondary) + ThumbStyle = lipgloss.NewStyle().Foreground(Info).Background(BackgroundAlt).Bold(true) + ThumbActiveStyle = lipgloss.NewStyle().Foreground(White).Background(BackgroundAlt).Bold(true) + + // Resize handle styles + ResizeHandleStyle = BaseStyle.Foreground(BorderSecondary) + ResizeHandleHoverStyle = BaseStyle.Foreground(Info).Bold(true) + ResizeHandleActiveStyle = BaseStyle.Foreground(White).Bold(true) + + // Notification styles + NotificationStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(Success). + Padding(0, 1) + + NotificationInfoStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(Info). + Padding(0, 1) + + NotificationWarningStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(Warning). + Padding(0, 1) + + NotificationErrorStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(Error). + Padding(0, 1) + + // Completion styles + CompletionBoxStyle = BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(BorderSecondary). + Padding(0, 1) + + CompletionNormalStyle = BaseStyle.Foreground(TextPrimary).Bold(true) + CompletionSelectedStyle = CompletionNormalStyle.Foreground(White).Background(MobyBlue) + CompletionDescStyle = BaseStyle.Foreground(TextSecondary) + CompletionSelectedDescStyle = CompletionDescStyle.Foreground(White).Background(MobyBlue) + CompletionNoResultsStyle = BaseStyle.Foreground(TextMuted).Italic(true).Align(lipgloss.Center) + + // Agent badge styles + AgentBadgeStyle = BaseStyle. + Foreground(AgentBadgeFg). + Background(AgentBadgeBg). + Padding(0, 1) + + ThinkingBadgeStyle = BaseStyle. + Foreground(TextMuted). + Bold(true). + Italic(true) + + // Selection styles + SelectionStyle = BaseStyle.Background(Selected).Foreground(SelectedFg) + + // Spinner styles + SpinnerDotsAccentStyle = BaseStyle.Foreground(Accent) + SpinnerDotsHighlightStyle = BaseStyle.Foreground(TabAccentFg) + SpinnerTextBrightestStyle = BaseStyle.Foreground(lipgloss.Color(CurrentTheme().Colors.SpinnerBrightest)) + SpinnerTextBrightStyle = BaseStyle.Foreground(lipgloss.Color(CurrentTheme().Colors.SpinnerBright)) + SpinnerTextDimStyle = BaseStyle.Foreground(lipgloss.Color(CurrentTheme().Colors.SpinnerDim)) + SpinnerTextDimmestStyle = BaseStyle.Foreground(Accent) +} + +func bestForegroundHex(bgHex string, candidates ...string) string { + if len(candidates) == 0 { + return "" + } + best := candidates[0] + bestRatio := -1.0 + + for _, cand := range candidates { + ratio, ok := contrastRatioHex(cand, bgHex) + if !ok { + continue + } + if ratio > bestRatio { + bestRatio = ratio + best = cand + } + } + + return best +} + +func contrastRatioHex(fgHex, bgHex string) (float64, bool) { + fgLum, ok := relativeLuminanceHex(fgHex) + if !ok { + return 0, false + } + bgLum, ok := relativeLuminanceHex(bgHex) + if !ok { + return 0, false + } + + L1, L2 := fgLum, bgLum + if L2 > L1 { + L1, L2 = L2, L1 + } + + return (L1 + 0.05) / (L2 + 0.05), true +} + +func relativeLuminanceHex(hex string) (float64, bool) { + r, g, b, ok := parseHexRGB01(hex) + if !ok { + return 0, false + } + + // WCAG 2.x relative luminance for sRGB + rl := 0.2126*srgbToLinear(r) + 0.7152*srgbToLinear(g) + 0.0722*srgbToLinear(b) + return rl, true +} + +func srgbToLinear(c float64) float64 { + if c <= 0.03928 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +func parseHexRGB01(hex string) (float64, float64, float64, bool) { + if !strings.HasPrefix(hex, "#") { + return 0, 0, 0, false + } + + h := strings.TrimPrefix(hex, "#") + if len(h) == 3 { + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) + } + if len(h) != 6 { + return 0, 0, 0, false + } + + r8, err := strconv.ParseUint(h[0:2], 16, 8) + if err != nil { + return 0, 0, 0, false + } + g8, err := strconv.ParseUint(h[2:4], 16, 8) + if err != nil { + return 0, 0, 0, false + } + b8, err := strconv.ParseUint(h[4:6], 16, 8) + if err != nil { + return 0, 0, 0, false + } + + return float64(r8) / 255.0, float64(g8) / 255.0, float64(b8) / 255.0, true +} + +// init applies the default theme at package initialization time. +// This ensures color variables are set before any code uses them, +// including tests that don't explicitly call ApplyTheme(). +// +//nolint:gochecknoinits // Intentional: color vars must be initialized before use +func init() { + ApplyTheme(DefaultTheme()) +} diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go new file mode 100644 index 000000000..d7865f6b5 --- /dev/null +++ b/pkg/tui/styles/theme_test.go @@ -0,0 +1,440 @@ +package styles + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultThemeRef(t *testing.T) { + t.Parallel() + + // DefaultThemeRef should be "default" + assert.Equal(t, "default", DefaultThemeRef) +} + +func TestDefaultTheme(t *testing.T) { + t.Parallel() + + theme := DefaultTheme() + require.NotNil(t, theme) + + assert.Equal(t, 1, theme.Version) + assert.Equal(t, "Default", theme.Name) + assert.Equal(t, DefaultThemeRef, theme.Ref) + + // Check that colors are populated (values come from embedded default.yaml) + assert.NotEmpty(t, theme.Colors.TextBright) + assert.NotEmpty(t, theme.Colors.Accent) + assert.NotEmpty(t, theme.Colors.Background) + assert.NotEmpty(t, theme.Colors.Success) + + // Check chroma colors + assert.NotEmpty(t, theme.Chroma.Keyword) + + // Check markdown theme + assert.NotEmpty(t, theme.Markdown.Heading) +} + +func TestListThemeRefs_EmptyDir(t *testing.T) { + t.Parallel() + + themesDir := t.TempDir() + + refs, err := listThemeRefsFrom(themesDir) + require.NoError(t, err) + + // listThemeRefsFrom only lists files, no default injection + assert.Empty(t, refs) +} + +func TestListThemeRefs_NonexistentDir(t *testing.T) { + t.Parallel() + + refs, err := listThemeRefsFrom("/nonexistent/path/that/does/not/exist") + require.NoError(t, err) + + // listThemeRefsFrom returns empty for nonexistent dir + assert.Empty(t, refs) +} + +func TestListThemeRefs_WithThemes(t *testing.T) { + t.Parallel() + + themesDir := t.TempDir() + + // Create some theme files + require.NoError(t, os.WriteFile(filepath.Join(themesDir, "dark.yaml"), []byte("version: 1\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(themesDir, "light.yml"), []byte("version: 1\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(themesDir, "notatheme.txt"), []byte(""), 0o644)) + + refs, err := listThemeRefsFrom(themesDir) + require.NoError(t, err) + + // Should contain dark + light (not the .txt file), no default injection + assert.Contains(t, refs, "dark") + assert.Contains(t, refs, "light") + assert.NotContains(t, refs, "notatheme") + assert.Len(t, refs, 2) +} + +func TestLoadTheme_Default(t *testing.T) { + t.Parallel() + + // LoadTheme("default") should return the built-in default theme + theme, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + require.NotNil(t, theme) + + assert.Equal(t, "Default", theme.Name) + assert.Equal(t, DefaultThemeRef, theme.Ref) + assert.NotEmpty(t, theme.Colors.TextBright) +} + +func TestLoadTheme_EmptyRef_Error(t *testing.T) { + t.Parallel() + + // LoadTheme("") should return an error - caller should pass a valid ref + _, err := LoadTheme("") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty ref") +} + +func TestValidateThemeRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + wantErr bool + }{ + {"empty is valid", "", false}, + {"default is valid", "default", false}, + {"simple name is valid", "tokyo-night", false}, + {"path separator rejected", "foo/bar", true}, + {"backslash rejected", "foo\\bar", true}, + {"traversal rejected", "..", true}, + {"hidden traversal rejected", "foo..bar", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateThemeRef(tt.ref) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestLoadTheme_NotFound(t *testing.T) { + t.Parallel() + + themesDir := t.TempDir() + + _, err := loadThemeFrom("nonexistent", themesDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestLoadTheme_PartialOverride(t *testing.T) { + t.Parallel() + + themesDir := t.TempDir() + + // Create a theme that only overrides a few colors + themeContent := `version: 1 +name: Custom Theme +colors: + accent: "#FF0000" + background: "#000000" +` + require.NoError(t, os.WriteFile(filepath.Join(themesDir, "custom.yaml"), []byte(themeContent), 0o644)) + + theme, err := loadThemeFrom("custom", themesDir) + require.NoError(t, err) + require.NotNil(t, theme) + + assert.Equal(t, "Custom Theme", theme.Name) + assert.Equal(t, "custom", theme.Ref) + + // Overridden values + assert.Equal(t, "#FF0000", theme.Colors.Accent) + assert.Equal(t, "#000000", theme.Colors.Background) + + // Non-overridden values should be defaults (from default.yaml) + assert.Equal(t, DefaultTheme().Colors.TextBright, theme.Colors.TextBright) + assert.Equal(t, DefaultTheme().Colors.Success, theme.Colors.Success) +} + +func TestLoadTheme_YmlExtension(t *testing.T) { + t.Parallel() + + themesDir := t.TempDir() + + themeContent := `version: 1 +name: YML Theme +` + require.NoError(t, os.WriteFile(filepath.Join(themesDir, "ymltheme.yml"), []byte(themeContent), 0o644)) + + theme, err := loadThemeFrom("ymltheme", themesDir) + require.NoError(t, err) + require.NotNil(t, theme) + + assert.Equal(t, "YML Theme", theme.Name) +} + +func TestApplyTheme(t *testing.T) { + // Note: Cannot use t.Parallel() because ApplyTheme modifies global state + + // Create a custom theme + theme := DefaultTheme() + theme.Colors.Accent = "#123456" + theme.Name = "Test Theme" + theme.Ref = "test" + + ApplyTheme(theme) + + // CurrentTheme should return the applied theme + current := CurrentTheme() + assert.Equal(t, "Test Theme", current.Name) + assert.Equal(t, "test", current.Ref) + + // Reset to default for other tests + ApplyTheme(DefaultTheme()) +} + +func TestMergeTheme_AllFields(t *testing.T) { + t.Parallel() + + base := DefaultTheme() + override := &Theme{ + Version: 2, + Name: "Override", + Colors: ThemeColors{ + TextBright: "#FFFFFF", + Accent: "#0000FF", + }, + Chroma: ChromaColors{ + Keyword: "#FF00FF", + }, + Markdown: MarkdownTheme{ + Heading: "#00FF00", + }, + } + + merged := mergeTheme(base, override) + + // Overridden values + assert.Equal(t, 2, merged.Version) + assert.Equal(t, "Override", merged.Name) + assert.Equal(t, "#FFFFFF", merged.Colors.TextBright) + assert.Equal(t, "#0000FF", merged.Colors.Accent) + assert.Equal(t, "#FF00FF", merged.Chroma.Keyword) + assert.Equal(t, "#00FF00", merged.Markdown.Heading) + + // Non-overridden values from base (default theme) + assert.Equal(t, DefaultTheme().Colors.Background, merged.Colors.Background) + assert.Equal(t, DefaultTheme().Chroma.Comment, merged.Chroma.Comment) + assert.Equal(t, DefaultTheme().Markdown.Link, merged.Markdown.Link) +} + +// --- Theme Infrastructure Reliability Tests --- +// These tests use reflection to automatically catch when new fields are added +// but not properly handled in DefaultTheme(), merge functions, or built-in themes. + +// TestDefaultTheme_AllColorsPopulated ensures DefaultTheme() sets every ThemeColors field. +// This catches: adding a new field to ThemeColors but forgetting to set it in DefaultTheme(). +func TestDefaultTheme_AllColorsPopulated(t *testing.T) { + t.Parallel() + + theme := DefaultTheme() + + // Check ThemeColors - all fields must be non-empty + colorsVal := reflect.ValueOf(theme.Colors) + colorsType := colorsVal.Type() + for i := range colorsType.NumField() { + field := colorsType.Field(i) + value := colorsVal.Field(i).String() + assert.NotEmpty(t, value, "DefaultTheme().Colors.%s is empty - add default in DefaultTheme()", field.Name) + } + + // Check ChromaColors - all fields must be non-empty + chromaVal := reflect.ValueOf(theme.Chroma) + chromaType := chromaVal.Type() + for i := range chromaType.NumField() { + field := chromaType.Field(i) + value := chromaVal.Field(i).String() + assert.NotEmpty(t, value, "DefaultTheme().Chroma.%s is empty - add default in DefaultTheme()", field.Name) + } + + // Check MarkdownTheme - all fields must be non-empty + mdVal := reflect.ValueOf(theme.Markdown) + mdType := mdVal.Type() + for i := range mdType.NumField() { + field := mdType.Field(i) + value := mdVal.Field(i).String() + assert.NotEmpty(t, value, "DefaultTheme().Markdown.%s is empty - add default in DefaultTheme()", field.Name) + } +} + +// TestMergeColors_HandlesAllFields ensures mergeColors handles every ThemeColors field. +// This catches: adding a new field to ThemeColors but forgetting to merge it. +func TestMergeColors_HandlesAllFields(t *testing.T) { + t.Parallel() + + // Create a base with all fields set to "BASE" + base := ThemeColors{} + baseVal := reflect.ValueOf(&base).Elem() + for i := range baseVal.NumField() { + baseVal.Field(i).SetString("BASE") + } + + // Create an override with all fields set to "OVERRIDE" + override := ThemeColors{} + overrideVal := reflect.ValueOf(&override).Elem() + for i := range overrideVal.NumField() { + overrideVal.Field(i).SetString("OVERRIDE") + } + + // Merge should replace all base values with override values + merged := mergeColors(base, override) + mergedVal := reflect.ValueOf(merged) + mergedType := mergedVal.Type() + + for i := range mergedType.NumField() { + field := mergedType.Field(i) + value := mergedVal.Field(i).String() + assert.Equal(t, "OVERRIDE", value, + "mergeColors() doesn't handle ThemeColors.%s - add merge logic in mergeColors()", field.Name) + } +} + +// TestMergeChromaColors_HandlesAllFields ensures mergeChromaColors handles every ChromaColors field. +func TestMergeChromaColors_HandlesAllFields(t *testing.T) { + t.Parallel() + + base := ChromaColors{} + baseVal := reflect.ValueOf(&base).Elem() + for i := range baseVal.NumField() { + baseVal.Field(i).SetString("BASE") + } + + override := ChromaColors{} + overrideVal := reflect.ValueOf(&override).Elem() + for i := range overrideVal.NumField() { + overrideVal.Field(i).SetString("OVERRIDE") + } + + merged := mergeChromaColors(base, override) + mergedVal := reflect.ValueOf(merged) + mergedType := mergedVal.Type() + + for i := range mergedType.NumField() { + field := mergedType.Field(i) + value := mergedVal.Field(i).String() + assert.Equal(t, "OVERRIDE", value, + "mergeChromaColors() doesn't handle ChromaColors.%s - add merge logic", field.Name) + } +} + +// TestMergeMarkdownTheme_HandlesAllFields ensures mergeMarkdownTheme handles every MarkdownTheme field. +func TestMergeMarkdownTheme_HandlesAllFields(t *testing.T) { + t.Parallel() + + base := MarkdownTheme{} + baseVal := reflect.ValueOf(&base).Elem() + for i := range baseVal.NumField() { + baseVal.Field(i).SetString("BASE") + } + + override := MarkdownTheme{} + overrideVal := reflect.ValueOf(&override).Elem() + for i := range overrideVal.NumField() { + overrideVal.Field(i).SetString("OVERRIDE") + } + + merged := mergeMarkdownTheme(base, override) + mergedVal := reflect.ValueOf(merged) + mergedType := mergedVal.Type() + + for i := range mergedType.NumField() { + field := mergedType.Field(i) + value := mergedVal.Field(i).String() + assert.Equal(t, "OVERRIDE", value, + "mergeMarkdownTheme() doesn't handle MarkdownTheme.%s - add merge logic", field.Name) + } +} + +// TestAllBuiltinThemes_LoadSuccessfully ensures all embedded theme YAMLs parse correctly. +// This catches: YAML syntax errors, incorrect field names, or broken theme files. +func TestAllBuiltinThemes_LoadSuccessfully(t *testing.T) { + t.Parallel() + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + require.NotEmpty(t, refs, "no built-in themes found - check //go:embed directive") + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + theme, err := loadBuiltinTheme(ref) + require.NoError(t, err, "failed to load built-in theme %q", ref) + require.NotNil(t, theme) + assert.Equal(t, ref, theme.Ref) + assert.NotEmpty(t, theme.Name, "built-in theme %q has no name", ref) + }) + } +} + +// TestAllBuiltinThemes_HaveCoreColors ensures built-in themes explicitly define critical colors. +// These are colors that significantly affect usability and should be intentionally designed. +func TestAllBuiltinThemes_HaveCoreColors(t *testing.T) { + t.Parallel() + + // Core colors that every theme should explicitly define for good UX + coreColorFields := []string{ + "TextPrimary", + "TextSecondary", + "Background", + "BackgroundAlt", + "Accent", + "Success", + "Error", + } + + refs, err := listBuiltinThemeRefs() + require.NoError(t, err) + + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + // Load the raw theme without merging to check what's explicitly defined + data, err := builtinThemes.ReadFile("themes/" + ref + ".yaml") + require.NoError(t, err) + + var rawTheme Theme + require.NoError(t, yaml.Unmarshal(data, &rawTheme)) + + colorsVal := reflect.ValueOf(rawTheme.Colors) + colorsType := colorsVal.Type() + + for _, fieldName := range coreColorFields { + field, found := colorsType.FieldByName(fieldName) + require.True(t, found, "field %s not found in ThemeColors struct", fieldName) + + value := colorsVal.FieldByName(field.Name).String() + assert.NotEmpty(t, value, + "built-in theme %q should explicitly define Colors.%s for good UX", ref, fieldName) + } + }) + } +} diff --git a/pkg/tui/styles/theme_watcher.go b/pkg/tui/styles/theme_watcher.go new file mode 100644 index 000000000..8b6377401 --- /dev/null +++ b/pkg/tui/styles/theme_watcher.go @@ -0,0 +1,237 @@ +package styles + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// ThemeWatcher watches the current theme file for changes and signals +// when the file is modified. It does NOT apply the theme directly to avoid +// race conditions with the TUI goroutine. +type ThemeWatcher struct { + mu sync.Mutex + watcher *fsnotify.Watcher + currentPath string + currentThemeRef string + stopChan chan struct{} + onThemeChanged func(themeRef string) // Callback when theme file changes (themeRef included) + + // themesDir can be set for testing to override the default ThemesDir() + themesDir string +} + +// NewThemeWatcher creates a new theme watcher. +// The onThemeChanged callback is called whenever the theme file is modified. +// The callback receives the theme ref so the caller can load and apply the theme +// on the appropriate goroutine (e.g., the TUI main loop). +func NewThemeWatcher(onThemeChanged func(themeRef string)) *ThemeWatcher { + return &ThemeWatcher{ + onThemeChanged: onThemeChanged, + } +} + +// Watch starts watching the theme file for the given theme reference. +// Only watches if a user theme file exists for this ref (in ~/.cagent/themes/). +// Handles "user:" prefix - e.g., "user:nord" watches ~/.cagent/themes/nord.yaml. +// If the theme is the built-in default or no user file exists, no watching occurs. +func (tw *ThemeWatcher) Watch(themeRef string) error { + tw.mu.Lock() + defer tw.mu.Unlock() + + // Stop existing watcher if any + tw.stopLocked() + + // Don't watch the built-in default (it's embedded, no file to watch) + // Also handle edge case of "user:default" + baseRef := strings.TrimPrefix(themeRef, UserThemePrefix) + if baseRef == DefaultThemeRef && !strings.HasPrefix(themeRef, UserThemePrefix) { + slog.Debug("Not watching built-in default theme", "theme", themeRef) + return nil + } + + // Try to find a user theme file - only watch if one exists + // (even if there's a built-in with the same name, user can override it) + themePath, err := tw.findThemePath(themeRef) + if err != nil { + slog.Debug("No user theme file found, not watching", "theme", themeRef) + return nil // Not an error - theme might be built-in only + } + + // Create the watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + // Watch the directory containing the theme file (more reliable for editors that + // do atomic saves by writing to a temp file and renaming) + dir := filepath.Dir(themePath) + if err := watcher.Add(dir); err != nil { + watcher.Close() + return err + } + + tw.watcher = watcher + tw.currentPath = themePath + tw.currentThemeRef = themeRef + tw.stopChan = make(chan struct{}) + + go tw.watchLoop() + + slog.Debug("Started watching theme file", "theme", themeRef, "path", themePath) + return nil +} + +// Stop stops watching the current theme file. +func (tw *ThemeWatcher) Stop() { + tw.mu.Lock() + defer tw.mu.Unlock() + tw.stopLocked() +} + +func (tw *ThemeWatcher) stopLocked() { + if tw.stopChan != nil { + close(tw.stopChan) + tw.stopChan = nil + } + if tw.watcher != nil { + tw.watcher.Close() + tw.watcher = nil + } + tw.currentPath = "" + tw.currentThemeRef = "" +} + +func (tw *ThemeWatcher) getThemesDir() string { + if tw.themesDir != "" { + return tw.themesDir + } + return ThemesDir() +} + +func (tw *ThemeWatcher) findThemePath(themeRef string) (string, error) { + dir := tw.getThemesDir() + + // Strip user: prefix if present to get the base filename + baseRef := strings.TrimPrefix(themeRef, UserThemePrefix) + + // Try .yaml first, then .yml + yamlPath := filepath.Join(dir, baseRef+".yaml") + if _, err := os.Stat(yamlPath); err == nil { + return yamlPath, nil + } + + ymlPath := filepath.Join(dir, baseRef+".yml") + if _, err := os.Stat(ymlPath); err == nil { + return ymlPath, nil + } + + return "", os.ErrNotExist +} + +func (tw *ThemeWatcher) watchLoop() { + // Debounce timer to handle rapid successive events (e.g., editor save operations) + var debounceTimer *time.Timer + debounceDuration := 500 * time.Millisecond + + tw.mu.Lock() + watcher := tw.watcher + stopChan := tw.stopChan + tw.mu.Unlock() + + if watcher == nil { + return + } + + for { + select { + case <-stopChan: + if debounceTimer != nil { + debounceTimer.Stop() + } + return + + case event, ok := <-watcher.Events: + if !ok { + return + } + + tw.mu.Lock() + currentPath := tw.currentPath + tw.mu.Unlock() + + // Check if this event might affect our theme file. + // Some editors use atomic saves (write to temp, then rename), so we need to handle: + // - Write/Create on exact path (direct save) + // - Rename events where the target becomes our file + // - Any event with matching basename (covers temp file renames) + eventPath := filepath.Clean(event.Name) + targetPath := filepath.Clean(currentPath) + + isExactMatch := eventPath == targetPath + isBasenameMatch := filepath.Base(eventPath) == filepath.Base(targetPath) + + // React to Write, Create, Rename, or Remove events + // - Write/Create: direct modifications + // - Rename: atomic save patterns (temp file renamed to target) + // - Remove: file deleted then recreated + relevantOp := event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) != 0 + + if !relevantOp { + continue + } + + // For exact matches, always trigger + // For basename matches with Rename/Create, also trigger (catches atomic saves) + if !isExactMatch && (!isBasenameMatch || event.Op&(fsnotify.Rename|fsnotify.Create) == 0) { + continue + } + + // Debounce: reset timer on each event + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(debounceDuration, func() { + // After debounce, verify the file still exists before signaling + tw.mu.Lock() + path := tw.currentPath + tw.mu.Unlock() + if _, err := os.Stat(path); err == nil { + tw.signalThemeChange() + } + }) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + slog.Warn("Theme file watcher error", "error", err) + } + } +} + +// signalThemeChange signals that the theme file has been modified. +// It does NOT load or apply the theme - that's the caller's responsibility +// to avoid race conditions with global style variables. +func (tw *ThemeWatcher) signalThemeChange() { + tw.mu.Lock() + themeRef := tw.currentThemeRef + tw.mu.Unlock() + + if themeRef == "" { + return + } + + slog.Debug("Theme file changed, signaling", "theme", themeRef) + + // Call the callback with the theme ref - caller will load and apply + if tw.onThemeChanged != nil { + tw.onThemeChanged(themeRef) + } +} diff --git a/pkg/tui/styles/theme_watcher_test.go b/pkg/tui/styles/theme_watcher_test.go new file mode 100644 index 000000000..0d78bc7c5 --- /dev/null +++ b/pkg/tui/styles/theme_watcher_test.go @@ -0,0 +1,218 @@ +package styles + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestThemeWatcher creates a theme watcher with a custom themes directory for testing. +func newTestThemeWatcher(themesDir string, onThemeChanged func(themeRef string)) *ThemeWatcher { + tw := NewThemeWatcher(onThemeChanged) + tw.themesDir = themesDir + return tw +} + +func TestThemeWatcher_WatchUserTheme(t *testing.T) { + t.Parallel() + + // Create a temporary theme file + tempDir := t.TempDir() + themePath := filepath.Join(tempDir, "test-theme.yaml") + initialContent := `version: 1 +name: Test Theme +colors: + accent_blue: "#FF0000" +` + require.NoError(t, os.WriteFile(themePath, []byte(initialContent), 0o644)) + + // Track callback invocations + var callbackCount atomic.Int32 + var lastThemeRef atomic.Value + callback := func(themeRef string) { + callbackCount.Add(1) + lastThemeRef.Store(themeRef) + } + + // Create watcher with custom themes directory + watcher := newTestThemeWatcher(tempDir, callback) + defer watcher.Stop() + + // Start watching + err := watcher.Watch("test-theme") + require.NoError(t, err) + + // Give the watcher time to start + time.Sleep(100 * time.Millisecond) + + // Modify the theme file + updatedContent := `version: 1 +name: Updated Theme +colors: + accent_blue: "#00FF00" +` + require.NoError(t, os.WriteFile(themePath, []byte(updatedContent), 0o644)) + + // Wait for the debounce timer and callback + time.Sleep(1 * time.Second) + + // Verify callback was called with the correct themeRef + assert.GreaterOrEqual(t, callbackCount.Load(), int32(1), "callback should have been called at least once") + ref, ok := lastThemeRef.Load().(string) + assert.True(t, ok) + assert.Equal(t, "test-theme", ref, "callback should receive the correct theme ref") +} + +func TestThemeWatcher_DoesNotWatchDefaultTheme(t *testing.T) { + t.Parallel() + + var callbackCount atomic.Int32 + callback := func(themeRef string) { + callbackCount.Add(1) + } + + watcher := NewThemeWatcher(callback) + defer watcher.Stop() + + // Try to watch the default theme (built-in, no file to watch) + err := watcher.Watch(DefaultThemeRef) + require.NoError(t, err) + + // The watcher should not be active for the built-in default theme + watcher.mu.Lock() + isWatching := watcher.watcher != nil + watcher.mu.Unlock() + + assert.False(t, isWatching, "should not watch built-in default theme") +} + +func TestThemeWatcher_WatchesUserOverrideOfBuiltin(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create a user theme file with a built-in name + themePath := filepath.Join(tempDir, "tokyo-night.yaml") + require.NoError(t, os.WriteFile(themePath, []byte("version: 1\nname: My Tokyo Night\n"), 0o644)) + + var callbackCount atomic.Int32 + callback := func(themeRef string) { + callbackCount.Add(1) + } + + // Create watcher with custom themes directory + watcher := newTestThemeWatcher(tempDir, callback) + defer watcher.Stop() + + // Watch tokyo-night - since user file exists, it should be watched + err := watcher.Watch("tokyo-night") + require.NoError(t, err) + + watcher.mu.Lock() + isWatching := watcher.watcher != nil + currentPath := watcher.currentPath + watcher.mu.Unlock() + + assert.True(t, isWatching, "should watch user override of built-in theme") + assert.Equal(t, themePath, currentPath) +} + +func TestThemeWatcher_StopCleansUp(t *testing.T) { + t.Parallel() + + // Create a temporary theme + tempDir := t.TempDir() + themePath := filepath.Join(tempDir, "cleanup-test.yaml") + require.NoError(t, os.WriteFile(themePath, []byte("version: 1\nname: Test\n"), 0o644)) + + // Create watcher with custom themes directory + watcher := newTestThemeWatcher(tempDir, nil) + + // Start watching + err := watcher.Watch("cleanup-test") + require.NoError(t, err) + + // Verify watcher is active + watcher.mu.Lock() + isActive := watcher.watcher != nil + watcher.mu.Unlock() + assert.True(t, isActive, "watcher should be active") + + // Stop and verify cleanup + watcher.Stop() + + watcher.mu.Lock() + isActiveAfterStop := watcher.watcher != nil + watcher.mu.Unlock() + assert.False(t, isActiveAfterStop, "watcher should be stopped") +} + +func TestThemeWatcher_SwitchingThemesUpdatesWatcher(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create two theme files + theme1Path := filepath.Join(tempDir, "theme1.yaml") + theme2Path := filepath.Join(tempDir, "theme2.yaml") + require.NoError(t, os.WriteFile(theme1Path, []byte("version: 1\nname: Theme1\n"), 0o644)) + require.NoError(t, os.WriteFile(theme2Path, []byte("version: 1\nname: Theme2\n"), 0o644)) + + // Create watcher with custom themes directory + watcher := newTestThemeWatcher(tempDir, nil) + defer watcher.Stop() + + // Watch first theme + err := watcher.Watch("theme1") + require.NoError(t, err) + + watcher.mu.Lock() + path1 := watcher.currentPath + watcher.mu.Unlock() + assert.Equal(t, theme1Path, path1) + + // Switch to second theme + err = watcher.Watch("theme2") + require.NoError(t, err) + + watcher.mu.Lock() + path2 := watcher.currentPath + watcher.mu.Unlock() + assert.Equal(t, theme2Path, path2) +} + +func TestThemeWatcher_SignalsOnAnyFileChange(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + themePath := filepath.Join(tempDir, "signal-test.yaml") + require.NoError(t, os.WriteFile(themePath, []byte("version: 1\nname: Valid\n"), 0o644)) + + var callbackCount atomic.Int32 + callback := func(themeRef string) { + callbackCount.Add(1) + } + + // Create watcher with custom themes directory + watcher := newTestThemeWatcher(tempDir, callback) + defer watcher.Stop() + + err := watcher.Watch("signal-test") + require.NoError(t, err) + + time.Sleep(100 * time.Millisecond) + + // Write any content (even invalid YAML) - watcher just signals, doesn't validate + require.NoError(t, os.WriteFile(themePath, []byte("this is: [not: valid yaml"), 0o644)) + + // Wait for debounce + time.Sleep(1 * time.Second) + + // Callback SHOULD be called because watcher only signals - validation is TUI's job + assert.GreaterOrEqual(t, callbackCount.Load(), int32(1), "callback should be called for any file change") +} diff --git a/pkg/tui/styles/themes/catppuccin-latte.yaml b/pkg/tui/styles/themes/catppuccin-latte.yaml new file mode 100644 index 000000000..6d7c30899 --- /dev/null +++ b/pkg/tui/styles/themes/catppuccin-latte.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Catppuccin Latte (Light)" +colors: + # Text colors + text_bright: "#4c4f69" + text_primary: "#4c4f69" + text_secondary: "#5c5f77" + text_muted: "#6c6f85" + text_faint: "#9ca0b0" + + # Accent colors + accent: "#1e66f5" + accent_muted: "#7287fd" + + # Background colors + background: "#eff1f5" + background_alt: "#dce0e8" + + # Border colors + border_secondary: "#9ca0b0" + + # Status colors + success: "#40a02b" + error: "#d20f39" + warning: "#df8e1d" + info: "#04a5e5" + highlight: "#ea76cb" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#1a4a99" + brand_bg: "#eff1f5" + + # Error colors + error_strong: "#d20f39" + error_dark: "#f5e0e5" + + # Spinner colors + spinner_dim: "#1e66f5" + spinner_bright: "#3575f5" + spinner_brightest: "#4c84f5" + + # Diff colors + diff_add_bg: "#d8f0d8" + diff_remove_bg: "#f5d8d8" + + # UI element colors + line_number: "#7c7f93" + separator: "#bcc0cc" + selected: "#ccd0da" + selected_fg: "#ffffff" + suggestion_ghost: "#7c7f93" + tab_bg: "#e6e9ef" + placeholder: "#6c6f85" + + # Badge colors + badge_accent: "#8839ef" + badge_info: "#04a5e5" + badge_success: "#40a02b" + +chroma: + # Syntax highlighting colors - Catppuccin Latte style + comment: "#8c8fa1" + comment_preproc: "#ea76cb" + keyword: "#8839ef" + keyword_reserved: "#d20f39" + keyword_namespace: "#04a5e5" + keyword_type: "#1e66f5" + operator: "#04a5e5" + punctuation: "#4c4f69" + name_builtin: "#fe640b" + name_tag: "#d20f39" + name_attribute: "#1e66f5" + name_decorator: "#df8e1d" + literal_number: "#fe640b" + literal_string: "#40a02b" + literal_string_escape: "#179299" + generic_deleted: "#d20f39" + generic_subheading: "#8c8fa1" + background: "#eff1f5" + error_fg: "#d20f39" + error_bg: "#f2d5d5" + success: "#40a02b" + +markdown: + heading: "#1e66f5" + link: "#209fb5" + strong: "#4c4f69" + code: "#fe640b" + code_bg: "#e6e9ef" + blockquote: "#6c6f85" + list: "#4c4f69" + hr: "#acb0be" \ No newline at end of file diff --git a/pkg/tui/styles/themes/catppuccin-mocha.yaml b/pkg/tui/styles/themes/catppuccin-mocha.yaml new file mode 100644 index 000000000..a672ed9eb --- /dev/null +++ b/pkg/tui/styles/themes/catppuccin-mocha.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Catppuccin Mocha" +colors: + # Text colors + text_bright: "#cdd6f4" + text_primary: "#cdd6f4" + text_secondary: "#bac2de" + text_muted: "#9399b2" + text_faint: "#6c7086" + + # Accent colors + accent: "#89b4fa" + accent_muted: "#7f849c" + + # Background colors + background: "#1e1e2e" + background_alt: "#313244" + + # Border colors + border_secondary: "#585b70" + + # Status colors + success: "#a6e3a1" + error: "#f38ba8" + warning: "#f9e2af" + info: "#89dceb" + highlight: "#f5c2e7" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#3a5a8a" + brand_bg: "#1e1e2e" + + # Error colors + error_strong: "#f38ba8" + error_dark: "#3d2a36" + + # Spinner colors + spinner_dim: "#b4befe" + spinner_bright: "#c4c7fe" + spinner_brightest: "#d4d4fe" + + # Diff colors + diff_add_bg: "#1e3a1e" + diff_remove_bg: "#3d2a36" + + # UI element colors + line_number: "#7f849c" + separator: "#585b70" + selected: "#585b70" + selected_fg: "#ffffff" + suggestion_ghost: "#7f849c" + tab_bg: "#181825" + placeholder: "#9399b2" + + # Badge colors + badge_accent: "#cba6f7" + badge_info: "#89dceb" + badge_success: "#a6e3a1" + +chroma: + # Syntax highlighting colors - Catppuccin style + comment: "#7f849c" + comment_preproc: "#f5c2e7" + keyword: "#cba6f7" + keyword_reserved: "#f38ba8" + keyword_namespace: "#89dceb" + keyword_type: "#89b4fa" + operator: "#89dceb" + punctuation: "#cdd6f4" + name_builtin: "#fab387" + name_tag: "#f38ba8" + name_attribute: "#89b4fa" + name_decorator: "#f9e2af" + literal_number: "#fab387" + literal_string: "#a6e3a1" + literal_string_escape: "#94e2d5" + generic_deleted: "#f38ba8" + generic_subheading: "#7f849c" + background: "#1e1e2e" + error_fg: "#f38ba8" + error_bg: "#3d2a36" + success: "#a6e3a1" + +markdown: + heading: "#89b4fa" + link: "#74c7ec" + strong: "#cdd6f4" + code: "#fab387" + code_bg: "#313244" + blockquote: "#bac2de" + list: "#cdd6f4" + hr: "#585b70" diff --git a/pkg/tui/styles/themes/default.yaml b/pkg/tui/styles/themes/default.yaml new file mode 100644 index 000000000..8d6d00e38 --- /dev/null +++ b/pkg/tui/styles/themes/default.yaml @@ -0,0 +1,92 @@ +version: 1 +name: "Default" +colors: + # Text colors + text_bright: "#E5F2FC" + text_primary: "#C0C0C0" + text_secondary: "#808080" + text_muted: "#808080" + text_faint: "#404550" + + # Accent colors + accent: "#7AA2F7" + accent_muted: "#8B95C1" + + # Background colors + background: "#1C1C22" + background_alt: "#24283B" + + # Border colors + border_secondary: "#6B75A8" + + # Status colors + success: "#9ECE6A" + error: "#F7768E" + warning: "#E0AF68" + info: "#7DCFFF" + highlight: "#98C379" + + # Brand colors + brand: "#1D63ED" + brand_bg: "#202a4b" + + # Error colors + error_strong: "#d74532" + error_dark: "#4a2523" + + # Spinner colors + spinner_dim: "#9AB8F9" + spinner_bright: "#B8CFFB" + spinner_brightest: "#D6E5FC" + + # Diff colors + diff_add_bg: "#20303B" + diff_remove_bg: "#3C2A2A" + + # UI element colors + line_number: "#565F89" + separator: "#414868" + selected: "#364A82" + selected_fg: "#ffffff" + suggestion_ghost: "#6B6B6B" + tab_bg: "#25252c" + placeholder: "#808080" + + # Badge colors + badge_accent: "#B083EA" + badge_info: "#7DCFFF" + badge_success: "#9ECE6A" + +chroma: + # Syntax highlighting colors (Monokai-inspired) + error_fg: "#F1F1F1" + error_bg: "#F05B5B" + success: "#00D787" + comment: "#676767" + comment_preproc: "#FF875F" + keyword: "#00AAFF" + keyword_reserved: "#FF5FD2" + keyword_namespace: "#FF5F87" + keyword_type: "#6E6ED8" + operator: "#EF8080" + punctuation: "#E8E8A8" + name_builtin: "#FF8EC7" + name_tag: "#B083EA" + name_attribute: "#7A7AE6" + name_decorator: "#FFFF87" + literal_number: "#6EEFC0" + literal_string: "#C69669" + literal_string_escape: "#AFFFD7" + generic_deleted: "#FD5B5B" + generic_subheading: "#777777" + background: "#373737" + +markdown: + heading: "#7AA2F7" + link: "#7AA2F7" + strong: "#C0C0C0" + code: "#C0C0C0" + code_bg: "#24283B" + blockquote: "#808080" + list: "#C0C0C0" + hr: "#6B75A8" diff --git a/pkg/tui/styles/themes/dracula.yaml b/pkg/tui/styles/themes/dracula.yaml new file mode 100644 index 000000000..229487d27 --- /dev/null +++ b/pkg/tui/styles/themes/dracula.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Dracula" +colors: + # Text colors + text_bright: "#f8f8f2" + text_primary: "#f8f8f2" + text_secondary: "#6272a4" + text_muted: "#6272a4" + text_faint: "#44475a" + + # Accent colors + accent: "#8be9fd" + accent_muted: "#6272a4" + + # Background colors + background: "#282a36" + background_alt: "#44475a" + + # Border colors + border_secondary: "#6272a4" + + # Status colors + success: "#50fa7b" + error: "#ff5555" + warning: "#f1fa8c" + info: "#8be9fd" + highlight: "#ff79c6" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#5a4080" + brand_bg: "#282a36" + + # Error colors + error_strong: "#ff5555" + error_dark: "#44282a" + + # Spinner colors + spinner_dim: "#bd93f9" + spinner_bright: "#c9a6f9" + spinner_brightest: "#d5b9f9" + + # Diff colors + diff_add_bg: "#2a4a2a" + diff_remove_bg: "#4a2a2a" + + # UI element colors + line_number: "#6272a4" + separator: "#44475a" + selected: "#44475a" + selected_fg: "#ffffff" + suggestion_ghost: "#6272a4" + tab_bg: "#282a36" + placeholder: "#6272a4" + + # Badge colors + badge_accent: "#bd93f9" + badge_info: "#8be9fd" + badge_success: "#50fa7b" + +chroma: + # Syntax highlighting colors - Dracula style + comment: "#6272a4" + comment_preproc: "#ff79c6" + keyword: "#ff79c6" + keyword_reserved: "#bd93f9" + keyword_namespace: "#8be9fd" + keyword_type: "#bd93f9" + operator: "#ff79c6" + punctuation: "#f8f8f2" + name_builtin: "#50fa7b" + name_tag: "#ff79c6" + name_attribute: "#8be9fd" + name_decorator: "#f1fa8c" + literal_number: "#bd93f9" + literal_string: "#f1fa8c" + literal_string_escape: "#ff79c6" + generic_deleted: "#ff5555" + generic_subheading: "#6272a4" + background: "#282a36" + error_fg: "#ff5555" + error_bg: "#44282a" + success: "#50fa7b" + +markdown: + heading: "#bd93f9" + link: "#8be9fd" + strong: "#f8f8f2" + code: "#ff79c6" + code_bg: "#44475a" + blockquote: "#6272a4" + list: "#f8f8f2" + hr: "#44475a" diff --git a/pkg/tui/styles/themes/gruvbox-dark.yaml b/pkg/tui/styles/themes/gruvbox-dark.yaml new file mode 100644 index 000000000..482db361c --- /dev/null +++ b/pkg/tui/styles/themes/gruvbox-dark.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Gruvbox Dark" +colors: + # Text colors + text_bright: "#ebdbb2" + text_primary: "#ebdbb2" + text_secondary: "#a89984" + text_muted: "#928374" + text_faint: "#504945" + + # Accent colors + accent: "#83a598" + accent_muted: "#458588" + + # Background colors + background: "#282828" + background_alt: "#3c3836" + + # Border colors + border_secondary: "#504945" + + # Status colors + success: "#b8bb26" + error: "#fb4934" + warning: "#fabd2f" + info: "#8ec07c" + highlight: "#d3869b" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#2a5a5c" + brand_bg: "#282828" + + # Error colors + error_strong: "#cc241d" + error_dark: "#3c1f1e" + + # Spinner colors + spinner_dim: "#83a598" + spinner_bright: "#8fbcbb" + spinner_brightest: "#9bc5c8" + + # Diff colors + diff_add_bg: "#2d3319" + diff_remove_bg: "#3c1f1e" + + # UI element colors + line_number: "#7c6f64" + separator: "#504945" + selected: "#504945" + selected_fg: "#ffffff" + suggestion_ghost: "#665c54" + tab_bg: "#3c3836" + placeholder: "#928374" + + # Badge colors + badge_accent: "#d3869b" + badge_info: "#8ec07c" + badge_success: "#b8bb26" + +chroma: + # Syntax highlighting colors - Gruvbox style + comment: "#928374" + comment_preproc: "#fe8019" + keyword: "#fb4934" + keyword_reserved: "#d3869b" + keyword_namespace: "#8ec07c" + keyword_type: "#fabd2f" + operator: "#fe8019" + punctuation: "#ebdbb2" + name_builtin: "#d3869b" + name_tag: "#fb4934" + name_attribute: "#fabd2f" + name_decorator: "#fe8019" + literal_number: "#d3869b" + literal_string: "#b8bb26" + literal_string_escape: "#8ec07c" + generic_deleted: "#fb4934" + generic_subheading: "#928374" + background: "#282828" + error_fg: "#fb4934" + error_bg: "#3c1f1e" + success: "#b8bb26" + +markdown: + heading: "#83a598" + link: "#8ec07c" + strong: "#ebdbb2" + code: "#fabd2f" + code_bg: "#3c3836" + blockquote: "#a89984" + list: "#ebdbb2" + hr: "#504945" diff --git a/pkg/tui/styles/themes/gruvbox-light.yaml b/pkg/tui/styles/themes/gruvbox-light.yaml new file mode 100644 index 000000000..2ac9fc1f7 --- /dev/null +++ b/pkg/tui/styles/themes/gruvbox-light.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Gruvbox Light" +colors: + # Text colors - using darker gruvbox colors for better contrast on light bg + text_bright: "#282828" + text_primary: "#282828" + text_secondary: "#3c3836" + text_muted: "#504945" + text_faint: "#7c6f64" + + # Accent colors + accent: "#076678" + accent_muted: "#427b58" + + # Background colors + background: "#fbf1c7" + background_alt: "#ebdbb2" + + # Border colors + border_secondary: "#928374" + + # Status colors + success: "#79740e" + error: "#9d0006" + warning: "#b57614" + info: "#076678" + highlight: "#8f3f71" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#3a6a3c" + brand_bg: "#ebdbb2" + + # Error colors + error_strong: "#9d0006" + error_dark: "#f9e0e0" + + # Spinner colors + spinner_dim: "#076678" + spinner_bright: "#458588" + spinner_brightest: "#83a598" + + # Diff colors + diff_add_bg: "#d5e5d0" + diff_remove_bg: "#f2dede" + + # UI element colors + line_number: "#7c6f64" + separator: "#a89984" + selected: "#d5c4a1" + selected_fg: "#ffffff" + suggestion_ghost: "#7c6f64" + tab_bg: "#ebdbb2" + placeholder: "#504945" + + # Badge colors + badge_accent: "#8f3f71" + badge_info: "#076678" + badge_success: "#79740e" + +chroma: + # Syntax highlighting colors - Gruvbox Light style + comment: "#928374" + comment_preproc: "#af3a03" + keyword: "#9d0006" + keyword_reserved: "#8f3f71" + keyword_namespace: "#427b58" + keyword_type: "#b57614" + operator: "#af3a03" + punctuation: "#3c3836" + name_builtin: "#8f3f71" + name_tag: "#9d0006" + name_attribute: "#b57614" + name_decorator: "#af3a03" + literal_number: "#8f3f71" + literal_string: "#79740e" + literal_string_escape: "#427b58" + generic_deleted: "#cc241d" + generic_subheading: "#928374" + background: "#fbf1c7" + error_fg: "#cc241d" + error_bg: "#f2d5d5" + success: "#79740e" + +markdown: + heading: "#076678" + link: "#427b58" + strong: "#3c3836" + code: "#b57614" + code_bg: "#f2e5bc" + blockquote: "#7c6f64" + list: "#3c3836" + hr: "#bdae93" \ No newline at end of file diff --git a/pkg/tui/styles/themes/nord.yaml b/pkg/tui/styles/themes/nord.yaml new file mode 100644 index 000000000..8279d4349 --- /dev/null +++ b/pkg/tui/styles/themes/nord.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Nord" +colors: + # Text colors + text_bright: "#eceff4" + text_primary: "#d8dee9" + text_secondary: "#8fbcbb" + text_muted: "#616e88" + text_faint: "#434c5e" + + # Accent colors + accent: "#81a1c1" + accent_muted: "#5e81ac" + + # Background colors + background: "#2e3440" + background_alt: "#3b4252" + + # Border colors + border_secondary: "#4c566a" + + # Status colors + success: "#a3be8c" + error: "#bf616a" + warning: "#ebcb8b" + info: "#88c0d0" + highlight: "#b48ead" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#3a5070" + brand_bg: "#2e3440" + + # Error colors + error_strong: "#bf616a" + error_dark: "#3d2a2a" + + # Spinner colors + spinner_dim: "#81a1c1" + spinner_bright: "#88c0d0" + spinner_brightest: "#8fbcbb" + + # Diff colors + diff_add_bg: "#2a3d2a" + diff_remove_bg: "#3d2a2a" + + # UI element colors + line_number: "#616e88" + separator: "#434c5e" + selected: "#434c5e" + selected_fg: "#ffffff" + suggestion_ghost: "#616e88" + tab_bg: "#3b4252" + placeholder: "#616e88" + + # Badge colors + badge_accent: "#b48ead" + badge_info: "#88c0d0" + badge_success: "#a3be8c" + +chroma: + # Syntax highlighting colors - Nord style + comment: "#616e88" + comment_preproc: "#5e81ac" + keyword: "#81a1c1" + keyword_reserved: "#5e81ac" + keyword_namespace: "#88c0d0" + keyword_type: "#81a1c1" + operator: "#81a1c1" + punctuation: "#eceff4" + name_builtin: "#d08770" + name_tag: "#bf616a" + name_attribute: "#d08770" + name_decorator: "#d08770" + literal_number: "#b48ead" + literal_string: "#a3be8c" + literal_string_escape: "#ebcb8b" + generic_deleted: "#bf616a" + generic_subheading: "#4c566a" + background: "#2e3440" + error_fg: "#bf616a" + error_bg: "#3d2a2a" + success: "#a3be8c" + +markdown: + heading: "#81a1c1" + link: "#88c0d0" + strong: "#eceff4" + code: "#d08770" + code_bg: "#3b4252" + blockquote: "#616e88" + list: "#d8dee9" + hr: "#434c5e" diff --git a/pkg/tui/styles/themes/one-dark.yaml b/pkg/tui/styles/themes/one-dark.yaml new file mode 100644 index 000000000..b873b0ff0 --- /dev/null +++ b/pkg/tui/styles/themes/one-dark.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "One Dark" +colors: + # Text colors + text_bright: "#abb2bf" + text_primary: "#abb2bf" + text_secondary: "#636d83" + text_muted: "#636d83" + text_faint: "#3e4451" + + # Accent colors + accent: "#61afef" + accent_muted: "#5c6370" + + # Background colors + background: "#282c34" + background_alt: "#32363e" + + # Border colors + border_secondary: "#5c6370" + + # Status colors + success: "#98c379" + error: "#e06c75" + warning: "#e5c07b" + info: "#56b6c2" + highlight: "#c678dd" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#2c5a8a" + brand_bg: "#282c34" + + # Error colors + error_strong: "#e06c75" + error_dark: "#3e2a2e" + + # Spinner colors + spinner_dim: "#61afef" + spinner_bright: "#71bfef" + spinner_brightest: "#81cfef" + + # Diff colors + diff_add_bg: "#2d3d29" + diff_remove_bg: "#3e2a2e" + + # UI element colors + line_number: "#5c6370" + separator: "#3e4451" + selected: "#3e4451" + selected_fg: "#ffffff" + suggestion_ghost: "#5c6370" + tab_bg: "#21252b" + placeholder: "#636d83" + + # Badge colors + badge_accent: "#c678dd" + badge_info: "#56b6c2" + badge_success: "#98c379" + +chroma: + # Syntax highlighting colors - One Dark style + comment: "#5c6370" + comment_preproc: "#c678dd" + keyword: "#c678dd" + keyword_reserved: "#e06c75" + keyword_namespace: "#56b6c2" + keyword_type: "#61afef" + operator: "#56b6c2" + punctuation: "#abb2bf" + name_builtin: "#e5c07b" + name_tag: "#e06c75" + name_attribute: "#d19a66" + name_decorator: "#e5c07b" + literal_number: "#d19a66" + literal_string: "#98c379" + literal_string_escape: "#56b6c2" + generic_deleted: "#e06c75" + generic_subheading: "#5c6370" + background: "#282c34" + error_fg: "#e06c75" + error_bg: "#3e2a2e" + success: "#98c379" + +markdown: + heading: "#61afef" + link: "#56b6c2" + strong: "#abb2bf" + code: "#e5c07b" + code_bg: "#32363e" + blockquote: "#636d83" + list: "#abb2bf" + hr: "#3e4451" diff --git a/pkg/tui/styles/themes/solarized-dark.yaml b/pkg/tui/styles/themes/solarized-dark.yaml new file mode 100644 index 000000000..2dc3900b2 --- /dev/null +++ b/pkg/tui/styles/themes/solarized-dark.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Solarized Dark" +colors: + # Text colors + text_bright: "#eee8d5" + text_primary: "#93a1a1" + text_secondary: "#839496" + text_muted: "#839496" + text_faint: "#586e75" + + # Accent colors + accent: "#268bd2" + accent_muted: "#268bd2" + + # Background colors + background: "#002b36" + background_alt: "#073642" + + # Border colors + border_secondary: "#586e75" + + # Status colors + success: "#859900" + error: "#dc322f" + warning: "#b58900" + info: "#2aa198" + highlight: "#d33682" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#1a5a8a" + brand_bg: "#002b36" + + # Error colors + error_strong: "#dc322f" + error_dark: "#3d1b1e" + + # Spinner colors + spinner_dim: "#268bd2" + spinner_bright: "#3498d2" + spinner_brightest: "#42a5d2" + + # Diff colors + diff_add_bg: "#2d3d1b" + diff_remove_bg: "#3d1b1e" + + # UI element colors + line_number: "#657b83" + separator: "#073642" + selected: "#073642" + selected_fg: "#ffffff" + suggestion_ghost: "#657b83" + tab_bg: "#073642" + placeholder: "#839496" + + # Badge colors + badge_accent: "#d33682" + badge_info: "#2aa198" + badge_success: "#859900" + +chroma: + # Syntax highlighting colors - Solarized style + comment: "#586e75" + comment_preproc: "#cb4b16" + keyword: "#859900" + keyword_reserved: "#d33682" + keyword_namespace: "#2aa198" + keyword_type: "#268bd2" + operator: "#859900" + punctuation: "#839496" + name_builtin: "#b58900" + name_tag: "#268bd2" + name_attribute: "#2aa198" + name_decorator: "#cb4b16" + literal_number: "#d33682" + literal_string: "#2aa198" + literal_string_escape: "#dc322f" + generic_deleted: "#dc322f" + generic_subheading: "#586e75" + background: "#002b36" + error_fg: "#dc322f" + error_bg: "#3d1b1e" + success: "#859900" + +markdown: + heading: "#268bd2" + link: "#2aa198" + strong: "#eee8d5" + code: "#b58900" + code_bg: "#073642" + blockquote: "#657b83" + list: "#839496" + hr: "#073642" diff --git a/pkg/tui/styles/themes/tokyo-night.yaml b/pkg/tui/styles/themes/tokyo-night.yaml new file mode 100644 index 000000000..1ca3acd2e --- /dev/null +++ b/pkg/tui/styles/themes/tokyo-night.yaml @@ -0,0 +1,93 @@ +version: 1 +name: "Tokyo Night" +colors: + # Text colors + text_bright: "#c0caf5" + text_primary: "#c0caf5" + text_secondary: "#9aa5ce" + text_muted: "#9aa5ce" + text_faint: "#414868" + + # Accent colors + accent: "#7aa2f7" + accent_muted: "#565f89" + + # Background colors + background: "#1a1b26" + background_alt: "#24283b" + + # Border colors + border_secondary: "#414868" + + # Status colors + success: "#9ece6a" + error: "#f7768e" + warning: "#e0af68" + info: "#7dcfff" + highlight: "#bb9af7" + + # Brand colors + # NOTE: Used as selection background in dialogs - must be dark for white text + brand: "#3a5280" + brand_bg: "#24283b" + + # Error colors + error_strong: "#f7768e" + error_dark: "#3d2a2a" + + # Spinner colors + spinner_dim: "#7aa2f7" + spinner_bright: "#9bb3f7" + spinner_brightest: "#bcc4f7" + + # Diff colors + diff_add_bg: "#20303b" + diff_remove_bg: "#3c2a2a" + + # UI element colors + line_number: "#565f89" + separator: "#414868" + selected: "#33467c" + selected_fg: "#ffffff" + suggestion_ghost: "#565f89" + tab_bg: "#1f2335" + placeholder: "#9aa5ce" + + # Badge colors + badge_accent: "#bb9af7" + badge_info: "#7dcfff" + badge_success: "#9ece6a" + +chroma: + # Syntax highlighting colors - Tokyo Night style + comment: "#565f89" + comment_preproc: "#bb9af7" + keyword: "#bb9af7" + keyword_reserved: "#f7768e" + keyword_namespace: "#7dcfff" + keyword_type: "#7aa2f7" + operator: "#89ddff" + punctuation: "#c0caf5" + name_builtin: "#e0af68" + name_tag: "#f7768e" + name_attribute: "#7aa2f7" + name_decorator: "#e0af68" + literal_number: "#ff9e64" + literal_string: "#9ece6a" + literal_string_escape: "#73daca" + generic_deleted: "#f7768e" + generic_subheading: "#565f89" + background: "#1a1b26" + error_fg: "#f7768e" + error_bg: "#3d2a2a" + success: "#9ece6a" + +markdown: + heading: "#7aa2f7" + link: "#73daca" + strong: "#c0caf5" + code: "#e0af68" + code_bg: "#24283b" + blockquote: "#9aa5ce" + list: "#c0caf5" + hr: "#414868" diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index d875800ea..f16f8f75f 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "cmp" "context" + "fmt" "os" "os/exec" goruntime "runtime" @@ -18,6 +19,7 @@ import ( "github.com/docker/cagent/pkg/tui/animation" "github.com/docker/cagent/pkg/tui/commands" "github.com/docker/cagent/pkg/tui/components/completion" + "github.com/docker/cagent/pkg/tui/components/markdown" "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/statusbar" "github.com/docker/cagent/pkg/tui/core" @@ -46,6 +48,10 @@ type appModel struct { transcriber *transcribe.Transcriber + themeWatcher *styles.ThemeWatcher + themeWatcherEventCh chan string // Channel for theme file change events (carries themeRef) + themeListenerStarted bool // Guard to prevent multiple listeners + ready bool err error } @@ -104,23 +110,44 @@ func DefaultKeyMap() KeyMap { func New(ctx context.Context, a *app.App) tea.Model { sessionState := service.NewSessionState(a.Session()) + // Create a channel for theme file change events (carries themeRef) + themeEventCh := make(chan string, 1) + t := &appModel{ - keyMap: DefaultKeyMap(), - dialog: dialog.New(), - notification: notification.New(), - completions: completion.New(), - application: a, - sessionState: sessionState, - transcriber: transcribe.New(os.Getenv("OPENAI_API_KEY")), // TODO(dga): should use envProvider + keyMap: DefaultKeyMap(), + dialog: dialog.New(), + notification: notification.New(), + completions: completion.New(), + application: a, + sessionState: sessionState, + transcriber: transcribe.New(os.Getenv("OPENAI_API_KEY")), // TODO(dga): should use envProvider + themeWatcherEventCh: themeEventCh, } + // Create theme watcher with callback that sends themeRef to channel + t.themeWatcher = styles.NewThemeWatcher(func(themeRef string) { + // Non-blocking send to the event channel + select { + case themeEventCh <- themeRef: + default: + // Channel full, event will be coalesced + } + }) + t.statusBar = statusbar.New(t) t.chatPage = chat.New(a, sessionState) - // Make sure to stop the progress bar when the app quits abruptly. + // Start watching the current theme (if it's a user theme file) + currentTheme := styles.CurrentTheme() + if currentTheme != nil && currentTheme.Ref != "" { + _ = t.themeWatcher.Watch(currentTheme.Ref) + } + + // Make sure to stop the progress bar and theme watcher when the app quits abruptly. go func() { <-ctx.Done() t.chatPage.Cleanup() + t.themeWatcher.Stop() }() return t @@ -128,11 +155,31 @@ func New(ctx context.Context, a *app.App) tea.Model { // Init initializes the application func (a *appModel) Init() tea.Cmd { - return tea.Sequence( + cmds := []tea.Cmd{ a.dialog.Init(), a.chatPage.Init(), a.application.SendFirstMessage(), - ) + } + + // Start theme file listener only once (guard against Init being called multiple times) + if !a.themeListenerStarted { + a.themeListenerStarted = true + cmds = append(cmds, a.listenForThemeFileChanges()) + } + + return tea.Sequence(cmds...) +} + +// listenForThemeFileChanges returns a command that listens for theme file change events +// and sends them as messages to the TUI. +func (a *appModel) listenForThemeFileChanges() tea.Cmd { + return func() tea.Msg { + themeRef, ok := <-a.themeWatcherEventCh + if !ok { + return nil // Channel closed + } + return messages.ThemeFileChangedMsg{ThemeRef: themeRef} + } } // Help returns help information @@ -334,6 +381,39 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ChangeModelMsg: return a.handleChangeModel(msg.ModelRef) + case messages.OpenThemePickerMsg: + return a.handleOpenThemePicker() + + case messages.ChangeThemeMsg: + return a.handleChangeTheme(msg.ThemeRef) + + case messages.ThemePreviewMsg: + return a.handleThemePreview(msg.ThemeRef) + + case messages.ThemeCancelPreviewMsg: + return a.handleThemeCancelPreview(msg.OriginalRef) + + case messages.ThemeChangedMsg: + return a.applyThemeChanged() + + case messages.ThemeFileChangedMsg: + // Theme file was modified on disk - load and apply on the main goroutine + theme, err := styles.LoadTheme(msg.ThemeRef) + if err != nil { + // Failed to load - show error but keep current theme + return a, tea.Batch( + a.listenForThemeFileChanges(), + notification.ErrorCmd(fmt.Sprintf("Failed to hot-reload theme: %v", err)), + ) + } + styles.ApplyTheme(theme) + // Continue listening for more changes and emit ThemeChangedMsg for cache invalidation + return a, tea.Batch( + a.listenForThemeFileChanges(), + notification.SuccessCmd("Theme hot-reloaded"), + core.CmdHandler(messages.ThemeChangedMsg{}), + ) + case messages.ElicitationResponseMsg: return a.handleElicitationResponse(msg.Action, msg.Content) @@ -652,6 +732,42 @@ func (a *appModel) startShell() (tea.Model, tea.Cmd) { return a, tea.ExecProcess(cmd, nil) } +// invalidateCachesForThemeChange performs synchronous cache invalidation +// after a theme change. This does NOT forward messages to child components. +// Use applyThemeChanged() when you also need to forward ThemeChangedMsg. +func (a *appModel) invalidateCachesForThemeChange() { + markdown.ResetStyles() + a.statusBar.InvalidateCache() +} + +// applyThemeChanged invalidates all theme-dependent caches and forwards +// ThemeChangedMsg to child components. This is called synchronously when +// themes are changed/previewed to ensure View() renders with updated styles. +func (a *appModel) applyThemeChanged() (tea.Model, tea.Cmd) { + // Invalidate all caches + a.invalidateCachesForThemeChange() + + // Update theme watcher to watch new theme file + currentTheme := styles.CurrentTheme() + if currentTheme != nil { + _ = a.themeWatcher.Watch(currentTheme.Ref) + } + + var cmds []tea.Cmd + + // Forward to dialog manager to propagate to all open dialogs + dialogUpdated, dialogCmd := a.dialog.Update(messages.ThemeChangedMsg{}) + a.dialog = dialogUpdated.(dialog.Manager) + cmds = append(cmds, dialogCmd) + + // Forward to chat page to propagate to all child components + chatUpdated, chatCmd := a.chatPage.Update(messages.ThemeChangedMsg{}) + a.chatPage = chatUpdated.(chat.Page) + cmds = append(cmds, chatCmd) + + return a, tea.Batch(cmds...) +} + func toFullscreenView(content, windowTitle string) tea.View { view := tea.NewView(content) view.AltScreen = true diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 28b354eab..7c320f508 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -40,6 +40,9 @@ func (a *Alias) HasOptions() bool { type Settings struct { // HideToolResults hides tool call results in the TUI by default HideToolResults bool `yaml:"hide_tool_results,omitempty"` + // Theme is the default theme reference (e.g., "dark", "light") + // Theme files are loaded from ~/.cagent/themes/.yaml + Theme string `yaml:"theme,omitempty"` } // CredentialHelper contains configuration for a credential helper command