on main help sign in
perf(ui): skip theme rebuild when provider keeps the same theme
commit173b2be61b2906bf0bcb107081d700ed36550a85 authorKieran Klukas <kieran@dunkirk.sh> dateMon 22 Jun 2026, 16:06 (4 months ago) parent81cb9d9
2 files changed+46−3
internal/ui/model/ui.go+30−2 internal/ui/styles/themes.go+16−1
internal/ui/model/ui.go+30−2
@@ -196,6 +196,11 @@ type UI struct {
196196
197197 isTransparent bool
198198
199+ // themeKey identifies the currently applied theme so applyTheme can
200+ // skip the expensive style rebuild when switching to a provider that
201+ // resolves to the same theme.
202+ themeKey string
203+
199204 focus uiFocusState
200205 state uiState
201206
@@ -380,6 +385,12 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
380385
381386 status := NewStatus(com, ui)
382387
388+ // Seed the active theme key from the large model provider so the
389+ // first model selection can correctly skip a redundant theme swap.
390+ if cfg := com.Config(); cfg != nil {
391+ ui.themeKey = styles.ThemeKeyForProvider(cfg.Models[config.SelectedModelTypeLarge].Provider)
392+ }
393+
383394 ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
384395 ui.randomizePlaceholders()
385396 ui.textarea.Placeholder = ui.readyPlaceholder
@@ -1842,8 +1853,10 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
18421853 } else {
18431854 if msg.ModelType == config.SelectedModelTypeLarge {
18441855 // Swap the theme live based on the newly selected large
1845− // model's provider.
1846− m.applyTheme(styles.ThemeForProvider(providerID))
1856+ // model's provider. Skipped when the provider resolves to
1857+ // the already-active theme, which avoids a full markdown
1858+ // re-render of the transcript on every selection.
1859+ m.applyThemeForProvider(providerID)
18471860 }
18481861 if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
18491862 // Ensure small model is set is unset.
@@ -3371,6 +3384,21 @@ func (m *UI) cacheSidebarLogo(width int) {
33713384 m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
33723385}
33733386
3387+// applyThemeForProvider swaps the active theme to the one associated with
3388+// the given provider, but only when that theme differs from the one
3389+// already applied. Most providers share a single theme, so re-selecting a
3390+// model from the same theme family would otherwise pay the full cost of
3391+// invalidating the markdown renderer cache and re-rendering the entire
3392+// transcript for no visible change.
3393+func (m *UI) applyThemeForProvider(providerID string) {
3394+ key := styles.ThemeKeyForProvider(providerID)
3395+ if key == m.themeKey {
3396+ return
3397+ }
3398+ m.themeKey = key
3399+ m.applyTheme(styles.ThemeForProvider(providerID))
3400+}
3401+
33743402// applyTheme replaces the active styles with the given theme, drops the
33753403// shared markdown renderer cache, and refreshes every component that
33763404// caches style data.
internal/ui/styles/themes.go+16−1
@@ -4,11 +4,26 @@ import (
44 "github.com/charmbracelet/x/exp/charmtone"
55)
66
7+// ThemeKeyForProvider returns a stable identifier for the theme
8+// associated with the given provider ID. Providers that share a theme
9+// yield the same key, so callers can cheaply detect when switching
10+// providers would not actually change the active theme and skip the
11+// expensive style rebuild. This is the single source of truth for the
12+// provider-to-theme mapping; [ThemeForProvider] builds on it.
13+func ThemeKeyForProvider(providerID string) string {
14+ switch providerID {
15+ case "hyper":
16+ return "hyper"
17+ default:
18+ return "default"
19+ }
20+}
21+
722// ThemeForProvider returns the Styles associated with the given provider
823// ID. Unknown or empty provider IDs yield the default Charmtone Pantera
924// theme.
1025func ThemeForProvider(providerID string) Styles {
11− switch providerID {
26+ switch ThemeKeyForProvider(providerID) {
1227 case "hyper":
1328 return HypercrushObsidiana()
1429 default: