Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
!./**/*.css
!./**/*.go
!./**/*.txt
!/pkg/config/default-agent.yaml
!/pkg/config/default-agent.yaml
!/pkg/tui/styles/themes/*.yaml
24 changes: 24 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
84 changes: 84 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion pkg/tui/components/markdown/fast_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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),
Expand Down
6 changes: 4 additions & 2 deletions pkg/tui/components/markdown/fast_renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion pkg/tui/components/reasoningblock/reasoningblock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions pkg/tui/components/statusbar/statusbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions pkg/tui/components/tool/editfile/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading