From b2ea91b901f6698e322312f7763462b9e71d7a1e Mon Sep 17 00:00:00 2001 From: Thomas Glanzmann Date: Sat, 15 Aug 2026 08:37:49 +0200 Subject: [PATCH 02/10] feat: add light theme with auto-detection for white backgrounds Introduces CharmtoneLight theme that inverts the neutral color ramp for readability on light terminals. ThemeForProvider now accepts a hasDarkBG parameter and all call sites auto-detect via lipgloss.HasDarkBackground. --- internal/app/app.go | 7 +- internal/cmd/run.go | 5 +- internal/cmd/session.go | 11 +- internal/ui/common/common.go | 12 ++- internal/ui/exitbanner/exitbanner.go | 4 +- internal/ui/model/ui.go | 17 ++-- internal/ui/styles/themes.go | 145 ++++++++++++++++++++++++++- 7 files changed, 179 insertions(+), 22 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 683d484e..323d5fa4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/notify" "github.com/charmbracelet/crush/internal/agent/tools/mcp" @@ -319,7 +320,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, if cfg := app.config.Config(); cfg != nil && cfg.Options != nil && cfg.Options.TUI != nil { activeTheme = cfg.Options.TUI.ActiveTheme } - t := styles.ThemeFromConfig(activeTheme) + // Detect background color so the theme can adapt to light + // terminals and the spinner label text is always legible. + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + providerID := app.config.Config().Models[config.SelectedModelTypeLarge].Provider + t := styles.ThemeForConfig(activeTheme, providerID, hasDarkBG) spinner = format.NewSpinner(ctx, cancel, anim.Settings{ Size: 10, diff --git a/internal/cmd/run.go b/internal/cmd/run.go index e4734005..2710f121 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "charm.land/lipgloss/v2" "charm.land/log/v2" "github.com/charmbracelet/crush/internal/client" "github.com/charmbracelet/crush/internal/config" @@ -21,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/workspace" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" @@ -218,7 +220,8 @@ func runNonInteractive( progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress if !hideSpinner && stderrTTY { - t := common.ThemeStylesFromConfig(ws.Config) + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + t := styles.ThemeForConfig(common.ThemeNameFromConfig(ws.Config), ws.Config.Models[config.SelectedModelTypeLarge].Provider, hasDarkBG) spinner = format.NewSpinner(ctx, cancel, anim.Settings{ Size: 10, diff --git a/internal/cmd/session.go b/internal/cmd/session.go index 85204bca..7ccb2c32 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -24,6 +24,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/charmtone" "github.com/charmbracelet/x/term" @@ -445,7 +446,13 @@ func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Messag } func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess session.Session, msgs []*message.Message) error { - styles := common.ThemeStylesFromConfig(cfg.Config()) + var activeTheme, providerID string + if cfg != nil { + activeTheme = common.ThemeNameFromConfig(cfg.Config()) + providerID = cfg.Config().Models[config.SelectedModelTypeLarge].Provider + } + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + st := styles.ThemeForConfig(activeTheme, providerID, hasDarkBG) toolResults := chat.BuildToolResultMap(msgs) width := sessionOutputWidth @@ -486,7 +493,7 @@ func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess sessi first := true for _, msg := range msgs { - items := chat.ExtractMessageItems(&styles, msg, toolResults, "") + items := chat.ExtractMessageItems(&st, msg, toolResults, "") for _, item := range items { if !first { fmt.Fprintln(&buf) diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 88dc1ab6..8bdf1978 100644 --- a/internal/ui/common/common.go +++ b/internal/ui/common/common.go @@ -9,6 +9,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/clipboard" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/styles" @@ -141,14 +142,15 @@ func (c *Common) Config() *config.Config { } // DefaultCommon returns the default common UI configurations using the -// theme from config (or the default Charmtone theme if unset). +// theme from config (or a theme derived from the selected model's +// provider and terminal background when none is configured). func DefaultCommon(ws workspace.Workspace) *Common { - var s styles.Styles + var name string if ws != nil { - s = ThemeStylesFromConfig(ws.Config()) - } else { - s = LoadThemeStyles("") + name = ThemeNameFromConfig(ws.Config()) } + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + s := styles.ThemeForConfig(name, largeModelProviderID(ws), hasDarkBG) return &Common{ Workspace: ws, Styles: &s, diff --git a/internal/ui/exitbanner/exitbanner.go b/internal/ui/exitbanner/exitbanner.go index e8dc926d..59e25db2 100644 --- a/internal/ui/exitbanner/exitbanner.go +++ b/internal/ui/exitbanner/exitbanner.go @@ -3,6 +3,7 @@ package exitbanner import ( "math/rand/v2" + "os" "strings" "charm.land/lipgloss/v2" @@ -53,7 +54,8 @@ func Render(banner config.ExitBanner, sess *session.Session, width int) string { // logoSection returns the ASCII art logo followed by the parting message. func logoSection(contentWidth int) string { - t := styles.ThemeForProvider("") + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + t := styles.ThemeForProvider("", hasDarkBG) crushLogo := logo.Render(t.Logo.GradCanvas, version.Version, true, logo.Opts{ FieldColor: t.Logo.FieldColor, TitleColorA: t.Logo.TitleColorA, diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b81fc490..6c2d68c9 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2689,7 +2689,8 @@ func (m *UI) restoreModelFromSession(msgs []message.Message) tea.Cmd { return nil } - m.applyThemeForProvider(lastAssistant.Provider) + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + m.applyThemeForProvider(lastAssistant.Provider, hasDarkBG) if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { smallModel := m.com.Workspace.GetDefaultSmallModel(lastAssistant.Provider) @@ -2785,10 +2786,12 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd { } else { if msg.ModelType == config.SelectedModelTypeLarge { // Swap the theme live based on the newly selected large - // model's provider. Skipped when the provider resolves to - // the already-active theme, which avoids a full markdown - // re-render of the transcript on every selection. - m.applyThemeForProvider(providerID) + // model's provider and terminal background. Skipped when the + // provider resolves to the already-active theme, which avoids + // a full markdown re-render of the transcript on every + // selection. + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + m.applyThemeForProvider(providerID, hasDarkBG) } if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok { // Ensure small model is set is unset. @@ -4892,7 +4895,7 @@ func (m *UI) cacheSidebarLogo(width int) { // transcript for no visible change. // A theme explicitly selected in the config always wins, so provider // changes never discard the user's choice. -func (m *UI) applyThemeForProvider(providerID string) { +func (m *UI) applyThemeForProvider(providerID string, hasDarkBG bool) { // A theme the user explicitly selected always wins over the // per-provider default, so provider or session changes never discard // their choice. The in-memory flag covers client/server mode, where @@ -4905,7 +4908,7 @@ func (m *UI) applyThemeForProvider(providerID string) { return } m.themeKey = key - m.applyTheme(styles.ThemeForProvider(providerID)) + m.applyTheme(styles.ThemeForProvider(providerID, hasDarkBG)) } // applyTheme replaces the active styles with the given theme, drops the diff --git a/internal/ui/styles/themes.go b/internal/ui/styles/themes.go index a5846e7c..06093f24 100644 --- a/internal/ui/styles/themes.go +++ b/internal/ui/styles/themes.go @@ -26,14 +26,21 @@ func ThemeKeyForProvider(providerID string) string { } // 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 { +// ID and terminal background. Unknown or empty provider IDs yield the +// default Charmtone theme. When hasDarkBG is false, a light-friendly +// variant is returned instead. +func ThemeForProvider(providerID string, hasDarkBG bool) Styles { switch ThemeKeyForProvider(providerID) { case "hyper": - return HypercrushObsidiana() + if hasDarkBG { + return HypercrushObsidiana() + } + return HypercrushLight() default: - return CharmtonePantera() + if hasDarkBG { + return CharmtonePantera() + } + return CharmtoneLight() } } @@ -332,6 +339,17 @@ func ThemeFromConfig(name string) Styles { return s } +// ThemeForConfig resolves a theme from the configured name, provider ID, +// and terminal background. An explicitly configured theme always wins. +// When none is set, the theme is chosen from the provider and background +// so light terminals get a light-friendly variant. +func ThemeForConfig(name, providerID string, hasDarkBG bool) Styles { + if name != "" { + return ThemeFromConfig(name) + } + return ThemeForProvider(providerID, hasDarkBG) +} + // ThemeSource indicates where a theme definition comes from. type ThemeSource int @@ -431,3 +449,120 @@ func IsBuiltinTheme(name string) bool { _, ok := builtinThemes[normalizeThemeName(name)] return ok } + +// HypercrushLight returns the Hypercrush light theme. +func HypercrushLight() Styles { + return CharmtoneLight() +} + +// CharmtoneLight returns the Charmtone light theme for terminals with +// light (white) backgrounds. It inverts the neutral ramp so that +// foreground colors are dark and backgrounds are light, while keeping +// brand colors and most status hues unchanged. +func CharmtoneLight() Styles { + s := quickStyle(quickStyleOpts{ + primary: charmtone.Charple, + secondary: charmtone.Dolly, + accent: charmtone.Bok, + keyword: charmtone.Darple, + + fgBase: charmtone.Pepper, + fgMoreSubtle: charmtone.BBQ, + fgSubtle: charmtone.Char, + fgMostSubtle: charmtone.Iron, + + onPrimary: charmtone.Salt, + + bgBase: charmtone.Salt, + bgLeastVisible: charmtone.Soda, + bgLessVisible: charmtone.Sash, + bgMostVisible: charmtone.Oyster, + + separator: charmtone.Steep, + + destructive: charmtone.Coral, + error: charmtone.Sriracha, + warningSubtle: charmtone.Yam, + warning: charmtone.Paprika, + attention: charmtone.Tang, + busy: charmtone.Citron, + info: charmtone.Malibu, + infoMoreSubtle: charmtone.Sardine, + infoMostSubtle: charmtone.Damson, + success: charmtone.Julep, + successMoreSubtle: charmtone.Bok, + successMostSubtle: charmtone.Pickle, + + ansiBlack: charmtone.Pepper, + ansiRed: charmtone.Coral, + ansiGreen: charmtone.Pickle, + ansiYellow: charmtone.Yam, + ansiBlue: charmtone.Charple, + ansiMagenta: charmtone.Dolly, + ansiCyan: charmtone.Malibu, + ansiWhite: charmtone.Soda, + + ansiBrightBlack: charmtone.Iron, + ansiBrightRed: charmtone.Tuna, + ansiBrightGreen: charmtone.Julep, + ansiBrightYellow: charmtone.Zest, + ansiBrightBlue: charmtone.Guppy, + ansiBrightMagenta: charmtone.Blush, + ansiBrightCyan: charmtone.Sardine, + ansiBrightWhite: charmtone.Salt, + }) + + // Bang ! prompt overrides - use Darple/Larple/Charple colors on light. + s.Editor.PromptBangIconFocused = s.Editor.PromptBangIconFocused. + Foreground(charmtone.Salt). + Background(charmtone.Darple) + s.Editor.PromptBangDotsFocused = s.Editor.PromptBangDotsFocused. + Foreground(charmtone.Larple) + s.Editor.PromptBangDotsBlurred = s.Editor.PromptBangDotsBlurred. + Foreground(charmtone.Charple) + + // Shell bar/prompt overrides. + s.Messages.ShellBarFocused = s.Messages.ShellBarFocused. + BorderForeground(charmtone.Charple) + s.Messages.ShellBarBlurred = s.Messages.ShellBarBlurred. + BorderForeground(charmtone.Oyster) + s.Messages.ShellPrompt = s.Messages.ShellPrompt. + Foreground(charmtone.Darple) + s.Messages.ShellPromptBlurred = s.Messages.ShellPromptBlurred. + Foreground(charmtone.Darple) + + // Attachment overrides — use light backgrounds so text is readable. + s.Attachments.Normal = s.Attachments.Normal. + Background(charmtone.Sash). + Foreground(charmtone.BBQ) + + // Diff overrides - use light insert/delete backgrounds. + lightInsertNum := lipgloss.NewStyle(). + Foreground(charmtone.Pickle). + Background(lipgloss.Color("#c8e6c9")) + lightInsertSym := lipgloss.NewStyle(). + Foreground(charmtone.Pickle). + Background(lipgloss.Color("#e8f5e9")) + lightInsertCode := lipgloss.NewStyle(). + Foreground(charmtone.Pepper). + Background(lipgloss.Color("#e8f5e9")) + lightDeleteNum := lipgloss.NewStyle(). + Foreground(charmtone.Cherry). + Background(lipgloss.Color("#ffcdd2")) + lightDeleteSym := lipgloss.NewStyle(). + Foreground(charmtone.Cherry). + Background(lipgloss.Color("#ffebee")) + lightDeleteCode := lipgloss.NewStyle(). + Foreground(charmtone.Pepper). + Background(lipgloss.Color("#ffebee")) + + s.Diff.InsertLine.LineNumber = lightInsertNum + s.Diff.InsertLine.Symbol = lightInsertSym + s.Diff.InsertLine.Code = lightInsertCode + s.Diff.DeleteLine.LineNumber = lightDeleteNum + s.Diff.DeleteLine.Symbol = lightDeleteSym + s.Diff.DeleteLine.Code = lightDeleteCode + + return s + +} -- 2.53.0