Detailed changes
@@ -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
@@ -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 {
@@ -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
@@ -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
}
@@ -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
@@ -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 {
@@ -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
@@ -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)
@@ -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.
@@ -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() {
@@ -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,
+ })
+}