on main help sign in
refactor(ui): extract render-cache state into a renderCache type

The UI god-struct carried eight scattered per-frame memoization fields (sidebar view/key/scratch buffer, resolved large-model cache, pills view/key). Group them into a single renderCache value so the memoization layer is separable from model state (architecture cleanup; behavior-neutral — field names preserved, only the access path changes).

commit9f930300c4380d60dbe5465cdd12b6d4c9a8f820 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 20:34 (3 months ago) parent36d571e
4 files changed+53−47
internal/ui/model/landing.go+6−6 internal/ui/model/pills.go+5−5 internal/ui/model/sidebar.go+7−7 internal/ui/model/ui.go+35−29
internal/ui/model/landing.go+6−6
@@ -23,16 +23,16 @@ import (
2323// frame.
2424func (m *UI) selectedLargeModel() *workspace.AgentModel {
2525 key := m.largeModelConfigKey()
26− if m.largeModel != nil && key == m.largeModelCacheKey {
27− return m.largeModel
26+ if m.renderCache.largeModel != nil && key == m.renderCache.largeModelCacheKey {
27+ return m.renderCache.largeModel
2828 }
29− m.largeModelCacheKey = key
29+ m.renderCache.largeModelCacheKey = key
3030 if m.com.Workspace.AgentIsReady() {
3131 model := m.com.Workspace.AgentModel()
32− m.largeModel = &model
33− return m.largeModel
32+ m.renderCache.largeModel = &model
33+ return m.renderCache.largeModel
3434 }
35− m.largeModel = nil
35+ m.renderCache.largeModel = nil
3636 return nil
3737}
3838
internal/ui/model/pills.go+5−5
@@ -281,16 +281,16 @@ func (m *UI) pillsCacheState() string {
281281 return kb.String()
282282}
283283
284−// renderPills renders the pills panel and stores it in m.pillsView.
284+// renderPills renders the pills panel and stores it in m.renderCache.pillsView.
285285func (m *UI) renderPills() {
286286 // Skip re-rendering when nothing that affects the output changed.
287287 key := m.pillsCacheState()
288− if key == m.pillsCacheKey {
288+ if key == m.renderCache.pillsCacheKey {
289289 return
290290 }
291− m.pillsCacheKey = key
291+ m.renderCache.pillsCacheKey = key
292292
293− m.pillsView = ""
293+ m.renderCache.pillsView = ""
294294 if !m.hasSession() {
295295 return
296296 }
@@ -359,5 +359,5 @@ func (m *UI) renderPills() {
359359 pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
360360 }
361361
362− m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
362+ m.renderCache.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
363363}
internal/ui/model/sidebar.go+7−7
@@ -140,7 +140,7 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
140140 // sidebar render cache is consulted), so the reflection-based arg boxing
141141 // fmt.Fprintf performs was pure per-frame allocation churn. Appending to
142142 // a retained buffer and hashing it once is allocation-free after warmup.
143− b := m.sidebarFPBuf[:0]
143+ b := m.renderCache.sidebarFPBuf[:0]
144144 putInt := func(k string, v int64) { b = append(b, k...); b = strconv.AppendInt(b, v, 10) }
145145 putStr := func(k, v string) { b = append(b, k...); b = append(b, v...) }
146146 putBool := func(k string, v bool) { b = append(b, k...); b = strconv.AppendBool(b, v) }
@@ -248,7 +248,7 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle, skillItems []skillStatusItem,
248248 b = append(b, ';')
249249 }
250250
251− m.sidebarFPBuf = b // retain the grown buffer for reuse next frame
251+ m.renderCache.sidebarFPBuf = b // retain the grown buffer for reuse next frame
252252 h := fnv.New64a()
253253 h.Write(b)
254254 return h.Sum64()
@@ -277,13 +277,13 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
277277 mcpSorted := m.com.Config().MCP.Sorted()
278278
279279 key := m.sidebarFingerprint(area, skillItems, mcpSorted)
280− if !m.hasSidebarCache || key != m.sidebarCacheKey {
281− m.sidebarView = m.buildSidebar(area, skillItems, mcpSorted)
282− m.sidebarCacheKey = key
283− m.hasSidebarCache = true
280+ if !m.renderCache.hasSidebarCache || key != m.renderCache.sidebarCacheKey {
281+ m.renderCache.sidebarView = m.buildSidebar(area, skillItems, mcpSorted)
282+ m.renderCache.sidebarCacheKey = key
283+ m.renderCache.hasSidebarCache = true
284284 }
285285
286− uv.NewStyledString(m.sidebarView).Draw(scr, area)
286+ uv.NewStyledString(m.renderCache.sidebarView).Draw(scr, area)
287287}
288288
289289// buildSidebar renders the sidebar content block as a string. skillItems
internal/ui/model/ui.go+35−29
@@ -175,6 +175,33 @@ type (
175175)
176176
177177// UI represents the main user interface model.
178+// renderCache holds the per-frame render memoization the UI consults on
179+// every draw: the sidebar block (drawSidebar re-renders fully each frame),
180+// the resolved large model (an RPC in client mode), and the pills block.
181+// Grouped out of the UI struct so the memoization layer is separable from
182+// model state.
183+type renderCache struct {
184+ // sidebarView is the memoized sidebar block, keyed by sidebarCacheKey
185+ // (a fingerprint of every input affecting its output).
186+ sidebarView string
187+ sidebarCacheKey uint64
188+ hasSidebarCache bool
189+ // sidebarFPBuf is a scratch buffer reused across frames to build the
190+ // sidebar fingerprint without allocating (see sidebarFingerprint).
191+ sidebarFPBuf []byte
192+
193+ // largeModel caches the resolved selected large model — resolving it
194+ // hits the agent coordinator (an RPC in client mode) — keyed by
195+ // largeModelCacheKey. A nil result is never cached so the
196+ // not-ready → ready transition is picked up on the next frame.
197+ largeModel *workspace.AgentModel
198+ largeModelCacheKey string
199+
200+ // pillsView is the memoized pills block, keyed by pillsCacheKey.
201+ pillsView string
202+ pillsCacheKey string
203+}
204+
178205type UI struct {
179206 // ctx is the program's lifecycle context (cancelled on SIGINT / program
180207 // exit). Async workspace operations launched from tea.Cmd closures use
@@ -299,25 +326,9 @@ type UI struct {
299326 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
300327 sidebarLogo string
301328
302− // sidebar render cache: drawSidebar fully re-renders every frame, so we
303− // memoize the rendered block keyed by a fingerprint of every input that
304− // affects its output.
305− sidebarView string
306− sidebarCacheKey uint64
307− hasSidebarCache bool
308− // sidebarFPBuf is a scratch buffer reused across frames to build the
309− // sidebar fingerprint without allocating (see sidebarFingerprint).
310− sidebarFPBuf []byte
311−
312− // selectedLargeModel cache: resolving the selected large model hits
313− // the agent coordinator, which is an RPC (AgentIsReady + AgentModel)
314− // in client mode. Because the sidebar fingerprint runs every frame we
315− // cache the resolved model keyed by a cheap, locally-cached config
316− // fingerprint and only re-fetch when that changes. A nil result is
317− // never cached so the not-ready → ready transition is picked up on
318− // the next frame.
319− largeModel *workspace.AgentModel
320− largeModelCacheKey string
329+ // renderCache holds the per-frame render memoization (sidebar, pills,
330+ // and resolved large-model caches); see the renderCache type.
331+ renderCache renderCache
321332
322333 // Notification state
323334 notifyBackend notification.Backend
@@ -341,11 +352,6 @@ type UI struct {
341352 pillsAutoExpanded bool
342353 focusedPillSection pillSection
343354 promptQueue int
344− pillsView string
345− // pillsCacheKey fingerprints the inputs used to build pillsView so
346− // renderPills can skip re-rendering (and recomputing the queue
347− // gradient) when nothing that affects the output has changed.
348− pillsCacheKey string
349355
350356 // Todo spinner
351357 todoSpinner spinner.Model
@@ -2552,8 +2558,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
25522558 }
25532559
25542560 m.chat.Draw(scr, layout.main)
2555− if layout.pills.Dy() > 0 && m.pillsView != "" {
2556− uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2561+ if layout.pills.Dy() > 0 && m.renderCache.pillsView != "" {
2562+ uv.NewStyledString(m.renderCache.pillsView).Draw(scr, layout.pills)
25572563 }
25582564
25592565 editorWidth := scr.Bounds().Dx()
@@ -4105,9 +4111,9 @@ func (m *UI) newSession() tea.Cmd {
41054111 m.pillsExpanded = false
41064112 m.pillsAutoExpanded = false
41074113 m.promptQueue = 0
4108− m.pillsView = ""
4109− m.pillsCacheKey = ""
4110− m.hasSidebarCache = false
4114+ m.renderCache.pillsView = ""
4115+ m.renderCache.pillsCacheKey = ""
4116+ m.renderCache.hasSidebarCache = false
41114117 m.historyReset()
41124118 agenttools.ResetCache()
41134119 return tea.Batch(