Skip to content

Commit 0cd93cf

Browse files
authored
Merge pull request #1514 from krissetto/custom-themes
Custom TUI themes
2 parents 118df07 + 30bcf53 commit 0cd93cf

37 files changed

Lines changed: 4615 additions & 246 deletions

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
!./**/*.css
88
!./**/*.go
99
!./**/*.txt
10-
!/pkg/config/default-agent.yaml
10+
!/pkg/config/default-agent.yaml
11+
!/pkg/tui/styles/themes/*.yaml

cmd/root/run.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/docker/cagent/pkg/session"
2323
"github.com/docker/cagent/pkg/teamloader"
2424
"github.com/docker/cagent/pkg/telemetry"
25+
"github.com/docker/cagent/pkg/tui/styles"
2526
)
2627

2728
type runExecFlags struct {
@@ -234,6 +235,11 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
234235
}
235236
defer cleanup()
236237

238+
// Apply theme before TUI starts
239+
if tui {
240+
applyTheme()
241+
}
242+
237243
if f.dryRun {
238244
out.Println("Dry run mode enabled. Agent initialized but will not execute.")
239245
return nil
@@ -442,3 +448,21 @@ func (f *runExecFlags) handleRunMode(ctx context.Context, rt runtime.Runtime, se
442448

443449
return runTUI(ctx, rt, sess, opts...)
444450
}
451+
452+
// applyTheme applies the theme from user config, or the built-in default.
453+
func applyTheme() {
454+
// Resolve theme from user config > built-in default
455+
themeRef := styles.DefaultThemeRef
456+
if userSettings := config.GetUserSettings(); userSettings.Theme != "" {
457+
themeRef = userSettings.Theme
458+
}
459+
460+
theme, err := styles.LoadTheme(themeRef)
461+
if err != nil {
462+
slog.Warn("Failed to load theme, using default", "theme", themeRef, "error", err)
463+
theme = styles.DefaultTheme()
464+
}
465+
466+
styles.ApplyTheme(theme)
467+
slog.Debug("Applied theme", "theme_ref", themeRef, "theme_name", theme.Name)
468+
}

docs/USAGE.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ During TUI sessions, you can use special slash commands. Type `/` to see all ava
170170
| `/sessions` | Browse and load past sessions |
171171
| `/shell` | Start a shell |
172172
| `/star` | Toggle star on current session |
173+
| `/theme` | Change the color theme (see [Theming](#theming)) |
173174
| `/think` | Toggle thinking/reasoning mode |
174175
| `/yolo` | Toggle automatic approval of tool calls |
175176

@@ -195,6 +196,89 @@ The `/model` command (or `ctrl+m`) allows you to change the AI model used by the
195196

196197
To revert to the agent's default model, select the model marked with "(default)" in the picker.
197198

199+
#### Theming
200+
201+
The TUI supports customizable color themes. You can create and use custom themes to personalize the appearance of the terminal interface.
202+
203+
**Theme Configuration:**
204+
205+
Your theme preference is saved globally in `~/.config/cagent/config.yaml` under `settings.theme`. If not set, the built-in default theme is used.
206+
207+
**Creating Custom Themes:**
208+
209+
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.
210+
211+
```yaml
212+
# ~/.cagent/themes/my-theme.yaml
213+
name: "My Custom Theme"
214+
215+
colors:
216+
# Backgrounds
217+
background: "#1a1a2e"
218+
background_alt: "#16213e"
219+
220+
# Text colors
221+
text_bright: "#ffffff"
222+
text_primary: "#e8e8e8"
223+
text_secondary: "#b0b0b0"
224+
text_muted: "#707070"
225+
226+
# Accent colors
227+
accent: "#4fc3f7"
228+
brand: "#1d96f3"
229+
230+
# Status colors
231+
success: "#4caf50"
232+
error: "#f44336"
233+
warning: "#ff9800"
234+
info: "#00bcd4"
235+
236+
# Optional: Customize syntax highlighting colors
237+
chroma:
238+
comment: "#6a9955"
239+
keyword: "#569cd6"
240+
literal_string: "#ce9178"
241+
242+
# Optional: Customize markdown rendering colors
243+
markdown:
244+
heading: "#4fc3f7"
245+
link: "#569cd6"
246+
code: "#ce9178"
247+
```
248+
249+
**Applying Themes:**
250+
251+
- **In user config** (`~/.config/cagent/config.yaml`):
252+
```yaml
253+
settings:
254+
theme: my-theme # References ~/.cagent/themes/my-theme.yaml
255+
```
256+
257+
- **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.
258+
259+
**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.
260+
261+
262+
> **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.
263+
264+
**Built-in Themes:**
265+
266+
The following themes are included:
267+
- `default` — The built-in default theme with a dark color scheme
268+
- `catppuccin-latte`, `catppuccin-mocha` — Catppuccin themes (light and dark)
269+
- `dracula` — Dracula dark theme
270+
- `gruvbox-dark`, `gruvbox-light` — Gruvbox themes
271+
- `nord` — Nord dark theme
272+
- `one-dark` — One Dark theme
273+
- `solarized-dark` — Solarized dark theme
274+
- `tokyo-night` — Tokyo Night dark theme
275+
276+
**Available Color Keys:**
277+
278+
Themes can customize colors in three sections: `colors`, `chroma` (syntax highlighting), and `markdown` (markdown rendering).
279+
280+
See the [built-in themes on GitHub](https://github.com/docker/cagent/tree/main/pkg/tui/styles/themes) for complete examples.
281+
198282
## 🔧 Configuration Reference
199283

200284
### Agent Properties

pkg/tui/commands/commands.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,16 @@ func builtInSessionCommands() []Item {
186186
return core.CmdHandler(messages.AttachFileMsg{FilePath: arg})
187187
},
188188
},
189+
{
190+
ID: "settings.theme",
191+
Label: "Theme",
192+
SlashCommand: "/theme",
193+
Description: "Change the color theme (saved globally)",
194+
Category: "Settings",
195+
Execute: func(string) tea.Cmd {
196+
return core.CmdHandler(messages.OpenThemePickerMsg{})
197+
},
198+
},
189199
}
190200

191201
// Add speak command on supported platforms (macOS only)

pkg/tui/components/editor/editor.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
550550
e.keyboardEnhancementsSupported = msg.Flags != 0
551551
e.configureNewlineKeybinding()
552552
return e, nil
553+
case messages.ThemeChangedMsg:
554+
e.textarea.SetStyles(styles.InputStyle)
555+
return e, nil
553556
case tea.WindowSizeMsg:
554557
e.textarea.SetWidth(msg.Width - 2)
555558
return e, nil

pkg/tui/components/markdown/fast_renderer.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,27 @@ type cachedStyles struct {
160160
var (
161161
globalStyles *cachedStyles
162162
globalStylesOnce sync.Once
163+
globalStylesMu sync.Mutex
163164
)
164165

166+
// ResetStyles resets the cached markdown styles so they will be rebuilt on next use.
167+
// Call this when the theme changes to pick up new colors.
168+
func ResetStyles() {
169+
globalStylesMu.Lock()
170+
globalStyles = nil
171+
globalStylesOnce = sync.Once{}
172+
globalStylesMu.Unlock()
173+
174+
// Also clear chroma syntax highlighting cache
175+
chromaStyleCacheMu.Lock()
176+
chromaStyleCache = make(map[chroma.TokenType]ansiStyle)
177+
chromaStyleCacheMu.Unlock()
178+
}
179+
165180
func getGlobalStyles() *cachedStyles {
181+
globalStylesMu.Lock()
182+
defer globalStylesMu.Unlock()
183+
166184
globalStylesOnce.Do(func() {
167185
mdStyle := styles.MarkdownStyle()
168186

@@ -207,7 +225,7 @@ func getGlobalStyles() *cachedStyles {
207225
buildAnsiStyle(headingLipStyles[5]),
208226
},
209227
ansiBlockquote: buildAnsiStyle(blockquoteLipStyle),
210-
ansiFootnote: buildAnsiStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)),
228+
ansiFootnote: buildAnsiStyle(lipgloss.NewStyle().Foreground(styles.TextSecondary).Italic(true)),
211229
styleTaskTicked: mdStyle.Task.Ticked,
212230
styleTaskUntick: mdStyle.Task.Unticked,
213231
listIndent: int(mdStyle.List.LevelIndent),

pkg/tui/components/markdown/fast_renderer_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,9 +1061,11 @@ func TestInlineCodeRestoresBaseStyle(t *testing.T) {
10611061
// - Resets between styled segments
10621062
require.GreaterOrEqual(t, len(seqs), 3, "Should have at least 3 ANSI sequences")
10631063

1064-
// Verify that the base document style (color 252) appears somewhere (for text styling)
1064+
// Verify that text styling is applied (either ANSI 256 color or RGB)
10651065
allSeqs := strings.Join(seqs, "")
1066-
assert.Contains(t, allSeqs, "38;5;252", "Should have document text color applied")
1066+
// Text color can be either "38;5;N" (256 color) or "38;2;R;G;B" (RGB) depending on theme
1067+
hasTextColor := strings.Contains(allSeqs, "38;5;") || strings.Contains(allSeqs, "38;2;")
1068+
assert.True(t, hasTextColor, "Should have text color applied (38;5; or 38;2;)")
10671069

10681070
// Verify code style appears (RGB foreground and background)
10691071
assert.Contains(t, allSeqs, "38;2;", "Code style should have RGB foreground")

pkg/tui/components/messages/messages.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,19 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
188188
m.invalidateAllItems()
189189
return m, nil
190190

191+
case messages.ThemeChangedMsg:
192+
// Theme changed - invalidate all render caches
193+
m.invalidateAllItems()
194+
editfile.InvalidateCaches()
195+
for i, view := range m.views {
196+
updatedView, cmd := view.Update(msg)
197+
m.views[i] = updatedView
198+
if cmd != nil {
199+
cmds = append(cmds, cmd)
200+
}
201+
}
202+
return m, tea.Batch(cmds...)
203+
191204
case reasoningblock.BlockMsg:
192205
return m.forwardToReasoningBlock(msg.GetBlockID(), msg)
193206

pkg/tui/components/reasoningblock/reasoningblock.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/docker/cagent/pkg/tui/components/markdown"
1818
"github.com/docker/cagent/pkg/tui/components/tool"
1919
"github.com/docker/cagent/pkg/tui/core/layout"
20+
"github.com/docker/cagent/pkg/tui/messages"
2021
"github.com/docker/cagent/pkg/tui/service"
2122
"github.com/docker/cagent/pkg/tui/styles"
2223
"github.com/docker/cagent/pkg/tui/types"
@@ -369,7 +370,11 @@ func (m *Model) Init() tea.Cmd {
369370

370371
// Update handles messages.
371372
func (m *Model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
372-
if _, ok := msg.(animation.TickMsg); ok {
373+
switch msg.(type) {
374+
case messages.ThemeChangedMsg:
375+
// Theme changed - invalidate cached rendering
376+
m.cache = nil
377+
case animation.TickMsg:
373378
// Compute fade levels based on elapsed time (tick-rate independent)
374379
m.computeFadeProgressAt(nowFunc())
375380
// Unregister if no more fading tools (uses fadeProgress computed above)

pkg/tui/components/sidebar/sidebar.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,31 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
459459
delete(m.ragIndexing, k)
460460
}
461461
return m, nil
462+
case messages.ThemeChangedMsg:
463+
// Theme changed - recreate spinners with new colors
464+
// The spinner pre-renders frames with colors, so we need to recreate it
465+
var cmds []tea.Cmd
466+
467+
// Recreate main spinner
468+
wasActive := m.spinnerActive
469+
if wasActive {
470+
m.spinner.Stop()
471+
}
472+
m.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle)
473+
if wasActive {
474+
cmd := m.spinner.Init()
475+
m.spinnerActive = true
476+
cmds = append(cmds, cmd)
477+
}
478+
479+
// Recreate all RAG indexing spinners
480+
for _, state := range m.ragIndexing {
481+
state.spinner.Stop()
482+
state.spinner = spinner.New(spinner.ModeSpinnerOnly, styles.SpinnerDotsHighlightStyle)
483+
cmds = append(cmds, state.spinner.Init())
484+
}
485+
486+
return m, tea.Batch(cmds...)
462487
default:
463488
var cmds []tea.Cmd
464489

0 commit comments

Comments
 (0)