diff --git a/internal/app/app.go b/internal/app/app.go index d8a3abc63b..b045598b65 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -254,7 +254,10 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress if !hideSpinner && stderrTTY { - t := styles.ThemeForProvider(app.config.Config().Models[config.SelectedModelTypeLarge].Provider) + t := styles.ThemeForProvider( + app.config.Config().Models[config.SelectedModelTypeLarge].Provider, + app.config.Config().Options.ThemePreference(), + ) // Detect background color to set the appropriate color for the // spinner's 'Generating...' text. Without this, that text would be diff --git a/internal/cmd/session.go b/internal/cmd/session.go index 31765cd4aa..d4b765c7fc 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -444,7 +444,11 @@ func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess sessi if cfg != nil { providerID = cfg.Config().Models[config.SelectedModelTypeLarge].Provider } - styles := styles.ThemeForProvider(providerID) + themePref := "auto" + if cfg != nil { + themePref = cfg.Config().Options.ThemePreference() + } + styles := styles.ThemeForProvider(providerID, themePref) toolResults := chat.BuildToolResultMap(msgs) width := sessionOutputWidth diff --git a/internal/config/config.go b/internal/config/config.go index abd36f3e71..cd05abccaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -214,8 +214,7 @@ type LSPConfig struct { type TUIOptions struct { CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"` DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"` - // Here we can add themes later or any TUI related options - // + Theme string `json:"theme,omitempty" jsonschema:"description=Color theme for the TUI interface,enum=auto,enum=dark,enum=light,default=auto"` Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"` Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"` @@ -284,6 +283,15 @@ type Options struct { DisabledSkills []string `json:"disabled_skills,omitempty" jsonschema:"description=List of skill names to disable and hide from the agent,example=crush-config"` } +// ThemePreference returns the configured TUI theme preference, defaulting to +// "auto" when none is set. +func (o Options) ThemePreference() string { + if o.TUI != nil && o.TUI.Theme != "" { + return o.TUI.Theme + } + return "auto" +} + type MCPs map[string]MCPConfig type MCP struct { diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 88d6f1f643..68e8a55de4 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -35,7 +35,11 @@ func (c *Common) Config() *config.Config { // workspace has a large model selected, the theme is chosen based on its // provider; otherwise the default theme is used. func DefaultCommon(ws workspace.Workspace) *Common { - s := styles.ThemeForProvider(largeModelProviderID(ws)) + themePref := "auto" + if ws != nil && ws.Config() != nil { + themePref = ws.Config().Options.ThemePreference() + } + s := styles.ThemeForProvider(largeModelProviderID(ws), themePref) return &Common{ Workspace: ws, Styles: &s, diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 465916db8f..9cf9244e8a 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -1,6 +1,7 @@ package styles import ( + "fmt" "image/color" "charm.land/bubbles/v2/filepicker" @@ -55,6 +56,15 @@ type quickStyleOpts struct { success color.Color successMoreSubtle color.Color successMostSubtle color.Color + + // Diff highlight colors. If left as nil, quickStyle falls back to the + // hard-coded dark-mode defaults for backwards compatibility. + diffInsertFg color.Color + diffInsertBg color.Color + diffInsertSymbolBg color.Color + diffDeleteFg color.Color + diffDeleteBg color.Color + diffDeleteSymbolBg color.Color } // quickStyle builds the default Styles (that is, the default theme, Charmtone @@ -530,23 +540,23 @@ func quickStyle(o quickStyleOpts) Styles { }, InsertLine: diffview.LineStyle{ LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#2b322a")), + Foreground(lipgloss.Color(diffColor(o.diffInsertFg, "#629657"))). + Background(lipgloss.Color(diffColor(o.diffInsertBg, "#2b322a"))), Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#629657")). - Background(lipgloss.Color("#323931")), + Foreground(lipgloss.Color(diffColor(o.diffInsertFg, "#629657"))). + Background(lipgloss.Color(diffColor(o.diffInsertSymbolBg, "#323931"))), Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#323931")), + Background(lipgloss.Color(diffColor(o.diffInsertBg, "#323931"))), }, DeleteLine: diffview.LineStyle{ LineNumber: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#312929")), + Foreground(lipgloss.Color(diffColor(o.diffDeleteFg, "#a45c59"))). + Background(lipgloss.Color(diffColor(o.diffDeleteBg, "#312929"))), Symbol: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#a45c59")). - Background(lipgloss.Color("#383030")), + Foreground(lipgloss.Color(diffColor(o.diffDeleteFg, "#a45c59"))). + Background(lipgloss.Color(diffColor(o.diffDeleteSymbolBg, "#383030"))), Code: lipgloss.NewStyle(). - Background(lipgloss.Color("#383030")), + Background(lipgloss.Color(diffColor(o.diffDeleteBg, "#383030"))), }, Filename: diffview.LineStyle{ LineNumber: lipgloss.NewStyle(). @@ -951,3 +961,22 @@ func quickStyle(o quickStyleOpts) Styles { return s } + +// colorHex returns a lowercase "#rrggbb" string for a color.Color value. +// It returns an empty string when c is nil. +func colorHex(c color.Color) string { + if c == nil { + return "" + } + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) +} + +// diffColor returns the hex color for an optional diff color, falling back to +// a hard-coded dark-mode default when none is provided. +func diffColor(c color.Color, fallback string) string { + if h := colorHex(c); h != "" { + return h + } + return fallback +} diff --git a/internal/ui/styles/themes.go b/internal/ui/styles/themes.go index 90115971bb..159e7ff18e 100644 --- a/internal/ui/styles/themes.go +++ b/internal/ui/styles/themes.go @@ -1,11 +1,35 @@ package styles -import "github.com/charmbracelet/x/exp/charmtone" +import ( + "image/color" + "os" + "strings" + + "github.com/charmbracelet/x/exp/charmtone" +) // ThemeForProvider returns the Styles associated with the given provider // ID. Unknown or empty provider IDs yield the default Charmtone Pantera // theme. -func ThemeForProvider(providerID string) Styles { +// +// An optional theme preference overrides the provider-based default. Valid +// values are "auto" (provider-based default), "dark", and "light". When no +// preference is supplied, the CRUSH_THEME environment variable is consulted. +func ThemeForProvider(providerID string, themePref ...string) Styles { + theme := "auto" + if len(themePref) > 0 && themePref[0] != "" { + theme = themePref[0] + } else if v := os.Getenv("CRUSH_THEME"); v != "" { + theme = v + } + + switch strings.ToLower(theme) { + case "light": + return CharmtoneLatte() + case "dark": + // Explicit dark falls through to provider defaults below. + } + switch providerID { case "hyper": return HypercrushObsidiana() @@ -49,6 +73,13 @@ func CharmtonePantera() Styles { success: charmtone.Julep, successMoreSubtle: charmtone.Bok, successMostSubtle: charmtone.Guac, + + diffInsertFg: color.RGBA{R: 0x62, G: 0x96, B: 0x57, A: 0xFF}, + diffInsertBg: color.RGBA{R: 0x2b, G: 0x32, B: 0x2a, A: 0xFF}, + diffInsertSymbolBg: color.RGBA{R: 0x32, G: 0x39, B: 0x31, A: 0xFF}, + diffDeleteFg: color.RGBA{R: 0xa4, G: 0x5c, B: 0x59, A: 0xFF}, + diffDeleteBg: color.RGBA{R: 0x31, G: 0x29, B: 0x29, A: 0xFF}, + diffDeleteSymbolBg: color.RGBA{R: 0x38, G: 0x30, B: 0x30, A: 0xFF}, }) } @@ -85,5 +116,57 @@ func HypercrushObsidiana() Styles { success: charmtone.Julep, successMoreSubtle: charmtone.Bok, successMostSubtle: charmtone.Guac, + + diffInsertFg: color.RGBA{R: 0x62, G: 0x96, B: 0x57, A: 0xFF}, + diffInsertBg: color.RGBA{R: 0x2b, G: 0x32, B: 0x2a, A: 0xFF}, + diffInsertSymbolBg: color.RGBA{R: 0x32, G: 0x39, B: 0x31, A: 0xFF}, + diffDeleteFg: color.RGBA{R: 0xa4, G: 0x5c, B: 0x59, A: 0xFF}, + diffDeleteBg: color.RGBA{R: 0x31, G: 0x29, B: 0x29, A: 0xFF}, + diffDeleteSymbolBg: color.RGBA{R: 0x38, G: 0x30, B: 0x30, A: 0xFF}, + }) +} + +// CharmtoneLatte returns a light variant of the Charmtone theme designed for +// terminals with a light background. +func CharmtoneLatte() Styles { + return quickStyle(quickStyleOpts{ + primary: charmtone.Charple, + secondary: charmtone.Dolly, + accent: charmtone.Malibu, + keyword: charmtone.Blush, + + fgBase: charmtone.Pepper, + fgMoreSubtle: charmtone.Char, + fgSubtle: charmtone.Iron, + fgMostSubtle: charmtone.Oyster, + + onPrimary: charmtone.Butter, + + bgBase: charmtone.Soda, + bgLeastVisible: charmtone.Sash, + bgLessVisible: charmtone.Steep, + bgMostVisible: charmtone.Squid, + + separator: charmtone.Steam, + + destructive: charmtone.Coral, + error: charmtone.Sriracha, + warningSubtle: charmtone.Yam, + warning: charmtone.Tang, + denied: charmtone.Tang, + busy: charmtone.Mustard, + info: charmtone.Malibu, + infoMoreSubtle: charmtone.Damson, + infoMostSubtle: charmtone.Sapphire, + success: charmtone.Guac, + successMoreSubtle: charmtone.Julep, + successMostSubtle: charmtone.Pickle, + + diffInsertFg: color.RGBA{R: 0x00, G: 0xa4, B: 0x75, A: 0xFF}, + diffInsertBg: color.RGBA{R: 0xe6, G: 0xf7, B: 0xf1, A: 0xFF}, + diffInsertSymbolBg: color.RGBA{R: 0xd1, G: 0xf2, B: 0xe6, A: 0xFF}, + diffDeleteFg: color.RGBA{R: 0xd7, G: 0x3a, B: 0x49, A: 0xFF}, + diffDeleteBg: color.RGBA{R: 0xff, G: 0xe6, B: 0xe6, A: 0xFF}, + diffDeleteSymbolBg: color.RGBA{R: 0xff, G: 0xd1, B: 0xd1, A: 0xFF}, }) }