chore(styles): use hypercrush theme when hyper is selected

Christian Rocha created

Change summary

internal/app/app.go                    |  2 
internal/cmd/run.go                    |  2 
internal/cmd/session.go                | 14 +++++--
internal/ui/attachments/attachments.go | 12 ++++++
internal/ui/chat/messages.go           | 16 +++++++++
internal/ui/common/common.go           | 19 +++++++++-
internal/ui/completions/completions.go |  9 +++++
internal/ui/model/chat.go              | 12 ++++++
internal/ui/model/header.go            | 13 ++++++-
internal/ui/model/ui.go                | 45 ++++++++++++++++++++++--
internal/ui/styles/themes.go           | 49 ++++++++++++++++++++++++++++
11 files changed, 178 insertions(+), 15 deletions(-)

Detailed changes

internal/app/app.go πŸ”—

@@ -233,7 +233,7 @@ 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.CharmtonePantera()
+		t := styles.ThemeForProvider(app.config.Config().Models[config.SelectedModelTypeLarge].Provider)
 
 		// Detect background color to set the appropriate color for the
 		// spinner's 'Generating...' text. Without this, that text would be

internal/cmd/run.go πŸ”—

@@ -189,7 +189,7 @@ func runNonInteractive(
 	progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
 
 	if !hideSpinner && stderrTTY {
-		t := styles.CharmtonePantera()
+		t := styles.ThemeForProvider(ws.Config.Models[config.SelectedModelTypeLarge].Provider)
 
 		hasDarkBG := true
 		if stdinTTY && stdoutTTY {

internal/cmd/session.go πŸ”—

@@ -101,6 +101,7 @@ func init() {
 type sessionServices struct {
 	sessions session.Service
 	messages message.Service
+	cfg      *config.ConfigStore
 }
 
 func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func(), error) {
@@ -127,6 +128,7 @@ func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func()
 	svc := &sessionServices{
 		sessions: session.NewService(queries, conn),
 		messages: message.NewService(queries),
+		cfg:      cfg,
 	}
 	return ctx, svc, func() { conn.Close() }, nil
 }
@@ -280,7 +282,7 @@ func runSessionShow(cmd *cobra.Command, args []string) error {
 	if sessionShowJSON {
 		return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
 	}
-	return outputSessionHuman(ctx, sess, msgPtrs)
+	return outputSessionHuman(ctx, svc.cfg, sess, msgPtrs)
 }
 
 func runSessionDelete(cmd *cobra.Command, args []string) error {
@@ -387,7 +389,7 @@ func runSessionLast(cmd *cobra.Command, _ []string) error {
 	if sessionLastJSON {
 		return outputSessionJSON(cmd.OutOrStdout(), sess, msgPtrs)
 	}
-	return outputSessionHuman(ctx, sess, msgPtrs)
+	return outputSessionHuman(ctx, svc.cfg, sess, msgPtrs)
 }
 
 const (
@@ -437,8 +439,12 @@ func outputSessionJSON(w io.Writer, sess session.Session, msgs []*message.Messag
 	return enc.Encode(output)
 }
 
-func outputSessionHuman(ctx context.Context, sess session.Session, msgs []*message.Message) error {
-	styles := styles.CharmtonePantera()
+func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess session.Session, msgs []*message.Message) error {
+	var providerID string
+	if cfg != nil {
+		providerID = cfg.Config().Models[config.SelectedModelTypeLarge].Provider
+	}
+	styles := styles.ThemeForProvider(providerID)
 	toolResults := chat.BuildToolResultMap(msgs)
 
 	width := sessionOutputWidth

internal/ui/attachments/attachments.go πŸ”—

@@ -78,6 +78,10 @@ func (m *Attachments) Render(width int) string {
 	return m.renderer.Render(m.list, m.deleting, width)
 }
 
+// Renderer returns the attachment renderer so callers can update its
+// styles in place.
+func (m *Attachments) Renderer() *Renderer { return m.renderer }
+
 func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
 	return &Renderer{
 		normalStyle:   normalStyle,
@@ -87,6 +91,14 @@ func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Styl
 	}
 }
 
+// SetStyles updates the renderer styles in place.
+func (r *Renderer) SetStyles(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) {
+	r.normalStyle = normalStyle
+	r.textStyle = textStyle
+	r.imageStyle = imageStyle
+	r.deletingStyle = deletingStyle
+}
+
 type Renderer struct {
 	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
 }

internal/ui/chat/messages.go πŸ”—

@@ -128,6 +128,22 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
 	}
 }
 
+// cacheClearable is implemented by message items that cache rendered
+// output and can be asked to drop the cache.
+type cacheClearable interface {
+	clearCache()
+}
+
+// ClearItemCaches drops any cached rendered output on each item so the
+// next render uses the current styles.
+func ClearItemCaches(items []MessageItem) {
+	for _, item := range items {
+		if cc, ok := item.(cacheClearable); ok {
+			cc.clearCache()
+		}
+	}
+}
+
 // cachedMessageItem caches rendered message content to avoid re-rendering.
 //
 // 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 πŸ”—

@@ -31,15 +31,30 @@ func (c *Common) Config() *config.Config {
 	return c.Workspace.Config()
 }
 
-// DefaultCommon returns the default common UI configurations.
+// DefaultCommon returns the default common UI configurations. When the
+// 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.CharmtonePantera()
+	s := styles.ThemeForProvider(largeModelProviderID(ws))
 	return &Common{
 		Workspace: ws,
 		Styles:    &s,
 	}
 }
 
+// largeModelProviderID returns the provider ID of the currently selected
+// large model, or the empty string if none is set or the workspace is nil.
+func largeModelProviderID(ws workspace.Workspace) string {
+	if ws == nil {
+		return ""
+	}
+	cfg := ws.Config()
+	if cfg == nil {
+		return ""
+	}
+	return cfg.Models[config.SelectedModelTypeLarge].Provider
+}
+
 // CenterRect returns a new [Rectangle] centered within the given area with the
 // specified width and height.
 func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {

internal/ui/completions/completions.go πŸ”—

@@ -110,6 +110,15 @@ func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
 	}
 }
 
+// SetStyles updates the styles used when rendering completion items.
+// Existing items are not restyled; subsequent SetItems calls pick up the
+// new styles.
+func (c *Completions) SetStyles(normalStyle, focusedStyle, matchStyle lipgloss.Style) {
+	c.normalStyle = normalStyle
+	c.focusedStyle = focusedStyle
+	c.matchStyle = matchStyle
+}
+
 // IsOpen returns whether the completions popup is open.
 func (c *Completions) IsOpen() bool {
 	return c.open

internal/ui/model/chat.go πŸ”—

@@ -107,6 +107,18 @@ func (m *Chat) Len() int {
 	return m.list.Len()
 }
 
+// InvalidateRenderCaches drops cached rendered output on every message
+// item so the next draw re-renders with the current styles.
+func (m *Chat) InvalidateRenderCaches() {
+	items := make([]chat.MessageItem, 0, m.list.Len())
+	for i := range m.list.Len() {
+		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
+			items = append(items, item)
+		}
+	}
+	chat.ClearItemCaches(items)
+}
+
 // SetMessages sets the chat messages to the provided list of message items.
 func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 	m.idInxMap = make(map[string]int)

internal/ui/model/header.go πŸ”—

@@ -37,10 +37,19 @@ func newHeader(com *common.Common) *header {
 	h := &header{
 		com: com,
 	}
-	t := com.Styles
+	h.refresh()
+	return h
+}
+
+// refresh rebuilds cached logo strings using the current styles. Call
+// after the theme changes.
+func (h *header) refresh() {
+	t := h.com.Styles
 	h.compactLogo = t.Header.Charm.Render("Charmβ„’") + " " +
 		styles.ApplyBoldForegroundGrad(t.Header.LogoGradCanvas, "CRUSH", t.Header.LogoGradFromColor, t.Header.LogoGradToColor) + " "
-	return h
+	// Force drawHeader to re-render the wide logo on the next frame.
+	h.width = 0
+	h.logo = ""
 }
 
 // drawHeader draws the header for the given session.

internal/ui/model/ui.go πŸ”—

@@ -1461,11 +1461,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
 			cmds = append(cmds, util.ReportError(err))
-		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
-			// Ensure small model is set is unset.
-			smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
-			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
-				cmds = append(cmds, util.ReportError(err))
+		} else {
+			if msg.ModelType == config.SelectedModelTypeLarge {
+				// Swap the theme live based on the newly selected large
+				// model's provider.
+				m.applyTheme(styles.ThemeForProvider(providerID))
+			}
+			if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
+				// Ensure small model is set is unset.
+				smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
+				if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
+					cmds = append(cmds, util.ReportError(err))
+				}
 			}
 		}
 
@@ -2980,6 +2987,34 @@ func (m *UI) cacheSidebarLogo(width int) {
 	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
 }
 
+// applyTheme replaces the active styles with the given theme and
+// refreshes every component that caches style data.
+func (m *UI) applyTheme(s styles.Styles) {
+	*m.com.Styles = s
+	m.refreshStyles()
+}
+
+// refreshStyles pushes the current *m.com.Styles into every subcomponent
+// that copies or pre-renders style-dependent values at construction time.
+func (m *UI) refreshStyles() {
+	t := m.com.Styles
+	m.header.refresh()
+	if m.layout.sidebar.Dx() > 0 {
+		m.cacheSidebarLogo(m.layout.sidebar.Dx())
+	}
+	m.textarea.SetStyles(t.Editor.Textarea)
+	m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
+	m.attachments.Renderer().SetStyles(
+		t.Attachments.Normal,
+		t.Attachments.Deleting,
+		t.Attachments.Image,
+		t.Attachments.Text,
+	)
+	m.todoSpinner.Style = t.Pills.TodoSpinner
+	m.status.help.Styles = t.Help
+	m.chat.InvalidateRenderCaches()
+}
+
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
 	if !m.com.Workspace.AgentIsReady() {

internal/ui/styles/themes.go πŸ”—

@@ -2,6 +2,18 @@ package styles
 
 import "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 {
+	switch providerID {
+	case "hyper":
+		return HypercrushObsidiana()
+	default:
+		return CharmtonePantera()
+	}
+}
+
 // CharmtonePantera returns the Charmtone dark theme. It's the default style
 // for the UI.
 func CharmtonePantera() Styles {
@@ -39,3 +51,40 @@ func CharmtonePantera() Styles {
 		successMuted:  charmtone.Guac,
 	})
 }
+
+// HypercrushObsidiana returns the Hypercrush dark theme.
+func HypercrushObsidiana() Styles {
+	return quickStyle(quickStyleOpts{
+		primary:   charmtone.Charple,
+		secondary: charmtone.Dolly,
+		tertiary:  charmtone.Bok,
+
+		fgBase:      charmtone.Ash,
+		fgMuted:     charmtone.Squid,
+		fgHalfMuted: charmtone.Smoke,
+		fgSubtle:    charmtone.Oyster,
+
+		onPrimary: charmtone.Salt,
+		onAccent:  charmtone.Butter,
+
+		bgBase:        charmtone.Pepper,
+		bgBaseLighter: charmtone.BBQ,
+		bgSubtle:      charmtone.Charcoal,
+		bgOverlay:     charmtone.Iron,
+
+		border:      charmtone.Charcoal,
+		borderFocus: charmtone.Charple,
+
+		danger:        charmtone.Coral,
+		error:         charmtone.Sriracha,
+		warning:       charmtone.Zest,
+		warningStrong: charmtone.Mustard,
+		busy:          charmtone.Citron,
+		info:          charmtone.Malibu,
+		infoSubtle:    charmtone.Sardine,
+		infoMuted:     charmtone.Damson,
+		success:       charmtone.Julep,
+		successSubtle: charmtone.Bok,
+		successMuted:  charmtone.Guac,
+	})
+}