on main help sign in
perf(chat): skip re-rendering chat list items that have not changed

The chat list now remembers what each item rendered last frame and reuses it when nothing about that item has changed. Once a message turn is fully done, its rendered output is reused verbatim on every subsequent frame for as long as it stays unchanged. Resizing the window, focusing or selecting an item, dragging a selection over it, toggling a thinking block, and any other state change still triggers a fresh render. The visible output is identical to before; the work to produce it is much less. Co-Authored-By: Charm Crush <crush@charm.land>

commit6b101f38600cd4e4b974ee56af3c9d2382cc2c43 authorChristian Rocha <christian@rocha.is> dateTue 12 May 2026, 13:21 (5 months ago) parent43b986c
18 files changed+1901−49
internal/ui/chat/agent.go+19−1 internal/ui/chat/applyhighlight_callback_test.go+207 internal/ui/chat/assistant.go+30−2 internal/ui/chat/messages.go+51−7 internal/ui/chat/tools.go+34−2 internal/ui/chat/user.go+12−2 internal/ui/chat/version_bump_test.go+425 internal/ui/completions/item.go+34−2 internal/ui/completions/version_bump_test.go+85 internal/ui/dialog/commands_item.go+27−8 internal/ui/dialog/models_item.go+29−2 internal/ui/dialog/reasoning.go+20−2 internal/ui/dialog/sessions_item.go+35−3 internal/ui/dialog/version_bump_test.go+219 internal/ui/list/item.go+66−1 internal/ui/list/list.go+205−17 internal/ui/list/list_test.go+401 internal/ui/model/layout_test.go+2
internal/ui/chat/agent.go+19−1
@@ -77,9 +77,23 @@ func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
7777}
7878
7979// SetNestedTools sets the nested tools.
80+//
81+// SetNestedTools always bumps the version. The previous design
82+// deduped when the slice's length and element pointers were
83+// unchanged, but the live update path in internal/ui/model/ui.go
84+// mutates existing children in place (SetToolCall / SetResult on the
85+// same pointers) and then calls SetNestedTools with the same slice.
86+// Pointer-equality dedupe in that case skips the parent Bump even
87+// though the parent's rendered output (which embeds the children
88+// inline) has changed, leaving a stale parent entry in the list
89+// cache. Always bumping is cheap (one uint64 increment) and called
90+// at most once per agent event; in the rare case the slice is
91+// truly unchanged the worst case is one extra parent re-render
92+// while every child cache hit stays warm.
8093func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
8194 a.nestedTools = tools
8295 a.clearCache()
96+ a.Bump()
8397}
8498
8599// AddNestedTool adds a nested tool.
@@ -90,6 +104,7 @@ func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
90104 }
91105 a.nestedTools = append(a.nestedTools, tool)
92106 a.clearCache()
107+ a.Bump()
93108}
94109
95110// AgentToolRenderContext renders agent tool messages.
@@ -201,10 +216,12 @@ func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201216 return a.nestedTools
202217}
203218
204−// SetNestedTools sets the nested tools.
219+// SetNestedTools sets the nested tools. Always bumps the version;
220+// see [AgentToolMessageItem.SetNestedTools] for the rationale.
205221func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206222 a.nestedTools = tools
207223 a.clearCache()
224+ a.Bump()
208225}
209226
210227// AddNestedTool adds a nested tool.
@@ -215,6 +232,7 @@ func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
215232 }
216233 a.nestedTools = append(a.nestedTools, tool)
217234 a.clearCache()
235+ a.Bump()
218236}
219237
220238// AgenticFetchToolRenderContext renders agentic fetch tool messages.
internal/ui/chat/applyhighlight_callback_test.gonew file+207
@@ -0,0 +1,207 @@
1+package chat
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/message"
7+ "github.com/charmbracelet/crush/internal/ui/list"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// renderCountingItem wraps a real chat item and counts Render calls
13+// to expose the list-level cache behaviour to tests. The wrapper
14+// forwards the list.Item methods exercised by this test — Render,
15+// Version, Finished — plus the list.Highlightable surface
16+// (SetHighlight / Highlight) used by the callback-driven scenario.
17+// Focus is not exercised here, so list.Focusable is not forwarded;
18+// add SetFocused/IsFocused if a future test needs to drive focus
19+// through the wrapper.
20+type renderCountingItem struct {
21+ inner MessageItem
22+ renderHits int
23+ highlightCb func(start [4]int)
24+}
25+
26+func newRenderCountingItem(inner MessageItem) *renderCountingItem {
27+ return &renderCountingItem{inner: inner}
28+}
29+
30+func (r *renderCountingItem) Render(width int) string {
31+ r.renderHits++
32+ return r.inner.Render(width)
33+}
34+
35+func (r *renderCountingItem) Version() uint64 {
36+ return r.inner.(versionedItem).Version()
37+}
38+
39+func (r *renderCountingItem) Finished() bool {
40+ return r.inner.Finished()
41+}
42+
43+// SetHighlight forwards to the embedded item; the underlying
44+// highlightableMessageItem dedupes equivalent ranges and bumps the
45+// shared version on observable change.
46+func (r *renderCountingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
47+ if h, ok := r.inner.(list.Highlightable); ok {
48+ h.SetHighlight(startLine, startCol, endLine, endCol)
49+ if r.highlightCb != nil {
50+ r.highlightCb([4]int{startLine, startCol, endLine, endCol})
51+ }
52+ }
53+}
54+
55+func (r *renderCountingItem) Highlight() (int, int, int, int) {
56+ if h, ok := r.inner.(list.Highlightable); ok {
57+ return h.Highlight()
58+ }
59+ return -1, -1, -1, -1
60+}
61+
62+// TestList_CallbackDrivenHighlightUnfreezeAndReFreeze covers F6
63+// §4.5.1 along the live applyHighlightRange path. Instead of
64+// driving BeginSelectionDrag directly, the test registers a render
65+// callback that mutates the chat items' highlight ranges (just like
66+// Chat.applyHighlightRange does in production) and verifies the
67+// resulting cache behaviour:
68+//
69+// - Items inside the active range pick up a SetHighlight call,
70+// their version bumps, the F6 cache invalidates, and the list
71+// re-renders them on the next draw. The post-render entry is
72+// frozen again because the items are Finished() — but their
73+// stored output now reflects the highlight.
74+// - Subsequent draws while the range is unchanged are cache hits:
75+// the callback's SetHighlight call dedupes (same range), the
76+// version is stable, and the list serves the previous output
77+// verbatim without calling Render.
78+// - When the range moves OFF an item, the callback clears the
79+// highlight, the version bumps, and the item re-renders. After
80+// that single re-render the entry re-freezes; further draws are
81+// cache hits.
82+func TestList_CallbackDrivenHighlightUnfreezeAndReFreeze(t *testing.T) {
83+ t.Parallel()
84+
85+ sty := styles.CharmtonePantera()
86+
87+ // Build three finished assistant messages so all three are
88+ // candidates for freezing. Real items (per Round 2 spec) — the
89+ // surrounding renderCountingItem wrapper just lets the test see
90+ // per-item Render calls.
91+ mk := func(id, body string) *renderCountingItem {
92+ msg := &message.Message{
93+ ID: id,
94+ Role: message.Assistant,
95+ Parts: []message.ContentPart{
96+ message.ReasoningContent{
97+ Thinking: "thinking",
98+ StartedAt: testStartedAt,
99+ FinishedAt: testFinishedAt,
100+ },
101+ message.TextContent{Text: body},
102+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
103+ },
104+ }
105+ inner := NewAssistantMessageItem(&sty, msg)
106+ require.True(t, inner.Finished(), "test fixture must be Finished()")
107+ return newRenderCountingItem(inner)
108+ }
109+
110+ a := mk("a", "alpha")
111+ b := mk("b", "bravo")
112+ c := mk("c", "charlie")
113+
114+ l := list.NewList(a, b, c)
115+ l.SetSize(80, 30)
116+
117+ // activeRange holds the inclusive [start, end] item indexes the
118+ // callback should highlight. -1 means no active selection.
119+ activeRange := [2]int{-1, -1}
120+
121+ cb := func(idx, _ int, item list.Item) list.Item {
122+ hi, ok := item.(list.Highlightable)
123+ if !ok {
124+ return item
125+ }
126+ if activeRange[0] >= 0 && idx >= activeRange[0] && idx <= activeRange[1] {
127+ // Inside the range: highlight the entire item.
128+ hi.SetHighlight(0, 0, -1, -1)
129+ } else {
130+ // Outside the range: clear highlight.
131+ hi.SetHighlight(-1, -1, -1, -1)
132+ }
133+ return item
134+ }
135+ l.RegisterRenderCallback(cb)
136+
137+ // First render populates the cache. Each item renders exactly
138+ // once even though the callback runs for all three.
139+ _ = l.Render()
140+ require.Equal(t, 1, a.renderHits, "first render: a renders once")
141+ require.Equal(t, 1, b.renderHits, "first render: b renders once")
142+ require.Equal(t, 1, c.renderHits, "first render: c renders once")
143+
144+ // Subsequent renders without an active range are cache hits.
145+ // The callback's SetHighlight call dedupes (already cleared),
146+ // no version bump, frozen entries served verbatim.
147+ for range 3 {
148+ _ = l.Render()
149+ }
150+ require.Equal(t, 1, a.renderHits, "frozen item must not re-render across stable draws")
151+ require.Equal(t, 1, b.renderHits, "frozen item must not re-render across stable draws")
152+ require.Equal(t, 1, c.renderHits, "frozen item must not re-render across stable draws")
153+
154+ // Activate a selection range over items a and b. The callback
155+ // will SetHighlight on both during the next render, bumping
156+ // their versions. The cache hit fails (version mismatch) and
157+ // each in-range item re-renders exactly once.
158+ activeRange = [2]int{0, 1}
159+ _ = l.Render()
160+ require.Equal(t, 2, a.renderHits, "in-range item must re-render after SetHighlight")
161+ require.Equal(t, 2, b.renderHits, "in-range item must re-render after SetHighlight")
162+ require.Equal(t, 1, c.renderHits, "out-of-range item stays frozen")
163+
164+ // Verify the highlight actually landed on the in-range items.
165+ sLine, _, eLine, _ := a.Highlight()
166+ require.Equal(t, 0, sLine)
167+ require.Equal(t, -1, eLine)
168+ sLine, _, eLine, _ = c.Highlight()
169+ require.Equal(t, -1, sLine, "out-of-range item must not be highlighted")
170+ require.Equal(t, -1, eLine)
171+
172+ // While the range stays the same, subsequent renders are cache
173+ // hits. The callback dedupes (same range), no version bump,
174+ // the post-render entry served verbatim. Note: items are
175+ // re-frozen because they're still Finished() and not in the
176+ // list's freezeSuppressed set.
177+ for range 3 {
178+ _ = l.Render()
179+ }
180+ require.Equal(t, 2, a.renderHits, "in-range item re-freezes after the highlight render")
181+ require.Equal(t, 2, b.renderHits, "in-range item re-freezes after the highlight render")
182+ require.Equal(t, 1, c.renderHits, "out-of-range item stays frozen")
183+
184+ // Move the range off the items entirely. The callback clears
185+ // each in-range item's highlight back to (-1,-1,-1,-1), which
186+ // bumps their versions and triggers exactly one re-render
187+ // each. After that, the entries re-freeze.
188+ activeRange = [2]int{-1, -1}
189+ _ = l.Render()
190+ require.Equal(t, 3, a.renderHits, "exiting-range item must re-render once when highlight clears")
191+ require.Equal(t, 3, b.renderHits, "exiting-range item must re-render once when highlight clears")
192+ require.Equal(t, 1, c.renderHits, "never-highlighted item stays frozen")
193+
194+ // Confirm the highlight has been fully cleared.
195+ sLine, _, eLine, _ = a.Highlight()
196+ require.Equal(t, -1, sLine)
197+ require.Equal(t, -1, eLine)
198+
199+ // And subsequent renders are cache hits again — the items
200+ // re-froze.
201+ for range 3 {
202+ _ = l.Render()
203+ }
204+ require.Equal(t, 3, a.renderHits, "re-frozen item must not re-render across stable draws")
205+ require.Equal(t, 3, b.renderHits, "re-frozen item must not re-render across stable draws")
206+ require.Equal(t, 1, c.renderHits, "never-highlighted item stays frozen")
207+}
internal/ui/chat/assistant.go+30−2
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/crush/internal/message"
1212 "github.com/charmbracelet/crush/internal/ui/anim"
1313 "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/list"
1415 "github.com/charmbracelet/crush/internal/ui/styles"
1516 "github.com/charmbracelet/x/ansi"
1617)
@@ -119,6 +120,7 @@ func fnvFields(fields ...[]byte) uint64 {
119120//
120121// This item includes thinking, and the content but does not include the tool calls.
121122type AssistantMessageItem struct {
123+ *list.Versioned
122124 *highlightableMessageItem
123125 *cachedMessageItem
124126 *focusableMessageItem
@@ -141,10 +143,12 @@ var _ Expandable = (*AssistantMessageItem)(nil)
141143
142144// NewAssistantMessageItem creates a new AssistantMessageItem.
143145func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
146+ v := list.NewVersioned()
144147 a := &AssistantMessageItem{
145− highlightableMessageItem: defaultHighlighter(sty),
148+ Versioned: v,
149+ highlightableMessageItem: defaultHighlighter(sty, v),
146150 cachedMessageItem: &cachedMessageItem{},
147− focusableMessageItem: &focusableMessageItem{},
151+ focusableMessageItem: newFocusableMessageItem(v),
148152 message: message,
149153 sty: sty,
150154 }
@@ -173,6 +177,13 @@ func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
173177 if !a.isSpinning() {
174178 return nil
175179 }
180+ // Bump the F6 list-cache version so the next draw re-renders
181+ // this item: a spinner tick mutates anim's internal frame
182+ // counter, which changes the rendered output but is invisible
183+ // to the per-section content hashes. Without the bump the
184+ // list cache would serve the previously rendered frame
185+ // indefinitely and the spinner would appear frozen.
186+ a.Bump()
176187 return a.anim.Animate(msg)
177188}
178189
@@ -523,6 +534,12 @@ func (a *AssistantMessageItem) isSpinning() bool {
523534func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
524535 wasSpinning := a.isSpinning()
525536 a.message = msg
537+ // Bump the F6 version even if the underlying *message.Message
538+ // pointer is identical: callers may have mutated the message in
539+ // place (delta append) and we cannot tell from here. The
540+ // per-section caches dedupe identical content via FNV-64 hashes,
541+ // so a redundant bump only costs one list-cache repopulation.
542+ a.Bump()
526543 // The prefix cache is keyed by a fingerprint that includes every
527544 // section's source hash, so an unchanged section keeps its prefix
528545 // cache valid while a changed section forces a miss naturally.
@@ -534,6 +551,16 @@ func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
534551 return nil
535552}
536553
554+// Finished implements list.Item. The assistant message is freezable
555+// once the message reports IsFinished() and is no longer spinning
556+// (no animation tick remains pending). Streaming tail animation is
557+// caught by isSpinning, so freezing only kicks in once the turn is
558+// fully terminal. The list cache invalidates the entry on the next
559+// version bump if anything (focus, highlight, expansion) changes.
560+func (a *AssistantMessageItem) Finished() bool {
561+ return a.message.IsFinished() && !a.isSpinning()
562+}
563+
537564// clearCache drops every cached render for this item, including the
538565// per-section caches. Shadows the embedded cachedMessageItem.clearCache
539566// so ClearItemCaches (style change) wipes the section caches too.
@@ -572,6 +599,7 @@ func (a *AssistantMessageItem) ToggleExpanded() bool {
572599 case thinkingFullExpanded:
573600 a.thinkingViewMode = thinkingCollapsed
574601 }
602+ a.Bump()
575603 return a.thinkingViewMode != thinkingCollapsed
576604}
577605
internal/ui/chat/messages.go+51−7
@@ -75,6 +75,12 @@ type SendMsg struct {
7575}
7676
7777type highlightableMessageItem struct {
78+ // version is the parent item's version counter. SetHighlight
79+ // bumps it on every observable change so the F6 list memo and
80+ // any frozen entry get invalidated when a selection drag enters
81+ // or leaves the item.
82+ version *list.Versioned
83+
7884 startLine int
7985 startCol int
8086 endLine int
@@ -103,13 +109,20 @@ func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, end
103109 // Adjust columns for the style's left inset (border + padding) since we
104110 // highlight the content only.
105111 offset := MessageLeftPaddingTotal
112+ newStartCol := max(0, startCol-offset)
113+ newEndCol := endCol
114+ if endCol >= 0 {
115+ newEndCol = max(0, endCol-offset)
116+ }
117+ if h.startLine == startLine && h.startCol == newStartCol && h.endLine == endLine && h.endCol == newEndCol {
118+ return
119+ }
106120 h.startLine = startLine
107− h.startCol = max(0, startCol-offset)
121+ h.startCol = newStartCol
108122 h.endLine = endLine
109− if endCol >= 0 {
110− h.endCol = max(0, endCol-offset)
111− } else {
112− h.endCol = endCol
123+ h.endCol = newEndCol
124+ if h.version != nil {
125+ h.version.Bump()
113126 }
114127}
115128
@@ -118,8 +131,9 @@ func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, end
118131 return h.startLine, h.startCol, h.endLine, h.endCol
119132}
120133
121−func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
134+func defaultHighlighter(sty *styles.Styles, v *list.Versioned) *highlightableMessageItem {
122135 return &highlightableMessageItem{
136+ version: v,
123137 startLine: -1,
124138 startCol: -1,
125139 endLine: -1,
@@ -135,12 +149,17 @@ type cacheClearable interface {
135149}
136150
137151// ClearItemCaches drops any cached rendered output on each item so the
138−// next render uses the current styles.
152+// next render uses the current styles. It also bumps each item's
153+// version so the F6 list-level memo invalidates frozen entries on
154+// the next render.
139155func ClearItemCaches(items []MessageItem) {
140156 for _, item := range items {
141157 if cc, ok := item.(cacheClearable); ok {
142158 cc.clearCache()
143159 }
160+ if v, ok := item.(interface{ Bump() }); ok {
161+ v.Bump()
162+ }
144163 }
145164}
146165
@@ -216,12 +235,28 @@ func (c *cachedMessageItem) clearCache() {
216235
217236// focusableMessageItem is a base struct for message items that can be focused.
218237type focusableMessageItem struct {
238+ // version is the parent item's version counter. SetFocused
239+ // bumps it whenever focus actually flips so the F6 list memo
240+ // invalidates the per-line focus prefix.
241+ version *list.Versioned
219242 focused bool
220243}
221244
245+// newFocusableMessageItem returns a focusableMessageItem wired to the
246+// shared version counter.
247+func newFocusableMessageItem(v *list.Versioned) *focusableMessageItem {
248+ return &focusableMessageItem{version: v}
249+}
250+
222251// SetFocused implements MessageItem.
223252func (f *focusableMessageItem) SetFocused(focused bool) {
253+ if f.focused == focused {
254+ return
255+ }
224256 f.focused = focused
257+ if f.version != nil {
258+ f.version.Bump()
259+ }
225260}
226261
227262// AssistantInfoID returns a stable ID for assistant info items.
@@ -231,6 +266,7 @@ func AssistantInfoID(messageID string) string {
231266
232267// AssistantInfoItem renders model info and response time after assistant completes.
233268type AssistantInfoItem struct {
269+ *list.Versioned
234270 *cachedMessageItem
235271
236272 id string
@@ -243,6 +279,7 @@ type AssistantInfoItem struct {
243279// NewAssistantInfoItem creates a new AssistantInfoItem.
244280func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
245281 return &AssistantInfoItem{
282+ Versioned: list.NewVersioned(),
246283 cachedMessageItem: &cachedMessageItem{},
247284 id: AssistantInfoID(message.ID),
248285 message: message,
@@ -252,6 +289,13 @@ func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *con
252289 }
253290}
254291
292+// Finished implements list.Item. Assistant info blocks render a fixed
293+// model/duration footer once the assistant turn finishes; the data
294+// is immutable after construction so the entry is safe to freeze.
295+func (a *AssistantInfoItem) Finished() bool {
296+ return true
297+}
298+
255299// ID implements MessageItem.
256300func (a *AssistantInfoItem) ID() string {
257301 return a.id
internal/ui/chat/tools.go+34−2
@@ -19,6 +19,7 @@ import (
1919 "github.com/charmbracelet/crush/internal/stringext"
2020 "github.com/charmbracelet/crush/internal/ui/anim"
2121 "github.com/charmbracelet/crush/internal/ui/common"
22+ "github.com/charmbracelet/crush/internal/ui/list"
2223 "github.com/charmbracelet/crush/internal/ui/styles"
2324 "github.com/charmbracelet/x/ansi"
2425)
@@ -135,6 +136,7 @@ func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRe
135136
136137// baseToolMessageItem represents a tool call message that can be displayed in the UI.
137138type baseToolMessageItem struct {
139+ *list.Versioned
138140 *highlightableMessageItem
139141 *cachedMessageItem
140142 *focusableMessageItem
@@ -176,10 +178,12 @@ func newBaseToolMessageItem(
176178 status = ToolStatusCanceled
177179 }
178180
181+ v := list.NewVersioned()
179182 t := &baseToolMessageItem{
180− highlightableMessageItem: defaultHighlighter(sty),
183+ Versioned: v,
184+ highlightableMessageItem: defaultHighlighter(sty, v),
181185 cachedMessageItem: &cachedMessageItem{},
182− focusableMessageItem: &focusableMessageItem{},
186+ focusableMessageItem: newFocusableMessageItem(v),
183187 sty: sty,
184188 toolRenderer: toolRenderer,
185189 toolCall: toolCall,
@@ -270,8 +274,12 @@ func NewToolMessageItem(
270274
271275// SetCompact implements the Compactable interface.
272276func (t *baseToolMessageItem) SetCompact(compact bool) {
277+ if t.isCompact == compact {
278+ return
279+ }
273280 t.isCompact = compact
274281 t.clearCache()
282+ t.Bump()
275283}
276284
277285// ID returns the unique identifier for this tool message item.
@@ -378,12 +386,14 @@ func (t *baseToolMessageItem) ToolCall() message.ToolCall {
378386func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
379387 t.toolCall = tc
380388 t.clearCache()
389+ t.Bump()
381390}
382391
383392// SetResult sets the tool result associated with this message item.
384393func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
385394 t.result = res
386395 t.clearCache()
396+ t.Bump()
387397}
388398
389399// MessageID returns the ID of the message containing this tool call.
@@ -392,14 +402,20 @@ func (t *baseToolMessageItem) MessageID() string {
392402}
393403
394404// SetMessageID sets the ID of the message containing this tool call.
405+// MessageID is metadata only and does not affect the rendered output,
406+// so we deliberately do not bump the version here.
395407func (t *baseToolMessageItem) SetMessageID(id string) {
396408 t.messageID = id
397409}
398410
399411// SetStatus sets the tool status.
400412func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
413+ if t.status == status {
414+ return
415+ }
401416 t.status = status
402417 t.clearCache()
418+ t.Bump()
403419}
404420
405421// Status returns the current tool status.
@@ -439,9 +455,25 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
439455func (t *baseToolMessageItem) ToggleExpanded() bool {
440456 t.expandedContent = !t.expandedContent
441457 t.clearCache()
458+ t.Bump()
442459 return t.expandedContent
443460}
444461
462+// Finished implements list.Item. A tool call is freezable once the
463+// tool call itself is marked finished AND a result has been recorded
464+// (or it has been canceled). Tools that override the spinning logic
465+// via spinningFunc would short-circuit live ticks; we still gate
466+// freezing on isSpinning to keep the contract conservative.
467+func (t *baseToolMessageItem) Finished() bool {
468+ if t.isSpinning() {
469+ return false
470+ }
471+ if t.status == ToolStatusCanceled {
472+ return true
473+ }
474+ return t.toolCall.Finished && t.result != nil
475+}
476+
445477// HandleMouseClick implements MouseClickable.
446478func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
447479 return btn == ansi.MouseLeft
internal/ui/chat/user.go+12−2
@@ -8,11 +8,13 @@ import (
88 "github.com/charmbracelet/crush/internal/message"
99 "github.com/charmbracelet/crush/internal/ui/attachments"
1010 "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/list"
1112 "github.com/charmbracelet/crush/internal/ui/styles"
1213)
1314
1415// UserMessageItem represents a user message in the chat UI.
1516type UserMessageItem struct {
17+ *list.Versioned
1618 *highlightableMessageItem
1719 *cachedMessageItem
1820 *focusableMessageItem
@@ -24,16 +26,24 @@ type UserMessageItem struct {
2426
2527// NewUserMessageItem creates a new UserMessageItem.
2628func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
29+ v := list.NewVersioned()
2730 return &UserMessageItem{
28− highlightableMessageItem: defaultHighlighter(sty),
31+ Versioned: v,
32+ highlightableMessageItem: defaultHighlighter(sty, v),
2933 cachedMessageItem: &cachedMessageItem{},
30− focusableMessageItem: &focusableMessageItem{},
34+ focusableMessageItem: newFocusableMessageItem(v),
3135 attachments: attachments,
3236 message: message,
3337 sty: sty,
3438 }
3539}
3640
41+// Finished implements list.Item. User messages are immutable once
42+// submitted, so the entry is always safe to freeze.
43+func (m *UserMessageItem) Finished() bool {
44+ return true
45+}
46+
3747// RawRender implements [MessageItem].
3848func (m *UserMessageItem) RawRender(width int) string {
3949 cappedWidth := cappedMessageWidth(width)
internal/ui/chat/version_bump_test.gonew file+425
@@ -0,0 +1,425 @@
1+package chat
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/anim"
10+ "github.com/charmbracelet/crush/internal/ui/attachments"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// versionedItem is the cross-cutting interface every chat item type
17+// must satisfy under F6: every documented mutator must bump the
18+// shared version counter so the list-level memo invalidates.
19+type versionedItem interface {
20+ list.Item
21+ Version() uint64
22+}
23+
24+// requireBump asserts that the supplied mutator advances the item's
25+// Version(). The mutator runs once; an absent bump is a regression
26+// (a finished item would keep serving stale frozen output to the
27+// list cache).
28+func requireBump(t *testing.T, name string, item versionedItem, mutate func()) {
29+ t.Helper()
30+ before := item.Version()
31+ mutate()
32+ after := item.Version()
33+ require.Greaterf(t, after, before, "%s must bump Version() (before=%d, after=%d)", name, before, after)
34+}
35+
36+// TestAssistantMessageItem_MutatorsBumpVersion enumerates every
37+// documented mutator on AssistantMessageItem and asserts each one
38+// advances Version().
39+func TestAssistantMessageItem_MutatorsBumpVersion(t *testing.T) {
40+ t.Parallel()
41+
42+ sty := styles.CharmtonePantera()
43+ build := func(thinking, content string) *message.Message {
44+ parts := []message.ContentPart{
45+ message.ReasoningContent{
46+ Thinking: thinking,
47+ StartedAt: testStartedAt,
48+ FinishedAt: testFinishedAt,
49+ },
50+ }
51+ if content != "" {
52+ parts = append(parts, message.TextContent{Text: content})
53+ }
54+ return &message.Message{ID: "a-mut", Role: message.Assistant, Parts: parts}
55+ }
56+
57+ item := NewAssistantMessageItem(&sty, build("thinking", "content")).(*AssistantMessageItem)
58+
59+ requireBump(t, "SetMessage", item, func() {
60+ item.SetMessage(build("thinking", "more content"))
61+ })
62+ requireBump(t, "SetFocused", item, func() {
63+ item.SetFocused(true)
64+ })
65+ requireBump(t, "SetHighlight", item, func() {
66+ item.SetHighlight(0, 0, 0, 5)
67+ })
68+ // ToggleExpanded only mutates state when there is non-empty
69+ // thinking text — which the build helper provides.
70+ requireBump(t, "ToggleExpanded", item, func() {
71+ item.ToggleExpanded()
72+ })
73+}
74+
75+// TestUserMessageItem_MutatorsBumpVersion enumerates UserMessageItem
76+// mutators.
77+func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
78+ t.Parallel()
79+
80+ sty := styles.CharmtonePantera()
81+ r := attachments.NewRenderer(
82+ sty.Attachments.Normal,
83+ sty.Attachments.Deleting,
84+ sty.Attachments.Image,
85+ sty.Attachments.Text,
86+ )
87+ msg := &message.Message{
88+ ID: "u-mut",
89+ Role: message.User,
90+ Parts: []message.ContentPart{
91+ message.TextContent{Text: "Hello"},
92+ },
93+ }
94+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
95+
96+ requireBump(t, "SetFocused", item, func() {
97+ item.SetFocused(true)
98+ })
99+ requireBump(t, "SetHighlight", item, func() {
100+ item.SetHighlight(0, 0, 0, 3)
101+ })
102+}
103+
104+// TestAssistantInfoItem_VersionedAndFinished sanity-checks the
105+// AssistantInfoItem wiring. The item carries only immutable data
106+// after construction; we still assert Version() is callable and
107+// Finished() returns true.
108+func TestAssistantInfoItem_VersionedAndFinished(t *testing.T) {
109+ t.Parallel()
110+
111+ sty := styles.CharmtonePantera()
112+ cfg := &config.Config{}
113+ msg := &message.Message{
114+ ID: "info",
115+ Role: message.Assistant,
116+ Parts: []message.ContentPart{message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()}},
117+ }
118+ item := NewAssistantInfoItem(&sty, msg, cfg, time.Unix(0, 0)).(*AssistantInfoItem)
119+
120+ require.True(t, item.Finished(), "AssistantInfoItem must be Finished()")
121+ // Version() is callable and starts at zero.
122+ require.Equal(t, uint64(0), item.Version())
123+}
124+
125+// TestBaseToolMessageItem_MutatorsBumpVersion enumerates the base
126+// tool item mutators. Specific tool types layer on top of this
127+// base; the base bumps cover the shared mutator surface.
128+func TestBaseToolMessageItem_MutatorsBumpVersion(t *testing.T) {
129+ t.Parallel()
130+
131+ sty := styles.CharmtonePantera()
132+ tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
133+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
134+
135+ v := item.(versionedItem)
136+
137+ requireBump(t, "SetFocused", v, func() {
138+ if f, ok := item.(list.Focusable); ok {
139+ f.SetFocused(true)
140+ }
141+ })
142+ requireBump(t, "SetHighlight", v, func() {
143+ if h, ok := item.(list.Highlightable); ok {
144+ h.SetHighlight(0, 0, 0, 3)
145+ }
146+ })
147+ requireBump(t, "SetToolCall", v, func() {
148+ tc2 := tc
149+ tc2.Input = `{"command":"echo"}`
150+ item.SetToolCall(tc2)
151+ })
152+ requireBump(t, "SetResult", v, func() {
153+ item.SetResult(&message.ToolResult{ToolCallID: "tc1", Content: "ok"})
154+ })
155+ requireBump(t, "SetStatus", v, func() {
156+ item.SetStatus(ToolStatusSuccess)
157+ })
158+ requireBump(t, "ToggleExpanded", v, func() {
159+ if e, ok := item.(Expandable); ok {
160+ e.ToggleExpanded()
161+ }
162+ })
163+ requireBump(t, "SetCompact", v, func() {
164+ if c, ok := item.(Compactable); ok {
165+ c.SetCompact(true)
166+ }
167+ })
168+}
169+
170+// TestAssistantMessageItem_AnimateBumpsVersion covers the spinner
171+// regression: while the assistant message is spinning, every
172+// anim.StepMsg fed through Animate must bump Version() so the
173+// list-level cache invalidates and the next draw re-renders the
174+// advanced spinner frame. Without this bump the cached entry's
175+// version stays put and the spinner appears frozen.
176+func TestAssistantMessageItem_AnimateBumpsVersion(t *testing.T) {
177+ t.Parallel()
178+
179+ sty := styles.CharmtonePantera()
180+ streaming := &message.Message{
181+ ID: "spin",
182+ Role: message.Assistant,
183+ Parts: []message.ContentPart{
184+ message.ReasoningContent{Thinking: "thinking..."},
185+ },
186+ }
187+ item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
188+
189+ requireBump(t, "Animate", item, func() {
190+ item.Animate(anim.StepMsg{})
191+ })
192+
193+ // A non-spinning item must not bump on Animate: the bump only
194+ // makes sense while the spinner is live, and a stray bump on a
195+ // finished item would needlessly invalidate frozen entries.
196+ finished := &message.Message{
197+ ID: "spin",
198+ Role: message.Assistant,
199+ Parts: []message.ContentPart{
200+ message.TextContent{Text: "done"},
201+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
202+ },
203+ }
204+ item.SetMessage(finished)
205+ require.True(t, item.Finished(), "item must report Finished() once the message finishes")
206+ before := item.Version()
207+ item.Animate(anim.StepMsg{})
208+ require.Equal(t, before, item.Version(), "Animate must not bump Version() on a non-spinning item")
209+}
210+
211+// TestAssistantMessageItem_FinishedTransition covers §4.5.1: a
212+// streaming assistant message reports Finished() == false; once the
213+// message reports IsFinished() and stops spinning, Finished() must
214+// return true.
215+func TestAssistantMessageItem_FinishedTransition(t *testing.T) {
216+ t.Parallel()
217+
218+ sty := styles.CharmtonePantera()
219+
220+ // Streaming: no finish part, no content yet — isSpinning == true.
221+ streaming := &message.Message{
222+ ID: "stream",
223+ Role: message.Assistant,
224+ Parts: []message.ContentPart{
225+ message.ReasoningContent{Thinking: "thinking..."},
226+ },
227+ }
228+ item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
229+ require.False(t, item.Finished(), "streaming assistant message must not be Finished()")
230+
231+ // Finished with content.
232+ finished := &message.Message{
233+ ID: "stream",
234+ Role: message.Assistant,
235+ Parts: []message.ContentPart{
236+ message.ReasoningContent{Thinking: "thinking", StartedAt: testStartedAt, FinishedAt: testFinishedAt},
237+ message.TextContent{Text: "the answer"},
238+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
239+ },
240+ }
241+ item.SetMessage(finished)
242+ require.True(t, item.Finished(), "finished assistant message must be Finished()")
243+}
244+
245+// TestUserMessageItem_FinishedAlwaysTrue locks in the freezable
246+// contract: user messages are never spinning.
247+func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
248+ t.Parallel()
249+
250+ sty := styles.CharmtonePantera()
251+ r := attachments.NewRenderer(
252+ sty.Attachments.Normal,
253+ sty.Attachments.Deleting,
254+ sty.Attachments.Image,
255+ sty.Attachments.Text,
256+ )
257+ msg := &message.Message{
258+ ID: "u-fin",
259+ Role: message.User,
260+ Parts: []message.ContentPart{message.TextContent{Text: "hi"}},
261+ }
262+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
263+ require.True(t, item.Finished())
264+}
265+
266+// TestAgentToolMessageItem_NestedToolMutatorsBumpVersion covers B1:
267+// the nested-tool mutators on AgentToolMessageItem must bump
268+// Version() so the list cache invalidates frozen entries when a
269+// nested tool is added or the slice changes. SetNestedTools always
270+// bumps unconditionally — the live update path in
271+// internal/ui/model/ui.go mutates existing children in place and
272+// then re-passes the same slice, so a pointer-equality dedupe would
273+// hide observable child-render changes. AddNestedTool also always
274+// observably mutates state and always bumps.
275+func TestAgentToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
276+ t.Parallel()
277+
278+ sty := styles.CharmtonePantera()
279+ parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
280+ item := NewAgentToolMessageItem(&sty, parent, nil, false)
281+
282+ mkChild := func(id string) ToolMessageItem {
283+ tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
284+ return NewToolMessageItem(&sty, "msg", tc, nil, false)
285+ }
286+
287+ // AddNestedTool always bumps.
288+ requireBump(t, "AddNestedTool", item, func() {
289+ item.AddNestedTool(mkChild("c1"))
290+ })
291+
292+ // SetNestedTools always bumps, even with a pointer-equal slice.
293+ current := append([]ToolMessageItem(nil), item.NestedTools()...)
294+ requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
295+ item.SetNestedTools(current)
296+ })
297+
298+ // SetNestedTools with a different slice (extra element) bumps.
299+ requireBump(t, "SetNestedTools[different]", item, func() {
300+ item.SetNestedTools(append(current, mkChild("c2")))
301+ })
302+
303+ // SetNestedTools to an empty slice from a non-empty state bumps.
304+ requireBump(t, "SetNestedTools[empty]", item, func() {
305+ item.SetNestedTools(nil)
306+ })
307+}
308+
309+// TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion is
310+// the agentic-fetch counterpart to the agent-tool nested mutator
311+// bump test above.
312+func TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
313+ t.Parallel()
314+
315+ sty := styles.CharmtonePantera()
316+ parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
317+ item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
318+
319+ mkChild := func(id string) ToolMessageItem {
320+ tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
321+ return NewToolMessageItem(&sty, "msg", tc, nil, false)
322+ }
323+
324+ requireBump(t, "AddNestedTool", item, func() {
325+ item.AddNestedTool(mkChild("c1"))
326+ })
327+
328+ current := append([]ToolMessageItem(nil), item.NestedTools()...)
329+ requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
330+ item.SetNestedTools(current)
331+ })
332+
333+ requireBump(t, "SetNestedTools[different]", item, func() {
334+ item.SetNestedTools(append(current, mkChild("c2")))
335+ })
336+
337+ requireBump(t, "SetNestedTools[empty]", item, func() {
338+ item.SetNestedTools(nil)
339+ })
340+}
341+
342+// TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent is
343+// the T5 regression test: it mirrors the live update flow at
344+// internal/ui/model/ui.go:1242-1281 where nested tool calls are
345+// updated in place (SetToolCall / SetResult on the same child
346+// pointers) and then the same slice is handed back to the parent
347+// via SetNestedTools. The parent must still bump its version so
348+// the list cache invalidates the parent's pre-rendered string and
349+// the freshly-rendered child output becomes visible.
350+func TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
351+ t.Parallel()
352+
353+ sty := styles.CharmtonePantera()
354+ parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
355+ item := NewAgentToolMessageItem(&sty, parent, nil, false)
356+
357+ childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
358+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
359+ item.AddNestedTool(child)
360+
361+ v0 := item.Version()
362+ childVersionBefore := child.(versionedItem).Version()
363+
364+ // In-place mutate the existing child, exactly like the live
365+ // flow in ui.go:1271-1278 does.
366+ child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
367+ require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
368+ "child SetResult must bump child version")
369+
370+ // Hand the same slice back to the parent (pointers unchanged).
371+ same := item.NestedTools()
372+ item.SetNestedTools(same)
373+ require.Greaterf(t, item.Version(), v0,
374+ "parent SetNestedTools must bump even when child pointers are unchanged (in-place child mutation invalidates parent's pre-rendered output)")
375+}
376+
377+// TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent
378+// is the agentic-fetch counterpart of the T5 regression test.
379+func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
380+ t.Parallel()
381+
382+ sty := styles.CharmtonePantera()
383+ parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
384+ item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
385+
386+ childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
387+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
388+ item.AddNestedTool(child)
389+
390+ v0 := item.Version()
391+ childVersionBefore := child.(versionedItem).Version()
392+
393+ child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
394+ require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
395+ "child SetResult must bump child version")
396+
397+ same := item.NestedTools()
398+ item.SetNestedTools(same)
399+ require.Greaterf(t, item.Version(), v0,
400+ "parent SetNestedTools must bump even when child pointers are unchanged")
401+}
402+
403+// TestBaseToolMessageItem_FinishedTransition covers §4.5.1 for
404+// tools: a still-running tool reports Finished() == false; once the
405+// tool call is marked finished and a result lands, Finished()
406+// returns true. Cancelled tools also become Finished.
407+func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
408+ t.Parallel()
409+
410+ sty := styles.CharmtonePantera()
411+ tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
412+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
413+ require.False(t, item.Finished(), "running tool must not be Finished()")
414+
415+ tcFinished := tc
416+ tcFinished.Finished = true
417+ item.SetToolCall(tcFinished)
418+ item.SetResult(&message.ToolResult{ToolCallID: "tc-fin", Content: "ok"})
419+ require.True(t, item.Finished(), "finished tool with result must be Finished()")
420+
421+ // Canceled tool with no result is also Finished.
422+ tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
423+ canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
424+ require.True(t, canceled.Finished(), "canceled tool must be Finished()")
425+}
internal/ui/completions/item.go+34−2
@@ -1,6 +1,8 @@
11package completions
22
33import (
4+ "slices"
5+
46 "charm.land/lipgloss/v2"
57 "github.com/charmbracelet/crush/internal/ui/list"
68 "github.com/charmbracelet/x/ansi"
@@ -23,6 +25,8 @@ type ResourceCompletionValue struct {
2325
2426// CompletionItem represents an item in the completions list.
2527type CompletionItem struct {
28+ *list.Versioned
29+
2630 text string
2731 value any
2832 match fuzzy.Match
@@ -38,6 +42,7 @@ type CompletionItem struct {
3842// NewCompletionItem creates a new completion item.
3943func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
4044 return &CompletionItem{
45+ Versioned: list.NewVersioned(),
4146 text: text,
4247 value: value,
4348 normalStyle: normalStyle,
@@ -46,6 +51,15 @@ func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchS
4651 }
4752}
4853
54+// Finished implements list.Item. Completion items render purely from
55+// (text, match, focus); any mutation (SetMatch / SetFocused) bumps
56+// Version() so the frozen cache entry invalidates on the next
57+// render. Marking them finished lets the F6 list memo skip the
58+// per-line work for the steady completions popup.
59+func (c *CompletionItem) Finished() bool {
60+ return true
61+}
62+
4963// Text returns the display text of the item.
5064func (c *CompletionItem) Text() string {
5165 return c.text
@@ -63,16 +77,34 @@ func (c *CompletionItem) Filter() string {
6377
6478// SetMatch implements [list.MatchSettable].
6579func (c *CompletionItem) SetMatch(m fuzzy.Match) {
80+ if sameFuzzyMatch(c.match, m) {
81+ return
82+ }
6683 c.cache = nil
6784 c.match = m
85+ c.Bump()
86+}
87+
88+// sameFuzzyMatch reports whether two fuzzy.Match values are
89+// observably equal. Because Match contains a slice (MatchedIndexes)
90+// it is not directly comparable with ==; we compare the scalar
91+// fields and then walk the indexes. SetMatch uses this to skip
92+// gratuitous version bumps when the same match is reapplied.
93+func sameFuzzyMatch(a, b fuzzy.Match) bool {
94+ return a.Str == b.Str &&
95+ a.Index == b.Index &&
96+ a.Score == b.Score &&
97+ slices.Equal(a.MatchedIndexes, b.MatchedIndexes)
6898}
6999
70100// SetFocused implements [list.Focusable].
71101func (c *CompletionItem) SetFocused(focused bool) {
72− if c.focused != focused {
73− c.cache = nil
102+ if c.focused == focused {
103+ return
74104 }
105+ c.cache = nil
75106 c.focused = focused
107+ c.Bump()
76108}
77109
78110// Render implements [list.Item].
internal/ui/completions/version_bump_test.gonew file+85
@@ -0,0 +1,85 @@
1+package completions
2+
3+import (
4+ "testing"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/sahilm/fuzzy"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// TestCompletionItem_MutatorsBumpVersion covers F6 §4.5 for the
12+// completions popup: SetMatch and SetFocused must bump Version()
13+// when the observable state changes, and dedupe (no bump) when the
14+// supplied value is identical to the current state. Without
15+// dedupe, the steady completions popup would invalidate the
16+// list-level memo on every keystroke that produced the same match.
17+func TestCompletionItem_MutatorsBumpVersion(t *testing.T) {
18+ t.Parallel()
19+
20+ mkItem := func() *CompletionItem {
21+ return NewCompletionItem(
22+ "internal/ui/chat/user.go",
23+ FileCompletionValue{Path: "internal/ui/chat/user.go"},
24+ lipgloss.NewStyle(),
25+ lipgloss.NewStyle(),
26+ lipgloss.NewStyle(),
27+ )
28+ }
29+
30+ t.Run("SetFocused", func(t *testing.T) {
31+ t.Parallel()
32+ item := mkItem()
33+
34+ // First transition (false -> true) must bump.
35+ before := item.Version()
36+ item.SetFocused(true)
37+ require.Greater(t, item.Version(), before, "SetFocused(true) must bump")
38+
39+ // Re-applying the same focus state must not bump.
40+ stable := item.Version()
41+ item.SetFocused(true)
42+ require.Equal(t, stable, item.Version(), "SetFocused with same value must not bump")
43+
44+ // Transition back must bump.
45+ item.SetFocused(false)
46+ require.Greater(t, item.Version(), stable, "SetFocused(false) must bump")
47+ })
48+
49+ t.Run("SetMatch", func(t *testing.T) {
50+ t.Parallel()
51+ item := mkItem()
52+
53+ match := fuzzy.Match{
54+ Str: "user",
55+ Index: 0,
56+ Score: 5,
57+ MatchedIndexes: []int{0, 1, 2, 3},
58+ }
59+ before := item.Version()
60+ item.SetMatch(match)
61+ require.Greater(t, item.Version(), before, "SetMatch with new value must bump")
62+
63+ // Re-applying an equivalent match (same fields, equal slice
64+ // contents) must not bump.
65+ stable := item.Version()
66+ same := fuzzy.Match{
67+ Str: "user",
68+ Index: 0,
69+ Score: 5,
70+ MatchedIndexes: []int{0, 1, 2, 3},
71+ }
72+ item.SetMatch(same)
73+ require.Equal(t, stable, item.Version(), "SetMatch with equivalent value must not bump")
74+
75+ // A different match (different MatchedIndexes) must bump.
76+ different := fuzzy.Match{
77+ Str: "user",
78+ Index: 0,
79+ Score: 5,
80+ MatchedIndexes: []int{0, 2},
81+ }
82+ item.SetMatch(different)
83+ require.Greater(t, item.Version(), stable, "SetMatch with different indexes must bump")
84+ })
85+}
internal/ui/dialog/commands_item.go+27−8
@@ -3,12 +3,14 @@ package dialog
33import (
44 "strings"
55
6+ "github.com/charmbracelet/crush/internal/ui/list"
67 "github.com/charmbracelet/crush/internal/ui/styles"
78 "github.com/sahilm/fuzzy"
89)
910
1011// CommandItem wraps a uicmd.Command to implement the ListItem interface.
1112type CommandItem struct {
13+ *list.Versioned
1214 id string
1315 title string
1416 shortcut string
@@ -20,19 +22,26 @@ type CommandItem struct {
2022 focused bool
2123}
2224
23−var _ ListItem = &CommandItem{}
25+var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
2426
2527// NewCommandItem creates a new CommandItem.
2628func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
2729 return &CommandItem{
28− id: id,
29− t: t,
30− title: title,
31− shortcut: shortcut,
32− action: action,
30+ Versioned: list.NewVersioned(),
31+ id: id,
32+ t: t,
33+ title: title,
34+ shortcut: shortcut,
35+ action: action,
3336 }
3437}
3538
39+// Finished implements list.Item. Command items are render-stable
40+// outside of explicit SetFocused / SetMatch.
41+func (c *CommandItem) Finished() bool {
42+ return true
43+}
44+
3645// WithAliases returns the CommandItem with the given aliases for filtering.
3746func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
3847 c.aliases = aliases
@@ -54,16 +63,26 @@ func (c *CommandItem) ID() string {
5463
5564// SetFocused implements ListItem.
5665func (c *CommandItem) SetFocused(focused bool) {
57− if c.focused != focused {
58− c.cache = nil
66+ if c.focused == focused {
67+ return
5968 }
69+ c.cache = nil
6070 c.focused = focused
71+ if c.Versioned != nil {
72+ c.Bump()
73+ }
6174}
6275
6376// SetMatch implements ListItem.
6477func (c *CommandItem) SetMatch(m fuzzy.Match) {
78+ if sameFuzzyMatch(c.m, m) {
79+ return
80+ }
6581 c.cache = nil
6682 c.m = m
83+ if c.Versioned != nil {
84+ c.Bump()
85+ }
6786}
6887
6988// Action returns the action associated with the command item.
internal/ui/dialog/models_item.go+29−2
@@ -5,6 +5,7 @@ import (
55 "charm.land/lipgloss/v2"
66 "github.com/charmbracelet/crush/internal/config"
77 "github.com/charmbracelet/crush/internal/ui/common"
8+ "github.com/charmbracelet/crush/internal/ui/list"
89 "github.com/charmbracelet/crush/internal/ui/styles"
910 "github.com/charmbracelet/x/ansi"
1011 "github.com/sahilm/fuzzy"
@@ -12,6 +13,7 @@ import (
1213
1314// ModelGroup represents a group of model items.
1415type ModelGroup struct {
16+ *list.Versioned
1517 Title string
1618 Items []*ModelItem
1719 configured bool
@@ -21,6 +23,7 @@ type ModelGroup struct {
2123// NewModelGroup creates a new ModelGroup.
2224func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
2325 return ModelGroup{
26+ Versioned: list.NewVersioned(),
2427 Title: title,
2528 Items: items,
2629 configured: configured,
@@ -28,6 +31,11 @@ func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*Mo
2831 }
2932}
3033
34+// Finished implements list.Item. Model groups are immutable headers.
35+func (m *ModelGroup) Finished() bool {
36+ return true
37+}
38+
3139// AppendItems appends [ModelItem]s to the group.
3240func (m *ModelGroup) AppendItems(items ...*ModelItem) {
3341 m.Items = append(m.Items, items...)
@@ -50,6 +58,8 @@ func (m *ModelGroup) Render(width int) string {
5058
5159// ModelItem represents a list item for a model type.
5260type ModelItem struct {
61+ *list.Versioned
62+
5363 prov catwalk.Provider
5464 model catwalk.Model
5565 modelType ModelType
@@ -61,6 +71,12 @@ type ModelItem struct {
6171 showProvider bool
6272}
6373
74+// Finished implements list.Item. Model items are render-stable
75+// outside of explicit SetFocused / SetMatch.
76+func (m *ModelItem) Finished() bool {
77+ return true
78+}
79+
6480// SelectedModel returns this model item as a [config.SelectedModel] instance.
6581func (m *ModelItem) SelectedModel() config.SelectedModel {
6682 return config.SelectedModel{
@@ -81,6 +97,7 @@ var _ ListItem = &ModelItem{}
8197// NewModelItem creates a new ModelItem.
8298func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
8399 return &ModelItem{
100+ Versioned: list.NewVersioned(),
84101 prov: prov,
85102 model: model,
86103 modelType: typ,
@@ -117,14 +134,24 @@ func (m *ModelItem) Render(width int) string {
117134
118135// SetFocused implements ListItem.
119136func (m *ModelItem) SetFocused(focused bool) {
120− if m.focused != focused {
121− m.cache = nil
137+ if m.focused == focused {
138+ return
122139 }
140+ m.cache = nil
123141 m.focused = focused
142+ if m.Versioned != nil {
143+ m.Bump()
144+ }
124145}
125146
126147// SetMatch implements ListItem.
127148func (m *ModelItem) SetMatch(fm fuzzy.Match) {
149+ if sameFuzzyMatch(m.m, fm) {
150+ return
151+ }
128152 m.cache = nil
129153 m.m = fm
154+ if m.Versioned != nil {
155+ m.Bump()
156+ }
130157}
internal/ui/dialog/reasoning.go+20−2
@@ -40,6 +40,7 @@ type Reasoning struct {
4040
4141// ReasoningItem represents a reasoning effort list item.
4242type ReasoningItem struct {
43+ *list.Versioned
4344 effort string
4445 title string
4546 isCurrent bool
@@ -49,6 +50,12 @@ type ReasoningItem struct {
4950 focused bool
5051}
5152
53+// Finished implements list.Item. Reasoning items are render-stable
54+// outside of explicit SetFocused / SetMatch.
55+func (r *ReasoningItem) Finished() bool {
56+ return true
57+}
58+
5259var (
5360 _ Dialog = (*Reasoning)(nil)
5461 _ ListItem = (*ReasoningItem)(nil)
@@ -243,6 +250,7 @@ func (r *Reasoning) setReasoningItems() error {
243250 selectedIndex := 0
244251 for i, effort := range model.ReasoningLevels {
245252 item := &ReasoningItem{
253+ Versioned: list.NewVersioned(),
246254 effort: effort,
247255 title: common.FormatReasoningEffort(effort),
248256 isCurrent: effort == currentEffort,
@@ -272,16 +280,26 @@ func (r *ReasoningItem) ID() string {
272280
273281// SetFocused sets the focus state of the reasoning item.
274282func (r *ReasoningItem) SetFocused(focused bool) {
275− if r.focused != focused {
276− r.cache = nil
283+ if r.focused == focused {
284+ return
277285 }
286+ r.cache = nil
278287 r.focused = focused
288+ if r.Versioned != nil {
289+ r.Bump()
290+ }
279291}
280292
281293// SetMatch sets the fuzzy match for the reasoning item.
282294func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
295+ if sameFuzzyMatch(r.m, m) {
296+ return
297+ }
283298 r.cache = nil
284299 r.m = m
300+ if r.Versioned != nil {
301+ r.Bump()
302+ }
285303}
286304
287305// Render returns the string representation of the reasoning item.
internal/ui/dialog/sessions_item.go+35−3
@@ -2,6 +2,7 @@ package dialog
22
33import (
44 "fmt"
5+ "slices"
56 "strings"
67 "time"
78
@@ -17,6 +18,19 @@ import (
1718 "github.com/sahilm/fuzzy"
1819)
1920
21+// sameFuzzyMatch reports whether two fuzzy.Match values are
22+// observably equal. Because Match contains a slice (MatchedIndexes)
23+// it is not directly comparable with ==; we compare the scalar
24+// fields and then walk the indexes. The dialog list items use this
25+// to skip gratuitous version bumps when SetMatch reapplies the same
26+// match.
27+func sameFuzzyMatch(a, b fuzzy.Match) bool {
28+ return a.Str == b.Str &&
29+ a.Index == b.Index &&
30+ a.Score == b.Score &&
31+ slices.Equal(a.MatchedIndexes, b.MatchedIndexes)
32+}
33+
2034// ListItem represents a selectable and searchable item in a dialog list.
2135type ListItem interface {
2236 list.FilterableItem
@@ -29,6 +43,7 @@ type ListItem interface {
2943
3044// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
3145type SessionItem struct {
46+ *list.Versioned
3247 session.Session
3348 t *styles.Styles
3449 sessionsMode sessionsMode
@@ -38,6 +53,13 @@ type SessionItem struct {
3853 focused bool
3954}
4055
56+// Finished implements list.Item. Session items are render-stable
57+// outside of explicit SetFocused / SetMatch calls, both of which
58+// bump Version() and therefore invalidate the F6 frozen entry.
59+func (s *SessionItem) Finished() bool {
60+ return true
61+}
62+
4163var _ ListItem = &SessionItem{}
4264
4365// Filter returns the filterable value of the session.
@@ -52,8 +74,14 @@ func (s *SessionItem) ID() string {
5274
5375// SetMatch sets the fuzzy match for the session item.
5476func (s *SessionItem) SetMatch(m fuzzy.Match) {
77+ if sameFuzzyMatch(s.m, m) {
78+ return
79+ }
5580 s.cache = nil
5681 s.m = m
82+ if s.Versioned != nil {
83+ s.Bump()
84+ }
5785}
5886
5987// InputValue returns the updated title value
@@ -177,10 +205,14 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width
177205
178206// SetFocused sets the focus state of the session item.
179207func (s *SessionItem) SetFocused(focused bool) {
180− if s.focused != focused {
181− s.cache = nil
208+ if s.focused == focused {
209+ return
182210 }
211+ s.cache = nil
183212 s.focused = focused
213+ if s.Versioned != nil {
214+ s.Bump()
215+ }
184216}
185217
186218// sessionItems takes a slice of [session.Session]s and convert them to a slice
@@ -188,7 +220,7 @@ func (s *SessionItem) SetFocused(focused bool) {
188220func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
189221 items := make([]list.FilterableItem, len(sessions))
190222 for i, s := range sessions {
191− item := &SessionItem{Session: s, t: t, sessionsMode: mode}
223+ item := &SessionItem{Versioned: list.NewVersioned(), Session: s, t: t, sessionsMode: mode}
192224 if mode == sessionsModeUpdating {
193225 item.updateTitleInput = textinput.New()
194226 item.updateTitleInput.SetVirtualCursor(false)
internal/ui/dialog/version_bump_test.gonew file+219
@@ -0,0 +1,219 @@
1+package dialog
2+
3+import (
4+ "testing"
5+
6+ "charm.land/catwalk/pkg/catwalk"
7+ "github.com/charmbracelet/crush/internal/session"
8+ "github.com/charmbracelet/crush/internal/ui/list"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/sahilm/fuzzy"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// versionedItem is the cross-cutting interface every dialog list
15+// item must satisfy under F6: every documented mutator must bump
16+// the shared version counter so the list-level memo invalidates
17+// frozen entries.
18+type versionedItem interface {
19+ list.Item
20+ Version() uint64
21+}
22+
23+// requireBump asserts that running mutate() advances the item's
24+// Version().
25+func requireBump(t *testing.T, name string, item versionedItem, mutate func()) {
26+ t.Helper()
27+ before := item.Version()
28+ mutate()
29+ after := item.Version()
30+ require.Greaterf(t, after, before, "%s must bump Version() (before=%d, after=%d)", name, before, after)
31+}
32+
33+// requireNoBump asserts that running mutate() leaves the item's
34+// Version() unchanged. Used to lock in the dedupe contract: a
35+// mutator called with a value identical to the current state must
36+// not gratuitously invalidate the list cache.
37+func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
38+ t.Helper()
39+ before := item.Version()
40+ mutate()
41+ after := item.Version()
42+ require.Equalf(t, before, after, "%s must NOT bump Version() when state is unchanged (before=%d, after=%d)", name, before, after)
43+}
44+
45+// equivMatch returns a fuzzy.Match whose fields and indexes are
46+// equivalent to the supplied seed but allocated as a fresh struct
47+// so callers exercise the value-equality dedupe path rather than
48+// referential equality.
49+func equivMatch(seed fuzzy.Match) fuzzy.Match {
50+ return fuzzy.Match{
51+ Str: seed.Str,
52+ Index: seed.Index,
53+ Score: seed.Score,
54+ MatchedIndexes: append([]int(nil), seed.MatchedIndexes...),
55+ }
56+}
57+
58+// TestCommandItem_MutatorsBumpVersion covers F6 §4.5 for the
59+// commands palette items: SetFocused and SetMatch bump Version()
60+// on observable change and dedupe otherwise.
61+func TestCommandItem_MutatorsBumpVersion(t *testing.T) {
62+ t.Parallel()
63+
64+ sty := styles.CharmtonePantera()
65+ item := NewCommandItem(&sty, "id", "Title", "ctrl+t", nil)
66+
67+ requireBump(t, "SetFocused[true]", item, func() {
68+ item.SetFocused(true)
69+ })
70+ requireNoBump(t, "SetFocused[true again]", item, func() {
71+ item.SetFocused(true)
72+ })
73+ requireBump(t, "SetFocused[false]", item, func() {
74+ item.SetFocused(false)
75+ })
76+
77+ match := fuzzy.Match{
78+ Str: "Title",
79+ Index: 0,
80+ Score: 5,
81+ MatchedIndexes: []int{0, 1, 2},
82+ }
83+ requireBump(t, "SetMatch[new]", item, func() {
84+ item.SetMatch(match)
85+ })
86+ requireNoBump(t, "SetMatch[same]", item, func() {
87+ item.SetMatch(equivMatch(match))
88+ })
89+ requireBump(t, "SetMatch[different]", item, func() {
90+ item.SetMatch(fuzzy.Match{
91+ Str: "Title",
92+ Index: 0,
93+ Score: 5,
94+ MatchedIndexes: []int{0, 2},
95+ })
96+ })
97+}
98+
99+// TestModelItem_MutatorsBumpVersion covers F6 §4.5 for the model
100+// picker items.
101+func TestModelItem_MutatorsBumpVersion(t *testing.T) {
102+ t.Parallel()
103+
104+ sty := styles.CharmtonePantera()
105+ prov := catwalk.Provider{ID: "openai", Name: "OpenAI"}
106+ model := catwalk.Model{ID: "gpt-4", Name: "GPT-4"}
107+ item := NewModelItem(&sty, prov, model, ModelTypeLarge, true)
108+
109+ requireBump(t, "SetFocused[true]", item, func() {
110+ item.SetFocused(true)
111+ })
112+ requireNoBump(t, "SetFocused[true again]", item, func() {
113+ item.SetFocused(true)
114+ })
115+
116+ match := fuzzy.Match{
117+ Str: "GPT-4",
118+ Index: 0,
119+ Score: 5,
120+ MatchedIndexes: []int{0, 1, 2},
121+ }
122+ requireBump(t, "SetMatch[new]", item, func() {
123+ item.SetMatch(match)
124+ })
125+ requireNoBump(t, "SetMatch[same]", item, func() {
126+ item.SetMatch(equivMatch(match))
127+ })
128+ requireBump(t, "SetMatch[different]", item, func() {
129+ item.SetMatch(fuzzy.Match{
130+ Str: "GPT-4",
131+ Index: 0,
132+ Score: 5,
133+ MatchedIndexes: []int{1},
134+ })
135+ })
136+}
137+
138+// TestSessionItem_MutatorsBumpVersion covers F6 §4.5 for the
139+// sessions dialog items.
140+func TestSessionItem_MutatorsBumpVersion(t *testing.T) {
141+ t.Parallel()
142+
143+ sty := styles.CharmtonePantera()
144+ item := &SessionItem{
145+ Versioned: list.NewVersioned(),
146+ Session: session.Session{ID: "sess-1", Title: "My Session"},
147+ t: &sty,
148+ }
149+
150+ requireBump(t, "SetFocused[true]", item, func() {
151+ item.SetFocused(true)
152+ })
153+ requireNoBump(t, "SetFocused[true again]", item, func() {
154+ item.SetFocused(true)
155+ })
156+
157+ match := fuzzy.Match{
158+ Str: "My Session",
159+ Index: 0,
160+ Score: 5,
161+ MatchedIndexes: []int{0, 1, 2},
162+ }
163+ requireBump(t, "SetMatch[new]", item, func() {
164+ item.SetMatch(match)
165+ })
166+ requireNoBump(t, "SetMatch[same]", item, func() {
167+ item.SetMatch(equivMatch(match))
168+ })
169+ requireBump(t, "SetMatch[different]", item, func() {
170+ item.SetMatch(fuzzy.Match{
171+ Str: "My Session",
172+ Index: 0,
173+ Score: 5,
174+ MatchedIndexes: []int{3, 4},
175+ })
176+ })
177+}
178+
179+// TestReasoningItem_MutatorsBumpVersion covers F6 §4.5 for the
180+// reasoning effort dialog items.
181+func TestReasoningItem_MutatorsBumpVersion(t *testing.T) {
182+ t.Parallel()
183+
184+ sty := styles.CharmtonePantera()
185+ item := &ReasoningItem{
186+ Versioned: list.NewVersioned(),
187+ effort: "medium",
188+ title: "Medium",
189+ t: &sty,
190+ }
191+
192+ requireBump(t, "SetFocused[true]", item, func() {
193+ item.SetFocused(true)
194+ })
195+ requireNoBump(t, "SetFocused[true again]", item, func() {
196+ item.SetFocused(true)
197+ })
198+
199+ match := fuzzy.Match{
200+ Str: "Medium",
201+ Index: 0,
202+ Score: 5,
203+ MatchedIndexes: []int{0, 1, 2},
204+ }
205+ requireBump(t, "SetMatch[new]", item, func() {
206+ item.SetMatch(match)
207+ })
208+ requireNoBump(t, "SetMatch[same]", item, func() {
209+ item.SetMatch(equivMatch(match))
210+ })
211+ requireBump(t, "SetMatch[different]", item, func() {
212+ item.SetMatch(fuzzy.Match{
213+ Str: "Medium",
214+ Index: 0,
215+ Score: 5,
216+ MatchedIndexes: []int{2, 3},
217+ })
218+ })
219+}
internal/ui/list/item.go+66−1
@@ -7,10 +7,66 @@ import (
77)
88
99// Item represents a single item in the lazy-loaded list.
10+//
11+// Items participate in the list-level render memo (F6). The cache key
12+// for each item is (pointer, width, version). Items must:
13+//
14+// - Bump their version (via the embedded *Versioned helper) on every
15+// mutation that changes the rendered output.
16+// - Return Finished() == true once their rendered output will not
17+// change again unless an explicit mutator is invoked. Frozen
18+// entries are emitted verbatim — no Render call — until either
19+// Version() bumps, the viewport width changes, or the list
20+// explicitly invalidates the entry.
1021type Item interface {
1122 // Render returns the string representation of the item for the given
1223 // width.
1324 Render(width int) string
25+
26+ // Version returns a monotonic counter that the list-level cache
27+ // uses to detect mutations. Items must increment the version
28+ // (via Versioned.Bump) on every state change that would alter
29+ // the rendered output.
30+ Version() uint64
31+
32+ // Finished reports whether the item's rendered output has reached
33+ // a terminal state and may be frozen by the list cache. Items
34+ // that animate, stream, or otherwise still mutate must return
35+ // false. A finished item that later mutates must bump its
36+ // version on the mutation; the cache treats version bumps as
37+ // implicit unfreeze + invalidate.
38+ Finished() bool
39+}
40+
41+// Versioned is a tiny embeddable helper that satisfies Item.Version()
42+// and provides a Bump() method to call from every state-mutating
43+// method. Items typically embed *Versioned alongside their other
44+// helpers; see chat.AssistantMessageItem for the canonical wiring.
45+//
46+// Bump() is not safe for concurrent use; callers must hold whatever
47+// synchronization their item type already requires for state
48+// mutations. The list itself never reads Version() from a goroutine
49+// other than the UI thread.
50+type Versioned struct {
51+ v uint64
52+}
53+
54+// NewVersioned returns a fresh *Versioned at version zero.
55+func NewVersioned() *Versioned {
56+ return &Versioned{}
57+}
58+
59+// Version returns the current version counter.
60+func (vc *Versioned) Version() uint64 {
61+ return vc.v
62+}
63+
64+// Bump advances the version counter by one. Mutators on items that
65+// affect the rendered output must call Bump exactly once per
66+// observable state change. Bumping more than once per change is
67+// harmless other than a single extra cache miss.
68+func (vc *Versioned) Bump() {
69+ vc.v++
1470}
1571
1672// RawRenderable represents an item that can provide a raw rendering
@@ -45,13 +101,15 @@ type MouseClickable interface {
45101
46102// SpacerItem is a spacer item that adds vertical space in the list.
47103type SpacerItem struct {
104+ *Versioned
48105 Height int
49106}
50107
51108// NewSpacerItem creates a new [SpacerItem] with the specified height.
52109func NewSpacerItem(height int) *SpacerItem {
53110 return &SpacerItem{
54− Height: max(0, height-1),
111+ Versioned: NewVersioned(),
112+ Height: max(0, height-1),
55113 }
56114}
57115
@@ -59,3 +117,10 @@ func NewSpacerItem(height int) *SpacerItem {
59117func (s *SpacerItem) Render(width int) string {
60118 return strings.Repeat("\n", s.Height)
61119}
120+
121+// Finished implements Item. SpacerItems are immutable in practice and
122+// safe to freeze; any mutation goes through Versioned.Bump which
123+// invalidates the frozen entry.
124+func (s *SpacerItem) Finished() bool {
125+ return true
126+}
internal/ui/list/list.go+205−17
@@ -33,9 +33,40 @@ type List struct {
3333
3434 // renderCallbacks is a list of callbacks to apply when rendering items.
3535 renderCallbacks []func(idx, selectedIdx int, item Item) Item
36+
37+ // cache is the F6 list-level render memo, keyed by item pointer.
38+ // Each entry stores the rendered content, a pre-split slice of
39+ // lines (so AtBottom / Render / VisibleItemIndices /
40+ // findItemAtY all share one render per frame), the height, and
41+ // the keys that govern invalidation (width and version). The
42+ // frozen flag mirrors §4.5.1: once a Finished() item is
43+ // rendered, subsequent draws return the stored output verbatim
44+ // without calling back into Render.
45+ cache map[Item]*listCacheEntry
46+
47+ // freezeSuppressed marks items the list must not freeze on the
48+ // next render even when their Finished() reports true. This is
49+ // the §4.5.1 selection-drag escape hatch (option (a)): items
50+ // inside an active selection range render as live items so that
51+ // per-line highlight overlays land on the latest content. Cleared
52+ // on EndSelectionDrag.
53+ freezeSuppressed map[Item]struct{}
54+}
55+
56+// listCacheEntry is the per-item entry in the list-level render memo.
57+type listCacheEntry struct {
58+ width int
59+ version uint64
60+ frozen bool
61+ content string
62+ lines []string
63+ height int
3664}
3765
38−// renderedItem holds the rendered content and height of an item.
66+// renderedItem is the legacy view of a cached entry returned by getItem.
67+// Internal callers that don't need the line slice keep using this
68+// shape; functions that walk lines (Render) take the slice off the
69+// cache entry directly.
3970type renderedItem struct {
4071 content string
4172 height int
@@ -46,6 +77,8 @@ func NewList(items ...Item) *List {
4677 l := new(List)
4778 l.items = items
4879 l.selectedIdx = -1
80+ l.cache = make(map[Item]*listCacheEntry)
81+ l.freezeSuppressed = make(map[Item]struct{})
4982 return l
5083}
5184
@@ -59,8 +92,13 @@ func (l *List) RegisterRenderCallback(cb RenderCallback) {
5992 l.renderCallbacks = append(l.renderCallbacks, cb)
6093}
6194
62−// SetSize sets the size of the list viewport.
95+// SetSize sets the size of the list viewport. A width change drops the
96+// entire render cache because every entry's wrapped output depends on
97+// width; a height-only change is a no-op for the cache.
6398func (l *List) SetSize(width, height int) {
99+ if l.width != width {
100+ l.invalidateAll()
101+ }
64102 l.width = width
65103 l.height = height
66104}
@@ -144,12 +182,42 @@ func (l *List) lastOffsetItem() (int, int, int) {
144182}
145183
146184// getItem renders (if needed) and returns the item at the given index.
185+// The result is served from the F6 cache when possible — see
186+// renderItemEntry for the cache-key semantics.
147187func (l *List) getItem(idx int) renderedItem {
148188 if idx < 0 || idx >= len(l.items) {
149189 return renderedItem{}
150190 }
191+ entry := l.renderItemEntry(idx)
192+ if entry == nil {
193+ return renderedItem{}
194+ }
195+ return renderedItem{content: entry.content, height: entry.height}
196+}
197+
198+// renderItemEntry returns the cache entry for the given index, populating
199+// the cache on miss. The result must not be retained past the next
200+// invalidation (SetSize width change, SetItems, etc.).
201+//
202+// Render callbacks always run, even for frozen entries: callbacks
203+// are how the list discovers per-frame state changes (selection,
204+// highlight range) and they bump the item's version when those
205+// changes affect the rendered output. A frozen item whose callback
206+// run is a no-op (same focus, same highlight) keeps its stored
207+// version and the cache hit is preserved on the post-callback
208+// version check.
209+func (l *List) renderItemEntry(idx int) *listCacheEntry {
210+ if idx < 0 || idx >= len(l.items) {
211+ return nil
212+ }
151213
152− item := l.items[idx]
214+ rawItem := l.items[idx]
215+ entry := l.cache[rawItem]
216+
217+ // Run render callbacks. Callbacks may mutate the item (focus,
218+ // highlight) which in turn bumps its version when state actually
219+ // changes. We capture the post-callback version below.
220+ item := rawItem
153221 if len(l.renderCallbacks) > 0 {
154222 for _, cb := range l.renderCallbacks {
155223 if it := cb(idx, l.selectedIdx, item); it != nil {
@@ -158,15 +226,128 @@ func (l *List) getItem(idx int) renderedItem {
158226 }
159227 }
160228
229+ version := rawItem.Version()
230+ if entry != nil && entry.width == l.width && entry.version == version {
231+ // Cache hit — frozen or unfrozen, the entry content is
232+ // still correct because no version bump landed since the
233+ // last render. Selection-drag suppression turns this into
234+ // a miss only if the entry is frozen.
235+ if !entry.frozen {
236+ return entry
237+ }
238+ if _, suppressed := l.freezeSuppressed[rawItem]; !suppressed {
239+ return entry
240+ }
241+ }
242+
161243 rendered := item.Render(l.width)
162244 rendered = strings.TrimRight(rendered, "\n")
163− height := strings.Count(rendered, "\n") + 1
164− ri := renderedItem{
165− content: rendered,
166− height: height,
245+ lines := strings.Split(rendered, "\n")
246+ height := len(lines)
247+
248+ // Re-read the version after Render so that any version bumps
249+ // caused by Render itself (e.g. an item that mutates internal
250+ // state during rendering) are captured. Without this we would
251+ // freeze a stale entry under the post-render version.
252+ finalVersion := rawItem.Version()
253+
254+ frozen := false
255+ if rawItem.Finished() {
256+ if _, suppressed := l.freezeSuppressed[rawItem]; !suppressed {
257+ frozen = true
258+ }
259+ }
260+
261+ if entry == nil {
262+ entry = &listCacheEntry{}
263+ l.cache[rawItem] = entry
264+ }
265+ entry.width = l.width
266+ entry.version = finalVersion
267+ entry.frozen = frozen
268+ entry.content = rendered
269+ entry.lines = lines
270+ entry.height = height
271+ return entry
272+}
273+
274+// invalidateAll drops every cache entry. Called on width changes.
275+func (l *List) invalidateAll() {
276+ for k := range l.cache {
277+ delete(l.cache, k)
167278 }
279+}
168280
169− return ri
281+// Invalidate drops the cache entry for the given item, forcing a
282+// re-render on the next getItem call. No-op if the item is not in
283+// the cache.
284+func (l *List) Invalidate(item Item) {
285+ delete(l.cache, item)
286+}
287+
288+// InvalidateFrozen drops the frozen flag (and stored content) for the
289+// given item. Equivalent to Invalidate but exposed under the F6
290+// frozen-items vocabulary so external callers can express intent.
291+func (l *List) InvalidateFrozen(item Item) {
292+ delete(l.cache, item)
293+}
294+
295+// retainCacheFor drops every cache entry whose key is not in the given
296+// item set. Used by SetItems to keep entries for stable items while
297+// dropping entries for removed ones.
298+func (l *List) retainCacheFor(items []Item) {
299+ if len(l.cache) == 0 {
300+ return
301+ }
302+ keep := make(map[Item]struct{}, len(items))
303+ for _, it := range items {
304+ keep[it] = struct{}{}
305+ }
306+ for k := range l.cache {
307+ if _, ok := keep[k]; !ok {
308+ delete(l.cache, k)
309+ }
310+ }
311+}
312+
313+// BeginSelectionDrag marks the items in the inclusive [startIdx, endIdx]
314+// range as un-freezable for the duration of an active selection drag.
315+// Frozen entries inside the range are dropped so the next render
316+// reflects live selection-overlay output. The corresponding
317+// EndSelectionDrag clears the suppression set and lets items
318+// re-freeze on their next render. Indices outside the items slice
319+// are clipped silently.
320+func (l *List) BeginSelectionDrag(startIdx, endIdx int) {
321+ if len(l.items) == 0 {
322+ return
323+ }
324+ if startIdx > endIdx {
325+ startIdx, endIdx = endIdx, startIdx
326+ }
327+ startIdx = max(startIdx, 0)
328+ endIdx = min(endIdx, len(l.items)-1)
329+ for i := startIdx; i <= endIdx; i++ {
330+ it := l.items[i]
331+ l.freezeSuppressed[it] = struct{}{}
332+ // Drop any cached frozen entry so the next render rebuilds
333+ // it as a live (un-frozen) entry that picks up the
334+ // selection overlay.
335+ if entry, ok := l.cache[it]; ok && entry.frozen {
336+ delete(l.cache, it)
337+ }
338+ }
339+}
340+
341+// EndSelectionDrag clears the selection-drag freeze suppression. Items
342+// inside the previous range will re-freeze on their next render once
343+// their Finished() reports true again.
344+func (l *List) EndSelectionDrag() {
345+ for k := range l.freezeSuppressed {
346+ delete(l.freezeSuppressed, k)
347+ // Drop the cache entry so the next render produces a clean
348+ // (un-highlighted) frozen entry.
349+ delete(l.cache, k)
350+ }
170351}
171352
172353// ScrollToIndex scrolls the list to the given item index.
@@ -288,8 +469,11 @@ func (l *List) Render() string {
288469 linesNeeded := l.height
289470
290471 for linesNeeded > 0 && currentIdx < len(l.items) {
291− item := l.getItem(currentIdx)
292− itemLines := strings.Split(item.content, "\n")
472+ entry := l.renderItemEntry(currentIdx)
473+ if entry == nil {
474+ break
475+ }
476+ itemLines := entry.lines
293477 itemHeight := len(itemLines)
294478
295479 if currentOffset >= 0 && currentOffset < itemHeight {
@@ -348,18 +532,15 @@ func (l *List) PrependItems(items ...Item) {
348532 }
349533}
350534
351−// SetItems sets the items in the list.
535+// SetItems sets the items in the list. Cache entries for items that
536+// remain after the swap are preserved; entries for removed items are
537+// dropped.
352538func (l *List) SetItems(items ...Item) {
353− l.setItems(true, items...)
354−}
355−
356−// setItems sets the items in the list. If evict is true, it clears the
357−// rendered item cache.
358−func (l *List) setItems(evict bool, items ...Item) {
359539 l.items = items
360540 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
361541 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
362542 l.offsetLine = 0
543+ l.retainCacheFor(items)
363544}
364545
365546// AppendItems appends items to the list.
@@ -373,9 +554,16 @@ func (l *List) RemoveItem(idx int) {
373554 return
374555 }
375556
557+ removed := l.items[idx]
558+
376559 // Remove the item
377560 l.items = append(l.items[:idx], l.items[idx+1:]...)
378561
562+ // Drop the cache entry for the removed item; entries for stable
563+ // items stay valid because they are keyed by pointer, not index.
564+ delete(l.cache, removed)
565+ delete(l.freezeSuppressed, removed)
566+
379567 // Adjust selection if needed
380568 if l.selectedIdx == idx {
381569 l.selectedIdx = -1
internal/ui/list/list_test.gonew file+401
@@ -0,0 +1,401 @@
1+package list
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// trackedItem is a test helper that counts Render calls. The body of
12+// Render is the item's content concatenated with the call counter so
13+// that "served from cache" vs "freshly rendered" is observable from
14+// the rendered string itself.
15+type trackedItem struct {
16+ *Versioned
17+ id string
18+ body string
19+ finished bool
20+ renderHits int
21+}
22+
23+func newTrackedItem(id, body string, finished bool) *trackedItem {
24+ return &trackedItem{
25+ Versioned: NewVersioned(),
26+ id: id,
27+ body: body,
28+ finished: finished,
29+ }
30+}
31+
32+func (t *trackedItem) Render(width int) string {
33+ t.renderHits++
34+ return t.body + ":w=" + strconv.Itoa(width)
35+}
36+
37+func (t *trackedItem) Finished() bool {
38+ return t.finished
39+}
40+
41+// TestList_RenderMemo_PointerKey covers the F6 invariant that the
42+// list-level cache is keyed by item pointer, not slice index, so
43+// PrependItems and AppendItems do not shift cached entries to the
44+// wrong item.
45+func TestList_RenderMemo_PointerKey(t *testing.T) {
46+ t.Parallel()
47+
48+ a := newTrackedItem("a", "alpha", false)
49+ b := newTrackedItem("b", "bravo", false)
50+ c := newTrackedItem("c", "charlie", false)
51+
52+ l := NewList(a, b, c)
53+ l.SetSize(40, 10)
54+
55+ // First render populates the cache for every item.
56+ first := l.Render()
57+ require.Equal(t, 1, a.renderHits)
58+ require.Equal(t, 1, b.renderHits)
59+ require.Equal(t, 1, c.renderHits)
60+
61+ // Prepending a new item must not shift the existing entries to
62+ // the wrong key. The existing items render exactly once more
63+ // only if their cache was lost, which would be a bug. Scroll to
64+ // the top so the prepended item is visible and gets rendered.
65+ z := newTrackedItem("z", "zulu", false)
66+ l.PrependItems(z)
67+ l.ScrollToTop()
68+ _ = l.Render()
69+ require.Equal(t, 1, z.renderHits, "prepended item rendered once")
70+ require.Equal(t, 1, a.renderHits, "stable item must keep its cached entry across PrependItems")
71+ require.Equal(t, 1, b.renderHits, "stable item must keep its cached entry across PrependItems")
72+ require.Equal(t, 1, c.renderHits, "stable item must keep its cached entry across PrependItems")
73+
74+ // AppendItems is symmetric.
75+ d := newTrackedItem("d", "delta", false)
76+ l.AppendItems(d)
77+ _ = l.Render()
78+ require.Equal(t, 1, d.renderHits, "appended item rendered once")
79+ require.Equal(t, 1, a.renderHits)
80+ require.Equal(t, 1, b.renderHits)
81+ require.Equal(t, 1, c.renderHits)
82+
83+ // The output is non-trivial.
84+ require.Contains(t, first, "alpha")
85+}
86+
87+// TestList_SetSize_WidthChangeInvalidates covers the F6 invariant
88+// that a width change drops every cached entry but a height-only
89+// change leaves the cache intact.
90+func TestList_SetSize_WidthChangeInvalidates(t *testing.T) {
91+ t.Parallel()
92+
93+ a := newTrackedItem("a", "alpha", false)
94+ b := newTrackedItem("b", "bravo", false)
95+
96+ l := NewList(a, b)
97+ l.SetSize(40, 10)
98+ _ = l.Render()
99+ require.Equal(t, 1, a.renderHits)
100+ require.Equal(t, 1, b.renderHits)
101+
102+ // Height-only change: no invalidation.
103+ l.SetSize(40, 20)
104+ _ = l.Render()
105+ require.Equal(t, 1, a.renderHits, "height-only change must keep cache entries")
106+ require.Equal(t, 1, b.renderHits, "height-only change must keep cache entries")
107+
108+ // Width change: every entry invalidates.
109+ l.SetSize(80, 20)
110+ _ = l.Render()
111+ require.Equal(t, 2, a.renderHits, "width change must invalidate cache entries")
112+ require.Equal(t, 2, b.renderHits, "width change must invalidate cache entries")
113+}
114+
115+// TestList_RemoveItem_DropsEntry covers the F6 invariant that
116+// RemoveItem drops the cache entry for the removed item but leaves
117+// the surviving entries in place.
118+func TestList_RemoveItem_DropsEntry(t *testing.T) {
119+ t.Parallel()
120+
121+ a := newTrackedItem("a", "alpha", false)
122+ b := newTrackedItem("b", "bravo", false)
123+ c := newTrackedItem("c", "charlie", false)
124+
125+ l := NewList(a, b, c)
126+ l.SetSize(40, 10)
127+ _ = l.Render()
128+ require.Equal(t, 1, a.renderHits)
129+ require.Equal(t, 1, b.renderHits)
130+ require.Equal(t, 1, c.renderHits)
131+
132+ l.RemoveItem(1) // remove b
133+ _ = l.Render()
134+ // a and c still cached.
135+ require.Equal(t, 1, a.renderHits, "stable item must keep cached entry across RemoveItem")
136+ require.Equal(t, 1, c.renderHits, "stable item must keep cached entry across RemoveItem")
137+ // The removed item's entry is dropped — verify by re-adding b
138+ // and confirming it renders as if fresh.
139+ l.AppendItems(b)
140+ _ = l.Render()
141+ require.Equal(t, 2, b.renderHits, "re-added item must re-render")
142+}
143+
144+// TestList_FrozenItem_NotReRendered covers §4.5.1: items that report
145+// Finished() == true on entry creation are marked frozen after the
146+// first render and are never re-rendered until width change or
147+// version bump.
148+func TestList_FrozenItem_NotReRendered(t *testing.T) {
149+ t.Parallel()
150+
151+ a := newTrackedItem("a", "alpha", true)
152+ b := newTrackedItem("b", "bravo", true)
153+
154+ l := NewList(a, b)
155+ l.SetSize(40, 10)
156+ _ = l.Render()
157+ require.Equal(t, 1, a.renderHits, "frozen items render exactly once on first draw")
158+ require.Equal(t, 1, b.renderHits, "frozen items render exactly once on first draw")
159+
160+ // Many subsequent renders must not re-render frozen items.
161+ for range 5 {
162+ _ = l.Render()
163+ }
164+ require.Equal(t, 1, a.renderHits, "frozen items must not re-render across redraws")
165+ require.Equal(t, 1, b.renderHits, "frozen items must not re-render across redraws")
166+}
167+
168+// TestList_FrozenItem_TransitionsAfterFinish covers §4.5.1: a
169+// streaming item that later reports Finished() == true transitions
170+// to frozen on the first render after finish.
171+func TestList_FrozenItem_TransitionsAfterFinish(t *testing.T) {
172+ t.Parallel()
173+
174+ a := newTrackedItem("a", "alpha", false) // streaming
175+ l := NewList(a)
176+ l.SetSize(40, 10)
177+
178+ // While unfinished, every render rebuilds the cache because the
179+ // item's Finished() is false.
180+ for range 3 {
181+ // Bump the version to simulate a streaming delta.
182+ a.Bump()
183+ _ = l.Render()
184+ }
185+ require.Equal(t, 3, a.renderHits)
186+
187+ // Item finishes; on the next render it freezes.
188+ a.finished = true
189+ a.Bump()
190+ _ = l.Render()
191+ require.Equal(t, 4, a.renderHits, "post-finish render still happens once")
192+
193+ for range 5 {
194+ _ = l.Render()
195+ }
196+ require.Equal(t, 4, a.renderHits, "frozen after finish, no further renders")
197+}
198+
199+// TestList_FrozenItem_VersionBumpUnfreezes covers §4.5.1: a frozen
200+// item that gets a version bump (unexpectedly mutated) is unfrozen
201+// and re-rendered — no stale output.
202+func TestList_FrozenItem_VersionBumpUnfreezes(t *testing.T) {
203+ t.Parallel()
204+
205+ a := newTrackedItem("a", "alpha", true)
206+ l := NewList(a)
207+ l.SetSize(40, 10)
208+
209+ _ = l.Render()
210+ _ = l.Render()
211+ require.Equal(t, 1, a.renderHits)
212+
213+ a.Bump()
214+ _ = l.Render()
215+ require.Equal(t, 2, a.renderHits, "version bump must invalidate frozen entry")
216+
217+ // Re-renders without bumping go back to cache hits.
218+ _ = l.Render()
219+ _ = l.Render()
220+ require.Equal(t, 2, a.renderHits, "post-bump render re-freezes")
221+}
222+
223+// TestList_FrozenItem_ResizeUnfreezes covers §4.5.1: resize
224+// invalidates frozen entries.
225+func TestList_FrozenItem_ResizeUnfreezes(t *testing.T) {
226+ t.Parallel()
227+
228+ a := newTrackedItem("a", "alpha", true)
229+ l := NewList(a)
230+ l.SetSize(40, 10)
231+
232+ _ = l.Render()
233+ require.Equal(t, 1, a.renderHits)
234+
235+ l.SetSize(80, 10)
236+ _ = l.Render()
237+ require.Equal(t, 2, a.renderHits, "width change must invalidate frozen entry")
238+}
239+
240+// TestList_FrozenItem_SelectionDragUnfreeze covers §4.5.1: an active
241+// selection-drag span must un-freeze items inside the range; ending
242+// the drag re-freezes them.
243+func TestList_FrozenItem_SelectionDragUnfreeze(t *testing.T) {
244+ t.Parallel()
245+
246+ a := newTrackedItem("a", "alpha", true)
247+ b := newTrackedItem("b", "bravo", true)
248+ c := newTrackedItem("c", "charlie", true)
249+
250+ l := NewList(a, b, c)
251+ l.SetSize(40, 10)
252+ _ = l.Render()
253+ require.Equal(t, 1, a.renderHits)
254+ require.Equal(t, 1, b.renderHits)
255+ require.Equal(t, 1, c.renderHits)
256+
257+ // Begin a selection drag spanning items 0..1. Items inside the
258+ // range must re-render (they re-render exactly once because
259+ // the un-freeze drops the cached entry, and the selection
260+ // suppression keeps them un-frozen until the drag ends).
261+ l.BeginSelectionDrag(0, 1)
262+ _ = l.Render()
263+ require.Equal(t, 2, a.renderHits, "drag-spanned item must re-render once on entering the drag")
264+ require.Equal(t, 2, b.renderHits, "drag-spanned item must re-render once on entering the drag")
265+ require.Equal(t, 1, c.renderHits, "out-of-range item must remain frozen")
266+
267+ // While the drag is active, items inside the range are NOT
268+ // frozen. Subsequent renders without state changes still
269+ // trigger re-renders (because version+width hit but frozen=false
270+ // also matches; we still re-use the cache — no, actually with
271+ // our implementation we DO cache unfrozen entries by version).
272+ _ = l.Render()
273+ require.Equal(t, 2, a.renderHits, "unfrozen but version-stable hits the cache")
274+ require.Equal(t, 2, b.renderHits, "unfrozen but version-stable hits the cache")
275+
276+ // End the drag. Items inside the range re-render once and
277+ // re-freeze.
278+ l.EndSelectionDrag()
279+ _ = l.Render()
280+ require.Equal(t, 3, a.renderHits, "post-drag render re-freezes the entry")
281+ require.Equal(t, 3, b.renderHits, "post-drag render re-freezes the entry")
282+
283+ // Subsequent renders are cache hits again.
284+ for range 3 {
285+ _ = l.Render()
286+ }
287+ require.Equal(t, 3, a.renderHits, "frozen after drag end")
288+ require.Equal(t, 3, b.renderHits, "frozen after drag end")
289+}
290+
291+// TestList_RenderOutputStableAcrossDraws is the F6 byte-equality
292+// invariant: rendering the same list multiple times must produce the
293+// same bytes.
294+func TestList_RenderOutputStableAcrossDraws(t *testing.T) {
295+ t.Parallel()
296+
297+ items := make([]Item, 0, 5)
298+ for i := range 5 {
299+ items = append(items, newTrackedItem(strconv.Itoa(i), "item-"+strconv.Itoa(i), i%2 == 0))
300+ }
301+ l := NewList(items...)
302+ l.SetSize(40, 20)
303+
304+ first := l.Render()
305+ for range 4 {
306+ require.Equal(t, first, l.Render(), "render output must be byte-stable across draws")
307+ }
308+ // And the output is non-trivial.
309+ require.True(t, strings.Contains(first, "item-0"))
310+}
311+
312+// TestList_SetItems_PointerOverlapRetainsCache covers F6 §4.5
313+// invalidation semantics for SetItems. When the new slice shares
314+// some pointers with the previous slice (a typical "swap a few
315+// items, keep the rest" scenario), the cache entries for the
316+// surviving items must be retained — re-rendering them would defeat
317+// the memo. Entries for the items that were removed must be
318+// dropped so they can't serve stale output if the same pointer is
319+// re-introduced later.
320+func TestList_SetItems_PointerOverlapRetainsCache(t *testing.T) {
321+ t.Parallel()
322+
323+ a := newTrackedItem("a", "alpha", false)
324+ b := newTrackedItem("b", "bravo", false)
325+ c := newTrackedItem("c", "charlie", false)
326+ d := newTrackedItem("d", "delta", false)
327+
328+ l := NewList(a, b, c)
329+ l.SetSize(40, 10)
330+ _ = l.Render()
331+ require.Equal(t, 1, a.renderHits)
332+ require.Equal(t, 1, b.renderHits)
333+ require.Equal(t, 1, c.renderHits)
334+
335+ // Replace the slice with one that shares a and c (b is dropped,
336+ // d is added). a and c must keep their cache entries; d renders
337+ // once on the next draw.
338+ l.SetItems(a, c, d)
339+ _ = l.Render()
340+ require.Equal(t, 1, a.renderHits, "stable item must keep cached entry across SetItems")
341+ require.Equal(t, 1, c.renderHits, "stable item must keep cached entry across SetItems")
342+ require.Equal(t, 1, d.renderHits, "new item renders once")
343+
344+ // Re-introducing b after it was dropped must rebuild its
345+ // entry (its previous cache entry was invalidated by SetItems).
346+ l.SetItems(a, b, c)
347+ _ = l.Render()
348+ require.Equal(t, 2, b.renderHits, "re-introduced item must re-render — its old entry was dropped")
349+ // a and c remained throughout both swaps.
350+ require.Equal(t, 1, a.renderHits, "stable item retained across multiple SetItems")
351+ require.Equal(t, 1, c.renderHits, "stable item retained across multiple SetItems")
352+}
353+
354+// TestList_SetItems_AllNewDropsEveryEntry covers F6 §4.5: when the
355+// SetItems slice has no pointer overlap with the previous slice,
356+// every cache entry from the previous slice is dropped. This is
357+// the pure-replace case (e.g. session switch).
358+func TestList_SetItems_AllNewDropsEveryEntry(t *testing.T) {
359+ t.Parallel()
360+
361+ a := newTrackedItem("a", "alpha", false)
362+ b := newTrackedItem("b", "bravo", false)
363+ c := newTrackedItem("c", "charlie", false)
364+
365+ l := NewList(a, b, c)
366+ l.SetSize(40, 10)
367+ _ = l.Render()
368+ require.Equal(t, 1, a.renderHits)
369+ require.Equal(t, 1, b.renderHits)
370+ require.Equal(t, 1, c.renderHits)
371+
372+ // Replace with a fully disjoint slice. Every entry from the
373+ // previous slice must be dropped.
374+ x := newTrackedItem("x", "xray", false)
375+ y := newTrackedItem("y", "yankee", false)
376+ l.SetItems(x, y)
377+ _ = l.Render()
378+ require.Equal(t, 1, x.renderHits, "new item renders once")
379+ require.Equal(t, 1, y.renderHits, "new item renders once")
380+
381+ // Re-introducing the originals must rebuild every entry.
382+ l.SetItems(a, b, c)
383+ _ = l.Render()
384+ require.Equal(t, 2, a.renderHits, "previously-dropped item must re-render")
385+ require.Equal(t, 2, b.renderHits, "previously-dropped item must re-render")
386+ require.Equal(t, 2, c.renderHits, "previously-dropped item must re-render")
387+}
388+
389+// TestVersioned_BumpMonotonic covers the basic Versioned contract:
390+// Version() starts at zero and Bump() advances it monotonically.
391+func TestVersioned_BumpMonotonic(t *testing.T) {
392+ t.Parallel()
393+
394+ v := NewVersioned()
395+ require.Equal(t, uint64(0), v.Version())
396+ v.Bump()
397+ require.Equal(t, uint64(1), v.Version())
398+ v.Bump()
399+ v.Bump()
400+ require.Equal(t, uint64(3), v.Version())
401+}
internal/ui/model/layout_test.go+2
@@ -20,6 +20,8 @@ type testMessageItem struct {
2020func (m testMessageItem) ID() string { return m.id }
2121func (m testMessageItem) Render(int) string { return m.text }
2222func (m testMessageItem) RawRender(int) string { return m.text }
23+func (m testMessageItem) Version() uint64 { return 0 }
24+func (m testMessageItem) Finished() bool { return true }
2325
2426var _ chat.MessageItem = testMessageItem{}
2527