on main help sign in
perf(chat): skip re-rendering chat list items that have not changed
6b101f3Christian Rocha
internal/ui/chat/agent.go+19−1
@@ -77,9 +77,23 @@ func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
77}
77}
78
78
79// SetNestedTools sets the nested tools.
79// 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.
80func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
93func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
81 a.nestedTools = tools
94 a.nestedTools = tools
82 a.clearCache()
95 a.clearCache()
96+ a.Bump()
83}
97}
84
98
85// AddNestedTool adds a nested tool.
99// AddNestedTool adds a nested tool.
@@ -90,6 +104,7 @@ func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
90 }
104 }
91 a.nestedTools = append(a.nestedTools, tool)
105 a.nestedTools = append(a.nestedTools, tool)
92 a.clearCache()
106 a.clearCache()
107+ a.Bump()
93}
108}
94
109
95// AgentToolRenderContext renders agent tool messages.
110// AgentToolRenderContext renders agent tool messages.
@@ -201,10 +216,12 @@ func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201 return a.nestedTools
216 return a.nestedTools
202}
217}
203
218
204−// SetNestedTools sets the nested tools.
219+// SetNestedTools sets the nested tools. Always bumps the version;
220+// see [AgentToolMessageItem.SetNestedTools] for the rationale.
205func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
221func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206 a.nestedTools = tools
222 a.nestedTools = tools
207 a.clearCache()
223 a.clearCache()
224+ a.Bump()
208}
225}
209
226
210// AddNestedTool adds a nested tool.
227// AddNestedTool adds a nested tool.
@@ -215,6 +232,7 @@ func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
215 }
232 }
216 a.nestedTools = append(a.nestedTools, tool)
233 a.nestedTools = append(a.nestedTools, tool)
217 a.clearCache()
234 a.clearCache()
235+ a.Bump()
218}
236}
219
237
220// AgenticFetchToolRenderContext renders agentic fetch tool messages.
238// 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 (
11 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/ui/anim"
12 "github.com/charmbracelet/crush/internal/ui/anim"
13 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
16)
17)
@@ -119,6 +120,7 @@ func fnvFields(fields ...[]byte) uint64 {
119//
120//
120// This item includes thinking, and the content but does not include the tool calls.
121// This item includes thinking, and the content but does not include the tool calls.
121type AssistantMessageItem struct {
122type AssistantMessageItem struct {
123+ *list.Versioned
122 *highlightableMessageItem
124 *highlightableMessageItem
123 *cachedMessageItem
125 *cachedMessageItem
124 *focusableMessageItem
126 *focusableMessageItem
@@ -141,10 +143,12 @@ var _ Expandable = (*AssistantMessageItem)(nil)
141
143
142// NewAssistantMessageItem creates a new AssistantMessageItem.
144// NewAssistantMessageItem creates a new AssistantMessageItem.
143func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
145func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
146+ v := list.NewVersioned()
144 a := &AssistantMessageItem{
147 a := &AssistantMessageItem{
148+ Versioned: v,
145− highlightableMessageItem: defaultHighlighter(sty),
149+ highlightableMessageItem: defaultHighlighter(sty, v),
146 cachedMessageItem: &cachedMessageItem{},
150 cachedMessageItem: &cachedMessageItem{},
147− focusableMessageItem: &focusableMessageItem{},
151+ focusableMessageItem: newFocusableMessageItem(v),
148 message: message,
152 message: message,
149 sty: sty,
153 sty: sty,
150 }
154 }
@@ -173,6 +177,13 @@ func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
173 if !a.isSpinning() {
177 if !a.isSpinning() {
174 return nil
178 return nil
175 }
179 }
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()
176 return a.anim.Animate(msg)
187 return a.anim.Animate(msg)
177}
188}
178
189
@@ -523,6 +534,12 @@ func (a *AssistantMessageItem) isSpinning() bool {
523func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
534func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
524 wasSpinning := a.isSpinning()
535 wasSpinning := a.isSpinning()
525 a.message = msg
536 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()
526 // The prefix cache is keyed by a fingerprint that includes every
543 // The prefix cache is keyed by a fingerprint that includes every
527 // section's source hash, so an unchanged section keeps its prefix
544 // section's source hash, so an unchanged section keeps its prefix
528 // cache valid while a changed section forces a miss naturally.
545 // cache valid while a changed section forces a miss naturally.
@@ -534,6 +551,16 @@ func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
534 return nil
551 return nil
535}
552}
536
553
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+
537// clearCache drops every cached render for this item, including the
564// clearCache drops every cached render for this item, including the
538// per-section caches. Shadows the embedded cachedMessageItem.clearCache
565// per-section caches. Shadows the embedded cachedMessageItem.clearCache
539// so ClearItemCaches (style change) wipes the section caches too.
566// so ClearItemCaches (style change) wipes the section caches too.
@@ -572,6 +599,7 @@ func (a *AssistantMessageItem) ToggleExpanded() bool {
572 case thinkingFullExpanded:
599 case thinkingFullExpanded:
573 a.thinkingViewMode = thinkingCollapsed
600 a.thinkingViewMode = thinkingCollapsed
574 }
601 }
602+ a.Bump()
575 return a.thinkingViewMode != thinkingCollapsed
603 return a.thinkingViewMode != thinkingCollapsed
576}
604}
577
605
internal/ui/chat/messages.go+51−7
@@ -75,6 +75,12 @@ type SendMsg struct {
75}
75}
76
76
77type highlightableMessageItem struct {
77type 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+
78 startLine int
84 startLine int
79 startCol int
85 startCol int
80 endLine int
86 endLine int
@@ -103,13 +109,20 @@ func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, end
103 // Adjust columns for the style's left inset (border + padding) since we
109 // Adjust columns for the style's left inset (border + padding) since we
104 // highlight the content only.
110 // highlight the content only.
105 offset := MessageLeftPaddingTotal
111 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+ }
106 h.startLine = startLine
120 h.startLine = startLine
107− h.startCol = max(0, startCol-offset)
121+ h.startCol = newStartCol
108 h.endLine = endLine
122 h.endLine = endLine
109− if endCol >= 0 {
123+ h.endCol = newEndCol
110− h.endCol = max(0, endCol-offset)
124+ if h.version != nil {
111− } else {
125+ h.version.Bump()
112− h.endCol = endCol
113 }
126 }
114}
127}
115
128
@@ -118,8 +131,9 @@ func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, end
118 return h.startLine, h.startCol, h.endLine, h.endCol
131 return h.startLine, h.startCol, h.endLine, h.endCol
119}
132}
120
133
121−func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
134+func defaultHighlighter(sty *styles.Styles, v *list.Versioned) *highlightableMessageItem {
122 return &highlightableMessageItem{
135 return &highlightableMessageItem{
136+ version: v,
123 startLine: -1,
137 startLine: -1,
124 startCol: -1,
138 startCol: -1,
125 endLine: -1,
139 endLine: -1,
@@ -135,12 +149,17 @@ type cacheClearable interface {
135}
149}
136
150
137// ClearItemCaches drops any cached rendered output on each item so the
151// 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.
139func ClearItemCaches(items []MessageItem) {
155func ClearItemCaches(items []MessageItem) {
140 for _, item := range items {
156 for _, item := range items {
141 if cc, ok := item.(cacheClearable); ok {
157 if cc, ok := item.(cacheClearable); ok {
142 cc.clearCache()
158 cc.clearCache()
143 }
159 }
160+ if v, ok := item.(interface{ Bump() }); ok {
161+ v.Bump()
162+ }
144 }
163 }
145}
164}
146
165
@@ -216,12 +235,28 @@ func (c *cachedMessageItem) clearCache() {
216
235
217// focusableMessageItem is a base struct for message items that can be focused.
236// focusableMessageItem is a base struct for message items that can be focused.
218type focusableMessageItem struct {
237type 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
219 focused bool
242 focused bool
220}
243}
221
244
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+
222// SetFocused implements MessageItem.
251// SetFocused implements MessageItem.
223func (f *focusableMessageItem) SetFocused(focused bool) {
252func (f *focusableMessageItem) SetFocused(focused bool) {
253+ if f.focused == focused {
254+ return
255+ }
224 f.focused = focused
256 f.focused = focused
257+ if f.version != nil {
258+ f.version.Bump()
259+ }
225}
260}
226
261
227// AssistantInfoID returns a stable ID for assistant info items.
262// AssistantInfoID returns a stable ID for assistant info items.
@@ -231,6 +266,7 @@ func AssistantInfoID(messageID string) string {
231
266
232// AssistantInfoItem renders model info and response time after assistant completes.
267// AssistantInfoItem renders model info and response time after assistant completes.
233type AssistantInfoItem struct {
268type AssistantInfoItem struct {
269+ *list.Versioned
234 *cachedMessageItem
270 *cachedMessageItem
235
271
236 id string
272 id string
@@ -243,6 +279,7 @@ type AssistantInfoItem struct {
243// NewAssistantInfoItem creates a new AssistantInfoItem.
279// NewAssistantInfoItem creates a new AssistantInfoItem.
244func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
280func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
245 return &AssistantInfoItem{
281 return &AssistantInfoItem{
282+ Versioned: list.NewVersioned(),
246 cachedMessageItem: &cachedMessageItem{},
283 cachedMessageItem: &cachedMessageItem{},
247 id: AssistantInfoID(message.ID),
284 id: AssistantInfoID(message.ID),
248 message: message,
285 message: message,
@@ -252,6 +289,13 @@ func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *con
252 }
289 }
253}
290}
254
291
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+
255// ID implements MessageItem.
299// ID implements MessageItem.
256func (a *AssistantInfoItem) ID() string {
300func (a *AssistantInfoItem) ID() string {
257 return a.id
301 return a.id
internal/ui/chat/tools.go+34−2
@@ -19,6 +19,7 @@ import (
19 "github.com/charmbracelet/crush/internal/stringext"
19 "github.com/charmbracelet/crush/internal/stringext"
20 "github.com/charmbracelet/crush/internal/ui/anim"
20 "github.com/charmbracelet/crush/internal/ui/anim"
21 "github.com/charmbracelet/crush/internal/ui/common"
21 "github.com/charmbracelet/crush/internal/ui/common"
22+ "github.com/charmbracelet/crush/internal/ui/list"
22 "github.com/charmbracelet/crush/internal/ui/styles"
23 "github.com/charmbracelet/crush/internal/ui/styles"
23 "github.com/charmbracelet/x/ansi"
24 "github.com/charmbracelet/x/ansi"
24)
25)
@@ -135,6 +136,7 @@ func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRe
135
136
136// baseToolMessageItem represents a tool call message that can be displayed in the UI.
137// baseToolMessageItem represents a tool call message that can be displayed in the UI.
137type baseToolMessageItem struct {
138type baseToolMessageItem struct {
139+ *list.Versioned
138 *highlightableMessageItem
140 *highlightableMessageItem
139 *cachedMessageItem
141 *cachedMessageItem
140 *focusableMessageItem
142 *focusableMessageItem
@@ -176,10 +178,12 @@ func newBaseToolMessageItem(
176 status = ToolStatusCanceled
178 status = ToolStatusCanceled
177 }
179 }
178
180
181+ v := list.NewVersioned()
179 t := &baseToolMessageItem{
182 t := &baseToolMessageItem{
183+ Versioned: v,
180− highlightableMessageItem: defaultHighlighter(sty),
184+ highlightableMessageItem: defaultHighlighter(sty, v),
181 cachedMessageItem: &cachedMessageItem{},
185 cachedMessageItem: &cachedMessageItem{},
182− focusableMessageItem: &focusableMessageItem{},
186+ focusableMessageItem: newFocusableMessageItem(v),
183 sty: sty,
187 sty: sty,
184 toolRenderer: toolRenderer,
188 toolRenderer: toolRenderer,
185 toolCall: toolCall,
189 toolCall: toolCall,
@@ -270,8 +274,12 @@ func NewToolMessageItem(
270
274
271// SetCompact implements the Compactable interface.
275// SetCompact implements the Compactable interface.
272func (t *baseToolMessageItem) SetCompact(compact bool) {
276func (t *baseToolMessageItem) SetCompact(compact bool) {
277+ if t.isCompact == compact {
278+ return
279+ }
273 t.isCompact = compact
280 t.isCompact = compact
274 t.clearCache()
281 t.clearCache()
282+ t.Bump()
275}
283}
276
284
277// ID returns the unique identifier for this tool message item.
285// ID returns the unique identifier for this tool message item.
@@ -378,12 +386,14 @@ func (t *baseToolMessageItem) ToolCall() message.ToolCall {
378func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
386func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
379 t.toolCall = tc
387 t.toolCall = tc
380 t.clearCache()
388 t.clearCache()
389+ t.Bump()
381}
390}
382
391
383// SetResult sets the tool result associated with this message item.
392// SetResult sets the tool result associated with this message item.
384func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
393func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
385 t.result = res
394 t.result = res
386 t.clearCache()
395 t.clearCache()
396+ t.Bump()
387}
397}
388
398
389// MessageID returns the ID of the message containing this tool call.
399// MessageID returns the ID of the message containing this tool call.
@@ -392,14 +402,20 @@ func (t *baseToolMessageItem) MessageID() string {
392}
402}
393
403
394// SetMessageID sets the ID of the message containing this tool call.
404// 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.
395func (t *baseToolMessageItem) SetMessageID(id string) {
407func (t *baseToolMessageItem) SetMessageID(id string) {
396 t.messageID = id
408 t.messageID = id
397}
409}
398
410
399// SetStatus sets the tool status.
411// SetStatus sets the tool status.
400func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
412func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
413+ if t.status == status {
414+ return
415+ }
401 t.status = status
416 t.status = status
402 t.clearCache()
417 t.clearCache()
418+ t.Bump()
403}
419}
404
420
405// Status returns the current tool status.
421// Status returns the current tool status.
@@ -439,9 +455,25 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
439func (t *baseToolMessageItem) ToggleExpanded() bool {
455func (t *baseToolMessageItem) ToggleExpanded() bool {
440 t.expandedContent = !t.expandedContent
456 t.expandedContent = !t.expandedContent
441 t.clearCache()
457 t.clearCache()
458+ t.Bump()
442 return t.expandedContent
459 return t.expandedContent
443}
460}
444
461
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+
445// HandleMouseClick implements MouseClickable.
477// HandleMouseClick implements MouseClickable.
446func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
478func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
447 return btn == ansi.MouseLeft
479 return btn == ansi.MouseLeft
internal/ui/chat/user.go+12−2
@@ -8,11 +8,13 @@ import (
8 "github.com/charmbracelet/crush/internal/message"
8 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/ui/attachments"
9 "github.com/charmbracelet/crush/internal/ui/attachments"
10 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/list"
11 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
12)
13)
13
14
14// UserMessageItem represents a user message in the chat UI.
15// UserMessageItem represents a user message in the chat UI.
15type UserMessageItem struct {
16type UserMessageItem struct {
17+ *list.Versioned
16 *highlightableMessageItem
18 *highlightableMessageItem
17 *cachedMessageItem
19 *cachedMessageItem
18 *focusableMessageItem
20 *focusableMessageItem
@@ -24,16 +26,24 @@ type UserMessageItem struct {
24
26
25// NewUserMessageItem creates a new UserMessageItem.
27// NewUserMessageItem creates a new UserMessageItem.
26func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
28func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
29+ v := list.NewVersioned()
27 return &UserMessageItem{
30 return &UserMessageItem{
31+ Versioned: v,
28− highlightableMessageItem: defaultHighlighter(sty),
32+ highlightableMessageItem: defaultHighlighter(sty, v),
29 cachedMessageItem: &cachedMessageItem{},
33 cachedMessageItem: &cachedMessageItem{},
30− focusableMessageItem: &focusableMessageItem{},
34+ focusableMessageItem: newFocusableMessageItem(v),
31 attachments: attachments,
35 attachments: attachments,
32 message: message,
36 message: message,
33 sty: sty,
37 sty: sty,
34 }
38 }
35}
39}
36
40
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+
37// RawRender implements [MessageItem].
47// RawRender implements [MessageItem].
38func (m *UserMessageItem) RawRender(width int) string {
48func (m *UserMessageItem) RawRender(width int) string {
39 cappedWidth := cappedMessageWidth(width)
49 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 @@
1package completions
1package completions
2
2
3import (
3import (
4+ "slices"
5+
4 "charm.land/lipgloss/v2"
6 "charm.land/lipgloss/v2"
5 "github.com/charmbracelet/crush/internal/ui/list"
7 "github.com/charmbracelet/crush/internal/ui/list"
6 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
@@ -23,6 +25,8 @@ type ResourceCompletionValue struct {
23
25
24// CompletionItem represents an item in the completions list.
26// CompletionItem represents an item in the completions list.
25type CompletionItem struct {
27type CompletionItem struct {
28+ *list.Versioned
29+
26 text string
30 text string
27 value any
31 value any
28 match fuzzy.Match
32 match fuzzy.Match
@@ -38,6 +42,7 @@ type CompletionItem struct {
38// NewCompletionItem creates a new completion item.
42// NewCompletionItem creates a new completion item.
39func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
43func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
40 return &CompletionItem{
44 return &CompletionItem{
45+ Versioned: list.NewVersioned(),
41 text: text,
46 text: text,
42 value: value,
47 value: value,
43 normalStyle: normalStyle,
48 normalStyle: normalStyle,
@@ -46,6 +51,15 @@ func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchS
46 }
51 }
47}
52}
48
53
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+
49// Text returns the display text of the item.
63// Text returns the display text of the item.
50func (c *CompletionItem) Text() string {
64func (c *CompletionItem) Text() string {
51 return c.text
65 return c.text
@@ -63,16 +77,34 @@ func (c *CompletionItem) Filter() string {
63
77
64// SetMatch implements [list.MatchSettable].
78// SetMatch implements [list.MatchSettable].
65func (c *CompletionItem) SetMatch(m fuzzy.Match) {
79func (c *CompletionItem) SetMatch(m fuzzy.Match) {
80+ if sameFuzzyMatch(c.match, m) {
81+ return
82+ }
66 c.cache = nil
83 c.cache = nil
67 c.match = m
84 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)
68}
98}
69
99
70// SetFocused implements [list.Focusable].
100// SetFocused implements [list.Focusable].
71func (c *CompletionItem) SetFocused(focused bool) {
101func (c *CompletionItem) SetFocused(focused bool) {
72− if c.focused != focused {
102+ if c.focused == focused {
73− c.cache = nil
103+ return
74 }
104 }
105+ c.cache = nil
75 c.focused = focused
106 c.focused = focused
107+ c.Bump()
76}
108}
77
109
78// Render implements [list.Item].
110// 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
3import (
3import (
4 "strings"
4 "strings"
5
5
6+ "github.com/charmbracelet/crush/internal/ui/list"
6 "github.com/charmbracelet/crush/internal/ui/styles"
7 "github.com/charmbracelet/crush/internal/ui/styles"
7 "github.com/sahilm/fuzzy"
8 "github.com/sahilm/fuzzy"
8)
9)
9
10
10// CommandItem wraps a uicmd.Command to implement the ListItem interface.
11// CommandItem wraps a uicmd.Command to implement the ListItem interface.
11type CommandItem struct {
12type CommandItem struct {
13+ *list.Versioned
12 id string
14 id string
13 title string
15 title string
14 shortcut string
16 shortcut string
@@ -20,19 +22,26 @@ type CommandItem struct {
20 focused bool
22 focused bool
21}
23}
22
24
23−var _ ListItem = &CommandItem{}
25+var _ ListItem = &CommandItem{Versioned: list.NewVersioned()}
24
26
25// NewCommandItem creates a new CommandItem.
27// NewCommandItem creates a new CommandItem.
26func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
28func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
27 return &CommandItem{
29 return &CommandItem{
28− id: id,
30+ Versioned: list.NewVersioned(),
31+ id: id,
29− t: t,
32+ t: t,
30− title: title,
33+ title: title,
31− shortcut: shortcut,
34+ shortcut: shortcut,
32− action: action,
35+ action: action,
33 }
36 }
34}
37}
35
38
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+
36// WithAliases returns the CommandItem with the given aliases for filtering.
45// WithAliases returns the CommandItem with the given aliases for filtering.
37func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
46func (c *CommandItem) WithAliases(aliases ...string) *CommandItem {
38 c.aliases = aliases
47 c.aliases = aliases
@@ -54,16 +63,26 @@ func (c *CommandItem) ID() string {
54
63
55// SetFocused implements ListItem.
64// SetFocused implements ListItem.
56func (c *CommandItem) SetFocused(focused bool) {
65func (c *CommandItem) SetFocused(focused bool) {
57− if c.focused != focused {
66+ if c.focused == focused {
58− c.cache = nil
67+ return
59 }
68 }
69+ c.cache = nil
60 c.focused = focused
70 c.focused = focused
71+ if c.Versioned != nil {
72+ c.Bump()
73+ }
61}
74}
62
75
63// SetMatch implements ListItem.
76// SetMatch implements ListItem.
64func (c *CommandItem) SetMatch(m fuzzy.Match) {
77func (c *CommandItem) SetMatch(m fuzzy.Match) {
78+ if sameFuzzyMatch(c.m, m) {
79+ return
80+ }
65 c.cache = nil
81 c.cache = nil
66 c.m = m
82 c.m = m
83+ if c.Versioned != nil {
84+ c.Bump()
85+ }
67}
86}
68
87
69// Action returns the action associated with the command item.
88// Action returns the action associated with the command item.
internal/ui/dialog/models_item.go+29−2
@@ -5,6 +5,7 @@ import (
5 "charm.land/lipgloss/v2"
5 "charm.land/lipgloss/v2"
6 "github.com/charmbracelet/crush/internal/config"
6 "github.com/charmbracelet/crush/internal/config"
7 "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/crush/internal/ui/common"
8+ "github.com/charmbracelet/crush/internal/ui/list"
8 "github.com/charmbracelet/crush/internal/ui/styles"
9 "github.com/charmbracelet/crush/internal/ui/styles"
9 "github.com/charmbracelet/x/ansi"
10 "github.com/charmbracelet/x/ansi"
10 "github.com/sahilm/fuzzy"
11 "github.com/sahilm/fuzzy"
@@ -12,6 +13,7 @@ import (
12
13
13// ModelGroup represents a group of model items.
14// ModelGroup represents a group of model items.
14type ModelGroup struct {
15type ModelGroup struct {
16+ *list.Versioned
15 Title string
17 Title string
16 Items []*ModelItem
18 Items []*ModelItem
17 configured bool
19 configured bool
@@ -21,6 +23,7 @@ type ModelGroup struct {
21// NewModelGroup creates a new ModelGroup.
23// NewModelGroup creates a new ModelGroup.
22func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
24func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
23 return ModelGroup{
25 return ModelGroup{
26+ Versioned: list.NewVersioned(),
24 Title: title,
27 Title: title,
25 Items: items,
28 Items: items,
26 configured: configured,
29 configured: configured,
@@ -28,6 +31,11 @@ func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*Mo
28 }
31 }
29}
32}
30
33
34+// Finished implements list.Item. Model groups are immutable headers.
35+func (m *ModelGroup) Finished() bool {
36+ return true
37+}
38+
31// AppendItems appends [ModelItem]s to the group.
39// AppendItems appends [ModelItem]s to the group.
32func (m *ModelGroup) AppendItems(items ...*ModelItem) {
40func (m *ModelGroup) AppendItems(items ...*ModelItem) {
33 m.Items = append(m.Items, items...)
41 m.Items = append(m.Items, items...)
@@ -50,6 +58,8 @@ func (m *ModelGroup) Render(width int) string {
50
58
51// ModelItem represents a list item for a model type.
59// ModelItem represents a list item for a model type.
52type ModelItem struct {
60type ModelItem struct {
61+ *list.Versioned
62+
53 prov catwalk.Provider
63 prov catwalk.Provider
54 model catwalk.Model
64 model catwalk.Model
55 modelType ModelType
65 modelType ModelType
@@ -61,6 +71,12 @@ type ModelItem struct {
61 showProvider bool
71 showProvider bool
62}
72}
63
73
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+
64// SelectedModel returns this model item as a [config.SelectedModel] instance.
80// SelectedModel returns this model item as a [config.SelectedModel] instance.
65func (m *ModelItem) SelectedModel() config.SelectedModel {
81func (m *ModelItem) SelectedModel() config.SelectedModel {
66 return config.SelectedModel{
82 return config.SelectedModel{
@@ -81,6 +97,7 @@ var _ ListItem = &ModelItem{}
81// NewModelItem creates a new ModelItem.
97// NewModelItem creates a new ModelItem.
82func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
98func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
83 return &ModelItem{
99 return &ModelItem{
100+ Versioned: list.NewVersioned(),
84 prov: prov,
101 prov: prov,
85 model: model,
102 model: model,
86 modelType: typ,
103 modelType: typ,
@@ -117,14 +134,24 @@ func (m *ModelItem) Render(width int) string {
117
134
118// SetFocused implements ListItem.
135// SetFocused implements ListItem.
119func (m *ModelItem) SetFocused(focused bool) {
136func (m *ModelItem) SetFocused(focused bool) {
120− if m.focused != focused {
137+ if m.focused == focused {
121− m.cache = nil
138+ return
122 }
139 }
140+ m.cache = nil
123 m.focused = focused
141 m.focused = focused
142+ if m.Versioned != nil {
143+ m.Bump()
144+ }
124}
145}
125
146
126// SetMatch implements ListItem.
147// SetMatch implements ListItem.
127func (m *ModelItem) SetMatch(fm fuzzy.Match) {
148func (m *ModelItem) SetMatch(fm fuzzy.Match) {
149+ if sameFuzzyMatch(m.m, fm) {
150+ return
151+ }
128 m.cache = nil
152 m.cache = nil
129 m.m = fm
153 m.m = fm
154+ if m.Versioned != nil {
155+ m.Bump()
156+ }
130}
157}
internal/ui/dialog/reasoning.go+20−2
@@ -40,6 +40,7 @@ type Reasoning struct {
40
40
41// ReasoningItem represents a reasoning effort list item.
41// ReasoningItem represents a reasoning effort list item.
42type ReasoningItem struct {
42type ReasoningItem struct {
43+ *list.Versioned
43 effort string
44 effort string
44 title string
45 title string
45 isCurrent bool
46 isCurrent bool
@@ -49,6 +50,12 @@ type ReasoningItem struct {
49 focused bool
50 focused bool
50}
51}
51
52
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+
52var (
59var (
53 _ Dialog = (*Reasoning)(nil)
60 _ Dialog = (*Reasoning)(nil)
54 _ ListItem = (*ReasoningItem)(nil)
61 _ ListItem = (*ReasoningItem)(nil)
@@ -243,6 +250,7 @@ func (r *Reasoning) setReasoningItems() error {
243 selectedIndex := 0
250 selectedIndex := 0
244 for i, effort := range model.ReasoningLevels {
251 for i, effort := range model.ReasoningLevels {
245 item := &ReasoningItem{
252 item := &ReasoningItem{
253+ Versioned: list.NewVersioned(),
246 effort: effort,
254 effort: effort,
247 title: common.FormatReasoningEffort(effort),
255 title: common.FormatReasoningEffort(effort),
248 isCurrent: effort == currentEffort,
256 isCurrent: effort == currentEffort,
@@ -272,16 +280,26 @@ func (r *ReasoningItem) ID() string {
272
280
273// SetFocused sets the focus state of the reasoning item.
281// SetFocused sets the focus state of the reasoning item.
274func (r *ReasoningItem) SetFocused(focused bool) {
282func (r *ReasoningItem) SetFocused(focused bool) {
275− if r.focused != focused {
283+ if r.focused == focused {
276− r.cache = nil
284+ return
277 }
285 }
286+ r.cache = nil
278 r.focused = focused
287 r.focused = focused
288+ if r.Versioned != nil {
289+ r.Bump()
290+ }
279}
291}
280
292
281// SetMatch sets the fuzzy match for the reasoning item.
293// SetMatch sets the fuzzy match for the reasoning item.
282func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
294func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
295+ if sameFuzzyMatch(r.m, m) {
296+ return
297+ }
283 r.cache = nil
298 r.cache = nil
284 r.m = m
299 r.m = m
300+ if r.Versioned != nil {
301+ r.Bump()
302+ }
285}
303}
286
304
287// Render returns the string representation of the reasoning item.
305// Render returns the string representation of the reasoning item.
internal/ui/dialog/sessions_item.go+35−3
@@ -2,6 +2,7 @@ package dialog
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "slices"
5 "strings"
6 "strings"
6 "time"
7 "time"
7
8
@@ -17,6 +18,19 @@ import (
17 "github.com/sahilm/fuzzy"
18 "github.com/sahilm/fuzzy"
18)
19)
19
20
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+
20// ListItem represents a selectable and searchable item in a dialog list.
34// ListItem represents a selectable and searchable item in a dialog list.
21type ListItem interface {
35type ListItem interface {
22 list.FilterableItem
36 list.FilterableItem
@@ -29,6 +43,7 @@ type ListItem interface {
29
43
30// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
44// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
31type SessionItem struct {
45type SessionItem struct {
46+ *list.Versioned
32 session.Session
47 session.Session
33 t *styles.Styles
48 t *styles.Styles
34 sessionsMode sessionsMode
49 sessionsMode sessionsMode
@@ -38,6 +53,13 @@ type SessionItem struct {
38 focused bool
53 focused bool
39}
54}
40
55
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+
41var _ ListItem = &SessionItem{}
63var _ ListItem = &SessionItem{}
42
64
43// Filter returns the filterable value of the session.
65// Filter returns the filterable value of the session.
@@ -52,8 +74,14 @@ func (s *SessionItem) ID() string {
52
74
53// SetMatch sets the fuzzy match for the session item.
75// SetMatch sets the fuzzy match for the session item.
54func (s *SessionItem) SetMatch(m fuzzy.Match) {
76func (s *SessionItem) SetMatch(m fuzzy.Match) {
77+ if sameFuzzyMatch(s.m, m) {
78+ return
79+ }
55 s.cache = nil
80 s.cache = nil
56 s.m = m
81 s.m = m
82+ if s.Versioned != nil {
83+ s.Bump()
84+ }
57}
85}
58
86
59// InputValue returns the updated title value
87// InputValue returns the updated title value
@@ -177,10 +205,14 @@ func renderItem(t ListItemStyles, title string, info string, focused bool, width
177
205
178// SetFocused sets the focus state of the session item.
206// SetFocused sets the focus state of the session item.
179func (s *SessionItem) SetFocused(focused bool) {
207func (s *SessionItem) SetFocused(focused bool) {
180− if s.focused != focused {
208+ if s.focused == focused {
181− s.cache = nil
209+ return
182 }
210 }
211+ s.cache = nil
183 s.focused = focused
212 s.focused = focused
213+ if s.Versioned != nil {
214+ s.Bump()
215+ }
184}
216}
185
217
186// sessionItems takes a slice of [session.Session]s and convert them to a slice
218// sessionItems takes a slice of [session.Session]s and convert them to a slice
@@ -188,7 +220,7 @@ func (s *SessionItem) SetFocused(focused bool) {
188func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
220func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem {
189 items := make([]list.FilterableItem, len(sessions))
221 items := make([]list.FilterableItem, len(sessions))
190 for i, s := range sessions {
222 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}
192 if mode == sessionsModeUpdating {
224 if mode == sessionsModeUpdating {
193 item.updateTitleInput = textinput.New()
225 item.updateTitleInput = textinput.New()
194 item.updateTitleInput.SetVirtualCursor(false)
226 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 (
7)
7)
8
8
9// Item represents a single item in the lazy-loaded list.
9// 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.
10type Item interface {
21type Item interface {
11 // Render returns the string representation of the item for the given
22 // Render returns the string representation of the item for the given
12 // width.
23 // width.
13 Render(width int) string
24 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++
14}
70}
15
71
16// RawRenderable represents an item that can provide a raw rendering
72// RawRenderable represents an item that can provide a raw rendering
@@ -45,13 +101,15 @@ type MouseClickable interface {
45
101
46// SpacerItem is a spacer item that adds vertical space in the list.
102// SpacerItem is a spacer item that adds vertical space in the list.
47type SpacerItem struct {
103type SpacerItem struct {
104+ *Versioned
48 Height int
105 Height int
49}
106}
50
107
51// NewSpacerItem creates a new [SpacerItem] with the specified height.
108// NewSpacerItem creates a new [SpacerItem] with the specified height.
52func NewSpacerItem(height int) *SpacerItem {
109func NewSpacerItem(height int) *SpacerItem {
53 return &SpacerItem{
110 return &SpacerItem{
111+ Versioned: NewVersioned(),
54− Height: max(0, height-1),
112+ Height: max(0, height-1),
55 }
113 }
56}
114}
57
115
@@ -59,3 +117,10 @@ func NewSpacerItem(height int) *SpacerItem {
59func (s *SpacerItem) Render(width int) string {
117func (s *SpacerItem) Render(width int) string {
60 return strings.Repeat("\n", s.Height)
118 return strings.Repeat("\n", s.Height)
61}
119}
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 {
33
33
34 // renderCallbacks is a list of callbacks to apply when rendering items.
34 // renderCallbacks is a list of callbacks to apply when rendering items.
35 renderCallbacks []func(idx, selectedIdx int, item Item) Item
35 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
36}
64}
37
65
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.
39type renderedItem struct {
70type renderedItem struct {
40 content string
71 content string
41 height int
72 height int
@@ -46,6 +77,8 @@ func NewList(items ...Item) *List {
46 l := new(List)
77 l := new(List)
47 l.items = items
78 l.items = items
48 l.selectedIdx = -1
79 l.selectedIdx = -1
80+ l.cache = make(map[Item]*listCacheEntry)
81+ l.freezeSuppressed = make(map[Item]struct{})
49 return l
82 return l
50}
83}
51
84
@@ -59,8 +92,13 @@ func (l *List) RegisterRenderCallback(cb RenderCallback) {
59 l.renderCallbacks = append(l.renderCallbacks, cb)
92 l.renderCallbacks = append(l.renderCallbacks, cb)
60}
93}
61
94
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.
63func (l *List) SetSize(width, height int) {
98func (l *List) SetSize(width, height int) {
99+ if l.width != width {
100+ l.invalidateAll()
101+ }
64 l.width = width
102 l.width = width
65 l.height = height
103 l.height = height
66}
104}
@@ -144,12 +182,42 @@ func (l *List) lastOffsetItem() (int, int, int) {
144}
182}
145
183
146// getItem renders (if needed) and returns the item at the given index.
184// 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.
147func (l *List) getItem(idx int) renderedItem {
187func (l *List) getItem(idx int) renderedItem {
148 if idx < 0 || idx >= len(l.items) {
188 if idx < 0 || idx >= len(l.items) {
149 return renderedItem{}
189 return renderedItem{}
150 }
190 }
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+ }
151
213
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
153 if len(l.renderCallbacks) > 0 {
221 if len(l.renderCallbacks) > 0 {
154 for _, cb := range l.renderCallbacks {
222 for _, cb := range l.renderCallbacks {
155 if it := cb(idx, l.selectedIdx, item); it != nil {
223 if it := cb(idx, l.selectedIdx, item); it != nil {
@@ -158,15 +226,128 @@ func (l *List) getItem(idx int) renderedItem {
158 }
226 }
159 }
227 }
160
228
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+
161 rendered := item.Render(l.width)
243 rendered := item.Render(l.width)
162 rendered = strings.TrimRight(rendered, "\n")
244 rendered = strings.TrimRight(rendered, "\n")
163− height := strings.Count(rendered, "\n") + 1
245+ lines := strings.Split(rendered, "\n")
164− ri := renderedItem{
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
165− content: rendered,
268+ entry.content = rendered
269+ entry.lines = lines
166− height: height,
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)
167 }
278 }
279+}
168
280
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 {
169− return ri
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+ }
170}
351}
171
352
172// ScrollToIndex scrolls the list to the given item index.
353// ScrollToIndex scrolls the list to the given item index.
@@ -288,8 +469,11 @@ func (l *List) Render() string {
288 linesNeeded := l.height
469 linesNeeded := l.height
289
470
290 for linesNeeded > 0 && currentIdx < len(l.items) {
471 for linesNeeded > 0 && currentIdx < len(l.items) {
291− item := l.getItem(currentIdx)
472+ entry := l.renderItemEntry(currentIdx)
292− itemLines := strings.Split(item.content, "\n")
473+ if entry == nil {
474+ break
475+ }
476+ itemLines := entry.lines
293 itemHeight := len(itemLines)
477 itemHeight := len(itemLines)
294
478
295 if currentOffset >= 0 && currentOffset < itemHeight {
479 if currentOffset >= 0 && currentOffset < itemHeight {
@@ -348,18 +532,15 @@ func (l *List) PrependItems(items ...Item) {
348 }
532 }
349}
533}
350
534
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.
352func (l *List) SetItems(items ...Item) {
538func (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) {
359 l.items = items
539 l.items = items
360 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
540 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
361 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
541 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
362 l.offsetLine = 0
542 l.offsetLine = 0
543+ l.retainCacheFor(items)
363}
544}
364
545
365// AppendItems appends items to the list.
546// AppendItems appends items to the list.
@@ -373,9 +554,16 @@ func (l *List) RemoveItem(idx int) {
373 return
554 return
374 }
555 }
375
556
557+ removed := l.items[idx]
558+
376 // Remove the item
559 // Remove the item
377 l.items = append(l.items[:idx], l.items[idx+1:]...)
560 l.items = append(l.items[:idx], l.items[idx+1:]...)
378
561
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+
379 // Adjust selection if needed
567 // Adjust selection if needed
380 if l.selectedIdx == idx {
568 if l.selectedIdx == idx {
381 l.selectedIdx = -1
569 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 {
20func (m testMessageItem) ID() string { return m.id }
20func (m testMessageItem) ID() string { return m.id }
21func (m testMessageItem) Render(int) string { return m.text }
21func (m testMessageItem) Render(int) string { return m.text }
22func (m testMessageItem) RawRender(int) string { return m.text }
22func (m testMessageItem) RawRender(int) string { return m.text }
23+func (m testMessageItem) Version() uint64 { return 0 }
24+func (m testMessageItem) Finished() bool { return true }
23
25
24var _ chat.MessageItem = testMessageItem{}
26var _ chat.MessageItem = testMessageItem{}
25
27