on main help sign in
chore(styles): use hypercrush theme when hyper is selected
commit9d4653e8c9436c59d3633599bbb400e4ce286f40 authorChristian Rocha <christian@rocha.is> dateFri 24 Apr 2026, 17:14 (6 months ago) parente29353f
11 files changed+178−15
internal/app/app.go+1−1 internal/cmd/run.go+1−1 internal/cmd/session.go+10−4 internal/ui/attachments/attachments.go+12 internal/ui/chat/messages.go+16 internal/ui/common/common.go+17−2 internal/ui/completions/completions.go+9 internal/ui/model/chat.go+12 internal/ui/model/header.go+11−2 internal/ui/model/ui.go+40−5 internal/ui/styles/themes.go+49
internal/app/app.go+1−1
@@ -233,7 +233,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
233233 progress = app.config.Config().Options.Progress == nil || *app.config.Config().Options.Progress
234234
235235 if !hideSpinner && stderrTTY {
236− t := styles.CharmtonePantera()
236+ t := styles.ThemeForProvider(app.config.Config().Models[config.SelectedModelTypeLarge].Provider)
237237
238238 // Detect background color to set the appropriate color for the
239239 // spinner's 'Generating...' text. Without this, that text would be
internal/cmd/run.go+1−1
@@ -189,7 +189,7 @@ func runNonInteractive(
189189 progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
190190
191191 if !hideSpinner && stderrTTY {
192− t := styles.CharmtonePantera()
192+ t := styles.ThemeForProvider(ws.Config.Models[config.SelectedModelTypeLarge].Provider)
193193
194194 hasDarkBG := true
195195 if stdinTTY && stdoutTTY {
internal/cmd/session.go+10−4
@@ -101,6 +101,7 @@ func init() {
101101type sessionServices struct {
102102 sessions session.Service
103103 messages message.Service
104+ cfg *config.ConfigStore
104105}
105106
106107func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
@@ -127,6 +128,7 @@ func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func()
127128 svc := &sessionServices{
128129 sessions: session.NewService(queries, conn),
129130 messages: message.NewService(queries),
131+ cfg: cfg,
130132 }
131133 return ctx, svc, func() { conn.Close() }, nil
132134}
@@ -280,7 +282,7 @@ func runSessionShow(cmd *cobra.Command, args []string) error {
280282 if sessionShowJSON {
281283 return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
282284 }
283− return outputSessionHuman(ctx, sess, msgPtrs)
285+ return outputSessionHuman(ctx, svc.cfg, sess, msgPtrs)
284286}
285287
286288func runSessionDelete(cmd *cobra.Command, args []string) error {
@@ -387,7 +389,7 @@ func runSessionLast(cmd *cobra.Command, _ []string) error {
387389 if sessionLastJSON {
388390 return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
389391 }
390− return outputSessionHuman(ctx, sess, msgPtrs)
392+ return outputSessionHuman(ctx, svc.cfg, sess, msgPtrs)
391393}
392394
393395const (
@@ -437,8 +439,12 @@ func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Messag
437439 return enc.Encode(output)
438440}
439441
440−func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
441− styles := styles.CharmtonePantera()
442+func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess session.Session, msgs []*message.Message) error {
443+ var providerID string
444+ if cfg != nil {
445+ providerID = cfg.Config().Models[config.SelectedModelTypeLarge].Provider
446+ }
447+ styles := styles.ThemeForProvider(providerID)
442448 toolResults := chat.BuildToolResultMap(msgs)
443449
444450 width := sessionOutputWidth
internal/ui/attachments/attachments.go+12
@@ -78,6 +78,10 @@ func (m *Attachments) Render(width int) string {
7878 return m.renderer.Render(m.list, m.deleting, width)
7979}
8080
81+// Renderer returns the attachment renderer so callers can update its
82+// styles in place.
83+func (m *Attachments) Renderer() *Renderer { return m.renderer }
84+
8185func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
8286 return &Renderer{
8387 normalStyle: normalStyle,
@@ -87,6 +91,14 @@ func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Styl
8791 }
8892}
8993
94+// SetStyles updates the renderer styles in place.
95+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
96+ r.normalStyle = normalStyle
97+ r.textStyle = textStyle
98+ r.imageStyle = imageStyle
99+ r.deletingStyle = deletingStyle
100+}
101+
90102type Renderer struct {
91103 normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
92104}
internal/ui/chat/messages.go+16
@@ -128,6 +128,22 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
128128 }
129129}
130130
131+// cacheClearable is implemented by message items that cache rendered
132+// output and can be asked to drop the cache.
133+type cacheClearable interface {
134+ clearCache()
135+}
136+
137+// ClearItemCaches drops any cached rendered output on each item so the
138+// next render uses the current styles.
139+func ClearItemCaches(items []MessageItem) {
140+ for _, item := range items {
141+ if cc, ok := item.(cacheClearable); ok {
142+ cc.clearCache()
143+ }
144+ }
145+}
146+
131147// cachedMessageItem caches rendered message content to avoid re-rendering.
132148//
133149// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
internal/ui/common/common.go+17−2
@@ -31,15 +31,30 @@ func (c *Common) Config() *config.Config {
3131 return c.Workspace.Config()
3232}
3333
34−// DefaultCommon returns the default common UI configurations.
34+// DefaultCommon returns the default common UI configurations. When the
35+// workspace has a large model selected, the theme is chosen based on its
36+// provider; otherwise the default theme is used.
3537func DefaultCommon(ws workspace.Workspace) *Common {
36− s := styles.CharmtonePantera()
38+ s := styles.ThemeForProvider(largeModelProviderID(ws))
3739 return &Common{
3840 Workspace: ws,
3941 Styles: &s,
4042 }
4143}
4244
45+// largeModelProviderID returns the provider ID of the currently selected
46+// large model, or the empty string if none is set or the workspace is nil.
47+func largeModelProviderID(ws workspace.Workspace) string {
48+ if ws == nil {
49+ return ""
50+ }
51+ cfg := ws.Config()
52+ if cfg == nil {
53+ return ""
54+ }
55+ return cfg.Models[config.SelectedModelTypeLarge].Provider
56+}
57+
4358// CenterRect returns a new [Rectangle] centered within the given area with the
4459// specified width and height.
4560func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
internal/ui/completions/completions.go+9
@@ -110,6 +110,15 @@ func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
110110 }
111111}
112112
113+// SetStyles updates the styles used when rendering completion items.
114+// Existing items are not restyled; subsequent SetItems calls pick up the
115+// new styles.
116+func (c *Completions) SetStyles(normalStyle, focusedStyle, matchStyle lipgloss.Style) {
117+ c.normalStyle = normalStyle
118+ c.focusedStyle = focusedStyle
119+ c.matchStyle = matchStyle
120+}
121+
113122// IsOpen returns whether the completions popup is open.
114123func (c *Completions) IsOpen() bool {
115124 return c.open
internal/ui/model/chat.go+12
@@ -107,6 +107,18 @@ func (m *Chat) Len() int {
107107 return m.list.Len()
108108}
109109
110+// InvalidateRenderCaches drops cached rendered output on every message
111+// item so the next draw re-renders with the current styles.
112+func (m *Chat) InvalidateRenderCaches() {
113+ items := make([]chat.MessageItem, 0, m.list.Len())
114+ for i := range m.list.Len() {
115+ if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
116+ items = append(items, item)
117+ }
118+ }
119+ chat.ClearItemCaches(items)
120+}
121+
110122// SetMessages sets the chat messages to the provided list of message items.
111123func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
112124 m.idInxMap = make(map[string]int)
internal/ui/model/header.go+11−2
@@ -37,10 +37,19 @@ func newHeader(com *common.Common) *header {
3737 h := &header{
3838 com: com,
3939 }
40− t := com.Styles
40+ h.refresh()
41+ return h
42+}
43+
44+// refresh rebuilds cached logo strings using the current styles. Call
45+// after the theme changes.
46+func (h *header) refresh() {
47+ t := h.com.Styles
4148 h.compactLogo = t.Header.Charm.Render("Charm™") + " " +
4249 styles.ApplyBoldForegroundGrad(t.Header.LogoGradCanvas, "CRUSH", t.Header.LogoGradFromColor, t.Header.LogoGradToColor) + " "
43− return h
50+ // Force drawHeader to re-render the wide logo on the next frame.
51+ h.width = 0
52+ h.logo = ""
4453}
4554
4655// drawHeader draws the header for the given session.
internal/ui/model/ui.go+40−5
@@ -1461,11 +1461,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14611461
14621462 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
14631463 cmds = append(cmds, util.ReportError(err))
1464− } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1465− // Ensure small model is set is unset.
1466− smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1467− if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1468− cmds = append(cmds, util.ReportError(err))
1464+ } else {
1465+ if msg.ModelType == config.SelectedModelTypeLarge {
1466+ // Swap the theme live based on the newly selected large
1467+ // model's provider.
1468+ m.applyTheme(styles.ThemeForProvider(providerID))
1469+ }
1470+ if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1471+ // Ensure small model is set is unset.
1472+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1473+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1474+ cmds = append(cmds, util.ReportError(err))
1475+ }
14691476 }
14701477 }
14711478
@@ -2980,6 +2987,34 @@ func (m *UI) cacheSidebarLogo(width int) {
29802987 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
29812988}
29822989
2990+// applyTheme replaces the active styles with the given theme and
2991+// refreshes every component that caches style data.
2992+func (m *UI) applyTheme(s styles.Styles) {
2993+ *m.com.Styles = s
2994+ m.refreshStyles()
2995+}
2996+
2997+// refreshStyles pushes the current *m.com.Styles into every subcomponent
2998+// that copies or pre-renders style-dependent values at construction time.
2999+func (m *UI) refreshStyles() {
3000+ t := m.com.Styles
3001+ m.header.refresh()
3002+ if m.layout.sidebar.Dx() > 0 {
3003+ m.cacheSidebarLogo(m.layout.sidebar.Dx())
3004+ }
3005+ m.textarea.SetStyles(t.Editor.Textarea)
3006+ m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
3007+ m.attachments.Renderer().SetStyles(
3008+ t.Attachments.Normal,
3009+ t.Attachments.Deleting,
3010+ t.Attachments.Image,
3011+ t.Attachments.Text,
3012+ )
3013+ m.todoSpinner.Style = t.Pills.TodoSpinner
3014+ m.status.help.Styles = t.Help
3015+ m.chat.InvalidateRenderCaches()
3016+}
3017+
29833018// sendMessage sends a message with the given content and attachments.
29843019func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
29853020 if !m.com.Workspace.AgentIsReady() {
internal/ui/styles/themes.go+49
@@ -2,6 +2,18 @@ package styles
22
33import "github.com/charmbracelet/x/exp/charmtone"
44
5+// ThemeForProvider returns the Styles associated with the given provider
6+// ID. Unknown or empty provider IDs yield the default Charmtone Pantera
7+// theme.
8+func ThemeForProvider(providerID string) Styles {
9+ switch providerID {
10+ case "hyper":
11+ return HypercrushObsidiana()
12+ default:
13+ return CharmtonePantera()
14+ }
15+}
16+
517// CharmtonePantera returns the Charmtone dark theme. It's the default style
618// for the UI.
719func CharmtonePantera() Styles {
@@ -39,3 +51,40 @@ func CharmtonePantera() Styles {
3951 successMuted: charmtone.Guac,
4052 })
4153}
54+
55+// HypercrushObsidiana returns the Hypercrush dark theme.
56+func HypercrushObsidiana() Styles {
57+ return quickStyle(quickStyleOpts{
58+ primary: charmtone.Charple,
59+ secondary: charmtone.Dolly,
60+ tertiary: charmtone.Bok,
61+
62+ fgBase: charmtone.Ash,
63+ fgMuted: charmtone.Squid,
64+ fgHalfMuted: charmtone.Smoke,
65+ fgSubtle: charmtone.Oyster,
66+
67+ onPrimary: charmtone.Salt,
68+ onAccent: charmtone.Butter,
69+
70+ bgBase: charmtone.Pepper,
71+ bgBaseLighter: charmtone.BBQ,
72+ bgSubtle: charmtone.Charcoal,
73+ bgOverlay: charmtone.Iron,
74+
75+ border: charmtone.Charcoal,
76+ borderFocus: charmtone.Charple,
77+
78+ danger: charmtone.Coral,
79+ error: charmtone.Sriracha,
80+ warning: charmtone.Zest,
81+ warningStrong: charmtone.Mustard,
82+ busy: charmtone.Citron,
83+ info: charmtone.Malibu,
84+ infoSubtle: charmtone.Sardine,
85+ infoMuted: charmtone.Damson,
86+ success: charmtone.Julep,
87+ successSubtle: charmtone.Bok,
88+ successMuted: charmtone.Guac,
89+ })
90+}