Skip to content

Commit ad38082

Browse files
committed
Support for TUI themes
- Includes some classic built-in themes - Supports custom user-defined themes - Supports hot-reloading of the custom themes for ease of customization - Comes with a /theme command to change theme in the TUI, gets saved to global user config Signed-off-by: krissetto <chrisjpetito@gmail.com>
1 parent aa4fa87 commit ad38082

33 files changed

Lines changed: 3869 additions & 244 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
@@ -440,3 +446,21 @@ func (f *runExecFlags) handleRunMode(ctx context.Context, rt runtime.Runtime, se
440446

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

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/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

pkg/tui/components/statusbar/statusbar.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ type StatusBar struct {
2525
// New creates a new StatusBar instance
2626
func New(help core.KeyMapHelp) StatusBar {
2727
return StatusBar{
28-
help: help,
29-
cachedVersionText: styles.MutedStyle.Render("cagent " + version.Version),
28+
help: help,
3029
}
3130
}
3231

@@ -58,8 +57,21 @@ func (s *StatusBar) formatHelpString(bindings []key.Binding) string {
5857
return strings.Join(helpParts, " ")
5958
}
6059

60+
// InvalidateCache clears all cached values.
61+
// Call this when the theme changes to pick up new colors.
62+
func (s *StatusBar) InvalidateCache() {
63+
s.cachedHelpText = ""
64+
s.cachedVersionText = ""
65+
s.cachedBindingsLen = 0
66+
}
67+
6168
// View renders the status bar
6269
func (s *StatusBar) View() string {
70+
// Regenerate version text if empty
71+
if s.cachedVersionText == "" {
72+
s.cachedVersionText = styles.MutedStyle.Render("cagent " + version.Version)
73+
}
74+
6375
var helpText string
6476
if s.help != nil {
6577
help := s.help.Help()

pkg/tui/components/tool/editfile/render.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ var (
4949
lexerCacheMu sync.RWMutex
5050
)
5151

52+
// InvalidateCaches clears all render caches.
53+
// Call this when the theme changes to pick up new colors.
54+
func InvalidateCaches() {
55+
cacheMu.Lock()
56+
for _, c := range cache {
57+
c.renderCached = false
58+
}
59+
cacheMu.Unlock()
60+
}
61+
5262
type chromaToken struct {
5363
Text string
5464
Style lipgloss.Style

0 commit comments

Comments
 (0)