diff --git a/internal/app/app.go b/internal/app/app.go index fe31bda6f03d50cd53c3811568858bbfc9316974..d16ee32e9b24a141e834f750b046e1332b6d050c 100644 --- a/internal/app/app.go +++ b/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 diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 4f73fe5644b9c7ba39ec8d4246c2dc72773469e6..d957720068c47b85038731d7c4ec6eb2dbe9c135 100644 --- a/internal/cmd/run.go +++ b/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 { diff --git a/internal/cmd/session.go b/internal/cmd/session.go index d673ad07c3d11191506498ff4f97410eb1177596..31765cd4aa30cbcf849376addbd3abac2ede4e16 100644 --- a/internal/cmd/session.go +++ b/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 diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go index 558c7576ee1edb3756be3dc7b4ccfcb89a5597b7..d56ea7ac43706ecf36b35fd9ec912d660370eaf1 100644 --- a/internal/ui/attachments/attachments.go +++ b/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 } diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 4906516cc1d259037f27675e92f60efa34fa817c..6c86cb74951e0bf8f20fe7af4fa420b4527936ca 100644 --- a/internal/ui/chat/messages.go +++ b/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 diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 798ee2c1bf1d99aa15d4f343956bf16170c147f3..43a195826097daa368af7d7b7e93fb4fdecc2f23 100644 --- a/internal/ui/common/common.go +++ b/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 { diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index 0a63e789b040f3b17b671c1f6ddb5c8be0f12f1c..9393cce52410884a7e410588adccf1863f4aed3b 100644 --- a/internal/ui/completions/completions.go +++ b/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 diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index ccd2325507545b35c9ee2e664cd869da9d2a8a4f..5cc620102febbe4af724ee1770f93f0f8de13212 100644 --- a/internal/ui/model/chat.go +++ b/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) diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go index d3b728738cf622fe966d43fde566c29495d4d69a..7c15f58e1f1784f385dce61d1166fd7131b3f959 100644 --- a/internal/ui/model/header.go +++ b/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. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2bbd59906440ab08036622e646655638255a17c9..9cc33a339fa268ce9dddba668e7f5ed289bbb57a 100644 --- a/internal/ui/model/ui.go +++ b/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() { diff --git a/internal/ui/styles/themes.go b/internal/ui/styles/themes.go index 39782c32840e44ebd4b134432be2d7df7fcd99a8..ede17b6de57e80b2ac04ab81706603f5ca010194 100644 --- a/internal/ui/styles/themes.go +++ b/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, + }) +}