on main help sign in
Merge pull request #2908 from charmbracelet/fast-ui
4686002Christian Rocha
internal/agent/agent.go+14
@@ -245,6 +245,15 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
245
245
246 defer cancel()
246 defer cancel()
247 defer a.activeRequests.Del(call.SessionID)
247 defer a.activeRequests.Del(call.SessionID)
248+ // Drain any debounced message updates before returning. message.Service
249+ // already flushes synchronously on terminal updates, but a defer here
250+ // guarantees the contract at every Run exit (success, error, panic
251+ // recovery upstream) without callers needing to know.
252+ defer func() {
253+ if flushErr := a.messages.FlushAll(ctx); flushErr != nil {
254+ slog.Error("Failed to flush pending message updates after run", "error", flushErr)
255+ }
256+ }()
248
257
249 history, files := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages, call.Attachments...)
258 history, files := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages, call.Attachments...)
250
259
@@ -653,6 +662,11 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
653 a.activeRequests.Set(sessionID, cancel)
662 a.activeRequests.Set(sessionID, cancel)
654 defer a.activeRequests.Del(sessionID)
663 defer a.activeRequests.Del(sessionID)
655 defer cancel()
664 defer cancel()
665+ defer func() {
666+ if flushErr := a.messages.FlushAll(ctx); flushErr != nil {
667+ slog.Error("Failed to flush pending message updates after summarize", "error", flushErr)
668+ }
669+ }()
656
670
657 agent := fantasy.NewAgent(largeModel.Model,
671 agent := fantasy.NewAgent(largeModel.Model,
658 fantasy.WithSystemPrompt(string(summaryPrompt)),
672 fantasy.WithSystemPrompt(string(summaryPrompt)),
internal/app/app.go+13−3
@@ -584,13 +584,23 @@ func (app *App) Shutdown() {
584 app.AgentCoordinator.CancelAll()
584 app.AgentCoordinator.CancelAll()
585 }
585 }
586
586
587− // Now run remaining cleanup tasks in parallel.
588− var wg sync.WaitGroup
589−
590 // Shared shutdown context for all timeout-bounded cleanup.
587 // Shared shutdown context for all timeout-bounded cleanup.
591 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
588 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
592 defer cancel()
589 defer cancel()
593
590
591+ // Drain any debounced message updates before the DB-close cleanup
592+ // runs in the parallel block below. message.Service buffers
593+ // streaming deltas (see internal/message/message.go) and we must
594+ // land them while the connection is still open.
595+ if app.Messages != nil {
596+ if err := app.Messages.FlushAll(shutdownCtx); err != nil {
597+ slog.Error("Failed to flush pending message updates on shutdown", "error", err)
598+ }
599+ }
600+
601+ // Now run remaining cleanup tasks in parallel.
602+ var wg sync.WaitGroup
603+
594 // Send exit event
604 // Send exit event
595 wg.Go(func() {
605 wg.Go(func() {
596 event.AppExited()
606 event.AppExited()
internal/backend/session.go+6
@@ -72,6 +72,12 @@ func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionI
72 return nil, err
72 return nil, err
73 }
73 }
74
74
75+ // Drain debounced updates so HTTP clients (and the TUI on session
76+ // switch) observe the latest in-memory state rather than racing the
77+ // debounce timer in message.Service.
78+ if err := ws.Messages.FlushAll(ctx); err != nil {
79+ return nil, err
80+ }
75 return ws.Messages.List(ctx, sessionID)
81 return ws.Messages.List(ctx, sessionID)
76}
82}
77
83
internal/message/message.go+325−16
@@ -5,6 +5,7 @@ import (
5 "database/sql"
5 "database/sql"
6 "encoding/json"
6 "encoding/json"
7 "fmt"
7 "fmt"
8+ "sync"
8 "time"
9 "time"
9
10
10 "github.com/charmbracelet/crush/internal/db"
11 "github.com/charmbracelet/crush/internal/db"
@@ -12,6 +13,13 @@ import (
12 "github.com/google/uuid"
13 "github.com/google/uuid"
13)
14)
14
15
16+// defaultUpdateDebounce is the default debounce window for [Service.Update].
17+// Streaming deltas that arrive within the window are coalesced into a
18+// single SQL write and a single pubsub event. Terminal updates
19+// (finish/error/cancel/tool-call structural changes) bypass the
20+// debounce and flush synchronously.
21+const defaultUpdateDebounce = 33 * time.Millisecond
22+
15type CreateMessageParams struct {
23type CreateMessageParams struct {
16 Role MessageRole
24 Role MessageRole
17 Parts []ContentPart
25 Parts []ContentPart
@@ -20,6 +28,21 @@ type CreateMessageParams struct {
20 IsSummaryMessage bool
28 IsSummaryMessage bool
21}
29}
22
30
31+// Service is the public interface to the message store.
32+//
33+// [Service.Update] is eventually consistent: it accepts new state into
34+// an in-memory buffer and writes it to SQLite plus publishes a
35+// [pubsub.UpdatedEvent] on the next debounce tick (default
36+// [defaultUpdateDebounce]) or on the next terminal-state update,
37+// whichever comes first. Terminal-state updates — those that finish
38+// the message, add or finish a tool call, or end a reasoning section —
39+// flush synchronously before [Service.Update] returns.
40+//
41+// Callers that need stronger ordering (e.g. tests, shutdown,
42+// session-switch reads) must use [Service.Flush] or [Service.FlushAll]
43+// before reading via [Service.Get] / [Service.List]. Without an
44+// explicit flush, a read can race the debounce timer and miss the
45+// most recent in-memory state.
23type Service interface {
46type Service interface {
24 pubsub.Subscriber[Message]
47 pubsub.Subscriber[Message]
25 Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
48 Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
@@ -30,20 +53,87 @@ type Service interface {
30 ListAllUserMessages(ctx context.Context) ([]Message, error)
53 ListAllUserMessages(ctx context.Context) ([]Message, error)
31 Delete(ctx context.Context, id string) error
54 Delete(ctx context.Context, id string) error
32 DeleteSessionMessages(ctx context.Context, sessionID string) error
55 DeleteSessionMessages(ctx context.Context, sessionID string) error
56+
57+ // Flush synchronously drains any pending debounced state for the
58+ // given message ID, performs the SQL write, and publishes the
59+ // resulting [pubsub.UpdatedEvent]. Idempotent; cheap no-op if no
60+ // updates are pending. Use this before any read that must observe
61+ // the latest [Service.Update].
62+ Flush(ctx context.Context, id string) error
63+
64+ // FlushAll synchronously drains pending debounced state for every
65+ // message known to the service. Intended for shutdown and
66+ // session-switch paths.
67+ FlushAll(ctx context.Context) error
68+}
69+
70+// pendingState holds the in-memory coalescing buffer for a single
71+// message ID. All fields except where noted are guarded by
72+// service.mu. The flushing flag serializes concurrent flushers for
73+// the same ID so SQL writes never reorder.
74+type pendingState struct {
75+ // latest is the most recent [Message] passed to [Service.Update]
76+ // that has not yet been flushed.
77+ latest Message
78+
79+ // dirty is true when latest contains state that has not been
80+ // written to SQL since the last successful flush.
81+ dirty bool
82+
83+ // flushing is true while a goroutine is performing the SQL write
84+ // for this ID. New updates are still accepted (and re-mark dirty)
85+ // but other flushers must back off.
86+ flushing bool
87+
88+ // timer is the active debounce timer, or nil if no flush is
89+ // scheduled. Stopped and reset when a terminal update preempts
90+ // the debounce window.
91+ timer *time.Timer
92+
93+ // lastFlushed is the snapshot most recently written to SQL. Used
94+ // as the baseline for terminal-state detection.
95+ lastFlushed Message
96+
97+ // hasFlushed is false until the first successful write for this
98+ // ID; until then lastFlushed is the zero value and must not be
99+ // treated as a real prior state.
100+ hasFlushed bool
33}
101}
34
102
35type service struct {
103type service struct {
36 *pubsub.Broker[Message]
104 *pubsub.Broker[Message]
37− q db.Querier
105+ q db.Querier
106+ debounce time.Duration
107+
108+ mu sync.Mutex
109+ pending map[string]*pendingState
38}
110}
39
111
40−func NewService(q db.Querier) Service {
112+// ServiceOption configures a [Service] at construction.
113+type ServiceOption func(*service)
114+
115+// WithDebounce overrides the debounce window for [Service.Update]. A
116+// zero or negative value disables debouncing entirely (every update
117+// flushes synchronously). Intended primarily for tests.
118+func WithDebounce(d time.Duration) ServiceOption {
41− return &service{
119+ return func(s *service) {
42− Broker: pubsub.NewBroker[Message](),
120+ s.debounce = d
43− q: q,
44 }
121 }
45}
122}
46
123
124+func NewService(q db.Querier, opts ...ServiceOption) Service {
125+ s := &service{
126+ Broker: pubsub.NewBroker[Message](),
127+ q: q,
128+ debounce: defaultUpdateDebounce,
129+ pending: make(map[string]*pendingState),
130+ }
131+ for _, opt := range opts {
132+ opt(s)
133+ }
134+ return s
135+}
136+
47func (s *service) Delete(ctx context.Context, id string) error {
137func (s *service) Delete(ctx context.Context, id string) error {
48 message, err := s.Get(ctx, id)
138 message, err := s.Get(ctx, id)
49 if err != nil {
139 if err != nil {
@@ -53,6 +143,16 @@ func (s *service) Delete(ctx context.Context, id string) error {
53 if err != nil {
143 if err != nil {
54 return err
144 return err
55 }
145 }
146+ // Drop any pending coalesced state for this ID. We never want to
147+ // flush back over a deleted row.
148+ s.mu.Lock()
149+ if p, ok := s.pending[id]; ok {
150+ if p.timer != nil {
151+ p.timer.Stop()
152+ }
153+ delete(s.pending, id)
154+ }
155+ s.mu.Unlock()
56 // Clone the message before publishing to avoid race conditions with
156 // Clone the message before publishing to avoid race conditions with
57 // concurrent modifications to the Parts slice.
157 // concurrent modifications to the Parts slice.
58 s.Publish(pubsub.DeletedEvent, message.Clone())
158 s.Publish(pubsub.DeletedEvent, message.Clone())
@@ -111,31 +211,240 @@ func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) e
111 return nil
211 return nil
112}
212}
113
213
214+// Update accepts a new state for a message and either flushes
215+// synchronously (terminal updates, debounce <= 0) or buffers it until
216+// the next debounce tick. See [Service] for the contract.
114−func (s *service) Update(ctx context.Context, message Message) error {
217+func (s *service) Update(ctx context.Context, msg Message) error {
218+ cloned := msg.Clone()
219+
220+ // Zero or negative debounce: flush every update synchronously. This
221+ // preserves the pre-coalescing behaviour for tests and any caller
222+ // that explicitly opted out via [WithDebounce].
223+ if s.debounce <= 0 {
224+ s.mu.Lock()
225+ p, ok := s.pending[msg.ID]
226+ if !ok {
227+ p = &pendingState{}
228+ s.pending[msg.ID] = p
229+ }
230+ p.latest = cloned
231+ p.dirty = true
232+ s.mu.Unlock()
233+ return s.flushOne(ctx, msg.ID, true)
234+ }
235+
236+ s.mu.Lock()
237+ p, ok := s.pending[msg.ID]
238+ if !ok {
239+ p = &pendingState{}
240+ s.pending[msg.ID] = p
241+ }
242+ p.latest = cloned
243+ p.dirty = true
244+
245+ var prev *Message
246+ if p.hasFlushed {
247+ prev = &p.lastFlushed
248+ }
249+ terminal := shouldFlushNow(prev, &cloned)
250+
251+ if terminal {
252+ if p.timer != nil {
253+ p.timer.Stop()
254+ p.timer = nil
255+ }
256+ s.mu.Unlock()
257+ return s.flushOne(ctx, msg.ID, true)
258+ }
259+
260+ // Debounce: schedule a single flush per pending state. If a flush
261+ // is already running we let it finish; the trailing dirty bit will
262+ // be picked up by the next Update or by Flush.
263+ if p.timer == nil && !p.flushing {
264+ id := msg.ID
265+ p.timer = time.AfterFunc(s.debounce, func() {
266+ // Detached from caller ctx so a cancelled stream context
267+ // does not strand the buffered write.
268+ _ = s.flushOne(context.Background(), id, false)
269+ })
270+ }
271+ s.mu.Unlock()
272+ return nil
273+}
274+
275+// Flush implements [Service.Flush].
276+func (s *service) Flush(ctx context.Context, id string) error {
277+ return s.flushOne(ctx, id, true)
278+}
279+
280+// FlushAll implements [Service.FlushAll]. It snapshots every ID with
281+// outstanding work — either dirty buffered state or a flush already in
282+// flight — then drains each one. Picking up in-flight IDs ensures
283+// FlushAll cannot return while a timer-fired write is still mid-SQL,
284+// which is what shutdown and session-switch callers rely on.
285+func (s *service) FlushAll(ctx context.Context) error {
286+ s.mu.Lock()
287+ ids := make([]string, 0, len(s.pending))
288+ for id, p := range s.pending {
289+ if p.dirty || p.flushing {
290+ ids = append(ids, id)
291+ }
292+ }
293+ s.mu.Unlock()
294+ var firstErr error
295+ for _, id := range ids {
296+ if err := s.flushOne(ctx, id, true); err != nil && firstErr == nil {
297+ firstErr = err
298+ }
299+ }
300+ return firstErr
301+}
302+
303+// flushOne drains a single message ID. When syncCaller is true the
304+// caller is willing to wait through a concurrent in-flight flush so
305+// that, on return, lastFlushed equals latest at the moment of return.
306+// When false (timer-fired path) we bail if another flusher is already
307+// running; that flusher will pick up the trailing dirty bit.
308+//
309+// Order matters: a sync caller must wait for any in-flight flush to
310+// drain even when the buffer is currently clean — that in-flight
311+// write has not yet updated the SQL row, so returning early would
312+// violate the contract that on success lastFlushed reflects the most
313+// recent state.
314+func (s *service) flushOne(ctx context.Context, id string, syncCaller bool) error {
315+ for {
316+ s.mu.Lock()
317+ p, ok := s.pending[id]
318+ if !ok {
319+ s.mu.Unlock()
320+ return nil
321+ }
322+ if p.flushing {
323+ if !syncCaller {
324+ s.mu.Unlock()
325+ return nil
326+ }
327+ s.mu.Unlock()
328+ // Brief yield; in-flight write should land in <1ms typical.
329+ time.Sleep(time.Millisecond)
330+ continue
331+ }
332+ if !p.dirty {
333+ s.mu.Unlock()
334+ return nil
335+ }
336+
337+ if p.timer != nil {
338+ p.timer.Stop()
339+ p.timer = nil
340+ }
341+ snap := p.latest
342+ // Decide whether this snapshot represents a terminal event
343+ // against the prior baseline. We must do this before resetting
344+ // dirty/flushing because shouldFlushNow looks at p.lastFlushed
345+ // (which is what was on disk before this write).
346+ var prev *Message
347+ if p.hasFlushed {
348+ prev = &p.lastFlushed
349+ }
350+ isTerminal := shouldFlushNow(prev, &snap)
351+ p.flushing = true
352+ p.dirty = false
353+ s.mu.Unlock()
354+
355+ err := s.write(ctx, snap)
356+
357+ s.mu.Lock()
358+ p.flushing = false
359+ if err == nil {
360+ p.lastFlushed = snap
361+ p.hasFlushed = true
362+ } else {
363+ // Restore dirty so the next caller retries.
364+ p.dirty = true
365+ }
366+ // If a delta arrived during the SQL write and we are a sync
367+ // caller, the user expects that delta to land too.
368+ wasDirty := p.dirty
369+ s.mu.Unlock()
370+
371+ if err != nil {
372+ return err
373+ }
374+
375+ // Terminal events — message finished, tool call added or
376+ // finished, reasoning ended — use the bounded must-deliver
377+ // path so they never get dropped under channel contention.
378+ if isTerminal {
379+ s.PublishMustDeliver(ctx, pubsub.UpdatedEvent, snap)
380+ } else {
381+ s.Publish(pubsub.UpdatedEvent, snap)
382+ }
383+
384+ if wasDirty && syncCaller {
385+ continue
386+ }
387+ return nil
388+ }
389+}
390+
391+// write performs the unguarded SQL write + UpdatedAt stamp. Caller
392+// owns publishing.
393+func (s *service) write(ctx context.Context, msg Message) error {
115− parts, err := marshalParts(message.Parts)
394+ parts, err := marshalParts(msg.Parts)
116 if err != nil {
395 if err != nil {
117 return err
396 return err
118 }
397 }
119 finishedAt := sql.NullInt64{}
398 finishedAt := sql.NullInt64{}
120− if f := message.FinishPart(); f != nil {
399+ if f := msg.FinishPart(); f != nil {
121 finishedAt.Int64 = f.Time
400 finishedAt.Int64 = f.Time
122 finishedAt.Valid = true
401 finishedAt.Valid = true
123 }
402 }
124− err = s.q.UpdateMessage(ctx, db.UpdateMessageParams{
403+ if err := s.q.UpdateMessage(ctx, db.UpdateMessageParams{
125− ID: message.ID,
404+ ID: msg.ID,
126 Parts: string(parts),
405 Parts: string(parts),
127 FinishedAt: finishedAt,
406 FinishedAt: finishedAt,
128− })
129− if err != nil {
407+ }); err != nil {
130 return err
408 return err
131 }
409 }
132− message.UpdatedAt = time.Now().Unix()
133− // Clone the message before publishing to avoid race conditions with
134− // concurrent modifications to the Parts slice.
135− s.Publish(pubsub.UpdatedEvent, message.Clone())
136 return nil
410 return nil
137}
411}
138
412
413+// shouldFlushNow returns true when next represents a structural
414+// change that must not be silently coalesced: the message just
415+// finished, the tool-call set grew, a tool call transitioned to
416+// finished, or reasoning just finished. prev is the last-flushed
417+// snapshot (or nil if no write has landed yet).
418+func shouldFlushNow(prev, next *Message) bool {
419+ if next.IsFinished() {
420+ return true
421+ }
422+
423+ var prevCalls []ToolCall
424+ var prevReasoningFinishedAt int64
425+ if prev != nil {
426+ prevCalls = prev.ToolCalls()
427+ prevReasoningFinishedAt = prev.ReasoningContent().FinishedAt
428+ }
429+ nextCalls := next.ToolCalls()
430+ if len(nextCalls) != len(prevCalls) {
431+ return true
432+ }
433+ for i := range nextCalls {
434+ // Bounds-safe: lengths are equal here.
435+ if nextCalls[i].Finished != prevCalls[i].Finished {
436+ return true
437+ }
438+ // A tool call's input only matters once it has landed (Finished
439+ // flips true). Earlier deltas to Input are debounced with the
440+ // rest of the streaming state.
441+ }
442+ if next.ReasoningContent().FinishedAt > 0 && prevReasoningFinishedAt == 0 {
443+ return true
444+ }
445+ return false
446+}
447+
139func (s *service) Get(ctx context.Context, id string) (Message, error) {
448func (s *service) Get(ctx context.Context, id string) (Message, error) {
140 dbMessage, err := s.q.GetMessage(ctx, id)
449 dbMessage, err := s.q.GetMessage(ctx, id)
141 if err != nil {
450 if err != nil {
internal/message/message_test.gonew file+695
@@ -0,0 +1,695 @@
1+package message
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/charmbracelet/crush/internal/db"
11+ "github.com/charmbracelet/crush/internal/pubsub"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// slowUpdateQuerier wraps a [db.Querier] and forces UpdateMessage to
17+// hang on a release channel. Used to simulate an in-flight SQL write.
18+type slowUpdateQuerier struct {
19+ db.Querier
20+ release chan struct{}
21+ started chan struct{}
22+ startOnce sync.Once
23+}
24+
25+func (s *slowUpdateQuerier) UpdateMessage(ctx context.Context, arg db.UpdateMessageParams) error {
26+ s.startOnce.Do(func() { close(s.started) })
27+ select {
28+ case <-s.release:
29+ case <-ctx.Done():
30+ return ctx.Err()
31+ }
32+ return s.Querier.UpdateMessage(ctx, arg)
33+}
34+
35+// newTestService spins up a fresh in-memory message.Service backed by a
36+// temporary on-disk SQLite database. Returns the service plus a session
37+// ID to attach messages to.
38+func newTestService(t *testing.T, opts ...ServiceOption) (Service, string) {
39+ t.Helper()
40+ conn, err := db.Connect(t.Context(), t.TempDir())
41+ require.NoError(t, err)
42+ t.Cleanup(func() { _ = conn.Close() })
43+
44+ q := db.New(conn)
45+ sessions := session.NewService(q, conn)
46+ sess, err := sessions.Create(t.Context(), "test")
47+ require.NoError(t, err)
48+
49+ svc := NewService(q, opts...)
50+ return svc, sess.ID
51+}
52+
53+// eventCollector consumes broker events into a slice in a goroutine
54+// and exposes thread-safe Snapshot / Reset helpers for assertions.
55+type eventCollector struct {
56+ mu sync.Mutex
57+ events []pubsub.Event[Message]
58+}
59+
60+func collect(ctx context.Context, sub <-chan pubsub.Event[Message]) *eventCollector {
61+ c := &eventCollector{}
62+ go func() {
63+ for {
64+ select {
65+ case <-ctx.Done():
66+ return
67+ case ev, ok := <-sub:
68+ if !ok {
69+ return
70+ }
71+ c.mu.Lock()
72+ c.events = append(c.events, ev)
73+ c.mu.Unlock()
74+ }
75+ }
76+ }()
77+ return c
78+}
79+
80+func (c *eventCollector) snapshot() []pubsub.Event[Message] {
81+ c.mu.Lock()
82+ defer c.mu.Unlock()
83+ out := make([]pubsub.Event[Message], len(c.events))
84+ copy(out, c.events)
85+ return out
86+}
87+
88+func (c *eventCollector) reset() {
89+ c.mu.Lock()
90+ defer c.mu.Unlock()
91+ c.events = nil
92+}
93+
94+func TestUpdate_DebouncesTextDeltas(t *testing.T) {
95+ t.Parallel()
96+
97+ // Long-enough debounce that we can verify nothing flushes prematurely.
98+ svc, sessionID := newTestService(t, WithDebounce(50*time.Millisecond))
99+
100+ subCtx, cancelSub := context.WithCancel(t.Context())
101+ defer cancelSub()
102+ sub := svc.Subscribe(subCtx)
103+ collector := collect(subCtx, sub)
104+
105+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{
106+ Role: Assistant,
107+ })
108+ require.NoError(t, err)
109+ // Drop the CreatedEvent emitted by Create.
110+ time.Sleep(5 * time.Millisecond)
111+ collector.reset()
112+
113+ // Push 5 deltas inside a single debounce window.
114+ for i := 0; i < 5; i++ {
115+ msg.AppendContent("a")
116+ require.NoError(t, svc.Update(t.Context(), msg))
117+ }
118+
119+ // Before the debounce expires no UpdatedEvent should have landed.
120+ time.Sleep(10 * time.Millisecond)
121+ require.Empty(t, collector.snapshot(), "no events should land before debounce window expires")
122+
123+ // Wait for the debounce timer to fire.
124+ require.Eventually(t, func() bool {
125+ return len(collector.snapshot()) >= 1
126+ }, time.Second, 5*time.Millisecond)
127+ events := collector.snapshot()
128+ require.Len(t, events, 1, "5 deltas should coalesce into 1 UpdatedEvent")
129+ require.Equal(t, pubsub.UpdatedEvent, events[0].Type)
130+ require.Equal(t, "aaaaa", events[0].Payload.Content().Text)
131+
132+ // Final state must be persisted.
133+ got, err := svc.Get(t.Context(), msg.ID)
134+ require.NoError(t, err)
135+ require.Equal(t, "aaaaa", got.Content().Text)
136+}
137+
138+func TestUpdate_TerminalUpdatesFlushSynchronously(t *testing.T) {
139+ t.Parallel()
140+
141+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
142+
143+ subCtx, cancelSub := context.WithCancel(t.Context())
144+ defer cancelSub()
145+ sub := svc.Subscribe(subCtx)
146+ collector := collect(subCtx, sub)
147+
148+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
149+ require.NoError(t, err)
150+ time.Sleep(5 * time.Millisecond)
151+ collector.reset()
152+
153+ // AddFinish makes the message terminal; Update must flush
154+ // synchronously even with a 1-hour debounce.
155+ msg.AppendContent("done")
156+ msg.AddFinish(FinishReasonEndTurn, "", "")
157+ require.NoError(t, svc.Update(t.Context(), msg))
158+
159+ require.Eventually(t, func() bool {
160+ return len(collector.snapshot()) >= 1
161+ }, time.Second, 5*time.Millisecond,
162+ "terminal update must publish without waiting for debounce")
163+ events := collector.snapshot()
164+ require.Len(t, events, 1)
165+ require.True(t, events[0].Payload.IsFinished())
166+
167+ got, err := svc.Get(t.Context(), msg.ID)
168+ require.NoError(t, err)
169+ require.True(t, got.IsFinished())
170+}
171+
172+func TestUpdate_ToolCallStructuralChangeFlushes(t *testing.T) {
173+ t.Parallel()
174+
175+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
176+
177+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
178+ require.NoError(t, err)
179+
180+ // Adding a new tool call is a structural change → sync flush.
181+ msg.AddToolCall(ToolCall{ID: "tc1", Name: "view", Finished: false})
182+ require.NoError(t, svc.Update(t.Context(), msg))
183+
184+ got, err := svc.Get(t.Context(), msg.ID)
185+ require.NoError(t, err)
186+ require.Len(t, got.ToolCalls(), 1)
187+ require.Equal(t, "tc1", got.ToolCalls()[0].ID)
188+
189+ // Marking the tool call finished is also a structural change.
190+ msg.AddToolCall(ToolCall{ID: "tc1", Name: "view", Input: "{}", Finished: true})
191+ require.NoError(t, svc.Update(t.Context(), msg))
192+
193+ got, err = svc.Get(t.Context(), msg.ID)
194+ require.NoError(t, err)
195+ require.True(t, got.ToolCalls()[0].Finished)
196+}
197+
198+func TestUpdate_ReasoningEndFlushes(t *testing.T) {
199+ t.Parallel()
200+
201+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
202+
203+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
204+ require.NoError(t, err)
205+
206+ // Reasoning deltas alone debounce.
207+ msg.AppendReasoningContent("hmm")
208+ require.NoError(t, svc.Update(t.Context(), msg))
209+ got, err := svc.Get(t.Context(), msg.ID)
210+ require.NoError(t, err)
211+ require.Empty(t, got.ReasoningContent().Thinking, "reasoning delta should still be in the debounce buffer")
212+
213+ // FinishThinking sets FinishedAt → terminal flush.
214+ msg.FinishThinking()
215+ require.NoError(t, svc.Update(t.Context(), msg))
216+
217+ got, err = svc.Get(t.Context(), msg.ID)
218+ require.NoError(t, err)
219+ require.Equal(t, "hmm", got.ReasoningContent().Thinking)
220+ require.NotZero(t, got.ReasoningContent().FinishedAt)
221+}
222+
223+func TestFlush_DrainsPendingDebouncedUpdates(t *testing.T) {
224+ t.Parallel()
225+
226+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
227+
228+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
229+ require.NoError(t, err)
230+ msg.AppendContent("buffered")
231+ require.NoError(t, svc.Update(t.Context(), msg))
232+
233+ // Without a flush the SQL row is unchanged from Create.
234+ got, err := svc.Get(t.Context(), msg.ID)
235+ require.NoError(t, err)
236+ require.Empty(t, got.Content().Text)
237+
238+ require.NoError(t, svc.Flush(t.Context(), msg.ID))
239+
240+ got, err = svc.Get(t.Context(), msg.ID)
241+ require.NoError(t, err)
242+ require.Equal(t, "buffered", got.Content().Text)
243+
244+ // Subsequent Flush is a no-op.
245+ require.NoError(t, svc.Flush(t.Context(), msg.ID))
246+}
247+
248+func TestFlushAll_DrainsAllPending(t *testing.T) {
249+ t.Parallel()
250+
251+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
252+
253+ const n = 5
254+ msgs := make([]Message, n)
255+ for i := range msgs {
256+ m, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
257+ require.NoError(t, err)
258+ m.AppendContent("hi")
259+ require.NoError(t, svc.Update(t.Context(), m))
260+ msgs[i] = m
261+ }
262+
263+ require.NoError(t, svc.FlushAll(t.Context()))
264+
265+ for _, m := range msgs {
266+ got, err := svc.Get(t.Context(), m.ID)
267+ require.NoError(t, err)
268+ require.Equal(t, "hi", got.Content().Text, "FlushAll should drain every pending message")
269+ }
270+}
271+
272+func TestUpdate_OrderingMatchesNonCoalesced(t *testing.T) {
273+ t.Parallel()
274+
275+ // Compare the final state after coalesced vs zero-debounce updates.
276+ // A sequence of interleaved text/reasoning/tool-call updates must
277+ // converge to the same final DB row either way.
278+ build := func(svc Service, sessionID string) Message {
279+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
280+ require.NoError(t, err)
281+ msg.AppendReasoningContent("thinking 1 ")
282+ require.NoError(t, svc.Update(t.Context(), msg))
283+ msg.AppendReasoningContent("thinking 2")
284+ require.NoError(t, svc.Update(t.Context(), msg))
285+ msg.FinishThinking()
286+ require.NoError(t, svc.Update(t.Context(), msg))
287+ msg.AppendContent("hello ")
288+ require.NoError(t, svc.Update(t.Context(), msg))
289+ msg.AppendContent("world")
290+ require.NoError(t, svc.Update(t.Context(), msg))
291+ msg.AddToolCall(ToolCall{ID: "tc", Name: "x", Finished: false})
292+ require.NoError(t, svc.Update(t.Context(), msg))
293+ msg.AddToolCall(ToolCall{ID: "tc", Name: "x", Input: "{}", Finished: true})
294+ require.NoError(t, svc.Update(t.Context(), msg))
295+ msg.AddFinish(FinishReasonEndTurn, "", "")
296+ require.NoError(t, svc.Update(t.Context(), msg))
297+ return msg
298+ }
299+
300+ coalesced, sid1 := newTestService(t, WithDebounce(20*time.Millisecond))
301+ a := build(coalesced, sid1)
302+ require.NoError(t, coalesced.FlushAll(t.Context()))
303+ gotA, err := coalesced.Get(t.Context(), a.ID)
304+ require.NoError(t, err)
305+
306+ immediate, sid2 := newTestService(t, WithDebounce(0))
307+ b := build(immediate, sid2)
308+ gotB, err := immediate.Get(t.Context(), b.ID)
309+ require.NoError(t, err)
310+
311+ require.Equal(t, gotA.Content().Text, gotB.Content().Text)
312+ require.Equal(t, gotA.ReasoningContent().Thinking, gotB.ReasoningContent().Thinking)
313+ require.Equal(t, len(gotA.ToolCalls()), len(gotB.ToolCalls()))
314+ require.Equal(t, gotA.IsFinished(), gotB.IsFinished())
315+}
316+
317+func TestDelete_DropsPendingState(t *testing.T) {
318+ t.Parallel()
319+
320+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
321+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
322+ require.NoError(t, err)
323+ msg.AppendContent("dropped")
324+ require.NoError(t, svc.Update(t.Context(), msg))
325+
326+ require.NoError(t, svc.Delete(t.Context(), msg.ID))
327+
328+ // FlushAll after Delete must not write to the deleted row.
329+ require.NoError(t, svc.FlushAll(t.Context()))
330+
331+ _, err = svc.Get(t.Context(), msg.ID)
332+ require.Error(t, err, "deleted message must remain deleted")
333+}
334+
335+func TestBroker_PublishLossyDropCounter(t *testing.T) {
336+ t.Parallel()
337+
338+ // Tiny channel buffer so we can saturate from a single sender.
339+ b := pubsub.NewBrokerWithOptions[int](1)
340+ defer b.Shutdown()
341+
342+ subCtx, cancel := context.WithCancel(t.Context())
343+ defer cancel()
344+ sub := b.Subscribe(subCtx)
345+ require.NotNil(t, sub)
346+
347+ // Don't read from sub. Saturate the buffer.
348+ for range 100 {
349+ b.Publish(pubsub.UpdatedEvent, 1)
350+ }
351+
352+ require.GreaterOrEqual(t, b.DropCount(), uint64(1),
353+ "lossy Publish must increment the drop counter under contention")
354+}
355+
356+func TestBroker_PublishMustDeliverHonorsTimeout(t *testing.T) {
357+ t.Parallel()
358+
359+ b := pubsub.NewBrokerWithOptions[int](1)
360+ b.SetMustDeliverTimeout(20 * time.Millisecond)
361+ defer b.Shutdown()
362+
363+ subCtx, cancel := context.WithCancel(t.Context())
364+ defer cancel()
365+ sub := b.Subscribe(subCtx)
366+ require.NotNil(t, sub)
367+
368+ // Saturate: one event sits in the buffer, the second must wait.
369+ b.Publish(pubsub.UpdatedEvent, 1)
370+
371+ // PublishMustDeliver should block up to 20ms then drop.
372+ start := time.Now()
373+ b.PublishMustDeliver(t.Context(), pubsub.UpdatedEvent, 2)
374+ elapsed := time.Since(start)
375+
376+ require.GreaterOrEqual(t, elapsed, 20*time.Millisecond,
377+ "PublishMustDeliver should block at least the timeout under contention")
378+ require.Less(t, elapsed, 200*time.Millisecond,
379+ "PublishMustDeliver must not block indefinitely")
380+ require.GreaterOrEqual(t, b.MustDeliverDropCount(), uint64(1),
381+ "timeout must increment the must-deliver drop counter")
382+}
383+
384+func TestBroker_PublishMustDeliverWithReader(t *testing.T) {
385+ t.Parallel()
386+
387+ b := pubsub.NewBrokerWithOptions[int](1)
388+ b.SetMustDeliverTimeout(50 * time.Millisecond)
389+ defer b.Shutdown()
390+
391+ subCtx, cancel := context.WithCancel(t.Context())
392+ defer cancel()
393+ sub := b.Subscribe(subCtx)
394+
395+ var received atomic.Uint64
396+ done := make(chan struct{})
397+ go func() {
398+ defer close(done)
399+ for {
400+ select {
401+ case <-subCtx.Done():
402+ return
403+ case _, ok := <-sub:
404+ if !ok {
405+ return
406+ }
407+ received.Add(1)
408+ }
409+ }
410+ }()
411+
412+ for i := range 10 {
413+ b.PublishMustDeliver(t.Context(), pubsub.UpdatedEvent, i)
414+ }
415+
416+ // All 10 should land within the must-deliver timeout window.
417+ require.Eventually(t, func() bool { return received.Load() == 10 },
418+ time.Second, 5*time.Millisecond,
419+ "all must-deliver events should reach an active subscriber")
420+ require.Zero(t, b.MustDeliverDropCount(),
421+ "no drops expected when subscriber drains promptly")
422+}
423+
424+func TestUpdate_TerminalEventUsesMustDeliver(t *testing.T) {
425+ t.Parallel()
426+
427+ svc, sessionID := newTestService(t, WithDebounce(time.Hour))
428+
429+ subCtx, cancel := context.WithCancel(t.Context())
430+ defer cancel()
431+ sub := svc.Subscribe(subCtx)
432+
433+ var seenFinish atomic.Bool
434+ done := make(chan struct{})
435+ go func() {
436+ defer close(done)
437+ for {
438+ select {
439+ case <-subCtx.Done():
440+ return
441+ case ev, ok := <-sub:
442+ if !ok {
443+ return
444+ }
445+ if ev.Type == pubsub.UpdatedEvent && ev.Payload.IsFinished() {
446+ seenFinish.Store(true)
447+ }
448+ }
449+ }
450+ }()
451+
452+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
453+ require.NoError(t, err)
454+ msg.AppendContent("final")
455+ msg.AddFinish(FinishReasonEndTurn, "", "")
456+ require.NoError(t, svc.Update(t.Context(), msg))
457+
458+ require.Eventually(t, func() bool { return seenFinish.Load() },
459+ time.Second, 10*time.Millisecond,
460+ "terminal update must reach subscribers via the must-deliver path")
461+}
462+
463+func TestUpdate_ZeroDebounceFlushesEveryUpdate(t *testing.T) {
464+ t.Parallel()
465+
466+ svc, sessionID := newTestService(t, WithDebounce(0))
467+
468+ msg, err := svc.Create(t.Context(), sessionID, CreateMessageParams{Role: Assistant})
469+ require.NoError(t, err)
470+
471+ for i := 0; i < 3; i++ {
472+ msg.AppendContent("x")
473+ require.NoError(t, svc.Update(t.Context(), msg))
474+ got, err := svc.Get(t.Context(), msg.ID)
475+ require.NoError(t, err)
476+ require.Len(t, got.Content().Text, i+1, "every update must land synchronously when debounce is 0")
477+ }
478+}
479+
480+// TestFlush_WaitsForInFlightWrite reproduces the failure where Flush
481+// or FlushAll could return before a concurrent in-flight SQL write
482+// completed. We block UpdateMessage on a release channel, fire the
483+// debounce timer, then call Flush and assert it does not return until
484+// the in-flight write actually lands.
485+func TestFlush_WaitsForInFlightWrite(t *testing.T) {
486+ t.Parallel()
487+
488+ conn, err := db.Connect(t.Context(), t.TempDir())
489+ require.NoError(t, err)
490+ t.Cleanup(func() { _ = conn.Close() })
491+
492+ q := db.New(conn)
493+ sessions := session.NewService(q, conn)
494+ sess, err := sessions.Create(t.Context(), "test")
495+ require.NoError(t, err)
496+
497+ slow := &slowUpdateQuerier{
498+ Querier: q,
499+ release: make(chan struct{}),
500+ started: make(chan struct{}),
501+ }
502+ // Short debounce so the timer fires quickly.
503+ svc := NewService(slow, WithDebounce(10*time.Millisecond))
504+
505+ msg, err := svc.Create(t.Context(), sess.ID, CreateMessageParams{Role: Assistant})
506+ require.NoError(t, err)
507+ msg.AppendContent("payload")
508+ require.NoError(t, svc.Update(t.Context(), msg))
509+
510+ // Wait for the timer-fired flush to enter UpdateMessage.
511+ select {
512+ case <-slow.started:
513+ case <-time.After(time.Second):
514+ t.Fatal("timer-fired flush never reached UpdateMessage")
515+ }
516+
517+ // At this point the buffer is dirty=false but flushing=true. A
518+ // naive Flush would early-return on !dirty. Spawn Flush in a
519+ // goroutine and assert it has not returned while the write is
520+ // still blocked.
521+ flushDone := make(chan error, 1)
522+ go func() { flushDone <- svc.Flush(t.Context(), msg.ID) }()
523+
524+ select {
525+ case err := <-flushDone:
526+ t.Fatalf("Flush returned %v while in-flight write was still blocked", err)
527+ case <-time.After(50 * time.Millisecond):
528+ // Expected: Flush is correctly waiting.
529+ }
530+
531+ // Release the slow write; Flush must now return cleanly.
532+ close(slow.release)
533+ select {
534+ case err := <-flushDone:
535+ require.NoError(t, err)
536+ case <-time.After(time.Second):
537+ t.Fatal("Flush did not return after in-flight write completed")
538+ }
539+
540+ // The SQL row should now reflect the buffered payload.
541+ got, err := svc.Get(t.Context(), msg.ID)
542+ require.NoError(t, err)
543+ require.Equal(t, "payload", got.Content().Text)
544+}
545+
546+// TestFlushAll_WaitsForInFlightWrite asserts FlushAll picks up IDs
547+// whose buffer is currently flushing (dirty=false) so shutdown and
548+// session-switch callers don't return while an SQL write is mid-flight.
549+func TestFlushAll_WaitsForInFlightWrite(t *testing.T) {
550+ t.Parallel()
551+
552+ conn, err := db.Connect(t.Context(), t.TempDir())
553+ require.NoError(t, err)
554+ t.Cleanup(func() { _ = conn.Close() })
555+
556+ q := db.New(conn)
557+ sessions := session.NewService(q, conn)
558+ sess, err := sessions.Create(t.Context(), "test")
559+ require.NoError(t, err)
560+
561+ slow := &slowUpdateQuerier{
562+ Querier: q,
563+ release: make(chan struct{}),
564+ started: make(chan struct{}),
565+ }
566+ svc := NewService(slow, WithDebounce(10*time.Millisecond))
567+
568+ msg, err := svc.Create(t.Context(), sess.ID, CreateMessageParams{Role: Assistant})
569+ require.NoError(t, err)
570+ msg.AppendContent("payload")
571+ require.NoError(t, svc.Update(t.Context(), msg))
572+
573+ select {
574+ case <-slow.started:
575+ case <-time.After(time.Second):
576+ t.Fatal("timer-fired flush never reached UpdateMessage")
577+ }
578+
579+ flushDone := make(chan error, 1)
580+ go func() { flushDone <- svc.FlushAll(t.Context()) }()
581+
582+ select {
583+ case err := <-flushDone:
584+ t.Fatalf("FlushAll returned %v while in-flight write was still blocked", err)
585+ case <-time.After(50 * time.Millisecond):
586+ }
587+
588+ close(slow.release)
589+ select {
590+ case err := <-flushDone:
591+ require.NoError(t, err)
592+ case <-time.After(time.Second):
593+ t.Fatal("FlushAll did not return after in-flight write completed")
594+ }
595+
596+ got, err := svc.Get(t.Context(), msg.ID)
597+ require.NoError(t, err)
598+ require.Equal(t, "payload", got.Content().Text)
599+}
600+
601+// TestUpdate_StructuralFlushUsesMustDeliver covers the second review
602+// finding: structural terminal events (tool-call add, tool-call
603+// finish, reasoning end) must publish via the must-deliver path even
604+// when the message itself is not yet IsFinished.
605+//
606+// We detect which path was taken by saturating a subscriber's channel
607+// buffer with no reader. With a short must-deliver timeout, the
608+// must-deliver path increments [pubsub.Broker.MustDeliverDropCount]
609+// after the timeout expires; the lossy path increments
610+// [pubsub.Broker.DropCount] immediately. The two counters are
611+// disjoint, so they precisely identify which call site published the
612+// event.
613+func TestUpdate_StructuralFlushUsesMustDeliver(t *testing.T) {
614+ t.Parallel()
615+
616+ cases := []struct {
617+ name string
618+ mut func(*Message)
619+ }{
620+ {
621+ name: "tool call add",
622+ mut: func(m *Message) {
623+ m.AddToolCall(ToolCall{ID: "tc1", Name: "view"})
624+ },
625+ },
626+ {
627+ name: "tool call finish",
628+ mut: func(m *Message) {
629+ m.AddToolCall(ToolCall{ID: "tc1", Name: "view", Input: "{}", Finished: true})
630+ },
631+ },
632+ {
633+ name: "reasoning end",
634+ mut: func(m *Message) {
635+ m.AppendReasoningContent("hmm")
636+ m.FinishThinking()
637+ },
638+ },
639+ }
640+
641+ for _, tc := range cases {
642+ t.Run(tc.name, func(t *testing.T) {
643+ t.Parallel()
644+
645+ conn, err := db.Connect(t.Context(), t.TempDir())
646+ require.NoError(t, err)
647+ t.Cleanup(func() { _ = conn.Close() })
648+
649+ q := db.New(conn)
650+ sessions := session.NewService(q, conn)
651+ sess, err := sessions.Create(t.Context(), "test")
652+ require.NoError(t, err)
653+
654+ // Replace the default broker with a tiny buffer + short
655+ // must-deliver timeout so we can fully saturate from a
656+ // single sender and observe drops without long waits.
657+ svc := NewService(q, WithDebounce(time.Hour))
658+ impl := svc.(*service)
659+ impl.Shutdown()
660+ impl.Broker = pubsub.NewBrokerWithOptions[Message](1)
661+ impl.SetMustDeliverTimeout(40 * time.Millisecond)
662+
663+ subCtx, cancel := context.WithCancel(t.Context())
664+ defer cancel()
665+ sub := svc.Subscribe(subCtx)
666+
667+ msg, err := svc.Create(t.Context(), sess.ID, CreateMessageParams{Role: Assistant})
668+ require.NoError(t, err)
669+
670+ // Saturate the subscriber's buffer (capacity 1). The
671+ // CreatedEvent from Create above already left one event
672+ // in the buffer; we never read sub, so the next publish
673+ // has nowhere to go.
674+ _ = sub // intentionally not drained.
675+
676+ // Drive the structural change. With debounce=1h, Update
677+ // flushes synchronously and routes through whichever
678+ // publish path the service chose for structural events.
679+ tc.mut(&msg)
680+ require.NoError(t, svc.Update(t.Context(), msg))
681+
682+ // Must-deliver timeout (40ms) should have expired with
683+ // no drain. If structural events are routed through
684+ // must-deliver: MustDeliverDropCount > 0, DropCount
685+ // unchanged. If routed through lossy Publish:
686+ // DropCount > 0, MustDeliverDropCount == 0.
687+ require.Eventually(t, func() bool {
688+ return impl.MustDeliverDropCount() >= 1
689+ }, time.Second, 5*time.Millisecond,
690+ "structural terminal event should publish via must-deliver, not lossy Publish")
691+ require.Zero(t, impl.DropCount(),
692+ "structural terminal event must not be silently dropped via lossy Publish")
693+ })
694+ }
695+}
internal/pubsub/broker.go+133−15
@@ -1,24 +1,59 @@
1+// Package pubsub provides a lightweight in-process broker for fan-out
2+// event delivery between services and the UI.
3+//
4+// Delivery semantics:
5+//
6+// - [Broker.Publish] is best-effort and lossy under contention. If a
7+// subscriber's channel is full, the event is dropped for that
8+// subscriber, a warning is logged, and a counter is incremented.
9+// This is the right choice for high-frequency intermediate updates
10+// (e.g. streaming token deltas) where only the latest state
11+// matters.
12+//
13+// - [Broker.PublishMustDeliver] is bounded-blocking. For each
14+// subscriber it first tries a non-blocking send, then falls back to
15+// a per-subscriber blocking send with a hard timeout. On timeout the
16+// event is dropped for that subscriber, an error is logged, and the
17+// must-deliver drop counter is incremented. The publisher never
18+// blocks indefinitely. This is the right choice for terminal events
19+// (finish, tool result, error, cancel) that must not be silently
20+// coalesced away.
21+//
22+// Drop counters ([Broker.DropCount], [Broker.MustDeliverDropCount]) are
23+// exposed so callers can surface saturation in telemetry.
1package pubsub
24package pubsub
2
25
3import (
26import (
4 "context"
27 "context"
5 "log/slog"
28 "log/slog"
6 "sync"
29 "sync"
30+ "sync/atomic"
31+ "time"
7)
32)
8
33
34+const (
9−// bufferSize is the per-subscriber channel capacity for any broker
35+ // bufferSize is the per-subscriber channel capacity for any broker
10−// created via NewBroker. Publish is non-blocking, so a full buffer
36+ // created via NewBroker. Publish is non-blocking, so a full buffer
11−// drops events (with a warning log); sized to cover a long streaming
37+ // drops events (with a warning log); sized to cover a long
12−// assistant turn (~one UpdatedEvent per token) even under TUI render
38+ // streaming assistant turn (~one UpdatedEvent per token) even under
13−// stalls.
39+ // TUI render stalls.
14−const bufferSize = 4096
40+ bufferSize = 4096
41+
42+ // defaultMustDeliverTimeout is the per-subscriber upper bound on how
43+ // long [Broker.PublishMustDeliver] will block waiting for buffer
44+ // space before giving up on that subscriber.
45+ defaultMustDeliverTimeout = 50 * time.Millisecond
46+)
15
47
16type Broker[T any] struct {
48type Broker[T any] struct {
17− subs map[chan Event[T]]struct{}
49+ subs map[chan Event[T]]struct{}
18− mu sync.RWMutex
50+ mu sync.RWMutex
19− done chan struct{}
51+ done chan struct{}
20− subCount int
52+ subCount int
21− channelBufferSize int
53+ channelBufferSize int
54+ mustDeliverTimeout time.Duration
55+ dropCount atomic.Uint64
56+ mustDeliverDropCount atomic.Uint64
22}
57}
23
58
24func NewBroker[T any]() *Broker[T] {
59func NewBroker[T any]() *Broker[T] {
@@ -27,12 +62,26 @@ func NewBroker[T any]() *Broker[T] {
27
62
28func NewBrokerWithOptions[T any](channelBufferSize int) *Broker[T] {
63func NewBrokerWithOptions[T any](channelBufferSize int) *Broker[T] {
29 return &Broker[T]{
64 return &Broker[T]{
30− subs: make(map[chan Event[T]]struct{}),
65+ subs: make(map[chan Event[T]]struct{}),
31− done: make(chan struct{}),
66+ done: make(chan struct{}),
32− channelBufferSize: channelBufferSize,
67+ channelBufferSize: channelBufferSize,
68+ mustDeliverTimeout: defaultMustDeliverTimeout,
33 }
69 }
34}
70}
35
71
72+// SetMustDeliverTimeout overrides the per-subscriber timeout used by
73+// [Broker.PublishMustDeliver]. A zero or negative value resets to the
74+// default. Intended primarily for tests.
75+func (b *Broker[T]) SetMustDeliverTimeout(d time.Duration) {
76+ b.mu.Lock()
77+ defer b.mu.Unlock()
78+ if d <= 0 {
79+ b.mustDeliverTimeout = defaultMustDeliverTimeout
80+ return
81+ }
82+ b.mustDeliverTimeout = d
83+}
84+
36func (b *Broker[T]) Shutdown() {
85func (b *Broker[T]) Shutdown() {
37 select {
86 select {
38 case <-b.done: // Already closed
87 case <-b.done: // Already closed
@@ -94,6 +143,25 @@ func (b *Broker[T]) GetSubscriberCount() int {
94 return b.subCount
143 return b.subCount
95}
144}
96
145
146+// DropCount returns the cumulative number of events dropped by
147+// [Broker.Publish] because a subscriber's channel was full.
148+func (b *Broker[T]) DropCount() uint64 {
149+ return b.dropCount.Load()
150+}
151+
152+// MustDeliverDropCount returns the cumulative number of events dropped
153+// by [Broker.PublishMustDeliver] after the per-subscriber timeout
154+// expired.
155+func (b *Broker[T]) MustDeliverDropCount() uint64 {
156+ return b.mustDeliverDropCount.Load()
157+}
158+
159+// Publish delivers an event to every active subscriber.
160+//
161+// Delivery is non-blocking and lossy: if a subscriber's channel is full
162+// the event is dropped for that subscriber, a warning is logged, and
163+// [Broker.DropCount] is incremented. Use [Broker.PublishMustDeliver]
164+// for events that must not be silently dropped.
97func (b *Broker[T]) Publish(t EventType, payload T) {
165func (b *Broker[T]) Publish(t EventType, payload T) {
98 b.mu.RLock()
166 b.mu.RLock()
99 defer b.mu.RUnlock()
167 defer b.mu.RUnlock()
@@ -111,8 +179,58 @@ func (b *Broker[T]) Publish(t EventType, payload T) {
111 case sub <- event:
179 case sub <- event:
112 default:
180 default:
113 // Channel is full, subscriber is slow — skip this event.
181 // Channel is full, subscriber is slow — skip this event.
114− // This prevents blocking the publisher.
182+ // Lossy by design; counted and logged so saturation is
183+ // observable.
184+ b.dropCount.Add(1)
115 slog.Warn("Pubsub buffer full; dropping event", "type", t)
185 slog.Warn("Pubsub buffer full; dropping event", "type", t)
116 }
186 }
117 }
187 }
118}
188}
189+
190+// PublishMustDeliver delivers an event with bounded-blocking semantics.
191+// For each subscriber it first attempts a non-blocking send, then falls
192+// back to a blocking send bounded by a per-subscriber timeout (default
193+// [defaultMustDeliverTimeout]). On timeout the event is dropped for
194+// that subscriber, [Broker.MustDeliverDropCount] is incremented, and an
195+// error is logged. The publisher never blocks indefinitely.
196+//
197+// Use this for terminal events that must reach subscribers (finish,
198+// tool result, error, cancel). Callers must still tolerate rare drops
199+// after timeout — recovery is the subscriber's responsibility (e.g. a
200+// re-fetch on the next session-visible event).
201+func (b *Broker[T]) PublishMustDeliver(ctx context.Context, t EventType, payload T) {
202+ b.mu.RLock()
203+ defer b.mu.RUnlock()
204+
205+ select {
206+ case <-b.done:
207+ return
208+ default:
209+ }
210+
211+ event := Event[T]{Type: t, Payload: payload}
212+ timeout := b.mustDeliverTimeout
213+
214+ for sub := range b.subs {
215+ // Fast path: non-blocking send.
216+ select {
217+ case sub <- event:
218+ continue
219+ default:
220+ }
221+
222+ // Slow path: bounded blocking send.
223+ timer := time.NewTimer(timeout)
224+ select {
225+ case sub <- event:
226+ timer.Stop()
227+ case <-timer.C:
228+ b.mustDeliverDropCount.Add(1)
229+ slog.Error("PublishMustDeliver timed out delivering event",
230+ "type", t, "timeout", timeout)
231+ case <-ctx.Done():
232+ timer.Stop()
233+ return
234+ }
235+ }
236+}
internal/ui/anim/anim.go+38−16
@@ -30,9 +30,14 @@ const (
30 // change every 8 frames (400 milliseconds).
30 // change every 8 frames (400 milliseconds).
31 ellipsisAnimSpeed = 8
31 ellipsisAnimSpeed = 8
32
32
33− // The maximum amount of time that can pass before a character appears.
33+ // The maximum number of animation steps that can pass before a
34− // This is used to create a staggered entrance effect.
34+ // character appears. With fps == 20 this is ~1s of staggered
35− maxBirthOffset = time.Second
35+ // entrance, identical to the previous wall-clock-driven value.
36+ // Switching from wall-clock + rand to a step-driven birth schedule
37+ // keeps Render() deterministic: two Anim instances built from the
38+ // same Settings produce byte-identical output when no Animate ticks
39+ // have advanced their step counter.
40+ maxBirthSteps = 20
36
41
37 // Number of frames to prerender for the animation. After this number
42 // Number of frames to prerender for the animation. After this number
38 // of frames, the animation will loop. This only applies when color
43 // of frames, the animation will loop. This only applies when color
@@ -107,12 +112,12 @@ type Anim struct {
107 label *csync.Slice[string]
112 label *csync.Slice[string]
108 labelWidth int
113 labelWidth int
109 labelColor color.Color
114 labelColor color.Color
110− startTime time.Time
115+ birthSteps []int
111− birthOffsets []time.Duration
112 initialFrames [][]string // frames for the initial characters
116 initialFrames [][]string // frames for the initial characters
113 initialized atomic.Bool
117 initialized atomic.Bool
114 cyclingFrames [][]string // frames for the cycling characters
118 cyclingFrames [][]string // frames for the cycling characters
115− step atomic.Int64 // current main frame step
119+ step atomic.Int64 // current main frame step (wraps)
120+ framesSinceStart atomic.Int64 // total Animate ticks (does not wrap)
116 ellipsisStep atomic.Int64 // current ellipsis frame step
121 ellipsisStep atomic.Int64 // current ellipsis frame step
117 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
122 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118 id string
123 id string
@@ -140,7 +145,6 @@ func New(opts Settings) *Anim {
140 } else {
145 } else {
141 a.id = fmt.Sprintf("%d", nextID())
146 a.id = fmt.Sprintf("%d", nextID())
142 }
147 }
143− a.startTime = time.Now()
144 a.cyclingCharWidth = opts.Size
148 a.cyclingCharWidth = opts.Size
145 a.labelColor = opts.LabelColor
149 a.labelColor = opts.LabelColor
146
150
@@ -207,7 +211,13 @@ func New(opts Settings) *Anim {
207 }
211 }
208 }
212 }
209
213
210− // Prerender scrambled rune frames for the animation.
214+ // Prerender scrambled rune frames for the animation. Seed
215+ // the rune picker off the settings hash so cyclingFrames is
216+ // a pure function of Settings: two processes with identical
217+ // Settings populate the cache with the same glyphs, which
218+ // keeps any cross-process golden-file comparison stable.
219+ seed := xxh3.HashString(cacheKey)
220+ rng := rand.New(rand.NewPCG(seed, ^seed))
211 a.cyclingFrames = make([][]string, numFrames)
221 a.cyclingFrames = make([][]string, numFrames)
212 offset = 0
222 offset = 0
213 for i := range a.cyclingFrames {
223 for i := range a.cyclingFrames {
@@ -219,7 +229,7 @@ func New(opts Settings) *Anim {
219
229
220 // Also prerender the color with Lip Gloss here to avoid processing
230 // Also prerender the color with Lip Gloss here to avoid processing
221 // in the render loop.
231 // in the render loop.
222− r := availableRunes[rand.IntN(len(availableRunes))]
232+ r := availableRunes[rng.IntN(len(availableRunes))]
223 a.cyclingFrames[i][j] = lipgloss.NewStyle().
233 a.cyclingFrames[i][j] = lipgloss.NewStyle().
224 Foreground(ramp[j+offset]).
234 Foreground(ramp[j+offset]).
225 Render(string(r))
235 Render(string(r))
@@ -249,10 +259,20 @@ func New(opts Settings) *Anim {
249 animCacheMap.Set(cacheKey, cached)
259 animCacheMap.Set(cacheKey, cached)
250 }
260 }
251
261
252− // Random assign a birth to each character for a stagged entrance effect.
262+ // Assign a deterministic birth step to each column for a
253− a.birthOffsets = make([]time.Duration, a.width)
263+ // staggered entrance effect. The schedule is seeded off the
264+ // spinner id and the settings hash, so two spinners with the
265+ // same role and identity stagger identically (this is what
266+ // keeps Render() byte-equal across cache hits and across
267+ // processes for the same Settings+ID) while spinners with
268+ // different ids — distinct assistant messages, different tool
269+ // calls, "Thinking" vs "Generating" labels — fade in with
270+ // different patterns instead of marching in lock-step.
271+ birthSeed := xxh3.HashString(a.id + "|" + cacheKey)
272+ birthRng := rand.New(rand.NewPCG(birthSeed, ^birthSeed))
273+ a.birthSteps = make([]int, a.width)
254− for i := range a.birthOffsets {
274+ for i := range a.birthSteps {
255− a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
275+ a.birthSteps[i] = birthRng.IntN(maxBirthSteps)
256 }
276 }
257
277
258 return a
278 return a
@@ -333,13 +353,14 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
333 a.step.Store(0)
353 a.step.Store(0)
334 }
354 }
335
355
356+ frames := a.framesSinceStart.Add(1)
336 if a.initialized.Load() && a.labelWidth > 0 {
357 if a.initialized.Load() && a.labelWidth > 0 {
337 // Manage the ellipsis animation.
358 // Manage the ellipsis animation.
338 ellipsisStep := a.ellipsisStep.Add(1)
359 ellipsisStep := a.ellipsisStep.Add(1)
339 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
360 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
340 a.ellipsisStep.Store(0)
361 a.ellipsisStep.Store(0)
341 }
362 }
342− } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
363+ } else if !a.initialized.Load() && int(frames) >= maxBirthSteps {
343 a.initialized.Store(true)
364 a.initialized.Store(true)
344 }
365 }
345 return a.Step()
366 return a.Step()
@@ -349,10 +370,11 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
349func (a *Anim) Render() string {
370func (a *Anim) Render() string {
350 var b strings.Builder
371 var b strings.Builder
351 step := int(a.step.Load())
372 step := int(a.step.Load())
373+ frames := int(a.framesSinceStart.Load())
352 for i := range a.width {
374 for i := range a.width {
353 switch {
375 switch {
354− case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
376+ case !a.initialized.Load() && i < len(a.birthSteps) && frames < a.birthSteps[i]:
355− // Birth offset not reached: render initial character.
377+ // Birth step not reached: render initial character.
356 b.WriteString(a.initialFrames[step][i])
378 b.WriteString(a.initialFrames[step][i])
357 case i < a.cyclingCharWidth:
379 case i < a.cyclingCharWidth:
358 // Render a cycling character.
380 // Render a cycling character.
internal/ui/chat/agent.go+57−1
@@ -53,11 +53,21 @@ func NewAgentToolMessageItem(
53}
53}
54
54
55// Animate progresses the message animation if it should be spinning.
55// Animate progresses the message animation if it should be spinning.
56+//
57+// Bumps the parent's F6 list-cache version on both the parent-tick and
58+// nested-tick branches. Nested tools are not list entries of their
59+// own — their IDs map to this parent's index in idInxMap
60+// (internal/ui/model/chat.go:240-246) and their renders are embedded
61+// inline in this parent's output — so the list only checks the
62+// parent's version. Without the bump, the list cache would serve the
63+// previously rendered frame indefinitely and the spinner would appear
64+// frozen.
56func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
65func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
57 if a.result != nil || a.Status() == ToolStatusCanceled {
66 if a.result != nil || a.Status() == ToolStatusCanceled {
58 return nil
67 return nil
59 }
68 }
60 if msg.ID == a.ID() {
69 if msg.ID == a.ID() {
70+ a.Bump()
61 return a.anim.Animate(msg)
71 return a.anim.Animate(msg)
62 }
72 }
63 for _, nestedTool := range a.nestedTools {
73 for _, nestedTool := range a.nestedTools {
@@ -65,6 +75,7 @@ func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
65 continue
75 continue
66 }
76 }
67 if s, ok := nestedTool.(Animatable); ok {
77 if s, ok := nestedTool.(Animatable); ok {
78+ a.Bump()
68 return s.Animate(msg)
79 return s.Animate(msg)
69 }
80 }
70 }
81 }
@@ -77,9 +88,23 @@ func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
77}
88}
78
89
79// SetNestedTools sets the nested tools.
90// SetNestedTools sets the nested tools.
91+//
92+// SetNestedTools always bumps the version. The previous design
93+// deduped when the slice's length and element pointers were
94+// unchanged, but the live update path in internal/ui/model/ui.go
95+// mutates existing children in place (SetToolCall / SetResult on the
96+// same pointers) and then calls SetNestedTools with the same slice.
97+// Pointer-equality dedupe in that case skips the parent Bump even
98+// though the parent's rendered output (which embeds the children
99+// inline) has changed, leaving a stale parent entry in the list
100+// cache. Always bumping is cheap (one uint64 increment) and called
101+// at most once per agent event; in the rare case the slice is
102+// truly unchanged the worst case is one extra parent re-render
103+// while every child cache hit stays warm.
80func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
104func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
81 a.nestedTools = tools
105 a.nestedTools = tools
82 a.clearCache()
106 a.clearCache()
107+ a.Bump()
83}
108}
84
109
85// AddNestedTool adds a nested tool.
110// AddNestedTool adds a nested tool.
@@ -90,6 +115,7 @@ func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
90 }
115 }
91 a.nestedTools = append(a.nestedTools, tool)
116 a.nestedTools = append(a.nestedTools, tool)
92 a.clearCache()
117 a.clearCache()
118+ a.Bump()
93}
119}
94
120
95// AgentToolRenderContext renders agent tool messages.
121// AgentToolRenderContext renders agent tool messages.
@@ -196,15 +222,44 @@ func NewAgenticFetchToolMessageItem(
196 return t
222 return t
197}
223}
198
224
225+// Animate progresses the message animation if it should be spinning.
226+// See [AgentToolMessageItem.Animate] for the parent-bump rationale —
227+// without an override, the embedded base.Animate would (a) drop
228+// StepMsgs whose ID matches a nested child instead of the parent
229+// (anim.Animate's ID check at internal/ui/anim/anim.go:326-329
230+// silently returns nil), and (b) never invalidate the parent's
231+// list-cache entry on a parent tick.
232+func (a *AgenticFetchToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
233+ if a.result != nil || a.Status() == ToolStatusCanceled {
234+ return nil
235+ }
236+ if msg.ID == a.ID() {
237+ a.Bump()
238+ return a.anim.Animate(msg)
239+ }
240+ for _, nestedTool := range a.nestedTools {
241+ if msg.ID != nestedTool.ID() {
242+ continue
243+ }
244+ if s, ok := nestedTool.(Animatable); ok {
245+ a.Bump()
246+ return s.Animate(msg)
247+ }
248+ }
249+ return nil
250+}
251+
199// NestedTools returns the nested tools.
252// NestedTools returns the nested tools.
200func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
253func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201 return a.nestedTools
254 return a.nestedTools
202}
255}
203
256
204−// SetNestedTools sets the nested tools.
257+// SetNestedTools sets the nested tools. Always bumps the version;
258+// see [AgentToolMessageItem.SetNestedTools] for the rationale.
205func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
259func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206 a.nestedTools = tools
260 a.nestedTools = tools
207 a.clearCache()
261 a.clearCache()
262+ a.Bump()
208}
263}
209
264
210// AddNestedTool adds a nested tool.
265// AddNestedTool adds a nested tool.
@@ -215,6 +270,7 @@ func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
215 }
270 }
216 a.nestedTools = append(a.nestedTools, tool)
271 a.nestedTools = append(a.nestedTools, tool)
217 a.clearCache()
272 a.clearCache()
273+ a.Bump()
218}
274}
219
275
220// AgenticFetchToolRenderContext renders agentic fetch tool messages.
276// 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+430−45
@@ -1,7 +1,9 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "encoding/binary"
4 "fmt"
5 "fmt"
6+ "hash/fnv"
5 "strings"
7 "strings"
6
8
7 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
@@ -9,21 +11,116 @@ import (
9 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
10 "github.com/charmbracelet/crush/internal/ui/anim"
12 "github.com/charmbracelet/crush/internal/ui/anim"
11 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/x/ansi"
16 "github.com/charmbracelet/x/ansi"
14)
17)
15
18
16// assistantMessageTruncateFormat is the text shown when an assistant message is
19// assistantMessageTruncateFormat is the text shown when an assistant message is
17−// truncated.
20+// truncated in the collapsed state.
18const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
21const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
19
22
23+// assistantMessageTailWindowFormat is shown above a tail-windowed thinking
24+// block to advertise that earlier lines exist and that the user can
25+// promote the view to a full expansion. The promotion is wired through
26+// the existing ToggleExpanded path (click / space) — F5 deliberately
27+// does not add a new keybinding.
28+const assistantMessageTailWindowFormat = "… %d earlier lines hidden [click or space for full view]"
29+
20// maxCollapsedThinkingHeight defines the maximum height of the thinking
30// maxCollapsedThinkingHeight defines the maximum height of the thinking
21const maxCollapsedThinkingHeight = 10
31const maxCollapsedThinkingHeight = 10
22
32
33+// maxExpandedThinkingTailLines is the F5 tail-window cap. When the user
34+// expands a thinking block whose post-glamour line count exceeds this
35+// threshold, only the last N lines are shown with an affordance line
36+// indicating how many earlier lines are hidden. Clicking / pressing
37+// space again promotes the view to a full expansion. The slice is
38+// taken AFTER glamour render (not before) so fenced code blocks,
39+// lists, and tables are not torn at arbitrary boundaries.
40+const maxExpandedThinkingTailLines = 200
41+
42+// thinkingViewMode is the F5 three-state view machine for the thinking
43+// block. ToggleExpanded cycles
44+// collapsed → tail-window → full-expanded → collapsed, skipping the
45+// tail-window step when the rendered thinking fits within the cap so
46+// short blocks still toggle in two clicks.
47+type thinkingViewMode uint8
48+
49+const (
50+ thinkingCollapsed thinkingViewMode = iota
51+ thinkingTailWindow
52+ thinkingFullExpanded
53+)
54+
55+// assistantSection is a per-section render cache for AssistantMessageItem.
56+// Each section (thinking, content, error) carries its own keys so that
57+// streaming a section does not invalidate a different — often more
58+// expensive — section's cached render. srcHash is an FNV-64 of the
59+// section's source text; extra captures any other state that changes
60+// the rendered output (e.g. thinkingExpanded, the thinking footer
61+// inputs). valid disambiguates a real cache hit from the zero value
62+// when both source text and extras hash to zero. aux carries any
63+// per-section side data that the caller needs to recover on a hit
64+// (e.g. the thinking box height for click detection).
65+type assistantSection struct {
66+ width int
67+ srcHash uint64
68+ extra uint64
69+ out string
70+ h int
71+ aux int
72+ valid bool
73+}
74+
75+// hit reports whether the cache entry matches the requested key.
76+func (s *assistantSection) hit(width int, srcHash, extra uint64) bool {
77+ return s.valid && s.width == width && s.srcHash == srcHash && s.extra == extra
78+}
79+
80+// store records the rendered output under the given key.
81+func (s *assistantSection) store(width int, srcHash, extra uint64, out string, aux int) {
82+ s.width = width
83+ s.srcHash = srcHash
84+ s.extra = extra
85+ s.out = out
86+ s.h = lipgloss.Height(out)
87+ s.aux = aux
88+ s.valid = true
89+}
90+
91+// reset drops the cached output.
92+func (s *assistantSection) reset() {
93+ *s = assistantSection{}
94+}
95+
96+// fnv64 hashes a single string with FNV-64.
97+func fnv64(s string) uint64 {
98+ h := fnv.New64a()
99+ _, _ = h.Write([]byte(s))
100+ return h.Sum64()
101+}
102+
103+// fnvFields hashes a list of byte fields with length-prefix framing
104+// so that no concatenation collision can occur between distinct
105+// field tuples (a NUL inside one field cannot impersonate a
106+// boundary between two fields). Each field is preceded by its
107+// length encoded as 8 bytes little-endian.
108+func fnvFields(fields ...[]byte) uint64 {
109+ h := fnv.New64a()
110+ var lenBuf [8]byte
111+ for _, f := range fields {
112+ binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
113+ _, _ = h.Write(lenBuf[:])
114+ _, _ = h.Write(f)
115+ }
116+ return h.Sum64()
117+}
118+
23// AssistantMessageItem represents an assistant message in the chat UI.
119// AssistantMessageItem represents an assistant message in the chat UI.
24//
120//
25// 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.
26type AssistantMessageItem struct {
122type AssistantMessageItem struct {
123+ *list.Versioned
27 *highlightableMessageItem
124 *highlightableMessageItem
28 *cachedMessageItem
125 *cachedMessageItem
29 *focusableMessageItem
126 *focusableMessageItem
@@ -31,18 +128,34 @@ type AssistantMessageItem struct {
31 message *message.Message
128 message *message.Message
32 sty *styles.Styles
129 sty *styles.Styles
33 anim *anim.Anim
130 anim *anim.Anim
34− thinkingExpanded bool
131+ thinkingViewMode thinkingViewMode
35 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
132 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
133+
134+ // Per-section render caches. Splitting these out means content
135+ // streaming does not invalidate the (often expensive) thinking
136+ // render, and vice versa.
137+ thinkingSec assistantSection
138+ contentSec assistantSection
139+ errorSec assistantSection
140+
141+ // streamingContent caches a "stable prefix" glamour render of
142+ // the assistant content body so each streaming flush only
143+ // re-renders the trailing partial. F8 of
144+ // docs/notes/2026-05-12-chat-rendering-perf.md. See
145+ // streaming_markdown.go for the full algorithm.
146+ streamingContent streamingMarkdown
36}
147}
37
148
38var _ Expandable = (*AssistantMessageItem)(nil)
149var _ Expandable = (*AssistantMessageItem)(nil)
39
150
40// NewAssistantMessageItem creates a new AssistantMessageItem.
151// NewAssistantMessageItem creates a new AssistantMessageItem.
41func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
152func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
153+ v := list.NewVersioned()
42 a := &AssistantMessageItem{
154 a := &AssistantMessageItem{
155+ Versioned: v,
43− highlightableMessageItem: defaultHighlighter(sty),
156+ highlightableMessageItem: defaultHighlighter(sty, v),
44 cachedMessageItem: &cachedMessageItem{},
157 cachedMessageItem: &cachedMessageItem{},
45− focusableMessageItem: &focusableMessageItem{},
158+ focusableMessageItem: newFocusableMessageItem(v),
46 message: message,
159 message: message,
47 sty: sty,
160 sty: sty,
48 }
161 }
@@ -71,6 +184,13 @@ func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
71 if !a.isSpinning() {
184 if !a.isSpinning() {
72 return nil
185 return nil
73 }
186 }
187+ // Bump the F6 list-cache version so the next draw re-renders
188+ // this item: a spinner tick mutates anim's internal frame
189+ // counter, which changes the rendered output but is invisible
190+ // to the per-section content hashes. Without the bump the
191+ // list cache would serve the previously rendered frame
192+ // indefinitely and the spinner would appear frozen.
193+ a.Bump()
74 return a.anim.Animate(msg)
194 return a.anim.Animate(msg)
75}
195}
76
196
@@ -88,14 +208,7 @@ func (a *AssistantMessageItem) RawRender(width int) string {
88 spinner = a.renderSpinning()
208 spinner = a.renderSpinning()
89 }
209 }
90
210
91− content, height, ok := a.getCachedRender(cappedWidth)
92− if !ok {
93− content = a.renderMessageContent(cappedWidth)
211+ content, height := a.renderMessageContent(cappedWidth)
94− height = lipgloss.Height(content)
95− // cache the rendered content
96− a.setCachedRender(content, cappedWidth, height)
97− }
98−
99 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
212 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
100 if spinner != "" {
213 if spinner != "" {
101 if highlightedContent != "" {
214 if highlightedContent != "" {
@@ -114,6 +227,23 @@ func (a *AssistantMessageItem) Render(width int) string {
114 // it's wrapping logic.
227 // it's wrapping logic.
115 // We already know that the content is wrapped to the correct width in
228 // We already know that the content is wrapped to the correct width in
116 // RawRender, so we can just apply the styles directly to each line.
229 // RawRender, so we can just apply the styles directly to each line.
230+ //
231+ // The split + per-line prefix loop is O(L); cache the result keyed
232+ // by (width, focused, sectionsFingerprint) so steady-state Render
233+ // becomes a pointer return. The sectionsFingerprint folds in the
234+ // per-section srcHash/extra so that any sub-cache change
235+ // invalidates this prefix cache without requiring an explicit
236+ // drop. Bypass the cache while spinning (RawRender's spinner
237+ // suffix changes every animation frame) or while a highlight
238+ // range is active (selection drag).
239+ useCache := !a.isSpinning() && !a.isHighlighted()
240+ cappedWidth := cappedMessageWidth(width)
241+ key := a.prefixCacheKey(cappedWidth)
242+ if useCache {
243+ if cached, ok := a.getCachedPrefixedRender(width, key); ok {
244+ return cached
245+ }
246+ }
117 focused := a.sty.Messages.AssistantFocused.Render()
247 focused := a.sty.Messages.AssistantFocused.Render()
118 blurred := a.sty.Messages.AssistantBlurred.Render()
248 blurred := a.sty.Messages.AssistantBlurred.Render()
119 rendered := a.RawRender(width)
249 rendered := a.RawRender(width)
@@ -125,45 +255,202 @@ func (a *AssistantMessageItem) Render(width int) string {
125 lines[i] = blurred + line
255 lines[i] = blurred + line
126 }
256 }
127 }
257 }
128− return strings.Join(lines, "\n")
258+ out := strings.Join(lines, "\n")
259+ if useCache {
260+ a.setCachedPrefixedRender(out, width, key)
261+ }
262+ return out
263+}
264+
265+// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
266+// focus bit into bit 0 and a fingerprint of the section caches into
267+// the upper bits, so any change to a sub-section's source text or
268+// extras forces the prefix cache to miss without needing an explicit
269+// drop. cappedWidth is included so a cached prefix never survives a
270+// section-cache miss caused by a width change. The finish reason is
271+// folded in too because it controls the composition of
272+// renderMessageContent (e.g. appending the constant "Canceled"
273+// string) — that decision lives outside any section's own hash.
274+func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
275+ thinkSrc, thinkExtra := a.thinkingKey()
276+ contentSrc, contentExtra := a.contentKey()
277+ errSrc, errExtra := a.errorKey()
278+ h := fnv.New64a()
279+ var buf [8]byte
280+ writeU64 := func(v uint64) {
281+ for i := range 8 {
282+ buf[i] = byte(v >> (8 * i))
283+ }
284+ _, _ = h.Write(buf[:])
285+ }
286+ writeU64(uint64(cappedWidth))
287+ writeU64(thinkSrc)
288+ writeU64(thinkExtra)
289+ writeU64(contentSrc)
290+ writeU64(contentExtra)
291+ writeU64(errSrc)
292+ writeU64(errExtra)
293+ writeU64(a.compositionKey())
294+ fingerprint := h.Sum64()
295+ var focusBit uint64
296+ if a.focused {
297+ focusBit = 1
298+ }
299+ return (fingerprint &^ 1) | focusBit
129}
300}
130
301
302+// compositionKey hashes the inputs to renderMessageContent's structural
303+// decisions (which sections to include, whether to append the
304+// constant "Canceled" footer) so that flipping IsFinished or the
305+// finish reason invalidates the prefix cache even when no section's
306+// own source text changed.
307+func (a *AssistantMessageItem) compositionKey() uint64 {
308+ var finishedFlag byte
309+ var reason string
310+ if a.message.IsFinished() {
311+ finishedFlag = 1
312+ reason = string(a.message.FinishReason())
313+ }
314+ // Length-prefixed framing keeps the finished flag and the reason
315+ // string from blending into one another.
316+ return fnvFields([]byte{finishedFlag}, []byte(reason))
317+}
318+
131−// renderMessageContent renders the message content including thinking, main content, and finish reason.
319+// renderMessageContent renders the message content including thinking, main
320+// content, and finish reason. Each section is served from its own cache;
321+// only the section whose source text or extras changed since the last
322+// render is recomputed.
132−func (a *AssistantMessageItem) renderMessageContent(width int) string {
323+func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
133 var messageParts []string
324 var messageParts []string
134 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
325 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
135 content := strings.TrimSpace(a.message.Content().Text)
326 content := strings.TrimSpace(a.message.Content().Text)
136− // if the massage has reasoning content add that first
327+
137 if thinking != "" {
328 if thinking != "" {
138− messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
329+ messageParts = append(messageParts, a.cachedThinking(width))
139 }
330 }
140
331
141− // then add the main content
142 if content != "" {
332 if content != "" {
143− // add a spacer between thinking and content
144 if thinking != "" {
333 if thinking != "" {
145 messageParts = append(messageParts, "")
334 messageParts = append(messageParts, "")
146 }
335 }
147− messageParts = append(messageParts, a.renderMarkdown(content, width))
336+ messageParts = append(messageParts, a.cachedContent(width))
148 }
337 }
149
338
150− // finally add any finish reason info
151 if a.message.IsFinished() {
339 if a.message.IsFinished() {
152 switch a.message.FinishReason() {
340 switch a.message.FinishReason() {
153 case message.FinishReasonCanceled:
341 case message.FinishReasonCanceled:
154 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
342 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
155 case message.FinishReasonError:
343 case message.FinishReasonError:
156− messageParts = append(messageParts, a.renderError(width))
344+ messageParts = append(messageParts, a.cachedError(width))
157 }
345 }
158 }
346 }
159
347
160− return strings.Join(messageParts, "\n")
348+ out := strings.Join(messageParts, "\n")
349+ return out, lipgloss.Height(out)
350+}
351+
352+// thinkingKey returns the (srcHash, extra) cache key components for the
353+// thinking section. extra folds in everything other than the raw
354+// thinking text that affects the rendered output: the view mode
355+// (collapsed / tail-window / full) and the footer state (which
356+// depends on IsThinking, ToolCalls, and ThinkingDuration).
357+func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
358+ thinking := a.message.ReasoningContent().Thinking
359+ srcHash := fnv64(thinking)
360+
361+ showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
362+ var durationStr string
363+ if showFooter {
364+ duration := a.message.ThinkingDuration()
365+ if duration.String() != "0s" {
366+ durationStr = duration.String()
367+ }
368+ }
369+ var footer byte
370+ if showFooter {
371+ footer = 1
372+ }
373+ // Length-prefixed framing avoids any delimiter collision between
374+ // the flag bytes and the duration string. The view mode is folded
375+ // in so that toggling collapsed ↔ tail-window ↔ full invalidates
376+ // only the thinking section, not content/error.
377+ extra := fnvFields([]byte{byte(a.thinkingViewMode), footer}, []byte(durationStr))
378+ return srcHash, extra
379+}
380+
381+// contentKey returns the (srcHash, extra) cache key components for the
382+// main content section.
383+func (a *AssistantMessageItem) contentKey() (uint64, uint64) {
384+ return fnv64(a.message.Content().Text), 0
385+}
386+
387+// errorKey returns the (srcHash, extra) cache key components for the
388+// error section. Returns (0, 0) when no error is present so the cache
389+// stays a no-op for non-error messages.
390+func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
391+ if !a.message.IsFinished() || a.message.FinishReason() != message.FinishReasonError {
392+ return 0, 0
393+ }
394+ finishPart := a.message.FinishPart()
395+ if finishPart == nil {
396+ return 0, 0
397+ }
398+ // Length-prefixed framing prevents Message+Details collisions
399+ // between distinct (Message, Details) tuples that would
400+ // otherwise concatenate to the same byte sequence.
401+ return fnvFields([]byte(finishPart.Message), []byte(finishPart.Details)), 0
402+}
403+
404+// cachedThinking returns the rendered thinking section, computing and
405+// caching it on miss. The thinking-box height (used for click target
406+// detection) is preserved across hits via assistantSection.aux so the
407+// cached path never desyncs click detection.
408+func (a *AssistantMessageItem) cachedThinking(width int) string {
409+ srcHash, extra := a.thinkingKey()
410+ if a.thinkingSec.hit(width, srcHash, extra) {
411+ a.thinkingBoxHeight = a.thinkingSec.aux
412+ return a.thinkingSec.out
413+ }
414+ out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
415+ a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
416+ return out
417+}
418+
419+// cachedContent returns the rendered content section.
420+func (a *AssistantMessageItem) cachedContent(width int) string {
421+ srcHash, extra := a.contentKey()
422+ if a.contentSec.hit(width, srcHash, extra) {
423+ return a.contentSec.out
424+ }
425+ out := a.renderMarkdown(a.message.Content().Text, width)
426+ a.contentSec.store(width, srcHash, extra, out, 0)
427+ return out
428+}
429+
430+// cachedError returns the rendered error section.
431+func (a *AssistantMessageItem) cachedError(width int) string {
432+ srcHash, extra := a.errorKey()
433+ if a.errorSec.hit(width, srcHash, extra) {
434+ return a.errorSec.out
435+ }
436+ out := a.renderError(width)
437+ a.errorSec.store(width, srcHash, extra, out, 0)
438+ return out
161}
439}
162
440
163// renderThinking renders the thinking/reasoning content with footer.
441// renderThinking renders the thinking/reasoning content with footer.
442+//
443+// Slicing happens AFTER glamour rendering so fenced code blocks, list
444+// continuations, and tables are not split mid-block — the same
445+// boundary problem §4.4 of the design note flags. The bordered
446+// ThinkingBox style is applied on top of the (already-windowed)
447+// lines so the visual box matches what the user sees today.
164func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
448func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
165 renderer := common.QuietMarkdownRenderer(a.sty, width)
449 renderer := common.QuietMarkdownRenderer(a.sty, width)
450+ mu := common.LockMarkdownRenderer(renderer)
451+ mu.Lock()
166 rendered, err := renderer.Render(thinking)
452 rendered, err := renderer.Render(thinking)
453+ mu.Unlock()
167 if err != nil {
454 if err != nil {
168 rendered = thinking
455 rendered = thinking
169 }
456 }
@@ -172,13 +459,23 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
172 lines := strings.Split(rendered, "\n")
459 lines := strings.Split(rendered, "\n")
173 totalLines := len(lines)
460 totalLines := len(lines)
174
461
175− isTruncated := totalLines > maxCollapsedThinkingHeight
462+ switch a.thinkingViewMode {
176− if !a.thinkingExpanded && isTruncated {
463+ case thinkingCollapsed:
464+ if totalLines > maxCollapsedThinkingHeight {
177− lines = lines[totalLines-maxCollapsedThinkingHeight:]
465+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
178− hint := a.sty.Messages.ThinkingTruncationHint.Render(
466+ hint := a.sty.Messages.ThinkingTruncationHint.Render(
179− fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
467+ fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
468+ )
469+ lines = append([]string{hint, ""}, lines...)
470+ }
471+ case thinkingTailWindow:
472+ if totalLines > maxExpandedThinkingTailLines {
473+ lines = lines[totalLines-maxExpandedThinkingTailLines:]
474+ hint := a.sty.Messages.ThinkingTruncationHint.Render(
475+ fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
180− )
476+ )
181− lines = append([]string{hint, ""}, lines...)
477+ lines = append([]string{hint, ""}, lines...)
478+ }
182 }
479 }
183
480
184 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
481 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
@@ -202,14 +499,18 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
202 return result
499 return result
203}
500}
204
501
205−// renderMarkdown renders content as markdown.
502+// renderMarkdown renders content as markdown. F8 routes the call
503+// through streamingContent, which caches the glamour render of a
504+// "stable prefix" so each streaming flush only re-renders the
505+// trailing partial. The streaming cache invalidates itself on
506+// width change and on any content that is not a prefix-extension
507+// of the previously rendered content (e.g. user retried the
508+// turn), and falls back to a full render whenever boundary
509+// detection has the slightest doubt — see
510+// findSafeMarkdownBoundary.
206func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
511func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
207 renderer := common.MarkdownRenderer(a.sty, width)
512 renderer := common.MarkdownRenderer(a.sty, width)
208− result, err := renderer.Render(content)
513+ return a.streamingContent.Render(content, width, renderer)
209− if err != nil {
210− return content
211− }
212− return strings.TrimSuffix(result, "\n")
213}
514}
214
515
215func (a *AssistantMessageItem) renderSpinning() string {
516func (a *AssistantMessageItem) renderSpinning() string {
@@ -240,23 +541,107 @@ func (a *AssistantMessageItem) isSpinning() bool {
240 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
541 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
241}
542}
242
543
243−// SetMessage is used to update the underlying message.
544+// SetMessage is used to update the underlying message. Only the
545+// sub-section caches whose source text or extras changed are
546+// invalidated; the others survive and serve cache hits on the next
547+// RawRender.
244−func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
548+func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
245 wasSpinning := a.isSpinning()
549 wasSpinning := a.isSpinning()
246− a.message = message
550+ a.message = msg
247− a.clearCache()
551+ // Bump the F6 version even if the underlying *message.Message
552+ // pointer is identical: callers may have mutated the message in
553+ // place (delta append) and we cannot tell from here. The
554+ // per-section caches dedupe identical content via FNV-64 hashes,
555+ // so a redundant bump only costs one list-cache repopulation.
556+ a.Bump()
557+ // The prefix cache is keyed by a fingerprint that includes every
558+ // section's source hash, so an unchanged section keeps its prefix
559+ // cache valid while a changed section forces a miss naturally.
560+ // Section caches themselves are content-keyed, so they do not
561+ // need an explicit drop here either.
248 if !wasSpinning && a.isSpinning() {
562 if !wasSpinning && a.isSpinning() {
249 return a.StartAnimation()
563 return a.StartAnimation()
250 }
564 }
251 return nil
565 return nil
252}
566}
253
567
568+// Finished implements list.Item. The assistant message is freezable
569+// once the message reports IsFinished() and is no longer spinning
570+// (no animation tick remains pending). Streaming tail animation is
571+// caught by isSpinning, so freezing only kicks in once the turn is
572+// fully terminal. The list cache invalidates the entry on the next
573+// version bump if anything (focus, highlight, expansion) changes.
574+func (a *AssistantMessageItem) Finished() bool {
575+ return a.message.IsFinished() && !a.isSpinning()
576+}
577+
578+// clearCache drops every cached render for this item, including the
579+// per-section caches. Shadows the embedded cachedMessageItem.clearCache
580+// so ClearItemCaches (style change) wipes the section caches too.
581+// F8: also drop the streaming-markdown stable-prefix cache because
582+// the cached glamour render embeds the OLD style's ANSI sequences
583+// and is no longer visually consistent with the new style.
584+func (a *AssistantMessageItem) clearCache() {
585+ a.cachedMessageItem.clearCache()
586+ a.thinkingSec.reset()
587+ a.contentSec.reset()
588+ a.errorSec.reset()
589+ a.streamingContent.Reset()
590+}
591+
254−// ToggleExpanded toggles the expanded state of the thinking box and returns
592+// ToggleExpanded advances the F5 thinking view-mode cycle and returns
255−// whether the item is now expanded.
593+// whether the item is now in any expanded state (tail-window or full).
594+// The cycle is collapsed → tail-window → full → collapsed, with the
595+// tail-window step skipped when the rendered thinking fits within
596+// maxExpandedThinkingTailLines so short blocks remain a two-click
597+// toggle. Both the thinking section cache and the F3 prefix cache
598+// fold thinkingViewMode into their keys, so no explicit invalidation
599+// is required here.
600+//
601+// When the message carries no thinking text the toggle is a no-op:
602+// there is nothing to expand, and mutating the view mode would
603+// thrash the thinking-section cache key for no visible benefit.
256func (a *AssistantMessageItem) ToggleExpanded() bool {
604func (a *AssistantMessageItem) ToggleExpanded() bool {
257− a.thinkingExpanded = !a.thinkingExpanded
605+ if strings.TrimSpace(a.message.ReasoningContent().Thinking) == "" {
258− a.clearCache()
606+ return a.thinkingViewMode != thinkingCollapsed
259− return a.thinkingExpanded
607+ }
608+ switch a.thinkingViewMode {
609+ case thinkingCollapsed:
610+ if a.tailWindowWouldTruncate() {
611+ a.thinkingViewMode = thinkingTailWindow
612+ } else {
613+ a.thinkingViewMode = thinkingFullExpanded
614+ }
615+ case thinkingTailWindow:
616+ a.thinkingViewMode = thinkingFullExpanded
617+ case thinkingFullExpanded:
618+ a.thinkingViewMode = thinkingCollapsed
619+ }
620+ a.Bump()
621+ return a.thinkingViewMode != thinkingCollapsed
622+}
623+
624+// tailWindowWouldTruncate reports whether the current thinking text
625+// is long enough that the tail-window step is worth inserting into
626+// the toggle cycle. We use a cheap source-text logical-line count
627+// as the heuristic rather than peeking into the cache: the cache
628+// may be populated in collapsed state (where its height is bounded
629+// by maxCollapsedThinkingHeight and tells us nothing about the
630+// underlying length), and re-running glamour just to count lines
631+// would defeat the cache. The heuristic can over-trigger (a source
632+// with many short lines may wrap to fewer than N lines), in which
633+// case the tail-window render is visually identical to full and
634+// the cycle costs the user one extra toggle — preferred over the
635+// alternative of failing to show the affordance on a genuinely
636+// long block.
637+//
638+// Logical line count is `1 + newlineCount` (a string with no
639+// newlines is one line). Comparing newline count alone introduced
640+// an off-by-one that let a source whose post-newline-split length
641+// equalled the cap skip the tail-window step.
642+func (a *AssistantMessageItem) tailWindowWouldTruncate() bool {
643+ lineCount := 1 + strings.Count(a.message.ReasoningContent().Thinking, "\n")
644+ return lineCount > maxExpandedThinkingTailLines
260}
645}
261
646
262// HandleMouseClick implements MouseClickable. It signals (via a true return)
647// HandleMouseClick implements MouseClickable. It signals (via a true return)
internal/ui/chat/assistant_section_cache_test.gonew file+464
@@ -0,0 +1,464 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// Fixed Unix timestamps for deterministic cache-equality tests. The
13+// thinking section's `extra` hash folds in ThinkingDuration, which
14+// in turn depends on (FinishedAt - StartedAt). Anchoring both
15+// timestamps removes any wall-clock dependency from the cache key
16+// so two builds across a second boundary still hit the cache.
17+const (
18+ testStartedAt int64 = 1_700_000_000
19+ testFinishedAt int64 = 1_700_000_005
20+ testFinishTime int64 = 1_700_000_006
21+)
22+
23+// thinkingMessage builds an assistant message with a fixed reasoning
24+// content and an optional text content. When text is empty the
25+// message represents a still-thinking turn (matches IsThinking()).
26+// Both reasoning timestamps are anchored to fixed Unix seconds so
27+// ThinkingDuration is deterministic and cache-equality assertions
28+// don't depend on wall-clock time.
29+func thinkingMessage(id, thinking, text string) *message.Message {
30+ parts := []message.ContentPart{
31+ message.ReasoningContent{
32+ Thinking: thinking,
33+ StartedAt: testStartedAt,
34+ FinishedAt: testFinishedAt,
35+ },
36+ }
37+ if text != "" {
38+ parts = append(parts, message.TextContent{Text: text})
39+ }
40+ return &message.Message{
41+ ID: id,
42+ Role: message.Assistant,
43+ Parts: parts,
44+ }
45+}
46+
47+// errorMessage builds a finished assistant message whose finish part
48+// carries an error reason plus a custom message and details.
49+func errorMessage(id, errMsg, errDetails string) *message.Message {
50+ return &message.Message{
51+ ID: id,
52+ Role: message.Assistant,
53+ Parts: []message.ContentPart{
54+ message.TextContent{Text: "partial output"},
55+ message.Finish{
56+ Reason: message.FinishReasonError,
57+ Message: errMsg,
58+ Details: errDetails,
59+ Time: testFinishTime,
60+ },
61+ },
62+ }
63+}
64+
65+// renderTwoSetMessages drives a SetMessage cycle and returns the
66+// section-cache identity (out string pointers via direct comparison
67+// of the cached fields). The test compares `out` strings; identical
68+// output across cycles is the cache-hit indicator we rely on.
69+type sectionSnapshot struct {
70+ thinking string
71+ content string
72+ errSec string
73+}
74+
75+func snapshot(a *AssistantMessageItem) sectionSnapshot {
76+ return sectionSnapshot{
77+ thinking: a.thinkingSec.out,
78+ content: a.contentSec.out,
79+ errSec: a.errorSec.out,
80+ }
81+}
82+
83+// TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking covers
84+// the central F4 invariant: streaming the main content through SetMessage
85+// must keep the cached thinking render intact, provided the inputs to
86+// the thinking section render (text, expanded flag, footer state) are
87+// unchanged. We seed an already-non-empty content so that IsThinking()
88+// is false on both renders — that's the steady streaming state where
89+// the thinking block has finished and content keeps growing.
90+func TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking(t *testing.T) {
91+ sty := styles.CharmtonePantera()
92+ thinking := "Step 1\nStep 2\nStep 3"
93+ msg := thinkingMessage("a1", thinking, "Initial answer.")
94+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
95+
96+ const width = 71
97+
98+ _ = item.RawRender(width)
99+ first := snapshot(item)
100+ require.NotEmpty(t, first.thinking, "thinking section must be populated after first render")
101+
102+ // Stream more content into the existing turn. Thinking text and
103+ // footer state are byte-identical between the two renders.
104+ updated := thinkingMessage("a1", thinking, "Initial answer. More streamed text.")
105+ item.SetMessage(updated)
106+ _ = item.RawRender(width)
107+ second := snapshot(item)
108+
109+ require.Equal(t, first.thinking, second.thinking,
110+ "content streaming must not invalidate the thinking section render")
111+ require.NotEqual(t, first.content, second.content,
112+ "content section must have been re-rendered")
113+}
114+
115+// TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent is the
116+// mirror of the previous test: extending thinking text must not force a
117+// re-render of the content section.
118+func TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent(t *testing.T) {
119+ sty := styles.CharmtonePantera()
120+ content := "Final answer goes here."
121+ msg := thinkingMessage("a2", "Step 1", content)
122+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
123+
124+ const width = 73
125+
126+ _ = item.RawRender(width)
127+ first := snapshot(item)
128+ require.NotEmpty(t, first.content)
129+
130+ updated := thinkingMessage("a2", "Step 1\nStep 2", content)
131+ item.SetMessage(updated)
132+ _ = item.RawRender(width)
133+ second := snapshot(item)
134+
135+ require.Equal(t, first.content, second.content,
136+ "thinking streaming must not invalidate the content section render")
137+ require.NotEqual(t, first.thinking, second.thinking,
138+ "thinking text changed; thinking section must have re-rendered")
139+}
140+
141+// TestAssistantSectionCache_HashKeyDiscrimination asserts that two
142+// messages with different source text hash to different per-section
143+// keys, and that messages with identical source text hit the cache.
144+func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
145+ sty := styles.CharmtonePantera()
146+ msgA := thinkingMessage("a3", "thinking A", "content A")
147+ msgB := thinkingMessage("a3", "thinking B", "content B")
148+
149+ itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
150+ itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
151+
152+ thinkSrcA, _ := itemA.thinkingKey()
153+ thinkSrcB, _ := itemB.thinkingKey()
154+ require.NotEqual(t, thinkSrcA, thinkSrcB,
155+ "distinct thinking text must produce distinct FNV-64 source hashes")
156+
157+ contentSrcA, _ := itemA.contentKey()
158+ contentSrcB, _ := itemB.contentKey()
159+ require.NotEqual(t, contentSrcA, contentSrcB,
160+ "distinct content text must produce distinct FNV-64 source hashes")
161+
162+ // Identical source text on a fresh item must produce the same
163+ // hashes — keying invariant for cache hits.
164+ itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
165+ thinkSrcAClone, _ := itemAClone.thinkingKey()
166+ contentSrcAClone, _ := itemAClone.contentKey()
167+ require.Equal(t, thinkSrcA, thinkSrcAClone)
168+ require.Equal(t, contentSrcA, contentSrcAClone)
169+}
170+
171+// TestAssistantSectionCache_CloneRoundTrip guards the contract that
172+// message.Clone() does not invalidate any section cache: re-keying off
173+// the cloned message must produce identical hashes and the section
174+// caches must serve byte-identical renders.
175+func TestAssistantSectionCache_CloneRoundTrip(t *testing.T) {
176+ sty := styles.CharmtonePantera()
177+ orig := thinkingMessage("a4", "Reasoning step.", "Answer text.")
178+ item := NewAssistantMessageItem(&sty, orig).(*AssistantMessageItem)
179+
180+ const width = 75
181+ _ = item.RawRender(width)
182+ first := snapshot(item)
183+
184+ cloned := orig.Clone()
185+ item.SetMessage(&cloned)
186+ _ = item.RawRender(width)
187+ second := snapshot(item)
188+
189+ require.Equal(t, first.thinking, second.thinking, "clone must hit the thinking cache")
190+ require.Equal(t, first.content, second.content, "clone must hit the content cache")
191+}
192+
193+// TestAssistantSectionCache_ResizeInvalidatesAll asserts that a width
194+// change forces a re-render of every section.
195+func TestAssistantSectionCache_ResizeInvalidatesAll(t *testing.T) {
196+ sty := styles.CharmtonePantera()
197+ msg := errorMessage("a5", "boom", strings.Repeat("detail line\n", 5))
198+ // errorMessage returns FinishReasonError; combine with thinking
199+ // content so all three sections are exercised.
200+ msg.Parts = append([]message.ContentPart{
201+ message.ReasoningContent{
202+ Thinking: "Considering options.",
203+ StartedAt: testStartedAt,
204+ FinishedAt: testFinishedAt,
205+ },
206+ }, msg.Parts...)
207+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
208+
209+ _ = item.RawRender(77)
210+ first := snapshot(item)
211+ require.NotEmpty(t, first.thinking)
212+ require.NotEmpty(t, first.content)
213+ require.NotEmpty(t, first.errSec)
214+
215+ _ = item.RawRender(117)
216+ second := snapshot(item)
217+
218+ require.NotEqual(t, first.thinking, second.thinking, "resize must re-render the thinking section")
219+ require.NotEqual(t, first.content, second.content, "resize must re-render the content section")
220+ require.NotEqual(t, first.errSec, second.errSec, "resize must re-render the error section")
221+}
222+
223+// TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent guards
224+// that the error section caches independently. Editing the error
225+// message must not invalidate the other two sections, and editing the
226+// content must not invalidate the error section.
227+func TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent(t *testing.T) {
228+ sty := styles.CharmtonePantera()
229+ build := func(thinking, content, errMsg, errDetails string) *message.Message {
230+ return &message.Message{
231+ ID: "a6",
232+ Role: message.Assistant,
233+ Parts: []message.ContentPart{
234+ message.ReasoningContent{
235+ Thinking: thinking,
236+ StartedAt: testStartedAt,
237+ FinishedAt: testFinishedAt,
238+ },
239+ message.TextContent{Text: content},
240+ message.Finish{
241+ Reason: message.FinishReasonError,
242+ Message: errMsg,
243+ Details: errDetails,
244+ Time: testFinishTime,
245+ },
246+ },
247+ }
248+ }
249+
250+ item := NewAssistantMessageItem(&sty, build("think", "content", "boom", "details")).(*AssistantMessageItem)
251+ _ = item.RawRender(79)
252+ first := snapshot(item)
253+
254+ // Change only the error text. Thinking and content caches must
255+ // survive; error cache must miss and re-render.
256+ item.SetMessage(build("think", "content", "different boom", "different details"))
257+ _ = item.RawRender(79)
258+ second := snapshot(item)
259+
260+ require.Equal(t, first.thinking, second.thinking, "error change must not invalidate thinking")
261+ require.Equal(t, first.content, second.content, "error change must not invalidate content")
262+ require.NotEqual(t, first.errSec, second.errSec, "error change must re-render the error section")
263+
264+ // Now change only the content; error cache must survive.
265+ item.SetMessage(build("think", "different content", "different boom", "different details"))
266+ _ = item.RawRender(79)
267+ third := snapshot(item)
268+
269+ require.Equal(t, second.thinking, third.thinking)
270+ require.NotEqual(t, second.content, third.content)
271+ require.Equal(t, second.errSec, third.errSec, "content change must not invalidate the error section")
272+}
273+
274+// TestAssistantSectionCache_PrefixCacheRespectsSectionChanges guards
275+// the F3/F4 boundary: the prefix cache must invalidate when any
276+// underlying section changes. We verify by comparing the F3-cached
277+// Render output across SetMessage cycles.
278+func TestAssistantSectionCache_PrefixCacheRespectsSectionChanges(t *testing.T) {
279+ sty := styles.CharmtonePantera()
280+ build := func(content string) *message.Message {
281+ return &message.Message{
282+ ID: "a7",
283+ Role: message.Assistant,
284+ Parts: []message.ContentPart{
285+ message.TextContent{Text: content},
286+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
287+ },
288+ }
289+ }
290+
291+ item := NewAssistantMessageItem(&sty, build("first content")).(*AssistantMessageItem)
292+ item.SetFocused(true)
293+
294+ const width = 81
295+ first := item.Render(width)
296+
297+ item.SetMessage(build("second content"))
298+ second := item.Render(width)
299+ require.NotEqual(t, first, second,
300+ "prefix cache must invalidate when the content section changes")
301+
302+ // Re-set to the original content; the prefix cache should
303+ // produce identical output again.
304+ item.SetMessage(build("first content"))
305+ third := item.Render(width)
306+ require.Equal(t, first, third)
307+}
308+
309+// TestAssistantSectionCache_ByteIdenticalToFreshRender asserts that the
310+// F4 cached path produces the same bytes as a fresh-instance render of
311+// the equivalent message — i.e. caching is invisible from the outside.
312+// Drives a sequence of mutations (thinking change, content change,
313+// finish) and compares every step against an independent item rendered
314+// from scratch.
315+func TestAssistantSectionCache_ByteIdenticalToFreshRender(t *testing.T) {
316+ sty := styles.CharmtonePantera()
317+ const width = 83
318+
319+ type step struct {
320+ name string
321+ msg *message.Message
322+ }
323+ startedAt := testStartedAt
324+ finishedAt := testFinishedAt
325+ finishTime := testFinishTime
326+ steps := []step{
327+ {
328+ name: "thinking-only",
329+ msg: &message.Message{
330+ ID: "iso", Role: message.Assistant,
331+ Parts: []message.ContentPart{
332+ message.ReasoningContent{Thinking: "first reasoning", StartedAt: startedAt},
333+ },
334+ },
335+ },
336+ {
337+ name: "thinking-grew",
338+ msg: &message.Message{
339+ ID: "iso", Role: message.Assistant,
340+ Parts: []message.ContentPart{
341+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt},
342+ },
343+ },
344+ },
345+ {
346+ name: "content-arrived",
347+ msg: &message.Message{
348+ ID: "iso", Role: message.Assistant,
349+ Parts: []message.ContentPart{
350+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
351+ message.TextContent{Text: "the answer"},
352+ },
353+ },
354+ },
355+ {
356+ name: "finished-end-turn",
357+ msg: &message.Message{
358+ ID: "iso", Role: message.Assistant,
359+ Parts: []message.ContentPart{
360+ message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
361+ message.TextContent{Text: "the answer"},
362+ message.Finish{Reason: message.FinishReasonEndTurn, Time: finishTime},
363+ },
364+ },
365+ },
366+ }
367+
368+ first := steps[0].msg.Clone()
369+ cached := NewAssistantMessageItem(&sty, &first).(*AssistantMessageItem)
370+ for _, s := range steps {
371+ cached.SetMessage(s.msg)
372+ freshMsg := s.msg.Clone()
373+ fresh := NewAssistantMessageItem(&sty, &freshMsg).(*AssistantMessageItem)
374+ require.Equal(t, fresh.RawRender(width), cached.RawRender(width),
375+ "step %q: cached path must match fresh render byte-for-byte", s.name)
376+ }
377+}
378+
379+// TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange
380+// guards the F3 prefix cache against composition-only changes:
381+// flipping the finish reason from EndTurn to Canceled appends a
382+// constant "Canceled" line via renderMessageContent, but no
383+// section's own source text changes. The prefix cache must observe
384+// the difference (compositionKey is folded into prefixCacheKey) and
385+// the resulting bytes must differ. As a second guarantee, a fresh
386+// item built with the same final state must produce byte-equal
387+// output to the cached item — caching must never produce stale or
388+// divergent renders.
389+func TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange(t *testing.T) {
390+ sty := styles.CharmtonePantera()
391+ const width = 87
392+
393+ build := func(reason message.FinishReason) *message.Message {
394+ return &message.Message{
395+ ID: "comp",
396+ Role: message.Assistant,
397+ Parts: []message.ContentPart{
398+ message.TextContent{Text: "hi"},
399+ message.Finish{Reason: reason, Time: testFinishTime},
400+ },
401+ }
402+ }
403+
404+ item := NewAssistantMessageItem(&sty, build(message.FinishReasonEndTurn)).(*AssistantMessageItem)
405+ endTurnOut := item.Render(width)
406+
407+ // Flip only the finish reason. Thinking is empty and content
408+ // text is unchanged, so no section's source hash moves; only
409+ // compositionKey shifts. The prefix cache must miss.
410+ item.SetMessage(build(message.FinishReasonCanceled))
411+ canceledOut := item.Render(width)
412+ require.NotEqual(t, endTurnOut, canceledOut,
413+ "prefix cache must invalidate on composition-only change (finish reason)")
414+
415+ // A fresh item built with the same final state must match the
416+ // cached item byte-for-byte — caching is invisible from the
417+ // outside and never serves stale output.
418+ fresh := NewAssistantMessageItem(&sty, build(message.FinishReasonCanceled)).(*AssistantMessageItem)
419+ require.Equal(t, fresh.Render(width), canceledOut,
420+ "cached output must equal a fresh render of the same final state")
421+}
422+
423+// TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit guards
424+// click-detection geometry across thinking-section cache hits. The
425+// thinking box height feeds HandleMouseClick; it is recomputed
426+// inside renderThinking and must be restored from
427+// assistantSection.aux when the thinking cache hits. We render once
428+// to capture the original height, trigger a content-only change so
429+// thinkingKey stays identical (thinking text, expanded flag, and
430+// footer state all unchanged), render again, and assert the
431+// thinkingBoxHeight field is preserved.
432+func TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit(t *testing.T) {
433+ sty := styles.CharmtonePantera()
434+ const width = 71
435+
436+ thinking := strings.Join([]string{
437+ "Considering the request.",
438+ "Looking at the relevant files.",
439+ "Drafting a plan.",
440+ "Verifying constraints.",
441+ }, "\n")
442+ msg := thinkingMessage("hbox", thinking, "initial answer")
443+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
444+ item.thinkingViewMode = thinkingFullExpanded
445+
446+ _ = item.RawRender(width)
447+ originalHeight := item.thinkingBoxHeight
448+ require.Greater(t, originalHeight, 0,
449+ "thinking box height must be populated after first render")
450+
451+ // Stomp the field so a stale read (cache hit that fails to
452+ // restore aux) is detectable. Then trigger a content-only
453+ // change: thinkingKey is byte-identical between renders, so
454+ // the thinking section cache must hit and restore the
455+ // preserved height via assistantSection.aux.
456+ item.thinkingBoxHeight = -1
457+ updated := thinkingMessage("hbox", thinking, "initial answer with more streamed text")
458+ item.SetMessage(updated)
459+ _ = item.RawRender(width)
460+
461+ require.Equal(t, originalHeight, item.thinkingBoxHeight,
462+ "thinkingBoxHeight must be preserved across thinking section cache hits "+
463+ "so HandleMouseClick keeps targeting the right rows")
464+}
internal/ui/chat/assistant_test.go+100−12
@@ -10,21 +10,108 @@ import (
10)
10)
11
11
12// TestAssistantMessageItemExpandable guards the Expandable contract on
12// TestAssistantMessageItemExpandable guards the Expandable contract on
13+// AssistantMessageItem along the keyboard-driven expand path. The earlier
13−// AssistantMessageItem. The earlier implementation returned no value, which
14+// implementation returned no value, which meant the type silently did
14−// meant the type silently did not satisfy chat.Expandable and the
15+// not satisfy chat.Expandable and the keyboard-driven expand path in
15−// keyboard-driven expand path in model/chat.go skipped thinking blocks.
16+// model/chat.go skipped thinking blocks.
17+//
18+// We exercise the contract through the bare Expandable interface (the
19+// same dispatch site model.Chat.ToggleExpandedSelectedItem uses), which
20+// proves both that AssistantMessageItem still satisfies the interface
21+// and that the bool return reports the right semantic state at every
22+// point in the cycle.
16func TestAssistantMessageItemExpandable(t *testing.T) {
23func TestAssistantMessageItemExpandable(t *testing.T) {
17 t.Parallel()
24 t.Parallel()
18
25
19 sty := styles.CharmtonePantera()
26 sty := styles.CharmtonePantera()
20− msg := &message.Message{ID: "m1", Role: message.Assistant}
27+ // Short thinking: under the tail-window cap, so the cycle is
28+ // collapsed -> full -> collapsed (tail-window is skipped).
29+ msg := thinkingMessage("m1", "step one\nstep two\nstep three", "")
21− item := NewAssistantMessageItem(&sty, msg)
30+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
22
31
23− exp, ok := item.(Expandable)
32+ exp, ok := any(item).(Expandable)
24 require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
33 require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
25
34
35+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
36+ "new items must start in the collapsed view-mode")
26− require.True(t, exp.ToggleExpanded(), "first toggle should report expanded")
37+ require.True(t, exp.ToggleExpanded(),
38+ "first toggle of a non-empty thinking block must report expanded")
39+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
40+ "short blocks must skip tail-window and land in full expansion")
41+ require.False(t, exp.ToggleExpanded(),
27− require.False(t, exp.ToggleExpanded(), "second toggle should report collapsed")
42+ "second toggle must report collapsed (cycle closed)")
43+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
44+}
45+
46+// TestAssistantMessageItemExpandableEmptyThinkingNoOp guards the B2
47+// fix: a message with no thinking text must treat ToggleExpanded as a
48+// no-op. Mutating the view mode in that case would thrash the
49+// thinking-section cache key for no visible benefit and would surprise
50+// the caller (model.Chat.ToggleExpandedSelectedItem would treat a
51+// "now collapsed" return as a real state change and re-scroll on it).
52+func TestAssistantMessageItemExpandableEmptyThinkingNoOp(t *testing.T) {
53+ t.Parallel()
54+
55+ sty := styles.CharmtonePantera()
56+ msg := &message.Message{ID: "m1-empty", Role: message.Assistant}
57+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
58+
59+ exp, ok := any(item).(Expandable)
60+ require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
61+
62+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
63+ require.False(t, exp.ToggleExpanded(),
64+ "empty thinking must report current (collapsed) state without flipping")
65+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
66+ "empty-thinking toggle must not mutate thinkingViewMode")
67+
68+ // Whitespace-only thinking is still effectively empty.
69+ item.message.Parts = []message.ContentPart{
70+ message.ReasoningContent{Thinking: " \n\n\t ", StartedAt: testStartedAt},
71+ }
72+ require.False(t, exp.ToggleExpanded())
73+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
74+}
75+
76+// TestAssistantMessageItemTailWindowBoundary guards the B1 fix: the
77+// tail-window heuristic must compare logical line counts (1 +
78+// newline count) against the cap, not raw newline counts. A source
79+// whose logical line count exactly equals the cap must NOT trip the
80+// tail-window step (full render still fits cleanly under the cap),
81+// while one logical line over the cap must trip it.
82+func TestAssistantMessageItemTailWindowBoundary(t *testing.T) {
83+ t.Parallel()
84+
85+ sty := styles.CharmtonePantera()
86+
87+ atCap := buildLines(maxExpandedThinkingTailLines)
88+ overCap := buildLines(maxExpandedThinkingTailLines + 1)
89+
90+ atItem := NewAssistantMessageItem(&sty, thinkingMessage("at-cap", atCap, "")).(*AssistantMessageItem)
91+ require.False(t, atItem.tailWindowWouldTruncate(),
92+ "a source with exactly N logical lines must not trip the tail-window step")
93+
94+ overItem := NewAssistantMessageItem(&sty, thinkingMessage("over-cap", overCap, "")).(*AssistantMessageItem)
95+ require.True(t, overItem.tailWindowWouldTruncate(),
96+ "a source with N+1 logical lines must trip the tail-window step")
97+}
98+
99+// buildLines returns a string of n logical lines (n-1 newlines). Each
100+// line is a unique short token so callers can distinguish head from
101+// tail in rendered output if they need to.
102+func buildLines(n int) string {
103+ if n <= 0 {
104+ return ""
105+ }
106+ var b []byte
107+ for i := 1; i <= n; i++ {
108+ if i > 1 {
109+ b = append(b, '\n')
110+ }
111+ b = append(b, 'l', 'n')
112+ b = append(b, []byte(itoa(i))...)
113+ }
114+ return string(b)
28}
115}
29
116
30// TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
117// TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
@@ -40,15 +127,16 @@ func TestAssistantMessageItemHandleMouseClick(t *testing.T) {
40 item.thinkingBoxHeight = 5
127 item.thinkingBoxHeight = 5
41
128
42 // Click inside the thinking box signals handled but must not mutate
129 // Click inside the thinking box signals handled but must not mutate
43− // the expanded state.
130+ // the view-mode state.
44 require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
131 require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
132+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
45− require.False(t, item.thinkingExpanded, "HandleMouseClick must not toggle expansion on its own")
133+ "HandleMouseClick must not toggle expansion on its own")
46
134
47 // Click outside the thinking box is ignored entirely.
135 // Click outside the thinking box is ignored entirely.
48 require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
136 require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
49− require.False(t, item.thinkingExpanded)
137+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
50
138
51 // Non-left button is ignored.
139 // Non-left button is ignored.
52 require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
140 require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
53− require.False(t, item.thinkingExpanded)
141+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
54}
142}
internal/ui/chat/assistant_thinking_window_test.gonew file+500
@@ -0,0 +1,500 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/charmbracelet/x/ansi"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// thinkingMessageWithLines builds a still-thinking assistant message
15+// whose reasoning content is `count` short paragraphs separated by
16+// blank lines. The blank-line separation is what matters: glamour
17+// renders paragraph blocks one-per-line in the output (with a
18+// trailing blank line between paragraphs) instead of reflowing the
19+// entire input into one big wrapped paragraph. That gives us a
20+// post-glamour line count we can drive past the tail-window
21+// threshold deterministically. Each paragraph is tagged with its
22+// (1-based) index so the test can identify head vs tail in the
23+// rendered output.
24+//
25+// The message has no text content and no Finish part, so
26+// IsThinking() returns true and the render path skips the
27+// "Thought for" footer — keeping the rendered height computation
28+// simple.
29+func thinkingMessageWithLines(id string, count int) *message.Message {
30+ var b strings.Builder
31+ for i := 1; i <= count; i++ {
32+ b.WriteString("ln")
33+ b.WriteString(itoa(i))
34+ if i < count {
35+ // Blank line between paragraphs: glamour preserves the
36+ // per-paragraph structure rather than reflowing into one
37+ // wrapped block, so totalLines tracks count predictably.
38+ b.WriteString("\n\n")
39+ }
40+ }
41+ return &message.Message{
42+ ID: id,
43+ Role: message.Assistant,
44+ Parts: []message.ContentPart{
45+ message.ReasoningContent{
46+ Thinking: b.String(),
47+ StartedAt: testStartedAt,
48+ },
49+ },
50+ }
51+}
52+
53+// itoa is a local stdlib-free integer formatter used only by these
54+// tests; pulling fmt in just for %d would be wasteful when the test
55+// fixtures already churn 5000+ short strings.
56+func itoa(n int) string {
57+ if n == 0 {
58+ return "0"
59+ }
60+ var buf [20]byte
61+ i := len(buf)
62+ for n > 0 {
63+ i--
64+ buf[i] = byte('0' + n%10)
65+ n /= 10
66+ }
67+ return string(buf[i:])
68+}
69+
70+// renderedThinkingHeight returns the line count of the cached
71+// thinking section render only (not the full RawRender, which also
72+// includes the content and error sections). Drives a render at
73+// `width` first to populate the cache.
74+func renderedThinkingHeight(t *testing.T, item *AssistantMessageItem, width int) int {
75+ t.Helper()
76+ _ = item.RawRender(width)
77+ require.NotEmpty(t, item.thinkingSec.out,
78+ "thinking section must be populated after RawRender")
79+ return lipgloss.Height(item.thinkingSec.out)
80+}
81+
82+// TestThinkingWindow_CollapsedCapPreserved guards that F5 did not
83+// regress the existing collapsed-mode behaviour: a 5000-line
84+// thinking block in the default (collapsed) state still renders at
85+// most a small bounded height — the last `maxCollapsedThinkingHeight`
86+// lines plus the truncation hint. The thinking message keeps
87+// IsThinking() == true, so the optional "Thought for" footer is
88+// suppressed and the section height equals the box height.
89+func TestThinkingWindow_CollapsedCapPreserved(t *testing.T) {
90+ t.Parallel()
91+
92+ sty := styles.CharmtonePantera()
93+ msg := thinkingMessageWithLines("collapsed", 5000)
94+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
95+
96+ // Default state must be collapsed.
97+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
98+
99+ // Unique odd width avoids sharing the glamour renderer cache with
100+ // any other parallel test (the renderer instance is memoized per
101+ // width and is not safe for concurrent Render calls).
102+ const width = 91
103+ height := renderedThinkingHeight(t, item, width)
104+
105+ // Collapsed mode keeps the existing cap: last 10 lines + a
106+ // 2-line hint prefix (hint + blank). Allow a small slack for
107+ // any future style-driven padding so the test is robust to
108+ // cosmetic tweaks while still being orders of magnitude below
109+ // the 5000-line source.
110+ const collapsedUpperBound = maxCollapsedThinkingHeight + 5
111+ require.LessOrEqual(t, height, collapsedUpperBound,
112+ "collapsed mode must remain bounded by the small cap; got %d", height)
113+}
114+
115+// TestThinkingWindow_ExpandedShortSkipsTailWindow guards that a
116+// short thinking block (well under the tail-window cap) still
117+// toggles directly to full expansion without an intermediate
118+// tail-window step and shows no affordance footer. The cycle is
119+// collapsed -> full -> collapsed for short blocks; tail-window is
120+// only inserted when it would actually elide content.
121+func TestThinkingWindow_ExpandedShortSkipsTailWindow(t *testing.T) {
122+ t.Parallel()
123+
124+ sty := styles.CharmtonePantera()
125+ const lines = 50
126+ require.Less(t, lines, maxExpandedThinkingTailLines,
127+ "this test relies on the source being well under the tail cap")
128+ msg := thinkingMessageWithLines("short", lines)
129+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
130+
131+ require.True(t, item.ToggleExpanded(),
132+ "first toggle should report expanded")
133+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
134+ "short blocks must skip tail-window and go straight to full expansion")
135+
136+ const width = 93
137+ _ = item.RawRender(width)
138+ out := item.thinkingSec.out
139+ plain := ansi.Strip(out)
140+
141+ require.NotContains(t, plain, "earlier lines hidden",
142+ "short blocks must not show the tail-window affordance")
143+ require.NotContains(t, plain, "lines hidden",
144+ "short expanded blocks must not show any truncation hint")
145+ require.Contains(t, plain, "ln1 ",
146+ "a fully expanded short block must include the very first source paragraph")
147+ require.Contains(t, plain, "ln50 ",
148+ "a fully expanded short block must include the last source paragraph")
149+}
150+
151+// TestThinkingWindow_TailWindowed asserts the central F5 behaviour:
152+// expanding a long thinking block produces a tail window of size
153+// `maxExpandedThinkingTailLines` plus the affordance footer, with
154+// the LAST source line present (i.e. we tailed, not headed) and
155+// earlier lines elided.
156+//
157+// Beyond presence/absence of sentinels, this test verifies a true
158+// `tail -K` relationship between the tail-windowed render and the
159+// fully-expanded render of the same source at the same width: the
160+// last K plain-ANSI lines of the windowed render must byte-equal
161+// the last K lines of the unwindowed render.
162+//
163+// K is sized below the cap to absorb the affordance prefix (hint +
164+// blank line) and any small framing differences introduced by the
165+// bordered ThinkingBox. The cap minus 5 leaves a comfortable margin
166+// for padding/footer rows while still asserting that the bulk of
167+// the rendered tail is identical.
168+func TestThinkingWindow_TailWindowed(t *testing.T) {
169+ t.Parallel()
170+
171+ sty := styles.CharmtonePantera()
172+ const total = 5000
173+ const width = 95
174+
175+ // Tail-windowed render.
176+ tailMsg := thinkingMessageWithLines("tail", total)
177+ tailItem := NewAssistantMessageItem(&sty, tailMsg).(*AssistantMessageItem)
178+ require.True(t, tailItem.ToggleExpanded(), "first toggle should report expanded")
179+ require.Equal(t, thinkingTailWindow, tailItem.thinkingViewMode,
180+ "a long block must enter tail-window after the first toggle")
181+
182+ height := renderedThinkingHeight(t, tailItem, width)
183+
184+ // The visible window is N tail lines plus an affordance line
185+ // and a blank-line spacer (matching the existing collapsed-mode
186+ // hint structure). Allow a small slack for style-driven
187+ // padding.
188+ const expectedFloor = maxExpandedThinkingTailLines + 1
189+ const expectedCeil = maxExpandedThinkingTailLines + 5
190+ require.GreaterOrEqual(t, height, expectedFloor,
191+ "tail-window must include at least N + affordance lines; got %d", height)
192+ require.LessOrEqual(t, height, expectedCeil,
193+ "tail-window must not exceed N + a small padding budget; got %d", height)
194+
195+ tailPlain := ansi.Strip(tailItem.thinkingSec.out)
196+
197+ require.Contains(t, tailPlain, "earlier lines hidden",
198+ "tail-windowed render must include the affordance footer")
199+ require.Contains(t, tailPlain, "ln5000",
200+ "tail-windowed render must include the LAST source paragraph — we tailed, not headed")
201+ require.NotContains(t, tailPlain, "ln1 ",
202+ "tail-windowed render must elide early source paragraphs")
203+
204+ // Independent reference render: same source, same width, full
205+ // expansion (no tail slice). The tail-windowed output's last K
206+ // lines must byte-equal the unwindowed output's last K lines.
207+ fullMsg := thinkingMessageWithLines("tail-full-ref", total)
208+ fullItem := NewAssistantMessageItem(&sty, fullMsg).(*AssistantMessageItem)
209+ fullItem.thinkingViewMode = thinkingFullExpanded
210+ _ = fullItem.RawRender(width)
211+ fullPlain := ansi.Strip(fullItem.thinkingSec.out)
212+
213+ tailLines := strings.Split(tailPlain, "\n")
214+ fullLines := strings.Split(fullPlain, "\n")
215+
216+ // K is the cap minus a small budget that covers the affordance
217+ // prefix (hint line + blank line) and any framing differences
218+ // the bordered ThinkingBox style may introduce around the
219+ // edges. Documented inline because going much larger lets the
220+ // affordance row leak into the comparison; going much smaller
221+ // dilutes the assertion.
222+ const K = maxExpandedThinkingTailLines - 5
223+ require.GreaterOrEqual(t, len(tailLines), K,
224+ "tail render must contain at least K lines; got %d", len(tailLines))
225+ require.GreaterOrEqual(t, len(fullLines), K,
226+ "full render must contain at least K lines; got %d", len(fullLines))
227+
228+ tailTail := tailLines[len(tailLines)-K:]
229+ fullTail := fullLines[len(fullLines)-K:]
230+ require.Equal(t, fullTail, tailTail,
231+ "tail-windowed render's last %d lines must byte-equal the unwindowed render's last %d lines (true tail -K relationship)",
232+ K, K)
233+}
234+
235+// TestThinkingWindow_PromoteToFull verifies the cycle continues from
236+// tail-window to full expansion: the second toggle drops the
237+// affordance, removes the tail slice, and produces a render that
238+// matches a fresh item rendered directly in the full-expanded
239+// state.
240+func TestThinkingWindow_PromoteToFull(t *testing.T) {
241+ t.Parallel()
242+
243+ sty := styles.CharmtonePantera()
244+ const total = 1500
245+ msg := thinkingMessageWithLines("promote", total)
246+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
247+
248+ const width = 97
249+
250+ require.True(t, item.ToggleExpanded())
251+ require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
252+ _ = item.RawRender(width)
253+ tailOut := item.thinkingSec.out
254+ require.Contains(t, ansi.Strip(tailOut), "earlier lines hidden")
255+
256+ require.True(t, item.ToggleExpanded(), "second toggle stays expanded (full)")
257+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
258+ _ = item.RawRender(width)
259+ fullOut := item.thinkingSec.out
260+ fullPlain := ansi.Strip(fullOut)
261+
262+ require.NotContains(t, fullPlain, "earlier lines hidden",
263+ "full expansion must drop the tail-window affordance")
264+ require.Contains(t, fullPlain, "ln1 ",
265+ "full expansion must include the first source paragraph")
266+ require.Contains(t, fullPlain, "ln1500 ",
267+ "full expansion must include the last source paragraph")
268+
269+ // Independent reference: a fresh item, rendered straight into
270+ // the full-expanded state, must produce byte-equal output.
271+ freshMsg := thinkingMessageWithLines("promote-fresh", total)
272+ fresh := NewAssistantMessageItem(&sty, freshMsg).(*AssistantMessageItem)
273+ fresh.thinkingViewMode = thinkingFullExpanded
274+ _ = fresh.RawRender(width)
275+ require.Equal(t, fresh.thinkingSec.out, fullOut,
276+ "cached full-expanded output must match a fresh full-expanded render")
277+
278+ // And the cycle closes back to collapsed.
279+ require.False(t, item.ToggleExpanded(), "third toggle must report collapsed")
280+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
281+}
282+
283+// sectionKey is the tuple that defines a cache-hit identity for an
284+// assistantSection: (width, srcHash, extra). Comparing this tuple
285+// across mutations is a stronger invariant than byte-equality of
286+// rendered output: byte-equality could in principle hold even if
287+// the cache invalidated and re-rendered identical bytes, while
288+// tuple-equality proves the lookup key never moved.
289+type sectionKey struct {
290+ width int
291+ srcHash uint64
292+ extra uint64
293+}
294+
295+func keyOf(s assistantSection) sectionKey {
296+ return sectionKey{width: s.width, srcHash: s.srcHash, extra: s.extra}
297+}
298+
299+// TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow
300+// guards the F4/F5 boundary: streaming the main content while the
301+// thinking block sits in tail-window mode must NOT invalidate the
302+// thinking section cache. Tail-window state is folded into
303+// thinkingKey()'s extra hash, so changing only the content text
304+// keeps thinking's (srcHash, extra) tuple identical and the cache
305+// hits.
306+//
307+// The assertion is on the cache key tuple, not just rendered bytes:
308+// equal output could in principle survive a re-render with
309+// identical inputs, but identical (width, srcHash, extra) tuples
310+// across the SetMessage cycle prove the thinking cache was never
311+// invalidated to begin with. The mirror tuple on the content
312+// section MUST move (the source text changed), or the test isn't
313+// exercising what it claims to.
314+func TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow(t *testing.T) {
315+ t.Parallel()
316+
317+ sty := styles.CharmtonePantera()
318+ const total = 1000
319+
320+ build := func(content string) *message.Message {
321+ var b strings.Builder
322+ for i := 1; i <= total; i++ {
323+ b.WriteString("ln")
324+ b.WriteString(itoa(i))
325+ if i < total {
326+ b.WriteString("\n\n")
327+ }
328+ }
329+ parts := []message.ContentPart{
330+ message.ReasoningContent{
331+ Thinking: b.String(),
332+ StartedAt: testStartedAt,
333+ FinishedAt: testFinishedAt,
334+ },
335+ }
336+ if content != "" {
337+ parts = append(parts, message.TextContent{Text: content})
338+ }
339+ return &message.Message{ID: "tail-stream", Role: message.Assistant, Parts: parts}
340+ }
341+
342+ item := NewAssistantMessageItem(&sty, build("first answer")).(*AssistantMessageItem)
343+ item.thinkingViewMode = thinkingTailWindow
344+
345+ const width = 99
346+ _ = item.RawRender(width)
347+ first := snapshot(item)
348+ firstThinkingKey := keyOf(item.thinkingSec)
349+ firstContentKey := keyOf(item.contentSec)
350+ require.NotEmpty(t, first.thinking)
351+
352+ item.SetMessage(build("first answer with more streaming text"))
353+ _ = item.RawRender(width)
354+ second := snapshot(item)
355+ secondThinkingKey := keyOf(item.thinkingSec)
356+ secondContentKey := keyOf(item.contentSec)
357+
358+ require.Equal(t, firstThinkingKey, secondThinkingKey,
359+ "thinking section's (width, srcHash, extra) tuple must not move "+
360+ "across a content-only update — proves the cache key never invalidated")
361+ require.Equal(t, first.thinking, second.thinking,
362+ "content streaming must not invalidate the tail-windowed thinking cache")
363+ require.NotEqual(t, firstContentKey, secondContentKey,
364+ "content section's tuple MUST move; otherwise this test isn't exercising a real content change")
365+ require.NotEqual(t, first.content, second.content,
366+ "content section must have re-rendered")
367+}
368+
369+// TestThinkingWindow_ToggleInvalidatesOnlyThinking verifies that
370+// cycling thinkingViewMode invalidates the thinking section cache
371+// alone — content and error caches survive across the toggle.
372+//
373+// Like TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow,
374+// the assertion is on the cache key tuple (width, srcHash, extra)
375+// at each section, not just on rendered bytes:
376+// - thinking's tuple MUST move (extra folds in thinkingViewMode)
377+// - content's and error's tuples MUST NOT move (their keys depend
378+// only on their own source text, untouched by the toggle).
379+func TestThinkingWindow_ToggleInvalidatesOnlyThinking(t *testing.T) {
380+ t.Parallel()
381+
382+ sty := styles.CharmtonePantera()
383+ const total = 1500
384+ build := func() *message.Message {
385+ var b strings.Builder
386+ for i := 1; i <= total; i++ {
387+ b.WriteString("ln")
388+ b.WriteString(itoa(i))
389+ if i < total {
390+ b.WriteString("\n\n")
391+ }
392+ }
393+ return &message.Message{
394+ ID: "toggle-iso",
395+ Role: message.Assistant,
396+ Parts: []message.ContentPart{
397+ message.ReasoningContent{
398+ Thinking: b.String(),
399+ StartedAt: testStartedAt,
400+ FinishedAt: testFinishedAt,
401+ },
402+ message.TextContent{Text: "answer text"},
403+ message.Finish{
404+ Reason: message.FinishReasonError,
405+ Message: "boom",
406+ Details: "details",
407+ Time: testFinishTime,
408+ },
409+ },
410+ }
411+ }
412+
413+ item := NewAssistantMessageItem(&sty, build()).(*AssistantMessageItem)
414+
415+ const width = 101
416+ _ = item.RawRender(width)
417+ first := snapshot(item)
418+ firstThink := keyOf(item.thinkingSec)
419+ firstContent := keyOf(item.contentSec)
420+ firstErr := keyOf(item.errorSec)
421+ require.NotEmpty(t, first.thinking)
422+ require.NotEmpty(t, first.content)
423+ require.NotEmpty(t, first.errSec)
424+
425+ // Cycle: collapsed -> tail-window. Only thinking should change.
426+ require.True(t, item.ToggleExpanded())
427+ require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
428+ _ = item.RawRender(width)
429+ second := snapshot(item)
430+ secondThink := keyOf(item.thinkingSec)
431+ secondContent := keyOf(item.contentSec)
432+ secondErr := keyOf(item.errorSec)
433+
434+ require.NotEqual(t, firstThink, secondThink,
435+ "thinking section's tuple MUST move on toggle (extra folds in thinkingViewMode)")
436+ require.Equal(t, firstContent, secondContent,
437+ "content section's tuple must not move on a thinking toggle")
438+ require.Equal(t, firstErr, secondErr,
439+ "error section's tuple must not move on a thinking toggle")
440+ require.NotEqual(t, first.thinking, second.thinking,
441+ "toggling into tail-window must re-render the thinking section")
442+ require.Equal(t, first.content, second.content,
443+ "toggling thinking view-mode must not invalidate the content section")
444+ require.Equal(t, first.errSec, second.errSec,
445+ "toggling thinking view-mode must not invalidate the error section")
446+
447+ // Cycle: tail-window -> full. Same expectation.
448+ require.True(t, item.ToggleExpanded())
449+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
450+ _ = item.RawRender(width)
451+ third := snapshot(item)
452+ thirdThink := keyOf(item.thinkingSec)
453+ thirdContent := keyOf(item.contentSec)
454+ thirdErr := keyOf(item.errorSec)
455+
456+ require.NotEqual(t, secondThink, thirdThink,
457+ "thinking section's tuple MUST move on the second toggle as well")
458+ require.Equal(t, secondContent, thirdContent,
459+ "content section's tuple must remain stable across the second toggle")
460+ require.Equal(t, secondErr, thirdErr,
461+ "error section's tuple must remain stable across the second toggle")
462+ require.NotEqual(t, second.thinking, third.thinking,
463+ "toggling into full expansion must re-render the thinking section")
464+ require.Equal(t, second.content, third.content)
465+ require.Equal(t, second.errSec, third.errSec)
466+}
467+
468+// TestThinkingWindow_BoxHeightTracksWindow asserts that
469+// thinkingBoxHeight reflects the WINDOWED render's height in
470+// tail-window mode, not the (much larger) full thinking height.
471+// This is what HandleMouseClick uses to detect whether a click
472+// landed on the thinking box, so getting it wrong would make
473+// click detection extend off the bottom of the visible box.
474+func TestThinkingWindow_BoxHeightTracksWindow(t *testing.T) {
475+ t.Parallel()
476+
477+ sty := styles.CharmtonePantera()
478+ const total = 5000
479+ msg := thinkingMessageWithLines("box-height", total)
480+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
481+
482+ const width = 103
483+
484+ // Tail-window: height should be roughly the cap.
485+ item.thinkingViewMode = thinkingTailWindow
486+ _ = item.RawRender(width)
487+ tailHeight := item.thinkingBoxHeight
488+ require.Greater(t, tailHeight, 0)
489+ require.LessOrEqual(t, tailHeight, maxExpandedThinkingTailLines+5,
490+ "tail-window box height must reflect the windowed render, not the full thinking height; got %d",
491+ tailHeight)
492+
493+ // Full expansion: height should grow well past the tail cap.
494+ item.thinkingViewMode = thinkingFullExpanded
495+ _ = item.RawRender(width)
496+ fullHeight := item.thinkingBoxHeight
497+ require.Greater(t, fullHeight, maxExpandedThinkingTailLines*2,
498+ "full expansion box height must reflect the full thinking render; got %d",
499+ fullHeight)
500+}
internal/ui/chat/incremental_glamour_test.gonew file+853
@@ -0,0 +1,853 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "charm.land/glamour/v2"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// newTestRenderer builds a fresh glamour renderer for the given
13+// width. We deliberately do NOT share renderers between calls in
14+// the equivalence tests so any hidden state in
15+// [glamour.TermRenderer] cannot leak from a "cached" rendering
16+// path into a "fresh" rendering path.
17+func newTestRenderer(t *testing.T, width int) *glamour.TermRenderer {
18+ t.Helper()
19+ sty := styles.CharmtonePantera()
20+ r, err := glamour.NewTermRenderer(
21+ glamour.WithStyles(sty.Markdown),
22+ glamour.WithWordWrap(width),
23+ )
24+ require.NoError(t, err)
25+ return r
26+}
27+
28+// freshRender renders content as a single document with a fresh
29+// glamour renderer and applies the same trailing-newline trim
30+// that streamingMarkdown.Render does. Use this for byte- and
31+// visible-equivalence comparisons against the streaming path.
32+func freshRender(t *testing.T, content string, width int) string {
33+ t.Helper()
34+ r := newTestRenderer(t, width)
35+ out, err := r.Render(content)
36+ require.NoError(t, err)
37+ return strings.TrimSuffix(out, "\n")
38+}
39+
40+// stripANSI removes all ANSI CSI escape sequences from s so two
41+// renders with different colour state can be compared on their
42+// visible glyphs alone.
43+func stripANSI(s string) string {
44+ var b strings.Builder
45+ b.Grow(len(s))
46+ i := 0
47+ for i < len(s) {
48+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
49+ j := i + 2
50+ for j < len(s) {
51+ c := s[j]
52+ if c >= 0x40 && c <= 0x7e {
53+ j++
54+ break
55+ }
56+ j++
57+ }
58+ i = j
59+ continue
60+ }
61+ b.WriteByte(s[i])
62+ i++
63+ }
64+ return b.String()
65+}
66+
67+// normalizeRender canonicalises a rendered glamour string for
68+// visual-equivalence comparison: strip ANSI, drop per-line
69+// trailing whitespace, drop leading/trailing blank lines, and
70+// collapse consecutive blank lines to a single blank line.
71+//
72+// Glamour pads rendered lines with trailing spaces and adds top/
73+// bottom block margins that differ subtly between "render the
74+// whole document at once" and "render two halves and concatenate
75+// them." Per F8 design principle D, those byte-level differences
76+// are acceptable as long as the visible content matches; this
77+// helper makes that comparison explicit.
78+func normalizeRender(s string) string {
79+ clean := stripANSI(s)
80+ lines := strings.Split(clean, "\n")
81+ for i, l := range lines {
82+ lines[i] = strings.TrimRight(l, " \t")
83+ }
84+ // Collapse consecutive blank lines.
85+ out := make([]string, 0, len(lines))
86+ prevBlank := false
87+ for _, l := range lines {
88+ blank := l == ""
89+ if blank && prevBlank {
90+ continue
91+ }
92+ out = append(out, l)
93+ prevBlank = blank
94+ }
95+ // Trim leading and trailing blanks.
96+ for len(out) > 0 && out[0] == "" {
97+ out = out[1:]
98+ }
99+ for len(out) > 0 && out[len(out)-1] == "" {
100+ out = out[:len(out)-1]
101+ }
102+ return strings.Join(out, "\n")
103+}
104+
105+// containsRawMarkdownSource reports whether the visible portion of
106+// rendered contains literal markdown source markers that should
107+// have been consumed by glamour. Used by T2 to assert that
108+// intermediate streaming flushes don't leak raw source through to
109+// the user. We deliberately only flag markers that glamour
110+// removes during rendering ("```" fence delimiters, "|" table
111+// pipes embedded in a line that also contains pipes — actual
112+// table syntax — and bare "###" headers); pipes-in-prose and
113+// dashes are too common to flag.
114+func containsRawMarkdownSource(rendered string) bool {
115+ clean := stripANSI(rendered)
116+ if strings.Contains(clean, "```") {
117+ return true
118+ }
119+ for _, line := range strings.Split(clean, "\n") {
120+ if strings.HasPrefix(strings.TrimLeft(line, " \t"), "###") {
121+ return true
122+ }
123+ }
124+ return false
125+}
126+
127+// -----------------------------------------------------------------------
128+// T1: findSafeMarkdownBoundary unit tests.
129+// -----------------------------------------------------------------------
130+
131+// TestFindSafeMarkdownBoundary_TableDriven exercises the
132+// findSafeMarkdownBoundary decision tree across the full set of
133+// constructs §4.4 calls out: plain paragraphs, fenced code (open
134+// and closed), lists, tables, block quotes, and setext headers.
135+func TestFindSafeMarkdownBoundary_TableDriven(t *testing.T) {
136+ t.Parallel()
137+
138+ cases := []struct {
139+ name string
140+ content string
141+ // want is the expected boundary; -1 means "no safe
142+ // boundary." When >=0 the test asserts content[:want]
143+ // ends after a blank-line separator and content[:want]
144+ // is a complete prefix.
145+ want int
146+ }{
147+ {
148+ name: "empty",
149+ content: "",
150+ want: -1,
151+ },
152+ {
153+ name: "single line",
154+ content: "Just a single paragraph",
155+ want: -1,
156+ },
157+ {
158+ name: "two paragraphs",
159+ content: "First paragraph.\n\nSecond paragraph.",
160+ // boundary at start of "Second"
161+ want: len("First paragraph.\n\n"),
162+ },
163+ {
164+ name: "three paragraphs picks latest",
165+ content: "First.\n\nSecond.\n\nThird.",
166+ want: len("First.\n\nSecond.\n\n"),
167+ },
168+ {
169+ name: "open fence at end",
170+ content: "Para.\n\n```go\nfoo()\n",
171+ // no closing fence — every blank-line candidate
172+ // before content end is INSIDE the fence (the open
173+ // fence opened at offset 7). Actually the ONLY
174+ // blank line is between "Para." and "```go", so
175+ // candidate boundary is right before "```go". At
176+ // that point fence count = 0, even, but the line
177+ // AFTER (the first non-blank) is "```go" which
178+ // would change rendering of the prefix… hmm,
179+ // actually it wouldn't change the prefix's
180+ // rendering because the prefix is just "Para.\n\n".
181+ // The boundary would be ACCEPTED. Let's check
182+ // what our impl does.
183+ want: len("Para.\n\n"),
184+ },
185+ {
186+ name: "inside open fence: no candidate after open",
187+ content: "Para.\n\n```go\nfoo()\n\nbar()\n",
188+ // blank line after "foo()" is INSIDE the fence
189+ // (fence count at that prefix = 1, odd), must
190+ // reject. The earlier blank line between "Para."
191+ // and "```go" should still be safe (fence count
192+ // at that prefix = 0).
193+ want: len("Para.\n\n"),
194+ },
195+ {
196+ name: "closed fence followed by paragraph",
197+ content: "Para1.\n\n```\nfoo()\n```\n\nPara2.",
198+ // latest blank line is between "```" and "Para2.";
199+ // fence count at that prefix = 2 (even), last
200+ // non-blank line is "```" which is not a list/
201+ // table/quote/setext.
202+ want: len("Para1.\n\n```\nfoo()\n```\n\n"),
203+ },
204+ {
205+ name: "open list at end",
206+ content: "Para.\n\n- one\n- two\n",
207+ // last non-blank line of any blank-bounded prefix
208+ // is a list item; our boundary check rejects.
209+ // The blank line between "Para." and "- one" is
210+ // the only candidate, but the line AFTER (first
211+ // non-blank of suffix) is "- one" — that's fine,
212+ // a list opening doesn't change the prefix's
213+ // rendering. So the boundary BEFORE the list is
214+ // accepted.
215+ want: len("Para.\n\n"),
216+ },
217+ {
218+ name: "list interior: no boundary",
219+ content: "- one\n- two\n",
220+ // no blank line at all.
221+ want: -1,
222+ },
223+ {
224+ name: "closed list then paragraph",
225+ content: "- one\n- two\n\nPara.",
226+ // blank line after the list. Last non-blank line
227+ // of prefix is "- two" — a list item — so the
228+ // candidate is REJECTED. (Conservative: we don't
229+ // know the list is "closed" without looking at
230+ // what follows.)
231+ want: -1,
232+ },
233+ {
234+ name: "table at end",
235+ content: "Para.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n",
236+ // blank-line candidate is between "Para." and
237+ // table opener. Last non-blank line of prefix is
238+ // "Para." — fine. Line AFTER is "| a | b |"
239+ // which is a table line; doesn't retroactively
240+ // change "Para." Boundary accepted.
241+ want: len("Para.\n\n"),
242+ },
243+ {
244+ name: "table interior with internal blank line: no late boundary",
245+ content: "| a | b |\n| --- | --- |\n\n| 1 | 2 |\n",
246+ // the blank line in the middle is followed by
247+ // another table line. Last non-blank line of
248+ // prefix is "| --- | --- |" which contains a
249+ // pipe — we reject.
250+ want: -1,
251+ },
252+ {
253+ name: "block quote at end",
254+ content: "Para.\n\n> quoted\n> still quoted\n",
255+ // Last non-blank line of any prefix that ends
256+ // inside the quote block is a "> ..." line —
257+ // rejected. The blank line BEFORE the quote
258+ // gives a prefix of "Para.\n\n" — last non-blank
259+ // "Para." — accepted.
260+ want: len("Para.\n\n"),
261+ },
262+ {
263+ name: "setext underline pending",
264+ content: "Heading\n\n=====\n",
265+ // blank line between "Heading" and "=====".
266+ // Prefix = "Heading\n\n", last non-blank "Heading"
267+ // — fine. But the FIRST non-blank line of the
268+ // suffix is "=====", a setext-underline
269+ // candidate. Splitting here would render the
270+ // prefix as a paragraph "Heading", but the
271+ // canonical render would treat the whole thing
272+ // as a setext header. Reject.
273+ //
274+ // (Note: per CommonMark, a blank line between a
275+ // paragraph and an underline actually breaks the
276+ // setext, so the setext interpretation may not
277+ // apply. But the boundary check is conservative
278+ // — being wrong costs one slow frame, being
279+ // over-aggressive costs visible breakage.)
280+ want: -1,
281+ },
282+ {
283+ name: "indented code at end of prefix",
284+ content: "Para.\n\n code line\n\nNext.",
285+ // prefix candidates:
286+ // "Para.\n\n" — last non-blank "Para.", accepted
287+ // "Para.\n\n code line\n\n" — last non-blank
288+ // is " code line" which is indented 4
289+ // spaces — REJECTED.
290+ // Latest accepted is the first.
291+ want: len("Para.\n\n"),
292+ },
293+ }
294+
295+ for _, c := range cases {
296+ t.Run(c.name, func(t *testing.T) {
297+ t.Parallel()
298+ got := findSafeMarkdownBoundary(c.content)
299+ require.Equalf(t, c.want, got,
300+ "findSafeMarkdownBoundary(%q) = %d, want %d", c.content, got, c.want)
301+ if got > 0 {
302+ // Boundary must point to the start of a line
303+ // (i.e. just after a newline) when the prefix
304+ // is non-empty.
305+ require.True(t, got <= len(c.content),
306+ "boundary %d out of range (len=%d)", got, len(c.content))
307+ if got > 0 && got <= len(c.content) {
308+ require.Equal(t, byte('\n'), c.content[got-1],
309+ "boundary %d does not sit immediately after a newline", got)
310+ }
311+ }
312+ })
313+ }
314+}
315+
316+// -----------------------------------------------------------------------
317+// T2: streaming-equivalence tests.
318+// -----------------------------------------------------------------------
319+
320+// streamingScenarios returns the four canonical document shapes
321+// that exercise different boundary-detection paths.
322+func streamingScenarios() []struct {
323+ name string
324+ doc string
325+} {
326+ return []struct {
327+ name string
328+ doc string
329+ }{
330+ {
331+ name: "plain-paragraphs",
332+ doc: strings.Join([]string{
333+ "This is the first paragraph of the document.",
334+ "",
335+ "Here is the second paragraph; it has some words.",
336+ "",
337+ "And a third paragraph for good measure.",
338+ "",
339+ "Finally a fourth paragraph to push past one boundary.",
340+ }, "\n"),
341+ },
342+ {
343+ name: "paragraphs-with-fence",
344+ doc: strings.Join([]string{
345+ "Intro paragraph.",
346+ "",
347+ "Some explanatory prose before the code.",
348+ "",
349+ "```go",
350+ "func hello() {",
351+ "\tfmt.Println(\"hi\")",
352+ "}",
353+ "```",
354+ "",
355+ "And a closing paragraph after the code block.",
356+ }, "\n"),
357+ },
358+ {
359+ name: "paragraphs-with-list",
360+ doc: strings.Join([]string{
361+ "Intro paragraph.",
362+ "",
363+ "- list item one",
364+ "- list item two",
365+ "- list item three",
366+ "",
367+ "Trailing paragraph.",
368+ }, "\n"),
369+ },
370+ {
371+ name: "paragraphs-with-table",
372+ doc: strings.Join([]string{
373+ "Intro paragraph.",
374+ "",
375+ "| col a | col b |",
376+ "| ----- | ----- |",
377+ "| 1 | 2 |",
378+ "| 3 | 4 |",
379+ "",
380+ "Trailing paragraph after the table.",
381+ }, "\n"),
382+ },
383+ }
384+}
385+
386+// progressivePrefixes splits doc into n monotonically growing
387+// byte prefixes, ending with the full document. n>=1.
388+func progressivePrefixes(doc string, n int) []string {
389+ if n < 1 {
390+ n = 1
391+ }
392+ out := make([]string, 0, n)
393+ for i := 1; i <= n; i++ {
394+ // integer scaling so the last entry is exactly len(doc)
395+ size := len(doc) * i / n
396+ if i == n {
397+ size = len(doc)
398+ }
399+ out = append(out, doc[:size])
400+ }
401+ return out
402+}
403+
404+// TestStreamingMarkdown_FinalVisuallyEquivalent drives a sequence
405+// of progressive prefixes through streamingMarkdown and asserts
406+// the FINAL output is visually equivalent (per design principle
407+// D) to a fresh full-document render. Strict byte-equality is
408+// not the bar — see the comment in normalizeRender for why.
409+func TestStreamingMarkdown_FinalVisuallyEquivalent(t *testing.T) {
410+ t.Parallel()
411+
412+ const width = 80
413+ const steps = 15
414+
415+ for _, sc := range streamingScenarios() {
416+ t.Run(sc.name, func(t *testing.T) {
417+ t.Parallel()
418+ renderer := newTestRenderer(t, width)
419+ var sm streamingMarkdown
420+ prefixes := progressivePrefixes(sc.doc, steps)
421+
422+ var lastOut string
423+ for _, p := range prefixes {
424+ lastOut = sm.Render(p, width, renderer)
425+ }
426+
427+ fresh := freshRender(t, sc.doc, width)
428+ require.Equal(t, normalizeRender(fresh), normalizeRender(lastOut),
429+ "final streaming output must match a fresh full render visually")
430+ })
431+ }
432+}
433+
434+// TestStreamingMarkdown_IntermediateOutputsPlausible asserts that
435+// every intermediate flush returns a non-empty string and does
436+// not leak raw markdown source through to the user. This is the
437+// "visually plausible" half of T2.
438+func TestStreamingMarkdown_IntermediateOutputsPlausible(t *testing.T) {
439+ t.Parallel()
440+
441+ const width = 80
442+ const steps = 12
443+
444+ for _, sc := range streamingScenarios() {
445+ t.Run(sc.name, func(t *testing.T) {
446+ t.Parallel()
447+ renderer := newTestRenderer(t, width)
448+ var sm streamingMarkdown
449+
450+ for i, p := range progressivePrefixes(sc.doc, steps) {
451+ if p == "" {
452+ continue
453+ }
454+ out := sm.Render(p, width, renderer)
455+ require.NotEmptyf(t, out, "step %d: empty render for prefix len %d", i, len(p))
456+ require.Falsef(t, containsRawMarkdownSource(out),
457+ "step %d: render leaked raw markdown source.\nprefix=%q\nout=%s",
458+ i, p, normalizeRender(out))
459+ }
460+ })
461+ }
462+}
463+
464+// -----------------------------------------------------------------------
465+// T3: cache invalidation tests.
466+// -----------------------------------------------------------------------
467+
468+// TestStreamingMarkdown_WidthChangeInvalidates asserts that a
469+// width change blows away the cached prefix so the next render
470+// is keyed against the new width. We can't observe the cache
471+// directly without reaching into the struct, so we assert the
472+// observable contract: after a width change, the rendered output
473+// reflects the new width AND the streamingMarkdown's internal
474+// cache fields are reset to the new state.
475+func TestStreamingMarkdown_WidthChangeInvalidates(t *testing.T) {
476+ t.Parallel()
477+
478+ doc := "Para one.\n\nPara two.\n\nPara three."
479+ r80 := newTestRenderer(t, 80)
480+ r40 := newTestRenderer(t, 40)
481+ var sm streamingMarkdown
482+
483+ out80 := sm.Render(doc, 80, r80)
484+ require.Equal(t, 80, sm.width, "width must be cached after first render")
485+ cachedPrefix := sm.stablePrefix
486+
487+ out40 := sm.Render(doc, 40, r40)
488+ require.Equal(t, 40, sm.width, "width change must update cached width")
489+ require.NotEqual(t, out80, out40,
490+ "different widths must produce different rendered output")
491+ // stablePrefix may legitimately have re-advanced after the
492+ // reset (tryAdvanceFromEmpty), but if it has, it can no
493+ // longer carry the OLD width's render. We assert the cache
494+ // reset by checking that the cached prefix length is at
495+ // most the current content length.
496+ require.True(t, len(sm.stablePrefix) <= len(doc),
497+ "stable prefix must be a prefix of the current content")
498+ _ = cachedPrefix
499+}
500+
501+// TestStreamingMarkdown_NonPrefixContentInvalidates verifies
502+// that content which is NOT a prefix-extension of the cached
503+// stable prefix triggers a Reset and a fresh render path. This
504+// guards the "user retried the turn" case.
505+func TestStreamingMarkdown_NonPrefixContentInvalidates(t *testing.T) {
506+ t.Parallel()
507+
508+ const width = 80
509+ r := newTestRenderer(t, width)
510+ var sm streamingMarkdown
511+
512+ // Drive a streaming sequence so the cache picks up a stable
513+ // prefix.
514+ doc := "Para one.\n\nPara two.\n\nPara three."
515+ for _, p := range progressivePrefixes(doc, 6) {
516+ _ = sm.Render(p, width, r)
517+ }
518+ require.NotEmpty(t, sm.stablePrefix,
519+ "stable prefix must be populated after streaming a multi-paragraph doc")
520+
521+ // Now switch to entirely different content (user retried).
522+ other := "Completely different opening paragraph.\n\nAnd a second."
523+ out := sm.Render(other, width, r)
524+ require.NotEmpty(t, out)
525+ // stablePrefix must be a prefix of `other`, i.e. cache was
526+ // reset off the OLD content.
527+ require.True(t, strings.HasPrefix(other, sm.stablePrefix),
528+ "stable prefix must be reset to a prefix of the new content")
529+
530+ // Visual equivalence to a fresh render of `other`.
531+ fresh := freshRender(t, other, width)
532+ require.Equal(t, normalizeRender(fresh), normalizeRender(out),
533+ "render after non-prefix content change must match a fresh render")
534+}
535+
536+// TestStreamingMarkdown_ResetClearsCache asserts Reset() drops
537+// every cached field; the next render is necessarily a full
538+// render path.
539+func TestStreamingMarkdown_ResetClearsCache(t *testing.T) {
540+ t.Parallel()
541+
542+ const width = 80
543+ r := newTestRenderer(t, width)
544+ var sm streamingMarkdown
545+
546+ doc := "Para one.\n\nPara two.\n\nPara three."
547+ _ = sm.Render(doc, width, r)
548+ // The sample doc has safe boundaries so the cache should
549+ // have advanced. If for some reason it didn't, we still
550+ // want Reset to be a no-op-safe operation; assert the
551+ // post-Reset state directly.
552+ sm.Reset()
553+ require.Equal(t, 0, sm.width)
554+ require.Equal(t, "", sm.stablePrefix)
555+ require.Equal(t, "", sm.stablePrefixRender)
556+
557+ // Next render must be a full render path. Drive one step
558+ // and verify the output matches a fresh full render.
559+ out := sm.Render(doc, width, r)
560+ fresh := freshRender(t, doc, width)
561+ require.Equal(t, normalizeRender(fresh), normalizeRender(out))
562+}
563+
564+// -----------------------------------------------------------------------
565+// T4: fallback safety.
566+// -----------------------------------------------------------------------
567+
568+// TestStreamingMarkdown_NoSafeBoundaryAlwaysFullRenders covers
569+// the "one giant table being built character by character" case.
570+// Every flush must fall back to a full render; the cache must
571+// not advance into an unsafe state. We compare each flush to a
572+// fresh full render of the same prefix; bytes must match for
573+// each prefix individually.
574+//
575+// (Byte equality is sound here because no concatenation happens:
576+// the streaming path delegates straight to renderer.Render when
577+// the cache is empty and no safe boundary exists.)
578+func TestStreamingMarkdown_NoSafeBoundaryAlwaysFullRenders(t *testing.T) {
579+ t.Parallel()
580+
581+ const width = 80
582+
583+ // One growing table — no blank lines anywhere, so no
584+ // boundary candidate is ever found.
585+ doc := strings.Join([]string{
586+ "| col a | col b | col c |",
587+ "| ----- | ----- | ----- |",
588+ "| 1 | 2 | 3 |",
589+ "| 4 | 5 | 6 |",
590+ "| 7 | 8 | 9 |",
591+ "| 10 | 11 | 12 |",
592+ "| 13 | 14 | 15 |",
593+ "| 16 | 17 | 18 |",
594+ "| 19 | 20 | 21 |",
595+ "| 22 | 23 | 24 |",
596+ }, "\n")
597+ require.Equal(t, -1, findSafeMarkdownBoundary(doc),
598+ "sanity check: no blank lines, no safe boundary")
599+
600+ r := newTestRenderer(t, width)
601+ var sm streamingMarkdown
602+
603+ prefixes := progressivePrefixes(doc, 10)
604+ for i, p := range prefixes {
605+ if p == "" {
606+ continue
607+ }
608+ out := sm.Render(p, width, r)
609+ fresh := freshRender(t, p, width)
610+ require.Equalf(t, fresh, out,
611+ "step %d (len=%d): streaming output must byte-equal a fresh render when boundary detection fails",
612+ i, len(p))
613+ }
614+ // Cache must remain empty: no boundary was ever found, no
615+ // width change occurred, no advance ever cached anything.
616+ require.Equal(t, "", sm.stablePrefix,
617+ "stable prefix must remain empty when no safe boundary ever exists")
618+}
619+
620+// TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash is the
621+// minimum-viability assertion of T4: even when boundary
622+// detection fails on every flush the streaming path must not
623+// crash and must produce non-empty output for non-empty input.
624+func TestStreamingMarkdown_NoSafeBoundaryDoesNotCrash(t *testing.T) {
625+ t.Parallel()
626+
627+ const width = 80
628+ r := newTestRenderer(t, width)
629+ var sm streamingMarkdown
630+
631+ // A deeply-pathological input: a single line that grows
632+ // one character at a time. There is never a blank-line
633+ // separator so the cache is never advanced.
634+ src := "The quick brown fox jumps over the lazy dog."
635+ for i := 1; i <= len(src); i++ {
636+ out := sm.Render(src[:i], width, r)
637+ require.NotEmpty(t, out, "streaming output must not be empty for non-empty input")
638+ }
639+}
640+
641+// -----------------------------------------------------------------------
642+// Integration assertions on the wired-in path.
643+// -----------------------------------------------------------------------
644+
645+// -----------------------------------------------------------------------
646+// T5 / T6 / T7: anywhere-in-prefix hazards (B1 / B2 / B3 from the
647+// F8 round-2 review). For each hazard we drive every progressive
648+// prefix of a document that exercises the hazard through the cache
649+// and assert two contracts:
650+//
651+// 1. The cached stable prefix never contains the hazard. If the
652+// hazard line is at byte offset H, then after every flush
653+// len(sm.stablePrefix) <= H. This is the "no silent
654+// corruption" half — the algorithm cannot accept a boundary
655+// that splits across the hazard.
656+//
657+// 2. The final flush is visually equivalent to a fresh full
658+// render of the complete document. This is the same T2-style
659+// equivalence assertion ported to the new doc shapes.
660+// -----------------------------------------------------------------------
661+
662+// nonBlankLines returns the non-blank visible lines of s with
663+// per-line trailing whitespace trimmed. Used to compare two
664+// rendered fragments for content equivalence when paragraph-
665+// margin behaviour legitimately differs between a single fresh
666+// render and a streaming split render (per F8 design principle D
667+// — visual equivalence is the bar, byte-equivalence is not).
668+//
669+// Some glamour block types (notably HTML blocks and reference
670+// link definitions) interact with adjacent paragraph blocks
671+// during a single render — adjacency effectively suppresses the
672+// blank-line margin between blocks. When the streaming path
673+// renders the prefix and trail in separate calls, the seam is
674+// re-introduced as a blank line. The visible TEXT is identical;
675+// only the inter-block margin differs.
676+func nonBlankLines(s string) []string {
677+ clean := stripANSI(s)
678+ out := make([]string, 0)
679+ for _, l := range strings.Split(clean, "\n") {
680+ l = strings.TrimRight(l, " \t")
681+ if strings.TrimSpace(l) == "" {
682+ continue
683+ }
684+ out = append(out, l)
685+ }
686+ return out
687+}
688+
689+// runProgressiveBoundaryRespectTest is the shared body of T5/T6/T7.
690+// It accepts a document and the byte offset of the line whose
691+// PRESENCE in the prefix must trigger the hazard reject; the
692+// cached stable prefix may never extend past hazardLineOffset.
693+//
694+// The final-output equivalence check is content-based (non-blank
695+// lines compared) rather than full-normalization: see
696+// nonBlankLines for the reason.
697+func runProgressiveBoundaryRespectTest(t *testing.T, doc string, hazardLineOffset int) {
698+ t.Helper()
699+ const width = 80
700+ const steps = 25
701+
702+ renderer := newTestRenderer(t, width)
703+ var sm streamingMarkdown
704+
705+ prefixes := progressivePrefixes(doc, steps)
706+ var lastOut string
707+ for i, p := range prefixes {
708+ if p == "" {
709+ continue
710+ }
711+ lastOut = sm.Render(p, width, renderer)
712+ require.NotEmptyf(t, lastOut, "step %d: empty render", i)
713+ require.LessOrEqualf(t, len(sm.stablePrefix), hazardLineOffset,
714+ "step %d: cached stable prefix advanced past the hazard line\n"+
715+ "prefix len=%d, hazard at %d, sm.stablePrefix=%q",
716+ i, len(sm.stablePrefix), hazardLineOffset, sm.stablePrefix)
717+ }
718+
719+ fresh := freshRender(t, doc, width)
720+ require.Equal(t, nonBlankLines(fresh), nonBlankLines(lastOut),
721+ "final streaming output must contain the same non-blank lines as a fresh full render")
722+}
723+
724+// TestStreamingMarkdown_LooseListContinuation locks in the B1 fix.
725+// A loose list followed by a continuation paragraph and then a
726+// trailing paragraph creates a candidate boundary between the list
727+// item and its continuation; the trailing non-blank line of that
728+// candidate prefix is the continuation paragraph (not a list
729+// marker), so the line-only check would accept it. The
730+// anywhere-in-prefix list-marker check rejects it.
731+func TestStreamingMarkdown_LooseListContinuation(t *testing.T) {
732+ t.Parallel()
733+
734+ doc := strings.Join([]string{
735+ "Intro paragraph.",
736+ "",
737+ "- item one",
738+ "",
739+ " continuation paragraph still belongs to item one",
740+ "",
741+ "- item two",
742+ "",
743+ "Trailing paragraph after the list.",
744+ }, "\n")
745+
746+ // The first list marker line begins after "Intro paragraph.\n\n".
747+ // The cached stable prefix may include that boundary (BEFORE
748+ // the list opens) but must never advance into the list.
749+ hazardOffset := strings.Index(doc, "- item one")
750+ require.Greater(t, hazardOffset, 0, "test setup")
751+
752+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
753+}
754+
755+// TestStreamingMarkdown_HTMLBlock locks in the B2 fix. A raw HTML
756+// block followed by a paragraph creates a candidate boundary
757+// between the closed HTML block and the trailing paragraph. The
758+// anywhere-in-prefix HTML-opener check rejects any boundary that
759+// would include the HTML block in the stable prefix.
760+func TestStreamingMarkdown_HTMLBlock(t *testing.T) {
761+ t.Parallel()
762+
763+ doc := strings.Join([]string{
764+ "Intro paragraph.",
765+ "",
766+ "<div>",
767+ "some block content",
768+ "</div>",
769+ "",
770+ "Trailing paragraph after the HTML block.",
771+ }, "\n")
772+
773+ hazardOffset := strings.Index(doc, "<div>")
774+ require.Greater(t, hazardOffset, 0, "test setup")
775+
776+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
777+}
778+
779+// TestStreamingMarkdown_HTMLBlockType7 covers HTML block type 7
780+// (CommonMark): a generic open/close tag whose name is NOT in the
781+// fixed type-6 set still opens an HTML block and must forfeit any
782+// boundary that would split the block off from following content.
783+func TestStreamingMarkdown_HTMLBlockType7(t *testing.T) {
784+ t.Parallel()
785+
786+ doc := strings.Join([]string{
787+ "Intro paragraph.",
788+ "",
789+ "<custom-tag>",
790+ "some block content",
791+ "</custom-tag>",
792+ "",
793+ "Trailing paragraph after the custom-tag block.",
794+ }, "\n")
795+
796+ hazardOffset := strings.Index(doc, "<custom-tag>")
797+ require.Greater(t, hazardOffset, 0, "test setup")
798+
799+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
800+}
801+
802+// TestStreamingMarkdown_LinkRefDefinition locks in the B3 fix. A
803+// reference link definition followed by a paragraph that uses the
804+// reference creates a boundary candidate between the def and the
805+// paragraph; rendering them in separate glamour passes loses the
806+// definition. The anywhere-in-prefix ref-def check rejects.
807+func TestStreamingMarkdown_LinkRefDefinition(t *testing.T) {
808+ t.Parallel()
809+
810+ doc := strings.Join([]string{
811+ "Intro paragraph.",
812+ "",
813+ "[ref]: http://example.com",
814+ "",
815+ "Trailing paragraph that links to [the example][ref] inline.",
816+ }, "\n")
817+
818+ hazardOffset := strings.Index(doc, "[ref]:")
819+ require.Greater(t, hazardOffset, 0, "test setup")
820+
821+ runProgressiveBoundaryRespectTest(t, doc, hazardOffset)
822+}
823+
824+// TestAssistantStreamingContent_ResetOnClearCache guards the
825+// integration contract that ClearItemCaches (style change) drops
826+// the streaming-markdown cache. Without this, a style change
827+// would leave the OLD style's ANSI sequences embedded in the
828+// stable-prefix render and the next flush would visually mix
829+// styles.
830+func TestAssistantStreamingContent_ResetOnClearCache(t *testing.T) {
831+ t.Parallel()
832+
833+ sty := styles.CharmtonePantera()
834+ doc := "Para one.\n\nPara two.\n\nPara three."
835+ msg := finishedAssistantMessage("stream-clear", doc)
836+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
837+
838+ const width = 80
839+ _ = item.RawRender(width)
840+ // Drive a second message that extends the content so the
841+ // streaming cache has a chance to advance (if it would).
842+ doc2 := doc + "\n\nFour."
843+ item.SetMessage(finishedAssistantMessage("stream-clear", doc2))
844+ _ = item.RawRender(width)
845+
846+ // Now wipe the caches the way ClearItemCaches does.
847+ item.clearCache()
848+
849+ require.Equal(t, "", item.streamingContent.stablePrefix,
850+ "clearCache must Reset the streaming-markdown cache")
851+ require.Equal(t, "", item.streamingContent.stablePrefixRender)
852+ require.Equal(t, 0, item.streamingContent.width)
853+}
internal/ui/chat/messages.go+94−8
@@ -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
@@ -156,6 +175,20 @@ type cachedMessageItem struct {
156 // width and height are the dimensions of the cached render
175 // width and height are the dimensions of the cached render
157 width int
176 width int
158 height int
177 height int
178+
179+ // prefixedRendered caches the per-line-prefixed Render output (the
180+ // result of splitting RawRender by newlines and prepending a focus
181+ // or selection prefix to every line). Items rebuild this every
182+ // frame today; caching it keyed by (prefixedWidth, prefixedKey)
183+ // turns Render into a pointer return when item state is stable.
184+ //
185+ // Invalidation lives in clearCache; callers must additionally
186+ // bypass this cache whenever the prefixed output would not be
187+ // stable (spinner ticks, active highlight ranges) by not calling
188+ // setCachedPrefixedRender for those frames.
189+ prefixedRendered string
190+ prefixedWidth int
191+ prefixedKey uint64
159}
192}
160
193
161// getCachedRender returns the cached render if it exists for the given width.
194// getCachedRender returns the cached render if it exists for the given width.
@@ -173,21 +206,57 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
173 c.height = height
206 c.height = height
174}
207}
175
208
209+// getCachedPrefixedRender returns the cached prefixed render if it exists
210+// for the given (width, key). The key encodes any state that changes the
211+// per-line prefix (focused/blurred, compact, ...).
212+func (c *cachedMessageItem) getCachedPrefixedRender(width int, key uint64) (string, bool) {
213+ if c.prefixedRendered != "" && c.prefixedWidth == width && c.prefixedKey == key {
214+ return c.prefixedRendered, true
215+ }
216+ return "", false
217+}
218+
219+// setCachedPrefixedRender stores the cached prefixed render.
220+func (c *cachedMessageItem) setCachedPrefixedRender(rendered string, width int, key uint64) {
221+ c.prefixedRendered = rendered
222+ c.prefixedWidth = width
223+ c.prefixedKey = key
224+}
225+
176// clearCache clears the cached render.
226// clearCache clears the cached render.
177func (c *cachedMessageItem) clearCache() {
227func (c *cachedMessageItem) clearCache() {
178 c.rendered = ""
228 c.rendered = ""
179 c.width = 0
229 c.width = 0
180 c.height = 0
230 c.height = 0
231+ c.prefixedRendered = ""
232+ c.prefixedWidth = 0
233+ c.prefixedKey = 0
181}
234}
182
235
183// 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.
184type 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
185 focused bool
242 focused bool
186}
243}
187
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+
188// SetFocused implements MessageItem.
251// SetFocused implements MessageItem.
189func (f *focusableMessageItem) SetFocused(focused bool) {
252func (f *focusableMessageItem) SetFocused(focused bool) {
253+ if f.focused == focused {
254+ return
255+ }
190 f.focused = focused
256 f.focused = focused
257+ if f.version != nil {
258+ f.version.Bump()
259+ }
191}
260}
192
261
193// AssistantInfoID returns a stable ID for assistant info items.
262// AssistantInfoID returns a stable ID for assistant info items.
@@ -197,6 +266,7 @@ func AssistantInfoID(messageID string) string {
197
266
198// AssistantInfoItem renders model info and response time after assistant completes.
267// AssistantInfoItem renders model info and response time after assistant completes.
199type AssistantInfoItem struct {
268type AssistantInfoItem struct {
269+ *list.Versioned
200 *cachedMessageItem
270 *cachedMessageItem
201
271
202 id string
272 id string
@@ -209,6 +279,7 @@ type AssistantInfoItem struct {
209// NewAssistantInfoItem creates a new AssistantInfoItem.
279// NewAssistantInfoItem creates a new AssistantInfoItem.
210func 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 {
211 return &AssistantInfoItem{
281 return &AssistantInfoItem{
282+ Versioned: list.NewVersioned(),
212 cachedMessageItem: &cachedMessageItem{},
283 cachedMessageItem: &cachedMessageItem{},
213 id: AssistantInfoID(message.ID),
284 id: AssistantInfoID(message.ID),
214 message: message,
285 message: message,
@@ -218,6 +289,13 @@ func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *con
218 }
289 }
219}
290}
220
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+
221// ID implements MessageItem.
299// ID implements MessageItem.
222func (a *AssistantInfoItem) ID() string {
300func (a *AssistantInfoItem) ID() string {
223 return a.id
301 return a.id
@@ -237,12 +315,20 @@ func (a *AssistantInfoItem) RawRender(width int) string {
237
315
238// Render implements MessageItem.
316// Render implements MessageItem.
239func (a *AssistantInfoItem) Render(width int) string {
317func (a *AssistantInfoItem) Render(width int) string {
318+ // AssistantInfoItem uses a single, state-independent prefix; key 0
319+ // is sufficient. The cache is invalidated whenever the underlying
320+ // cachedMessageItem render is cleared.
321+ if cached, ok := a.getCachedPrefixedRender(width, 0); ok {
322+ return cached
323+ }
240 prefix := a.sty.Messages.SectionHeader.Render()
324 prefix := a.sty.Messages.SectionHeader.Render()
241 lines := strings.Split(a.RawRender(width), "\n")
325 lines := strings.Split(a.RawRender(width), "\n")
242 for i, line := range lines {
326 for i, line := range lines {
243 lines[i] = prefix + line
327 lines[i] = prefix + line
244 }
328 }
245− return strings.Join(lines, "\n")
329+ out := strings.Join(lines, "\n")
330+ a.setCachedPrefixedRender(out, width, 0)
331+ return out
246}
332}
247
333
248func (a *AssistantInfoItem) renderContent(width int) string {
334func (a *AssistantInfoItem) renderContent(width int) string {
internal/ui/chat/prefix_cache_test.gonew file+196
@@ -0,0 +1,196 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/attachments"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// finishedAssistantMessage builds an assistant message with text content and a
15+// finish part so AssistantMessageItem.isSpinning returns false and the
16+// prefix cache is exercised.
17+func finishedAssistantMessage(id, text string) *message.Message {
18+ return &message.Message{
19+ ID: id,
20+ Role: message.Assistant,
21+ Parts: []message.ContentPart{
22+ message.TextContent{Text: text},
23+ message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()},
24+ },
25+ }
26+}
27+
28+// TestAssistantMessageItemRender_PrefixCacheFocusBlur covers the F3 invariant
29+// that focus → blur → focus produces the correct prefix every time and never
30+// leaks the previous focus state out of the cache.
31+func TestAssistantMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
32+ t.Parallel()
33+
34+ sty := styles.CharmtonePantera()
35+ msg := finishedAssistantMessage("m1", "Hello world from the cache test.")
36+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
37+
38+ const width = 60
39+
40+ item.SetFocused(true)
41+ focused1 := item.Render(width)
42+ focused2 := item.Render(width)
43+ require.Equal(t, focused1, focused2, "second render must hit the cache and match the first")
44+
45+ item.SetFocused(false)
46+ blurred1 := item.Render(width)
47+ require.NotEqual(t, focused1, blurred1, "blur must produce a different prefixed render than focus")
48+
49+ item.SetFocused(true)
50+ focused3 := item.Render(width)
51+ require.Equal(t, focused1, focused3, "re-focus must produce identical output to the original focused render")
52+}
53+
54+// TestAssistantMessageItemRender_PrefixCacheWidthInvalidates asserts that a
55+// width change does not return the previous width's cached output.
56+func TestAssistantMessageItemRender_PrefixCacheWidthInvalidates(t *testing.T) {
57+ t.Parallel()
58+
59+ sty := styles.CharmtonePantera()
60+ msg := finishedAssistantMessage("m2", "Some content that wraps differently at different widths so the rendered output diverges.")
61+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
62+ item.SetFocused(true)
63+
64+ narrow := item.Render(40)
65+ wide := item.Render(100)
66+ require.NotEqual(t, narrow, wide, "different widths must produce different rendered output")
67+
68+ narrowAgain := item.Render(40)
69+ require.Equal(t, narrow, narrowAgain, "returning to the original width must hit (or repopulate) the cache with the same output")
70+}
71+
72+// TestAssistantMessageItemRender_PrefixCacheHighlightOnTop guarantees that
73+// activating a highlight range bypasses the prefix cache so selection drags
74+// reflect immediately, and that clearing the highlight returns to the cached
75+// prefixed output unchanged.
76+func TestAssistantMessageItemRender_PrefixCacheHighlightOnTop(t *testing.T) {
77+ t.Parallel()
78+
79+ sty := styles.CharmtonePantera()
80+ msg := finishedAssistantMessage("m3", "Hello world from the highlight test.")
81+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
82+ item.SetFocused(true)
83+
84+ const width = 60
85+ plain := item.Render(width)
86+
87+ // Activating a highlight must change the rendered output (selection
88+ // painted on top) without poisoning the cache for the un-highlighted
89+ // state that follows.
90+ item.SetHighlight(0, 0, 0, 5)
91+ highlighted := item.Render(width)
92+ require.NotEqual(t, plain, highlighted, "active highlight must change Render output")
93+
94+ // Clear the highlight; the cached un-highlighted prefix render must
95+ // be returned unchanged.
96+ item.SetHighlight(-1, -1, -1, -1)
97+ plainAfter := item.Render(width)
98+ require.Equal(t, plain, plainAfter, "clearing the highlight must restore the cached prefixed output exactly")
99+}
100+
101+// TestUserMessageItemRender_PrefixCacheFocusBlur is the user-message
102+// counterpart of the assistant focus/blur cache test.
103+func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
104+ t.Parallel()
105+
106+ sty := styles.CharmtonePantera()
107+ msg := &message.Message{
108+ ID: "u1",
109+ Role: message.User,
110+ Parts: []message.ContentPart{
111+ message.TextContent{Text: "Hello from the user."},
112+ },
113+ }
114+ r := attachments.NewRenderer(
115+ sty.Attachments.Normal,
116+ sty.Attachments.Deleting,
117+ sty.Attachments.Image,
118+ sty.Attachments.Text,
119+ )
120+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
121+
122+ const width = 60
123+
124+ item.SetFocused(true)
125+ focused1 := item.Render(width)
126+ focused2 := item.Render(width)
127+ require.Equal(t, focused1, focused2)
128+
129+ item.SetFocused(false)
130+ blurred := item.Render(width)
131+ require.NotEqual(t, focused1, blurred)
132+
133+ item.SetFocused(true)
134+ focused3 := item.Render(width)
135+ require.Equal(t, focused1, focused3)
136+}
137+
138+// TestCachedMessageItem_PrefixCacheSemantics covers the constant-prefix
139+// path used by AssistantInfoItem and the (width, key) keying used by every
140+// item, against the underlying cachedMessageItem helper directly. This
141+// avoids constructing a full *config.Config with an initialized provider
142+// map just to exercise cache plumbing that is identical for all callers.
143+func TestCachedMessageItem_PrefixCacheSemantics(t *testing.T) {
144+ t.Parallel()
145+
146+ c := &cachedMessageItem{}
147+
148+ // Empty cache: miss.
149+ _, ok := c.getCachedPrefixedRender(80, 0)
150+ require.False(t, ok)
151+
152+ // Set then hit at the same (width, key).
153+ c.setCachedPrefixedRender("hello", 80, 0)
154+ got, ok := c.getCachedPrefixedRender(80, 0)
155+ require.True(t, ok)
156+ require.Equal(t, "hello", got)
157+
158+ // Different width: miss.
159+ _, ok = c.getCachedPrefixedRender(120, 0)
160+ require.False(t, ok)
161+
162+ // Different key (focused vs blurred): miss.
163+ _, ok = c.getCachedPrefixedRender(80, 1)
164+ require.False(t, ok)
165+
166+ // clearCache drops the prefixed cache too.
167+ c.setCachedRender("raw", 80, 1)
168+ c.setCachedPrefixedRender("hello", 80, 0)
169+ c.clearCache()
170+ _, ok = c.getCachedPrefixedRender(80, 0)
171+ require.False(t, ok, "clearCache must drop the prefixed render cache")
172+ _, _, ok = c.getCachedRender(80)
173+ require.False(t, ok, "clearCache must also drop the raw render cache")
174+}
175+
176+// TestAssistantMessageItemRender_PrefixCacheNoCacheLeak guards against a
177+// regression where the cache returned the prefixed output of the previous
178+// width. We verify that the cached output for width=W contains the W-sized
179+// prefix and not a stale wider one by checking that line lengths are
180+// consistent on cache hit.
181+func TestAssistantMessageItemRender_PrefixCacheNoCacheLeak(t *testing.T) {
182+ t.Parallel()
183+
184+ sty := styles.CharmtonePantera()
185+ msg := finishedAssistantMessage("m4", strings.Repeat("word ", 40))
186+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
187+ item.SetFocused(true)
188+
189+ out80 := item.Render(80)
190+ out120 := item.Render(120)
191+ require.NotEqual(t, out80, out120)
192+
193+ // Hit each cached entry again and confirm stability.
194+ require.Equal(t, out80, item.Render(80))
195+ require.Equal(t, out120, item.Render(120))
196+}
internal/ui/chat/streaming_markdown.gonew file+740
@@ -0,0 +1,740 @@
1+package chat
2+
3+import (
4+ "strings"
5+
6+ "charm.land/glamour/v2"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+)
9+
10+// streamingMarkdown caches a "stable prefix" glamour render so each
11+// streaming flush only re-renders the trailing portion of the
12+// document. F8 of docs/notes/2026-05-12-chat-rendering-perf.md.
13+//
14+// The boundary between "stable" and "trailing" is detected by
15+// [findSafeMarkdownBoundary]: a position immediately after a blank
16+// line at which we can prove no markdown construct is open
17+// (fenced code block, list, table, block quote, setext header).
18+//
19+// Two renders concatenated are NOT generally equal to a single
20+// render of the whole document — glamour's wrap state is reset
21+// between calls. The boundary check is therefore deliberately
22+// conservative; whenever it has the slightest doubt the call
23+// falls back to a full render and the cache is left untouched.
24+//
25+// Invariants:
26+//
27+// - stablePrefix is always a literal byte prefix of the most
28+// recently rendered content. If a new content does not have
29+// stablePrefix as its prefix the cache is dropped.
30+// - stablePrefixRender is the glamour render of stablePrefix
31+// alone, with surrounding whitespace trimmed for clean
32+// concatenation.
33+// - width is the glamour wrap width that produced
34+// stablePrefixRender. A width change drops the cache.
35+type streamingMarkdown struct {
36+ width int
37+ stablePrefix string
38+ stablePrefixRender string
39+}
40+
41+// Reset drops every cached field. After Reset the next Render call
42+// is guaranteed to be a full render.
43+func (s *streamingMarkdown) Reset() {
44+ s.width = 0
45+ s.stablePrefix = ""
46+ s.stablePrefixRender = ""
47+}
48+
49+// Render returns the glamour render of content at the given width,
50+// reusing the cached stable-prefix render when it is safe to do so.
51+// On any uncertainty the call falls back to a full render via
52+// renderer and leaves the cache untouched (or drops it).
53+//
54+// The returned string has its trailing newline trimmed to match
55+// the existing renderMarkdown contract on AssistantMessageItem.
56+//
57+// Concurrency: glamour's Render is stateful and not safe for
58+// concurrent invocation on a shared renderer. Crush's TUI is
59+// single-threaded so production never contends, but parallel
60+// callers (most notably the test suite) must serialize. We hold
61+// [common.LockMarkdownRenderer] for the entire prefix +
62+// trailing render sequence so other goroutines cannot interleave
63+// their own Render calls and corrupt goldmark's BlockStack.
64+func (s *streamingMarkdown) Render(content string, width int, renderer *glamour.TermRenderer) string {
65+ mu := common.LockMarkdownRenderer(renderer)
66+ mu.Lock()
67+ defer mu.Unlock()
68+ full := func() string {
69+ out, err := renderer.Render(content)
70+ if err != nil {
71+ return content
72+ }
73+ return strings.TrimSuffix(out, "\n")
74+ }
75+
76+ // Width change OR content not a prefix-extension: drop cache,
77+ // full render, optionally try to seed a fresh boundary on this
78+ // call (step "f" in the design note).
79+ if width != s.width || !strings.HasPrefix(content, s.stablePrefix) {
80+ s.Reset()
81+ s.width = width
82+ out := full()
83+ s.tryAdvanceFromEmpty(content, width, renderer)
84+ return out
85+ }
86+
87+ boundary := findSafeMarkdownBoundary(content)
88+ if boundary < 0 {
89+ // No safe boundary anywhere yet. Full render; do not
90+ // modify the cache (a future flush may find one).
91+ return full()
92+ }
93+
94+ if boundary <= len(s.stablePrefix) {
95+ // Cached prefix already covers an at-least-as-late
96+ // boundary. Render the trailing partial fresh and glue.
97+ trail := content[len(s.stablePrefix):]
98+ return glueRenders(s.stablePrefixRender, s.renderTrailing(trail, renderer))
99+ }
100+
101+ // boundary > len(stablePrefix): we have a NEW chunk of safe
102+ // content. Render the new chunk, append to stablePrefixRender,
103+ // promote the boundary, then render the remaining trail.
104+ newChunk := content[len(s.stablePrefix):boundary]
105+ newChunkRender := s.renderTrailing(newChunk, renderer)
106+ s.stablePrefixRender = glueRenders(s.stablePrefixRender, newChunkRender)
107+ s.stablePrefix = content[:boundary]
108+
109+ trail := content[boundary:]
110+ if trail == "" {
111+ // boundary == len(content): no trailing content. Returning
112+ // the cached prefix render directly is correct.
113+ return s.stablePrefixRender
114+ }
115+ return glueRenders(s.stablePrefixRender, s.renderTrailing(trail, renderer))
116+}
117+
118+// tryAdvanceFromEmpty seeds the cache from a fresh state. We've
119+// already paid the cost of a full render of `content`; if there is
120+// a safe boundary inside it, render the prefix once more (cheap
121+// relative to the full render we just did) and cache it so the
122+// next flush can avoid the full work.
123+//
124+// This is the optional optimisation step "f" from the design
125+// note. We render the prefix separately rather than try to
126+// recover it from the full render output because two renders
127+// concatenated ≠ a single render of the whole, and we prefer the
128+// cached prefix render to be byte-for-byte what we'd produce on a
129+// future cached call.
130+func (s *streamingMarkdown) tryAdvanceFromEmpty(content string, width int, renderer *glamour.TermRenderer) {
131+ boundary := findSafeMarkdownBoundary(content)
132+ if boundary <= 0 {
133+ return
134+ }
135+ prefix := content[:boundary]
136+ out, err := renderer.Render(prefix)
137+ if err != nil {
138+ return
139+ }
140+ s.stablePrefix = prefix
141+ s.stablePrefixRender = trimGlamourMargins(out)
142+ s.width = width
143+}
144+
145+// renderTrailing renders a trailing partial as a fresh glamour
146+// document and trims the surrounding whitespace so it can be
147+// concatenated to a cached prefix render without doubled blank
148+// lines.
149+func (s *streamingMarkdown) renderTrailing(text string, renderer *glamour.TermRenderer) string {
150+ if text == "" {
151+ return ""
152+ }
153+ out, err := renderer.Render(text)
154+ if err != nil {
155+ return text
156+ }
157+ return trimGlamourMargins(out)
158+}
159+
160+// glueRenders concatenates two glamour-rendered fragments with a
161+// single blank line separator. Glamour outputs typically carry
162+// their own surrounding margins; trimming on both sides and
163+// gluing with "\n\n" prevents the visible double-margin seam.
164+//
165+// Empty fragments are tolerated so the same helper works for the
166+// "boundary == len(content)" path where there is no trailing
167+// segment.
168+func glueRenders(prefix, trail string) string {
169+ prefix = trimGlamourMargins(prefix)
170+ trail = trimGlamourMargins(trail)
171+ switch {
172+ case prefix == "" && trail == "":
173+ return ""
174+ case prefix == "":
175+ return trail
176+ case trail == "":
177+ return prefix
178+ default:
179+ return prefix + "\n\n" + trail
180+ }
181+}
182+
183+// trimGlamourMargins strips leading and trailing whitespace
184+// (including newlines) from a glamour-rendered fragment.
185+// Glamour adds a leading blank line for documents that open with
186+// a heading or paragraph, plus a trailing newline; both must be
187+// removed before concatenation.
188+func trimGlamourMargins(s string) string {
189+ return strings.Trim(s, " \t\n")
190+}
191+
192+// findSafeMarkdownBoundary returns the byte offset of the END of
193+// the latest safe boundary in content, i.e. the offset such that
194+// content[:boundary] is a valid stable-prefix candidate. The
195+// returned offset always points immediately after a blank-line
196+// separator, so concatenating a fresh render of content[boundary:]
197+// to a cached render of content[:boundary] does not require glamour
198+// to share state across the cut.
199+//
200+// Returns -1 when no safe boundary exists. SAFETY FIRST: any time
201+// we have the slightest doubt we return -1 and let the caller fall
202+// back to a full render.
203+//
204+// Decision tree, in order of preference (latest boundary wins):
205+//
206+// 1. Walk backward through every "blank line" position p such that
207+// content[:p] ends with "\n\n" (or "\n[ \t]*\n").
208+// 2. For each candidate, check that content[:p] has an even
209+// number of triple-backtick fence lines (no open fenced
210+// block). Any odd count means we'd be cutting inside a fence
211+// and mis-syntax-highlighting the trailing partial.
212+// 2b. Reject if any line in content[:p] (outside fenced blocks)
213+// is a list-marker line, an HTML-block opener, or a link
214+// reference definition. See [prefixHasOpenHazard] for the
215+// reasoning behind these "anywhere in prefix" rejects.
216+// 3. Reject if the last non-blank line of content[:p] is:
217+// - a list item marker line ("^\s*([-*+]|\d+\.)\s")
218+// - a table line (contains "|")
219+// - a block quote ("^\s*>")
220+// - a setext header underline ("^=+\s*$" or "^-+\s*$")
221+// - an indented code line (4+ leading spaces or a tab)
222+// 4. Reject if the line immediately AFTER the boundary (skipping
223+// leading blank lines) looks like a setext underline (a line
224+// of '=' or '-' only). Rendering the prefix as a paragraph
225+// would change once the underline arrived; that's exactly the
226+// "splitting changes the prefix render" hazard §4.4 calls out.
227+//
228+// Returns the byte offset of the first character AFTER the blank
229+// line, i.e. the start of the trailing segment.
230+func findSafeMarkdownBoundary(content string) int {
231+ if len(content) == 0 {
232+ return -1
233+ }
234+
235+ // Iterate every blank-line position from latest to earliest.
236+ for p := blankLineBefore(content, len(content)); p > 0; p = blankLineBefore(content, p-1) {
237+ if !isSafeBoundaryAt(content, p) {
238+ continue
239+ }
240+ return p
241+ }
242+ return -1
243+}
244+
245+// blankLineBefore returns the byte offset of the first character
246+// AFTER the latest blank-line separator that ends strictly before
247+// `until`. A blank-line separator is a sequence "\n([ \t]*\n)+"
248+// — one newline, then one or more lines containing only spaces or
249+// tabs and terminated by another newline. The returned offset is
250+// the start of the first non-blank line that follows the
251+// separator (or the position immediately after the final newline,
252+// if no further content remains).
253+//
254+// Returns -1 when no blank-line separator exists before `until`.
255+func blankLineBefore(content string, until int) int {
256+ if until <= 0 {
257+ return -1
258+ }
259+ // Walk backward looking for a newline followed (after optional
260+ // blank-line content) by another newline. We track the latest
261+ // newline we've seen; if the next earlier newline has only
262+ // blank chars between them, we have a blank-line separator
263+ // and the boundary sits immediately after the latest newline.
264+ end := until
265+ for end > 0 {
266+ nl := strings.LastIndexByte(content[:end], '\n')
267+ if nl < 0 {
268+ return -1
269+ }
270+ // Look for an earlier newline whose gap to nl is empty
271+ // or whitespace only.
272+ prev := strings.LastIndexByte(content[:nl], '\n')
273+ for prev >= 0 {
274+ gap := content[prev+1 : nl]
275+ if isBlankOrSpaces(gap) {
276+ return nl + 1
277+ }
278+ // Gap had non-whitespace; nl is not a blank-line
279+ // separator. Move up: try with the earlier newline as
280+ // the new "nl" candidate.
281+ break
282+ }
283+ end = nl
284+ }
285+ return -1
286+}
287+
288+// isBlankOrSpaces reports whether s consists entirely of spaces
289+// and tabs (or is empty).
290+func isBlankOrSpaces(s string) bool {
291+ for i := range len(s) {
292+ if s[i] != ' ' && s[i] != '\t' {
293+ return false
294+ }
295+ }
296+ return true
297+}
298+
299+// isSafeBoundaryAt reports whether content[:p] is a safe stable
300+// prefix. p must be a blank-line boundary (start of a line, with a
301+// blank line immediately preceding).
302+//
303+// Beyond the last-line checks, three "anywhere in the prefix"
304+// hazards force a reject because they cannot be reliably reasoned
305+// about by inspecting the trailing line alone. For each of these
306+// the simplest, safest rule was chosen — see prefixHasOpenHazard.
307+func isSafeBoundaryAt(content string, p int) bool {
308+ prefix := content[:p]
309+
310+ // (2) Even number of triple-backtick fence lines.
311+ if countFenceLines(prefix)%2 != 0 {
312+ return false
313+ }
314+
315+ // (2b) Anywhere-in-prefix hazards: open list (B1), HTML block
316+ // opener (B2), reference link definition (B3). Any of these
317+ // anywhere in the prefix forces a fallback.
318+ if prefixHasOpenHazard(prefix) {
319+ return false
320+ }
321+
322+ // (3) Inspect the last non-blank line of the prefix.
323+ lastLine := lastNonBlankLine(prefix)
324+ if lastLine != "" && lineOpensConstruct(lastLine) {
325+ return false
326+ }
327+
328+ // (4) If anything follows, make sure it doesn't look like a
329+ // setext underline that would retroactively turn the last
330+ // paragraph of the prefix into a header.
331+ if rest := content[p:]; rest != "" {
332+ first := firstNonBlankLine(rest)
333+ if isSetextUnderlineCandidate(first) {
334+ return false
335+ }
336+ }
337+
338+ return true
339+}
340+
341+// prefixHasOpenHazard reports whether prefix contains any of three
342+// constructs that cannot be safely cut at a blank-line boundary
343+// even when the immediately preceding line looks fine. Each check
344+// uses the SIMPLEST viable conservative rule per the F8 round-2
345+// review:
346+//
347+// B1 (loose lists). A loose list has a blank line between an item
348+// and a continuation paragraph that begins with indentation
349+// but no list marker. If a candidate boundary lands on that
350+// blank line, the prefix's trailing non-blank line is the
351+// continuation paragraph, NOT a list marker, so the last-line
352+// check would accept it even though the list is still open.
353+//
354+// Rule chosen: any list-marker line ANYWHERE in the prefix
355+// forces -1. This is overly conservative — it forfeits
356+// boundary advancement past a closed list — but it eliminates
357+// the entire bug class with zero parsing of CommonMark's
358+// loose-list closure semantics. We retain the most useful
359+// boundary in practice: the one BEFORE the list opens (no
360+// marker has appeared in the prefix yet).
361+//
362+// B2 (HTML blocks). CommonMark defines seven HTML-block opener
363+// patterns (script/pre/style/textarea, comments, processing
364+// instructions, CDATA, declarations, recognised tag names).
365+// If the prefix opens an HTML block that the suffix closes,
366+// splitting renders the prefix as raw HTML and the suffix as
367+// prose.
368+//
369+// Rule chosen: any HTML-block opener anywhere in the prefix
370+// forces -1. Same trade-off as B1 — the typical assistant
371+// output contains no raw HTML, so the perf cost is zero in
372+// the common case.
373+//
374+// B3 (reference link definitions). A line of the form
375+// "[label]: <url>" defines a link reference that the suffix
376+// may later use as "[text][label]". Splitting the document
377+// loses the definition because each half is rendered as an
378+// independent glamour document.
379+//
380+// Rule chosen: any reference link definition line anywhere in
381+// the prefix forces -1. Suffix-side reference detection is
382+// fragile (three syntaxes: [text][label], [label][], [label]),
383+// so the prefix-side check is the simpler safe choice.
384+//
385+// All three rules accept the perf hit of "no boundary after a
386+// list / HTML block / link def" in exchange for guaranteed
387+// soundness. If profiling shows this kills the F8 win on real
388+// streaming traces, the next iteration can promote each rule to
389+// its less-conservative variant (closure-aware list tracking,
390+// per-tag HTML close detection, suffix-aware ref tracking).
391+func prefixHasOpenHazard(prefix string) bool {
392+ inFence := false
393+ for line := range splitLines(prefix) {
394+ // Track fenced state so list/html/ref patterns inside a
395+ // fenced code block do not falsely trigger the hazards.
396+ if isFenceLine(line) {
397+ inFence = !inFence
398+ continue
399+ }
400+ if inFence {
401+ continue
402+ }
403+ trimmed := strings.TrimLeft(line, " \t")
404+ if trimmed == "" {
405+ continue
406+ }
407+ // B1: any list-item marker.
408+ if isListItemMarker(trimmed) {
409+ return true
410+ }
411+ // B2: HTML block opener.
412+ if isHTMLBlockOpener(line) {
413+ return true
414+ }
415+ // B3: link reference definition.
416+ if isLinkRefDefinition(line) {
417+ return true
418+ }
419+ }
420+ return false
421+}
422+
423+// countFenceLines counts lines that begin a fenced code block in
424+// the CommonMark sense: a line whose first non-whitespace run is
425+// at least three consecutive backticks (or tildes). Each such
426+// line toggles the fenced state, so an even count means every
427+// opened fence has been closed.
428+//
429+// We accept up to three leading spaces of indentation (CommonMark
430+// rule) and require the fence characters to be the FIRST
431+// non-whitespace content of the line. We deliberately do NOT
432+// attempt to parse info-strings or differentiate opener from
433+// closer beyond toggling — a closing fence is just any line
434+// whose first non-whitespace run is >=3 of the same fence char.
435+func countFenceLines(s string) int {
436+ n := 0
437+ for line := range splitLines(s) {
438+ if isFenceLine(line) {
439+ n++
440+ }
441+ }
442+ return n
443+}
444+
445+// isFenceLine reports whether line opens or closes a fenced code
446+// block.
447+func isFenceLine(line string) bool {
448+ // Strip up to 3 spaces of indentation.
449+ i := 0
450+ for i < len(line) && i < 3 && line[i] == ' ' {
451+ i++
452+ }
453+ if i >= len(line) {
454+ return false
455+ }
456+ c := line[i]
457+ if c != '`' && c != '~' {
458+ return false
459+ }
460+ run := 0
461+ for i < len(line) && line[i] == c {
462+ i++
463+ run++
464+ }
465+ return run >= 3
466+}
467+
468+// lastNonBlankLine returns the last non-blank line of s, or ""
469+// when every line is blank.
470+func lastNonBlankLine(s string) string {
471+ last := ""
472+ for line := range splitLines(s) {
473+ if strings.TrimSpace(line) != "" {
474+ last = line
475+ }
476+ }
477+ return last
478+}
479+
480+// firstNonBlankLine returns the first non-blank line of s, or ""
481+// when every line is blank.
482+func firstNonBlankLine(s string) string {
483+ for line := range splitLines(s) {
484+ if strings.TrimSpace(line) != "" {
485+ return line
486+ }
487+ }
488+ return ""
489+}
490+
491+// splitLines yields the lines of s without their terminators. The
492+// final segment is yielded even if not newline-terminated.
493+func splitLines(s string) func(yield func(string) bool) {
494+ return func(yield func(string) bool) {
495+ start := 0
496+ for i := 0; i < len(s); i++ {
497+ if s[i] == '\n' {
498+ if !yield(s[start:i]) {
499+ return
500+ }
501+ start = i + 1
502+ }
503+ }
504+ if start <= len(s)-1 {
505+ yield(s[start:])
506+ }
507+ }
508+}
509+
510+// lineOpensConstruct reports whether line keeps a markdown
511+// construct open across the boundary. We err conservatively —
512+// any case that smells like list/table/quote/setext/indented-code
513+// returns true.
514+func lineOpensConstruct(line string) bool {
515+ // Indented code: a tab, or 4+ leading spaces.
516+ if len(line) > 0 && line[0] == '\t' {
517+ return true
518+ }
519+ if strings.HasPrefix(line, " ") {
520+ return true
521+ }
522+
523+ trimmed := strings.TrimLeft(line, " \t")
524+ if trimmed == "" {
525+ return false
526+ }
527+
528+ // Block quote.
529+ if trimmed[0] == '>' {
530+ return true
531+ }
532+
533+ // List item: "- " "* " "+ " or "<digits>. " or "<digits>) ".
534+ if isListItemMarker(trimmed) {
535+ return true
536+ }
537+
538+ // Table: any pipe character anywhere in the line. Conservative:
539+ // pipe-in-prose is rare and the cost of bailing is one slow
540+ // frame.
541+ if strings.ContainsRune(line, '|') {
542+ return true
543+ }
544+
545+ // Setext underline candidate as the LAST line of the prefix:
546+ // this would be a setext header for an even-earlier paragraph.
547+ // Refuse to split at all in this case — the boundary is right
548+ // in the middle of a header.
549+ if isSetextUnderlineCandidate(trimmed) {
550+ return true
551+ }
552+
553+ return false
554+}
555+
556+// isListItemMarker reports whether line (already left-trimmed)
557+// starts with a CommonMark list-item marker followed by a space
558+// or tab.
559+func isListItemMarker(line string) bool {
560+ if line == "" {
561+ return false
562+ }
563+ c := line[0]
564+ if c == '-' || c == '*' || c == '+' {
565+ if len(line) >= 2 && (line[1] == ' ' || line[1] == '\t') {
566+ return true
567+ }
568+ return false
569+ }
570+ // Ordered list: digits followed by '.' or ')' and a space.
571+ i := 0
572+ for i < len(line) && line[i] >= '0' && line[i] <= '9' {
573+ i++
574+ }
575+ if i == 0 || i > 9 {
576+ return false
577+ }
578+ if i >= len(line) {
579+ return false
580+ }
581+ if line[i] != '.' && line[i] != ')' {
582+ return false
583+ }
584+ if i+1 >= len(line) {
585+ return false
586+ }
587+ return line[i+1] == ' ' || line[i+1] == '\t'
588+}
589+
590+// isSetextUnderlineCandidate reports whether line (with optional
591+// leading whitespace) consists entirely of '=' or entirely of '-'
592+// characters with optional trailing whitespace. CommonMark
593+// requires no leading whitespace on the underline; we accept up
594+// to three spaces for safety so an indented underline still
595+// blocks a split.
596+func isSetextUnderlineCandidate(line string) bool {
597+ // Strip leading whitespace.
598+ i := 0
599+ for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
600+ i++
601+ }
602+ if i == len(line) {
603+ return false
604+ }
605+ c := line[i]
606+ if c != '=' && c != '-' {
607+ return false
608+ }
609+ j := i
610+ for j < len(line) && line[j] == c {
611+ j++
612+ }
613+ // Allow trailing whitespace.
614+ for j < len(line) {
615+ if line[j] != ' ' && line[j] != '\t' {
616+ return false
617+ }
618+ j++
619+ }
620+ // Need at least one underline character. "-" alone is also a
621+ // list marker without a trailing space; the listItem check
622+ // covers the marker case before we get here.
623+ return j-i >= 1
624+}
625+
626+// isHTMLBlockOpener reports whether line begins one of the seven
627+// CommonMark HTML block patterns. We accept up to three spaces of
628+// leading indentation (CommonMark rule). Matching is intentionally
629+// loose — we only need to know the line "looks like an HTML
630+// block start", not parse the contained markup.
631+func isHTMLBlockOpener(line string) bool {
632+ // Strip up to 3 spaces of indentation.
633+ i := 0
634+ for i < len(line) && i < 3 && line[i] == ' ' {
635+ i++
636+ }
637+ rest := line[i:]
638+ if len(rest) < 2 || rest[0] != '<' {
639+ return false
640+ }
641+
642+ // Type 2: HTML comment "<!--".
643+ if strings.HasPrefix(rest, "<!--") {
644+ return true
645+ }
646+ // Type 3: processing instruction "<?".
647+ if strings.HasPrefix(rest, "<?") {
648+ return true
649+ }
650+ // Type 5: CDATA "<![CDATA[".
651+ if strings.HasPrefix(rest, "<![CDATA[") {
652+ return true
653+ }
654+ // Type 4: declaration "<!" followed by an ASCII letter.
655+ if len(rest) >= 3 && rest[1] == '!' && isASCIILetter(rest[2]) {
656+ return true
657+ }
658+
659+ // Type 1: <script | <pre | <style | <textarea (case-insensitive)
660+ // followed by whitespace, '>', end-of-line, or other non-name
661+ // terminators. Use a permissive HasPrefix check on lowercase.
662+ low := strings.ToLower(rest)
663+ for _, t := range []string{"<script", "<pre", "<style", "<textarea"} {
664+ if strings.HasPrefix(low, t) {
665+ next := byte(0)
666+ if len(low) > len(t) {
667+ next = low[len(t)]
668+ }
669+ if next == 0 || next == ' ' || next == '\t' || next == '>' {
670+ return true
671+ }
672+ }
673+ }
674+
675+ // Types 6 & 7: open or close of a block-level tag.
676+ //
677+ // Type 6 matches a fixed CommonMark tag set; type 7 matches any
678+ // otherwise-valid open/close tag whose name is not in the
679+ // script/pre/style/textarea family. We collapse both into a
680+ // single check: the line must start with '<' or '</' followed
681+ // by an ASCII letter. This deliberately mirrors the other
682+ // hazards — when in doubt, forfeit the boundary. Lines like
683+ // "<3", "<-", "<<", or mid-line "<foo>" do NOT trigger because
684+ // we require the line to *start* (after up to 3 spaces) with
685+ // '<letter' or '</letter'.
686+ j := 1 // past '<'
687+ if j < len(rest) && rest[j] == '/' {
688+ j++
689+ }
690+ if j >= len(rest) || !isASCIILetter(rest[j]) {
691+ return false
692+ }
693+ return true
694+}
695+
696+// isASCIILetter reports whether b is an ASCII letter.
697+func isASCIILetter(b byte) bool {
698+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
699+}
700+
701+// isLinkRefDefinition reports whether line matches a CommonMark
702+// link reference definition opener. The conservative pattern:
703+//
704+// ^[ ]{0,3}\[[^\]]+\]:\s*\S+
705+//
706+// i.e. up to 3 spaces, then a bracketed label (no nested ']'),
707+// then a colon, then whitespace, then at least one non-whitespace
708+// character of destination. We do not validate the destination —
709+// presence of a ref-def opener anywhere in the prefix is enough
710+// to forfeit the boundary.
711+func isLinkRefDefinition(line string) bool {
712+ i := 0
713+ for i < len(line) && i < 3 && line[i] == ' ' {
714+ i++
715+ }
716+ if i >= len(line) || line[i] != '[' {
717+ return false
718+ }
719+ i++
720+ labelStart := i
721+ for i < len(line) && line[i] != ']' {
722+ i++
723+ }
724+ if i >= len(line) || i == labelStart {
725+ // No closing bracket, or empty label.
726+ return false
727+ }
728+ // i points at ']'.
729+ i++
730+ if i >= len(line) || line[i] != ':' {
731+ return false
732+ }
733+ i++
734+ // Skip required whitespace.
735+ for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
736+ i++
737+ }
738+ // At least one non-whitespace character of destination.
739+ return i < len(line)
740+}
internal/ui/chat/tools.go+72−3
@@ -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.
@@ -288,10 +296,22 @@ func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
288}
296}
289
297
290// Animate progresses the assistant message animation if it should be spinning.
298// Animate progresses the assistant message animation if it should be spinning.
299+//
300+// Bumps the F6 list-cache version so the next draw re-renders this
301+// item: a spinner tick mutates anim's internal frame counter, which
302+// changes the rendered output but is invisible to the per-item
303+// caches. Without the bump the list cache would serve the previously
304+// rendered frame indefinitely and the spinner would appear frozen.
305+// The ID gate keeps unrelated ticks (routed here by a future change
306+// to chat.Animate's dispatch) from churning the cache.
291func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
307func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
292 if !t.isSpinning() {
308 if !t.isSpinning() {
293 return nil
309 return nil
294 }
310 }
311+ if msg.ID != t.toolCall.ID {
312+ return nil
313+ }
314+ t.Bump()
295 return t.anim.Animate(msg)
315 return t.anim.Animate(msg)
296}
316}
297
317
@@ -332,6 +352,24 @@ func (t *baseToolMessageItem) RawRender(width int) string {
332
352
333// Render renders the tool message item at the given width.
353// Render renders the tool message item at the given width.
334func (t *baseToolMessageItem) Render(width int) string {
354func (t *baseToolMessageItem) Render(width int) string {
355+ // Cache the prefixed output keyed by (width, prefix variant).
356+ // Bypass the cache while spinning (RawRender output is
357+ // frame-dependent) or while a highlight range is active.
358+ useCache := !t.isSpinning() && !t.isHighlighted()
359+ var key uint64
360+ switch {
361+ case t.isCompact:
362+ key = 2
363+ case t.focused:
364+ key = 1
365+ default:
366+ key = 0
367+ }
368+ if useCache {
369+ if cached, ok := t.getCachedPrefixedRender(width, key); ok {
370+ return cached
371+ }
372+ }
335 var prefix string
373 var prefix string
336 if t.isCompact {
374 if t.isCompact {
337 prefix = t.sty.Messages.ToolCallCompact.Render()
375 prefix = t.sty.Messages.ToolCallCompact.Render()
@@ -344,7 +382,11 @@ func (t *baseToolMessageItem) Render(width int) string {
344 for i, ln := range lines {
382 for i, ln := range lines {
345 lines[i] = prefix + ln
383 lines[i] = prefix + ln
346 }
384 }
347− return strings.Join(lines, "\n")
385+ out := strings.Join(lines, "\n")
386+ if useCache {
387+ t.setCachedPrefixedRender(out, width, key)
388+ }
389+ return out
348}
390}
349
391
350// ToolCall returns the tool call associated with this message item.
392// ToolCall returns the tool call associated with this message item.
@@ -356,12 +398,14 @@ func (t *baseToolMessageItem) ToolCall() message.ToolCall {
356func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
398func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
357 t.toolCall = tc
399 t.toolCall = tc
358 t.clearCache()
400 t.clearCache()
401+ t.Bump()
359}
402}
360
403
361// SetResult sets the tool result associated with this message item.
404// SetResult sets the tool result associated with this message item.
362func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
405func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
363 t.result = res
406 t.result = res
364 t.clearCache()
407 t.clearCache()
408+ t.Bump()
365}
409}
366
410
367// MessageID returns the ID of the message containing this tool call.
411// MessageID returns the ID of the message containing this tool call.
@@ -370,14 +414,20 @@ func (t *baseToolMessageItem) MessageID() string {
370}
414}
371
415
372// SetMessageID sets the ID of the message containing this tool call.
416// SetMessageID sets the ID of the message containing this tool call.
417+// MessageID is metadata only and does not affect the rendered output,
418+// so we deliberately do not bump the version here.
373func (t *baseToolMessageItem) SetMessageID(id string) {
419func (t *baseToolMessageItem) SetMessageID(id string) {
374 t.messageID = id
420 t.messageID = id
375}
421}
376
422
377// SetStatus sets the tool status.
423// SetStatus sets the tool status.
378func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
424func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
425+ if t.status == status {
426+ return
427+ }
379 t.status = status
428 t.status = status
380 t.clearCache()
429 t.clearCache()
430+ t.Bump()
381}
431}
382
432
383// Status returns the current tool status.
433// Status returns the current tool status.
@@ -417,9 +467,25 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
417func (t *baseToolMessageItem) ToggleExpanded() bool {
467func (t *baseToolMessageItem) ToggleExpanded() bool {
418 t.expandedContent = !t.expandedContent
468 t.expandedContent = !t.expandedContent
419 t.clearCache()
469 t.clearCache()
470+ t.Bump()
420 return t.expandedContent
471 return t.expandedContent
421}
472}
422
473
474+// Finished implements list.Item. A tool call is freezable once the
475+// tool call itself is marked finished AND a result has been recorded
476+// (or it has been canceled). Tools that override the spinning logic
477+// via spinningFunc would short-circuit live ticks; we still gate
478+// freezing on isSpinning to keep the contract conservative.
479+func (t *baseToolMessageItem) Finished() bool {
480+ if t.isSpinning() {
481+ return false
482+ }
483+ if t.status == ToolStatusCanceled {
484+ return true
485+ }
486+ return t.toolCall.Finished && t.result != nil
487+}
488+
423// HandleMouseClick implements MouseClickable.
489// HandleMouseClick implements MouseClickable.
424func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
490func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
425 return btn == ansi.MouseLeft
491 return btn == ansi.MouseLeft
@@ -974,7 +1040,10 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex
974 }
1040 }
975
1041
976 renderer := common.QuietMarkdownRenderer(sty, width)
1042 renderer := common.QuietMarkdownRenderer(sty, width)
1043+ mu := common.LockMarkdownRenderer(renderer)
1044+ mu.Lock()
977 rendered, err := renderer.Render(content)
1045 rendered, err := renderer.Render(content)
1046+ mu.Unlock()
978 if err != nil {
1047 if err != nil {
979 return toolOutputPlainContent(sty, content, width, expanded)
1048 return toolOutputPlainContent(sty, content, width, expanded)
980 }
1049 }
internal/ui/chat/user.go+34−3
@@ -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)
@@ -47,7 +57,10 @@ func (m *UserMessageItem) RawRender(width int) string {
47 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
57 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
48
58
49 msgContent := strings.TrimSpace(m.message.Content().Text)
59 msgContent := strings.TrimSpace(m.message.Content().Text)
60+ mu := common.LockMarkdownRenderer(renderer)
61+ mu.Lock()
50 result, err := renderer.Render(msgContent)
62 result, err := renderer.Render(msgContent)
63+ mu.Unlock()
51 if err != nil {
64 if err != nil {
52 content = msgContent
65 content = msgContent
53 } else {
66 } else {
@@ -70,6 +83,20 @@ func (m *UserMessageItem) RawRender(width int) string {
70
83
71// Render implements MessageItem.
84// Render implements MessageItem.
72func (m *UserMessageItem) Render(width int) string {
85func (m *UserMessageItem) Render(width int) string {
86+ // Bypass the prefix cache while a highlight range is active so
87+ // selection drags reflect immediately without invalidating the
88+ // cache. Highlight changes are intentionally applied "above" the
89+ // prefix cache.
90+ useCache := !m.isHighlighted()
91+ var key uint64
92+ if m.focused {
93+ key = 1
94+ }
95+ if useCache {
96+ if cached, ok := m.getCachedPrefixedRender(width, key); ok {
97+ return cached
98+ }
99+ }
73 var prefix string
100 var prefix string
74 if m.focused {
101 if m.focused {
75 prefix = m.sty.Messages.UserFocused.Render()
102 prefix = m.sty.Messages.UserFocused.Render()
@@ -80,7 +107,11 @@ func (m *UserMessageItem) Render(width int) string {
80 for i, line := range lines {
107 for i, line := range lines {
81 lines[i] = prefix + line
108 lines[i] = prefix + line
82 }
109 }
83− return strings.Join(lines, "\n")
110+ out := strings.Join(lines, "\n")
111+ if useCache {
112+ m.setCachedPrefixedRender(out, width, key)
113+ }
114+ return out
84}
115}
85
116
86// ID implements MessageItem.
117// ID implements MessageItem.
internal/ui/chat/version_bump_test.gonew file+565
@@ -0,0 +1,565 @@
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+// requireNoBump asserts the supplied mutator leaves the item's
404+// Version() unchanged. The mutator runs once; an unexpected bump
405+// would force the F6 list memo to re-render an item whose output
406+// did not change, churning the cache.
407+func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
408+ t.Helper()
409+ before := item.Version()
410+ mutate()
411+ after := item.Version()
412+ require.Equalf(t, before, after,
413+ "%s must not bump Version() (before=%d, after=%d)", name, before, after)
414+}
415+
416+// TestBaseToolMessageItem_AnimateBumpsVersion is the spinner
417+// regression test for non-agent tools: while the tool is spinning,
418+// every anim.StepMsg whose ID matches the tool must bump Version()
419+// so the list-level cache invalidates and the next draw re-renders
420+// the advanced spinner frame. Foreign IDs must not bump (they would
421+// churn the cache on every frame), and a finished tool must not
422+// bump on any ID (the entry is frozen and stays frozen).
423+func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
424+ t.Parallel()
425+
426+ sty := styles.CharmtonePantera()
427+ tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
428+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
429+ v := item.(versionedItem)
430+ a, ok := item.(Animatable)
431+ require.True(t, ok, "base tool message item must implement Animatable")
432+
433+ // Spinning + matching ID → bump.
434+ requireBump(t, "Animate[spinning,own ID]", v, func() {
435+ a.Animate(anim.StepMsg{ID: tc.ID})
436+ })
437+
438+ // Spinning + foreign ID → no bump. Routing this StepMsg here at
439+ // all would mean a future chat.Animate refactor; the item must
440+ // be defensive against it so we don't churn the list cache.
441+ requireNoBump(t, "Animate[spinning,foreign ID]", v, func() {
442+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
443+ })
444+
445+ // Finished → no bump on any ID. The entry is frozen; a stray
446+ // bump would needlessly invalidate frozen entries.
447+ tcFinished := tc
448+ tcFinished.Finished = true
449+ item.SetToolCall(tcFinished)
450+ item.SetResult(&message.ToolResult{ToolCallID: tc.ID, Content: "ok"})
451+ require.True(t, item.Finished(), "tool must report Finished() once the result lands")
452+
453+ requireNoBump(t, "Animate[finished,own ID]", v, func() {
454+ a.Animate(anim.StepMsg{ID: tc.ID})
455+ })
456+ requireNoBump(t, "Animate[finished,foreign ID]", v, func() {
457+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
458+ })
459+}
460+
461+// TestAgentToolMessageItem_AnimateBumpsVersion is the spinner
462+// regression test for agent tools. The parent must bump on both
463+// the parent-tick branch (msg.ID == parent.ID()) and the
464+// nested-tick branch (msg.ID == nested.ID()) because the list
465+// only checks the parent's version — nested tools are not list
466+// entries of their own. Unrelated IDs must not bump, and a parent
467+// with a result must not bump on any ID.
468+func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
469+ t.Parallel()
470+
471+ sty := styles.CharmtonePantera()
472+ parentTC := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
473+ parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
474+
475+ childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
476+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
477+ parent.AddNestedTool(child)
478+
479+ // Spinning + parent's own ID → parent bumps.
480+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
481+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
482+ })
483+
484+ // Spinning + nested child ID → parent bumps. The list only
485+ // invalidates on the parent; without this the nested
486+ // spinner's frame would never reach the screen even though
487+ // the nested anim's step has advanced.
488+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
489+ parent.Animate(anim.StepMsg{ID: childTC.ID})
490+ })
491+
492+ // Spinning + unrelated ID → no bump.
493+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
494+ parent.Animate(anim.StepMsg{ID: "unrelated"})
495+ })
496+
497+ // Once the parent has a result, neither branch bumps.
498+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
499+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
500+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
501+ })
502+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
503+ parent.Animate(anim.StepMsg{ID: childTC.ID})
504+ })
505+}
506+
507+// TestAgenticFetchToolMessageItem_AnimateBumpsVersion is the
508+// agentic-fetch counterpart of the agent-tool Animate bump test.
509+// Without an explicit override the embedded base Animate would
510+// drop nested-child StepMsgs at anim.Animate's ID check and never
511+// bump the parent on its own ticks; this test locks in the
512+// override.
513+func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
514+ t.Parallel()
515+
516+ sty := styles.CharmtonePantera()
517+ parentTC := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
518+ parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
519+
520+ childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
521+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
522+ parent.AddNestedTool(child)
523+
524+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
525+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
526+ })
527+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
528+ parent.Animate(anim.StepMsg{ID: childTC.ID})
529+ })
530+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
531+ parent.Animate(anim.StepMsg{ID: "unrelated"})
532+ })
533+
534+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
535+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
536+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
537+ })
538+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
539+ parent.Animate(anim.StepMsg{ID: childTC.ID})
540+ })
541+}
542+
543+// TestBaseToolMessageItem_FinishedTransition covers §4.5.1 for
544+// tools: a still-running tool reports Finished() == false; once the
545+// tool call is marked finished and a result lands, Finished()
546+// returns true. Cancelled tools also become Finished.
547+func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
548+ t.Parallel()
549+
550+ sty := styles.CharmtonePantera()
551+ tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
552+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
553+ require.False(t, item.Finished(), "running tool must not be Finished()")
554+
555+ tcFinished := tc
556+ tcFinished.Finished = true
557+ item.SetToolCall(tcFinished)
558+ item.SetResult(&message.ToolResult{ToolCallID: "tc-fin", Content: "ok"})
559+ require.True(t, item.Finished(), "finished tool with result must be Finished()")
560+
561+ // Canceled tool with no result is also Finished.
562+ tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
563+ canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
564+ require.True(t, canceled.Finished(), "canceled tool must be Finished()")
565+}
internal/ui/common/markdown.go+70−4
@@ -19,6 +19,12 @@ func init() {
19 formatters.Register(formatterName, xchroma.Formatter(zero, nil))
19 formatters.Register(formatterName, xchroma.Formatter(zero, nil))
20}
20}
21
21
22+// mdCacheMu guards mdCache and quietMDCache.
23+//
24+// Lock ordering: when both mdCacheMu and rendererLocksMu are
25+// needed (only in InvalidateMarkdownRendererCache), acquire
26+// mdCacheMu FIRST, then rendererLocksMu. No other call site may
27+// hold rendererLocksMu while acquiring mdCacheMu.
22var (
28var (
23 mdCacheMu sync.Mutex
29 mdCacheMu sync.Mutex
24 mdCache = map[int]*glamour.TermRenderer{}
30 mdCache = map[int]*glamour.TermRenderer{}
@@ -29,6 +35,13 @@ var (
29// the given styles and width. Renderers are memoized per width and shared
35// the given styles and width. Renderers are memoized per width and shared
30// across callers; call InvalidateMarkdownRendererCache when the active
36// across callers; call InvalidateMarkdownRendererCache when the active
31// styles change.
37// styles change.
38+//
39+// The returned renderer is NOT safe for concurrent Render calls
40+// (goldmark's BlockStack carries state across the public Render
41+// API). Crush's TUI is single-threaded so production never
42+// contends, but parallel callers (most notably parallel tests)
43+// must serialize via [LockMarkdownRenderer]. Treat the renderer
44+// as effectively pinned to one goroutine at a time.
32func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
45func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
33 mdCacheMu.Lock()
46 mdCacheMu.Lock()
34 defer mdCacheMu.Unlock()
47 defer mdCacheMu.Unlock()
@@ -46,7 +59,8 @@ func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
46
59
47// QuietMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
60// QuietMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
48// (plain text with structure) and the given width. Renderers are memoized per
61// (plain text with structure) and the given width. Renderers are memoized per
49−// width and shared across callers.
62+// width and shared across callers. Same concurrency contract as
63+// [MarkdownRenderer]: serialize via [LockMarkdownRenderer].
50func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
64func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
51 mdCacheMu.Lock()
65 mdCacheMu.Lock()
52 defer mdCacheMu.Unlock()
66 defer mdCacheMu.Unlock()
@@ -62,12 +76,64 @@ func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer
62 return r
76 return r
63}
77}
64
78
65−// InvalidateMarkdownRendererCache drops every cached renderer. Call this
79+// InvalidateMarkdownRendererCache drops every cached renderer
80+// AND every per-renderer mutex in a single atomic critical
81+// section so the two maps cannot disagree mid-toggle. Call this
66−// whenever the active styles change so subsequent renderers pick up the new
82+// whenever the active styles change so subsequent renderers
67−// ansi.StyleConfig.
83+// pick up the new ansi.StyleConfig.
84+//
85+// Existing holders of an old mutex (mid-Render goroutines) keep
86+// their reference safely; new renderers minted after the
87+// invalidation get freshly minted mutexes.
88+//
89+// Lock ordering: mdCacheMu is acquired first, then
90+// rendererLocksMu — see the comments on each mutex.
68func InvalidateMarkdownRendererCache() {
91func InvalidateMarkdownRendererCache() {
69 mdCacheMu.Lock()
92 mdCacheMu.Lock()
70 defer mdCacheMu.Unlock()
93 defer mdCacheMu.Unlock()
94+ rendererLocksMu.Lock()
95+ defer rendererLocksMu.Unlock()
96+
71 mdCache = map[int]*glamour.TermRenderer{}
97 mdCache = map[int]*glamour.TermRenderer{}
72 quietMDCache = map[int]*glamour.TermRenderer{}
98 quietMDCache = map[int]*glamour.TermRenderer{}
99+ rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
100+}
101+
102+// rendererLocksMu guards rendererLocks. We key per-renderer
103+// mutexes by pointer so the lock granularity matches the
104+// renderer cache granularity (one mutex per (width, palette)
105+// renderer instance, not one mutex for the entire cache).
106+//
107+// Lock ordering: when both mdCacheMu and rendererLocksMu are
108+// needed (only in InvalidateMarkdownRendererCache), acquire
109+// mdCacheMu FIRST, then rendererLocksMu.
110+var (
111+ rendererLocksMu sync.Mutex
112+ rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
113+)
114+
115+// LockMarkdownRenderer returns the per-renderer mutex used to
116+// serialize concurrent Render calls on a shared
117+// [glamour.TermRenderer] instance. The returned [*sync.Mutex] is
118+// stable for the lifetime of the renderer (i.e. until
119+// [InvalidateMarkdownRendererCache] is called).
120+//
121+// Callers that issue more than one Render call in the same
122+// logical operation should hold the mutex for the entire
123+// sequence so other goroutines do not interleave their own
124+// Render calls and corrupt the renderer state. F8's
125+// streamingMarkdown is the immediate consumer; other call
126+// sites that today issue exactly one Render call per item
127+// render are safe without locking under the single-threaded
128+// TUI Update loop, but should adopt this lock if they ever run
129+// in parallel (e.g. background prerender workers).
130+func LockMarkdownRenderer(r *glamour.TermRenderer) *sync.Mutex {
131+ rendererLocksMu.Lock()
132+ defer rendererLocksMu.Unlock()
133+ if mu, ok := rendererLocks[r]; ok {
134+ return mu
135+ }
136+ mu := &sync.Mutex{}
137+ rendererLocks[r] = mu
138+ return mu
73}
139}
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+240−34
@@ -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)
165− content: rendered,
247+
166− height: height,
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
167 }
264 }
265+ entry.width = l.width
266+ entry.version = finalVersion
267+ entry.frozen = frozen
268+ entry.content = rendered
269+ entry.lines = lines
270+ entry.height = height
271+ return entry
272+}
273+
274+// invalidateAll drops every cache entry. Called on width changes.
275+func (l *List) invalidateAll() {
276+ for k := range l.cache {
277+ delete(l.cache, k)
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.
@@ -276,54 +457,75 @@ func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
276}
457}
277
458
278// Render renders the list and returns the visible lines.
459// Render renders the list and returns the visible lines.
460+//
461+// F7: per-item slicing is bounded by the remaining viewport budget so
462+// per-frame work is O(viewport) rather than O(total item heights).
463+// We never append beyond l.height lines to the output buffer; the
464+// final trim is therefore unnecessary. Reverse mode applies the same
465+// final reversal as before, which is byte-identical because the
466+// pre-F7 trim happened at the tail of the joined buffer (the same
467+// lines we now drop implicitly per item).
279func (l *List) Render() string {
468func (l *List) Render() string {
280 if len(l.items) == 0 {
469 if len(l.items) == 0 {
281 return ""
470 return ""
282 }
471 }
283
472
473+ budget := max(l.height, 0)
284− var lines []string
474+ lines := make([]string, 0, budget)
285 currentIdx := l.offsetIdx
475 currentIdx := l.offsetIdx
286 currentOffset := l.offsetLine
476 currentOffset := l.offsetLine
287
477
288− linesNeeded := l.height
478+ for currentIdx < len(l.items) {
479+ remaining := budget - len(lines)
480+ if remaining <= 0 {
481+ break
482+ }
289
483
290− for linesNeeded > 0 && currentIdx < len(l.items) {
484+ entry := l.renderItemEntry(currentIdx)
291− item := l.getItem(currentIdx)
485+ if entry == nil {
292− itemLines := strings.Split(item.content, "\n")
486+ break
487+ }
488+ itemLines := entry.lines
293 itemHeight := len(itemLines)
489 itemHeight := len(itemLines)
294
490
295 if currentOffset >= 0 && currentOffset < itemHeight {
491 if currentOffset >= 0 && currentOffset < itemHeight {
296− // Add visible content lines
492+ // Append only the visible slice that fits in the
493+ // remaining viewport budget. Anything past the
494+ // budget would be discarded by the pre-F7 tail
495+ // trim, so skipping the append here is
496+ // byte-identical and bounded.
297− lines = append(lines, itemLines[currentOffset:]...)
497+ visible := itemLines[currentOffset:]
498+ if len(visible) > remaining {
499+ visible = visible[:remaining]
500+ }
501+ lines = append(lines, visible...)
298
502
299− // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
503+ // Gap rows after the item, capped to the
300− // But in the loop we can just add it and trim later
504+ // remaining budget so a 30k-line item with a
505+ // trailing gap can't push past the viewport.
301 if l.gap > 0 {
506 if l.gap > 0 {
302− for i := 0; i < l.gap; i++ {
507+ gapBudget := min(budget-len(lines), l.gap)
508+ for range gapBudget {
303 lines = append(lines, "")
509 lines = append(lines, "")
304 }
510 }
305 }
511 }
306 } else {
512 } else {
307− // offsetLine starts in the gap
513+ // offsetLine starts inside the gap.
308 gapOffset := currentOffset - itemHeight
514 gapOffset := currentOffset - itemHeight
309 gapRemaining := l.gap - gapOffset
515 gapRemaining := l.gap - gapOffset
310 if gapRemaining > 0 {
516 if gapRemaining > 0 {
517+ gapBudget := min(budget-len(lines), gapRemaining)
311− for range gapRemaining {
518+ for range gapBudget {
312 lines = append(lines, "")
519 lines = append(lines, "")
313 }
520 }
314 }
521 }
315 }
522 }
316
523
317− linesNeeded = l.height - len(lines)
318 currentIdx++
524 currentIdx++
319− currentOffset = 0 // Reset offset for subsequent items
525+ currentOffset = 0 // Reset offset for subsequent items.
320 }
526 }
321
527
322− l.height = max(l.height, 0)
528+ l.height = budget
323−
324− if len(lines) > l.height {
325− lines = lines[:l.height]
326− }
327
529
328 if l.reverse {
530 if l.reverse {
329 // Reverse the lines so the list renders bottom-to-top.
531 // Reverse the lines so the list renders bottom-to-top.
@@ -348,18 +550,15 @@ func (l *List) PrependItems(items ...Item) {
348 }
550 }
349}
551}
350
552
351−// SetItems sets the items in the list.
553+// SetItems sets the items in the list. Cache entries for items that
554+// remain after the swap are preserved; entries for removed items are
555+// dropped.
352func (l *List) SetItems(items ...Item) {
556func (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
557 l.items = items
360 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
558 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
361 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
559 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
362 l.offsetLine = 0
560 l.offsetLine = 0
561+ l.retainCacheFor(items)
363}
562}
364
563
365// AppendItems appends items to the list.
564// AppendItems appends items to the list.
@@ -373,9 +572,16 @@ func (l *List) RemoveItem(idx int) {
373 return
572 return
374 }
573 }
375
574
575+ removed := l.items[idx]
576+
376 // Remove the item
577 // Remove the item
377 l.items = append(l.items[:idx], l.items[idx+1:]...)
578 l.items = append(l.items[:idx], l.items[idx+1:]...)
378
579
580+ // Drop the cache entry for the removed item; entries for stable
581+ // items stay valid because they are keyed by pointer, not index.
582+ delete(l.cache, removed)
583+ delete(l.freezeSuppressed, removed)
584+
379 // Adjust selection if needed
585 // Adjust selection if needed
380 if l.selectedIdx == idx {
586 if l.selectedIdx == idx {
381 l.selectedIdx = -1
587 l.selectedIdx = -1
internal/ui/list/list_test.gonew file+770
@@ -0,0 +1,770 @@
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+}
402+
403+// multiLineItem is a test helper whose Render returns a fixed
404+// multi-line body. Each line is uniquely identifiable (id:N) so a
405+// test can reconstruct the expected visible window by index. F7's
406+// byte-identity matrix is built around these.
407+type multiLineItem struct {
408+ *Versioned
409+ id string
410+ height int
411+}
412+
413+func newMultiLineItem(id string, height int) *multiLineItem {
414+ return &multiLineItem{
415+ Versioned: NewVersioned(),
416+ id: id,
417+ height: height,
418+ }
419+}
420+
421+func (m *multiLineItem) Render(_ int) string {
422+ if m.height <= 0 {
423+ return ""
424+ }
425+ parts := make([]string, m.height)
426+ for i := range m.height {
427+ parts[i] = m.id + ":" + strconv.Itoa(i)
428+ }
429+ return strings.Join(parts, "\n")
430+}
431+
432+func (m *multiLineItem) Finished() bool { return true }
433+
434+// expectedRender computes what list.Render *should* produce from
435+// first principles given the item heights, viewport, offsetIdx,
436+// offsetLine, gap, and reverse settings. It mirrors the pre-F7
437+// "build everything, trim to height, reverse" semantics so we can
438+// assert byte-identity against the new bounded path.
439+func expectedRender(items []*multiLineItem, height, offsetIdx, offsetLine, gap int, reverse bool) string {
440+ if len(items) == 0 {
441+ return ""
442+ }
443+ budget := max(height, 0)
444+ var lines []string
445+ currentOffset := offsetLine
446+ for idx := offsetIdx; idx < len(items) && len(lines) < budget; idx++ {
447+ body := items[idx].Render(0)
448+ body = strings.TrimRight(body, "\n")
449+ itemLines := strings.Split(body, "\n")
450+ itemHeight := len(itemLines)
451+
452+ if currentOffset >= 0 && currentOffset < itemHeight {
453+ lines = append(lines, itemLines[currentOffset:]...)
454+ for range gap {
455+ lines = append(lines, "")
456+ }
457+ } else {
458+ gapOffset := currentOffset - itemHeight
459+ gapRemaining := gap - gapOffset
460+ for range max(gapRemaining, 0) {
461+ lines = append(lines, "")
462+ }
463+ }
464+ currentOffset = 0
465+ }
466+ if len(lines) > budget {
467+ lines = lines[:budget]
468+ }
469+ if reverse {
470+ for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
471+ lines[i], lines[j] = lines[j], lines[i]
472+ }
473+ }
474+ return strings.Join(lines, "\n")
475+}
476+
477+// TestList_F7_ByteIdentityMatrix is T1 from the F7 plan: a sweep
478+// over (item heights × viewport heights × offsets × gaps × reverse)
479+// that asserts list.Render produces output byte-identical to a
480+// pre-F7-equivalent reference (build full buffer, trim at end).
481+func TestList_F7_ByteIdentityMatrix(t *testing.T) {
482+ t.Parallel()
483+
484+ itemHeights := [][]int{
485+ {1},
486+ {5},
487+ {1, 1, 1},
488+ {3, 7, 2},
489+ {20, 5, 30},
490+ {50, 1, 50, 1},
491+ }
492+ viewportHeights := []int{1, 3, 5, 10, 25, 100}
493+ offsetIdxs := []int{0, 1, 2}
494+ offsetLines := []int{0, 1, 4}
495+ gaps := []int{0, 1, 3}
496+ reverses := []bool{false, true}
497+
498+ for _, heights := range itemHeights {
499+ for _, vh := range viewportHeights {
500+ for _, oIdx := range offsetIdxs {
501+ if oIdx >= len(heights) {
502+ continue
503+ }
504+ for _, oLine := range offsetLines {
505+ maxOffset := heights[oIdx]
506+ if oLine >= maxOffset {
507+ continue
508+ }
509+ for _, gap := range gaps {
510+ for _, reverse := range reverses {
511+ items := make([]*multiLineItem, len(heights))
512+ asItems := make([]Item, len(heights))
513+ for i, h := range heights {
514+ items[i] = newMultiLineItem("i"+strconv.Itoa(i), h)
515+ asItems[i] = items[i]
516+ }
517+ l := NewList(asItems...)
518+ l.SetSize(40, vh)
519+ l.SetGap(gap)
520+ l.SetReverse(reverse)
521+ l.offsetIdx = oIdx
522+ l.offsetLine = oLine
523+
524+ got := l.Render()
525+ want := expectedRender(items, vh, oIdx, oLine, gap, reverse)
526+ require.Equalf(t, want, got,
527+ "mismatch heights=%v vh=%d oIdx=%d oLine=%d gap=%d reverse=%v",
528+ heights, vh, oIdx, oLine, gap, reverse)
529+ }
530+ }
531+ }
532+ }
533+ }
534+ }
535+}
536+
537+// TestList_F7_GiantItemBoundedRender is T2: a single 10,000-line
538+// item with a 50-line viewport. Render must return exactly 50
539+// lines — no off-by-one, no trim issue. This is the F7 win in
540+// test form: per-frame work is bounded by viewport, not item
541+// height.
542+func TestList_F7_GiantItemBoundedRender(t *testing.T) {
543+ t.Parallel()
544+
545+ const itemHeight = 10000
546+ const viewport = 50
547+
548+ giant := newMultiLineItem("giant", itemHeight)
549+ l := NewList(giant)
550+ l.SetSize(40, viewport)
551+
552+ out := l.Render()
553+ got := strings.Count(out, "\n") + 1
554+ require.Equal(t, viewport, got, "render output must be exactly viewport lines for an oversized item")
555+
556+ // And the lines are the prefix of the item starting at
557+ // offset 0.
558+ lines := strings.Split(out, "\n")
559+ for i, line := range lines {
560+ require.Equal(t, "giant:"+strconv.Itoa(i), line, "line %d does not match expected slice", i)
561+ }
562+}
563+
564+// TestList_F7_GiantItemWithOffsetBoundedRender complements T2 with
565+// a non-zero offsetLine so we exercise both the "skip prefix" and
566+// "bound suffix" sides of the slice.
567+func TestList_F7_GiantItemWithOffsetBoundedRender(t *testing.T) {
568+ t.Parallel()
569+
570+ const itemHeight = 10000
571+ const viewport = 50
572+ const offset = 1234
573+
574+ giant := newMultiLineItem("giant", itemHeight)
575+ l := NewList(giant)
576+ l.SetSize(40, viewport)
577+ l.offsetLine = offset
578+
579+ out := l.Render()
580+ lines := strings.Split(out, "\n")
581+ require.Len(t, lines, viewport, "render output must be exactly viewport lines for an oversized item")
582+ for i, line := range lines {
583+ require.Equal(t, "giant:"+strconv.Itoa(offset+i), line, "line %d does not match expected slice", i)
584+ }
585+}
586+
587+// TestList_F7_GapOverflow is T3: viewport height 5, two items each
588+// 10 lines, gap of 3. Render returns exactly 5 lines and never
589+// includes gap rows beyond the viewport.
590+func TestList_F7_GapOverflow(t *testing.T) {
591+ t.Parallel()
592+
593+ a := newMultiLineItem("a", 10)
594+ b := newMultiLineItem("b", 10)
595+ l := NewList(a, b)
596+ l.SetSize(40, 5)
597+ l.SetGap(3)
598+
599+ out := l.Render()
600+ lines := strings.Split(out, "\n")
601+ require.Len(t, lines, 5, "viewport must clamp output to height even with gap rows pending")
602+
603+ // Gap rows after item a would only appear if the viewport
604+ // extended past the first 10 lines, which it doesn't here.
605+ for i, line := range lines {
606+ require.Equal(t, "a:"+strconv.Itoa(i), line, "line %d", i)
607+ }
608+}
609+
610+// TestList_F7_GapOverflow_BoundaryStraddle exercises a viewport
611+// that lands inside the gap region between two items: 12 lines
612+// viewport, item a height 10, item b height 10, gap 3 — first 10
613+// lines from a, then 2 of the 3 gap rows, no b lines yet.
614+func TestList_F7_GapOverflow_BoundaryStraddle(t *testing.T) {
615+ t.Parallel()
616+
617+ a := newMultiLineItem("a", 10)
618+ b := newMultiLineItem("b", 10)
619+ l := NewList(a, b)
620+ l.SetSize(40, 12)
621+ l.SetGap(3)
622+
623+ out := l.Render()
624+ lines := strings.Split(out, "\n")
625+ require.Len(t, lines, 12)
626+
627+ for i := range 10 {
628+ require.Equal(t, "a:"+strconv.Itoa(i), lines[i])
629+ }
630+ require.Equal(t, "", lines[10])
631+ require.Equal(t, "", lines[11])
632+}
633+
634+// TestList_F7_ReverseGiantItem is T4: same bounded-slicing
635+// invariant in reverse mode. Reverse mode keeps the same final
636+// trim semantics; bounded slicing must produce the same window
637+// (just reversed).
638+func TestList_F7_ReverseGiantItem(t *testing.T) {
639+ t.Parallel()
640+
641+ const itemHeight = 10000
642+ const viewport = 50
643+
644+ giant := newMultiLineItem("giant", itemHeight)
645+ l := NewList(giant)
646+ l.SetSize(40, viewport)
647+ l.SetReverse(true)
648+
649+ out := l.Render()
650+ lines := strings.Split(out, "\n")
651+ require.Len(t, lines, viewport)
652+
653+ // Expected: the same first-50 slice as the non-reverse path
654+ // but reversed.
655+ for i, line := range lines {
656+ expectedIdx := viewport - 1 - i
657+ require.Equal(t, "giant:"+strconv.Itoa(expectedIdx), line, "reverse line %d", i)
658+ }
659+}
660+
661+// TestList_F7_OffsetLineAtItemBoundary is T5: offsetLine ==
662+// itemHeight lands exactly past the last visible line of the item
663+// at offsetIdx. The renderer must not address line N (which does
664+// not exist); the visible window starts at the gap rows (when gap
665+// > 0) or at the next item (when gap == 0).
666+func TestList_F7_OffsetLineAtItemBoundary(t *testing.T) {
667+ t.Parallel()
668+
669+ tests := []struct {
670+ name string
671+ gap int
672+ viewport int
673+ want []string
674+ }{
675+ {
676+ name: "gap zero jumps straight to next item",
677+ gap: 0,
678+ viewport: 5,
679+ want: []string{
680+ "b:0", "b:1", "b:2", "b:3", "b:4",
681+ },
682+ },
683+ {
684+ name: "gap two emits gap rows then next item",
685+ gap: 2,
686+ viewport: 5,
687+ want: []string{
688+ "", "", "b:0", "b:1", "b:2",
689+ },
690+ },
691+ }
692+
693+ const itemHeight = 4
694+ for _, tc := range tests {
695+ t.Run(tc.name, func(t *testing.T) {
696+ t.Parallel()
697+
698+ a := newMultiLineItem("a", itemHeight)
699+ b := newMultiLineItem("b", 10)
700+ l := NewList(a, b)
701+ l.SetSize(40, tc.viewport)
702+ l.SetGap(tc.gap)
703+ l.offsetIdx = 0
704+ l.offsetLine = itemHeight // exactly at the boundary
705+
706+ out := l.Render()
707+ require.Equal(t, strings.Join(tc.want, "\n"), out)
708+ })
709+ }
710+}
711+
712+// TestList_F7_OffsetLineInsideGap is T6: offsetLine is one row
713+// past the end of the item at offsetIdx, landing inside the gap
714+// region. The visible window starts at the second gap row and
715+// then continues into the next item.
716+func TestList_F7_OffsetLineInsideGap(t *testing.T) {
717+ t.Parallel()
718+
719+ const itemHeight = 4
720+ const gap = 3
721+ const viewport = 5
722+
723+ a := newMultiLineItem("a", itemHeight)
724+ b := newMultiLineItem("b", 10)
725+ l := NewList(a, b)
726+ l.SetSize(40, viewport)
727+ l.SetGap(gap)
728+ l.offsetIdx = 0
729+ l.offsetLine = itemHeight + 1 // one row into the gap
730+
731+ out := l.Render()
732+ want := strings.Join([]string{
733+ "", // second gap row
734+ "", // third gap row
735+ "b:0", // next item starts
736+ "b:1",
737+ "b:2",
738+ }, "\n")
739+ require.Equal(t, want, out)
740+}
741+
742+// TestList_F7_ViewportZeroOrNegative is T7: a non-positive
743+// viewport height must produce an empty string with no panic and
744+// must normalize l.height to zero (the budget := max(l.height, 0)
745+// side effect).
746+func TestList_F7_ViewportZeroOrNegative(t *testing.T) {
747+ t.Parallel()
748+
749+ tests := []struct {
750+ name string
751+ height int
752+ }{
753+ {name: "height zero", height: 0},
754+ {name: "height negative", height: -1},
755+ }
756+
757+ for _, tc := range tests {
758+ t.Run(tc.name, func(t *testing.T) {
759+ t.Parallel()
760+
761+ a := newMultiLineItem("a", 5)
762+ l := NewList(a)
763+ l.SetSize(40, tc.height)
764+
765+ out := l.Render()
766+ require.Equal(t, "", out, "render must be empty for non-positive viewport")
767+ require.Equal(t, 0, l.height, "render must normalize height to zero")
768+ })
769+ }
770+}
internal/ui/model/chat.go+112−1
@@ -63,6 +63,32 @@ type Chat struct {
63 // follow is a flag to indicate whether the view should auto-scroll to
63 // follow is a flag to indicate whether the view should auto-scroll to
64 // bottom on new messages.
64 // bottom on new messages.
65 follow bool
65 follow bool
66+
67+ // drawCache memoizes the decoded form of the last list.Render output so
68+ // repeat frames with byte-identical content skip the per-cell ANSI
69+ // reparse that uv.StyledString.Draw performs every call. See F9
70+ // (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
71+ // entry; invalidated implicitly by string inequality on the next Draw.
72+ drawCache *chatDrawCache
73+}
74+
75+// chatDrawCache holds the pre-decoded form of the last list.Render output.
76+// The cache is keyed by the rendered string and the screen's width method
77+// (graphemes vs wcwidth pick different decoders inside ultraviolet's
78+// printString, so a cached buffer is only valid for the method it was
79+// decoded with). The cached buffer is independent of the draw area, so
80+// resize / scroll changes that produce the same string still hit. We cannot
81+// use uv.StyledString.Lines because it bottoms out at the first iteration
82+// against a zero-bounds rectangle (see ultraviolet styled.go line 45 — the
83+// shared printString loop's `y >= bounds.Max.Y` exit applies to the
84+// line-building branch too). A Buffer of the rendered text's natural
85+// dimensions is the cheapest correct shape: StyledString.Draw runs once on
86+// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
87+// ANSI re-parse on hit.
88+type chatDrawCache struct {
89+ rendered string
90+ method ansi.Method
91+ buf uv.ScreenBuffer
66}
92}
67
93
68// NewChat creates a new instance of [Chat] that handles chat interactions and
94// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -89,8 +115,93 @@ func (m *Chat) Height() int {
89}
115}
90
116
91// Draw renders the chat UI component to the screen and the given area.
117// Draw renders the chat UI component to the screen and the given area.
118+//
119+// The list's rendered output is cached in decoded form (see chatDrawCache) so
120+// that frames with byte-identical content skip the ANSI reparse that
121+// uv.StyledString.Draw performs on every call. The cache is keyed by the
122+// rendered string and the screen's width method; area / scroll changes do not
123+// invalidate it.
92func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
124func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
125+ rendered := m.list.Render()
126+ method, ok := scr.WidthMethod().(ansi.Method)
127+ if !ok {
128+ // Width method isn't an ansi.Method (unlikely in practice — both
129+ // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
130+ // to the uncached path so behavior matches upstream exactly.
131+ uv.NewStyledString(rendered).Draw(scr, area)
132+ return
133+ }
134+ if m.drawCache == nil ||
135+ m.drawCache.rendered != rendered ||
136+ m.drawCache.method != method {
137+ m.drawCache = newChatDrawCache(rendered, method)
138+ }
139+ drawCachedBuffer(scr, area, m.drawCache.buf)
140+}
141+
142+// newChatDrawCache builds a chatDrawCache for the given rendered string by
143+// running uv.StyledString.Draw into a fresh buffer sized to the text's
144+// natural bounds under the active width method. This is the only place
145+// ANSI decoding happens for cached frames — subsequent draws reuse buf
146+// via drawCachedBuffer.
147+//
148+// We can't use uv.StyledString.Bounds() here: it is hard-coded to
149+// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
150+// destination buffer's WidthMethod (which we capture in `method`). For
151+// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
152+// some CJK, certain combining marks) the two answers diverge, leaving
153+// the cached buffer either too small (trailing cells dropped on hit) or
154+// too large (dead cells past the live content). Computing dimensions
155+// with `method.StringWidth` per line matches what printString tallies
156+// cell-by-cell, since both decode ANSI sequences and use the same width
157+// method.
158+func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
159+ w, h := renderedBounds(rendered, method)
160+ if w <= 0 {
161+ w = 1
162+ }
163+ if h <= 0 {
164+ h = 1
165+ }
166+ buf := uv.NewScreenBuffer(w, h)
167+ buf.Method = method
168+ uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
169+ return &chatDrawCache{
170+ rendered: rendered,
171+ method: method,
172+ buf: buf,
173+ }
174+}
175+
176+// renderedBounds returns the (width, height) cell extent of rendered
177+// when laid out by method. Width is the widest line's StringWidth (which
178+// strips ANSI sequences and tallies cells via method, exactly like
179+// printString); height is the line count. Both match what
180+// uv.StyledString.Draw will write into a buffer whose WidthMethod is
181+// method, so the cache buffer is always sized to fit the live content.
182+func renderedBounds(rendered string, method ansi.Method) (w, h int) {
183+ for line := range strings.SplitSeq(rendered, "\n") {
184+ w = max(w, method.StringWidth(line))
185+ h++
186+ }
187+ return w, h
188+}
189+
190+// drawCachedBuffer blits a previously-decoded buffer into scr at area,
191+// mirroring uv.StyledString.Draw's screen-mode behavior for the default
192+// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
193+// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
194+// printString does on every uncached frame with a pure cell copy.
195+func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
196+ // Clear the area first to match StyledString.Draw — leftover cells
197+ // from a previous frame outside the new content must be zeroed,
198+ // because Buffer.Draw skips empty cells (it doesn't clear).
199+ for y := area.Min.Y; y < area.Max.Y; y++ {
200+ for x := area.Min.X; x < area.Max.X; x++ {
201+ scr.SetCell(x, y, nil)
202+ }
203+ }
93− uv.NewStyledString(m.list.Render()).Draw(scr, area)
204+ buf.Draw(scr, area)
94}
205}
95
206
96// SetSize sets the size of the chat view port.
207// SetSize sets the size of the chat view port.
internal/ui/model/chat_draw_cache_test.gonew file+334
@@ -0,0 +1,334 @@
1+package model
2+
3+import (
4+ "strconv"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/ui/chat"
8+ uv "github.com/charmbracelet/ultraviolet"
9+ "github.com/charmbracelet/x/ansi"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// drawTestArea is a fixed area used by the cache tests; it must be at least
14+// as large as the test items so we can compare every cell.
15+func drawTestArea(width, height int) uv.Rectangle {
16+ return uv.Rect(0, 0, width, height)
17+}
18+
19+// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
20+// can assert byte-equivalence with a fresh uv.NewStyledString render.
21+func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
22+ t.Helper()
23+ scr := uv.NewScreenBuffer(w, h)
24+ c.Draw(scr, drawTestArea(w, h))
25+ return scr.Render()
26+}
27+
28+// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws
29+// against the same list output reuse the same chatDrawCache rather than
30+// allocating a fresh one. Both the wrapper pointer and the embedded
31+// RenderBuffer pointer must be stable across the second Draw — that's what
32+// proves no re-decoding happened.
33+func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
34+ t.Parallel()
35+
36+ u := newTestUI()
37+ u.chat.SetMessages(
38+ testMessageItem{id: "a", text: "alpha"},
39+ testMessageItem{id: "b", text: "beta"},
40+ )
41+ u.updateLayoutAndSize()
42+
43+ w, h := 80, 20
44+ _ = renderToBuffer(t, u.chat, w, h)
45+ require.NotNil(t, u.chat.drawCache, "first draw should populate cache")
46+ firstCache := u.chat.drawCache
47+ firstBuf := u.chat.drawCache.buf.RenderBuffer
48+
49+ _ = renderToBuffer(t, u.chat, w, h)
50+ require.Same(t, firstCache, u.chat.drawCache,
51+ "identical rendered string must reuse the same cache pointer")
52+ require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
53+ "identical rendered string must reuse the same RenderBuffer pointer")
54+}
55+
56+// TestChatDrawCache_MissOnDifferentRender asserts that when the list output
57+// changes, the cache is rebuilt and the new draw output matches a fresh
58+// uv.NewStyledString render byte-for-byte.
59+func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
60+ t.Parallel()
61+
62+ u := newTestUI()
63+ u.chat.SetMessages(
64+ testMessageItem{id: "a", text: "alpha"},
65+ )
66+ u.updateLayoutAndSize()
67+
68+ w, h := 80, 20
69+ _ = renderToBuffer(t, u.chat, w, h)
70+ require.NotNil(t, u.chat.drawCache)
71+ firstCache := u.chat.drawCache
72+
73+ // Replace the items so the rendered string differs.
74+ u.chat.SetMessages(
75+ testMessageItem{id: "c", text: "gamma delta"},
76+ )
77+ u.updateLayoutAndSize()
78+
79+ got := renderToBuffer(t, u.chat, w, h)
80+ require.NotSame(t, firstCache, u.chat.drawCache,
81+ "changed rendered string must replace the cache entry")
82+
83+ // Output must match a fresh uv.NewStyledString render of the current
84+ // list output for the same area. This is the byte-equivalence guard
85+ // that protects against blit drift from StyledString.Draw.
86+ want := freshStyledRender(u.chat.list.Render(), w, h)
87+ require.Equal(t, want, got)
88+}
89+
90+// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached
91+// decoded lines do not depend on the screen / area: the same string drawn
92+// into a smaller area must hit the cache, and the output must match a fresh
93+// uv.NewStyledString render against the new area.
94+func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
95+ t.Parallel()
96+
97+ u := newTestUI()
98+ u.chat.SetMessages(
99+ testMessageItem{id: "a", text: "alpha"},
100+ testMessageItem{id: "b", text: "beta"},
101+ )
102+ u.updateLayoutAndSize()
103+
104+ w, h := 80, 20
105+ _ = renderToBuffer(t, u.chat, w, h)
106+ require.NotNil(t, u.chat.drawCache)
107+ firstCache := u.chat.drawCache
108+ firstBuf := u.chat.drawCache.buf.RenderBuffer
109+
110+ // Draw the same content into a smaller buffer. The list output is
111+ // width-sensitive in production (list.Render depends on width), so we
112+ // hold the list width fixed via updateLayoutAndSize and only shrink
113+ // the destination area. The cache key is the rendered string + width
114+ // method, both unchanged here.
115+ smallScr := uv.NewScreenBuffer(40, 10)
116+ u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10))
117+
118+ require.Same(t, firstCache, u.chat.drawCache,
119+ "smaller area must still hit the cache")
120+ require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
121+ "cached RenderBuffer must be reused across area changes")
122+
123+ want := freshStyledRender(u.chat.list.Render(), 40, 10)
124+ require.Equal(t, want, smallScr.Render())
125+}
126+
127+// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one
128+// entry. This is structural — any future change that introduces an LRU or
129+// ring would have to update this test.
130+func TestChatDrawCache_BoundedSize(t *testing.T) {
131+ t.Parallel()
132+
133+ u := newTestUI()
134+ w, h := 80, 20
135+
136+ // Cycle through several distinct list outputs and confirm that
137+ // drawCache is still a single *chatDrawCache pointing at the most
138+ // recent rendered string each time.
139+ for i := range 5 {
140+ u.chat.SetMessages(
141+ testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
142+ )
143+ u.updateLayoutAndSize()
144+ _ = renderToBuffer(t, u.chat, w, h)
145+ require.NotNil(t, u.chat.drawCache)
146+ require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered,
147+ "cache.rendered must always match the most recent list output")
148+ }
149+
150+ // Sanity: the type of drawCache is a single pointer, not a slice/map.
151+ // This is enforced at compile time but checking it explicitly here
152+ // keeps the bounded-size invariant visible to future readers.
153+ require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache)
154+}
155+
156+// freshStyledRender renders the same string through uv.NewStyledString into
157+// a fresh ScreenBuffer of the given size. It's the reference implementation
158+// used by the cache tests to detect blit drift.
159+func freshStyledRender(s string, w, h int) string {
160+ scr := uv.NewScreenBuffer(w, h)
161+ uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h))
162+ return scr.Render()
163+}
164+
165+// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds
166+// when the destination screen's width method changes between frames.
167+// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other
168+// modern unicode, so the decoded buffer is only valid for the method it
169+// was decoded with. Reusing across methods would corrupt cell widths.
170+func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
171+ t.Parallel()
172+
173+ u := newTestUI()
174+ // Use content where the two methods disagree so any accidental
175+ // reuse would surface as a different rendered cell layout. The
176+ // woman-technologist ZWJ sequence is one cell under GraphemeWidth
177+ // but two under WcWidth (the components combine).
178+ u.chat.SetMessages(
179+ testMessageItem{id: "a", text: "hi 👩\u200d💻 there"},
180+ )
181+ u.updateLayoutAndSize()
182+
183+ w, h := 80, 5
184+
185+ // First draw under GraphemeWidth.
186+ scrA := uv.ScreenBuffer{
187+ RenderBuffer: uv.NewRenderBuffer(w, h),
188+ Method: ansi.GraphemeWidth,
189+ }
190+ u.chat.Draw(scrA, uv.Rect(0, 0, w, h))
191+ require.NotNil(t, u.chat.drawCache)
192+ require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method)
193+ firstCache := u.chat.drawCache
194+
195+ // Same string, swap the method. The cache key includes method, so
196+ // this must rebuild even though `rendered` is byte-identical.
197+ scrB := uv.ScreenBuffer{
198+ RenderBuffer: uv.NewRenderBuffer(w, h),
199+ Method: ansi.WcWidth,
200+ }
201+ u.chat.Draw(scrB, uv.Rect(0, 0, w, h))
202+ require.NotSame(t, firstCache, u.chat.drawCache,
203+ "width method change must invalidate the cache")
204+ require.Equal(t, ansi.WcWidth, u.chat.drawCache.method)
205+}
206+
207+// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non
208+// ansi.Method) WidthMethod implementation. Chat.Draw is expected to
209+// detect the type-assertion miss and fall through to the uncached path.
210+type fixedMethodScreen struct {
211+ uv.ScreenBuffer
212+ method uv.WidthMethod
213+}
214+
215+func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
216+
217+// customWidth is a WidthMethod whose concrete type is intentionally NOT
218+// ansi.Method, so the type assertion in Chat.Draw fails. The actual
219+// width math just delegates to ansi.GraphemeWidth so the rendered
220+// output is well-defined and comparable.
221+type customWidth struct{}
222+
223+func (customWidth) StringWidth(s string) int {
224+ return ansi.GraphemeWidth.StringWidth(s)
225+}
226+
227+// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen
228+// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw
229+// skips the cache (it has no comparable key for an arbitrary interface)
230+// and falls through to a direct uv.NewStyledString.Draw, producing the
231+// same output as the upstream uncached path.
232+func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
233+ t.Parallel()
234+
235+ u := newTestUI()
236+ u.chat.SetMessages(
237+ testMessageItem{id: "a", text: "alpha"},
238+ testMessageItem{id: "b", text: "beta"},
239+ )
240+ u.updateLayoutAndSize()
241+
242+ w, h := 40, 10
243+ scr := fixedMethodScreen{
244+ ScreenBuffer: uv.ScreenBuffer{
245+ RenderBuffer: uv.NewRenderBuffer(w, h),
246+ Method: ansi.GraphemeWidth,
247+ },
248+ method: customWidth{},
249+ }
250+ // Sanity: the wrapper actually returns a non-ansi.Method type so
251+ // the fallback path is exercised end-to-end.
252+ _, isAnsiMethod := scr.WidthMethod().(ansi.Method)
253+ require.False(t, isAnsiMethod,
254+ "test setup must hand Draw a non-ansi.Method WidthMethod")
255+
256+ u.chat.Draw(scr, uv.Rect(0, 0, w, h))
257+
258+ // The fallback path uses uv.NewStyledString(rendered).Draw — same
259+ // thing freshStyledRender does — so output must match the
260+ // reference render byte-for-byte.
261+ want := freshStyledRender(u.chat.list.Render(), w, h)
262+ require.Equal(t, want, scr.Render())
263+
264+ // And no cache was populated (or if it was, it wasn't used for
265+ // this draw). Either way: drawing through the fallback must never
266+ // leave a stale cache that future draws would reuse incorrectly.
267+ require.Nil(t, u.chat.drawCache,
268+ "fallback path must not populate the cache")
269+}
270+
271+// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant
272+// that renderedBounds (used to size the cache buffer) returns the same
273+// width as the cell tally StyledString.Draw will write into a buffer
274+// using the same WidthMethod. We compare the computed width to a
275+// reference render: lay the string into an oversized buffer, then count
276+// non-empty cells per row. A divergent-width sample (woman-technologist
277+// ZWJ sequence) is the canonical case that would expose a Bounds()
278+// vs printString mismatch — under GraphemeWidth the sequence is one
279+// cell, under WcWidth it's two — so the same string must produce two
280+// different widths under the two methods, both matching the tally.
281+func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
282+ t.Parallel()
283+
284+ const sample = "x 👩\u200d💻 y"
285+
286+ for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} {
287+ w, h := renderedBounds(sample, m)
288+ // Tally what printString would actually write.
289+ // Use an oversized buffer so nothing gets clipped.
290+ scr := uv.ScreenBuffer{
291+ RenderBuffer: uv.NewRenderBuffer(64, 4),
292+ Method: m,
293+ }
294+ uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
295+
296+ // Tally width: rightmost non-empty cell + its width, per row.
297+ gotW := 0
298+ gotH := 0
299+ for y := 0; y < 4; y++ {
300+ rowW := 0
301+ rowHasContent := false
302+ for x := 0; x < 64; x++ {
303+ cell := scr.CellAt(x, y)
304+ if cell == nil || cell.IsZero() ||
305+ cell.Content == " " && cell.Width == 1 &&
306+ cell.Style == (uv.Style{}) {
307+ continue
308+ }
309+ rowHasContent = true
310+ if x+cell.Width > rowW {
311+ rowW = x + cell.Width
312+ }
313+ }
314+ if rowHasContent {
315+ gotH = y + 1
316+ if rowW > gotW {
317+ gotW = rowW
318+ }
319+ }
320+ }
321+
322+ require.Equal(t, gotW, w,
323+ "renderedBounds width must match printString cell tally for method %v", m)
324+ // Height is just '\n' count + 1 — no special-casing needed
325+ // here, but pin it so future regressions on multi-line
326+ // inputs don't sneak by.
327+ require.Equal(t, gotH, h,
328+ "renderedBounds height must match printString row tally for method %v", m)
329+ }
330+}
331+
332+// Compile-time guard: testMessageItem is reused from layout_test.go and
333+// satisfies chat.MessageItem there. We rely on the same shape here.
334+var _ chat.MessageItem = testMessageItem{}
internal/ui/model/chat_expand_test.go+7−1
@@ -20,7 +20,13 @@ func TestChatToggleExpandedSelectedItem_AssistantMessage(t *testing.T) {
20
20
21 u := newTestUI()
21 u := newTestUI()
22
22
23+ msg := &message.Message{
24+ ID: "m-assist",
23− msg := &message.Message{ID: "m-assist", Role: message.Assistant}
25+ Role: message.Assistant,
26+ Parts: []message.ContentPart{
27+ message.ReasoningContent{Thinking: "thinking about it"},
28+ },
29+ }
24 item := chat.NewAssistantMessageItem(u.com.Styles, msg)
30 item := chat.NewAssistantMessageItem(u.com.Styles, msg)
25
31
26 // The keyboard expand path uses the generic Expandable interface;
32 // The keyboard expand path uses the generic Expandable interface;
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
internal/workspace/app_workspace.go+6
@@ -70,6 +70,12 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string
70// -- Messages --
70// -- Messages --
71
71
72func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
72func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
73+ // Drain any debounced updates so the caller observes the latest
74+ // in-memory state. message.Service buffers streaming deltas and a
75+ // cold List would otherwise miss them at session-switch time.
76+ if err := w.app.Messages.FlushAll(ctx); err != nil {
77+ return nil, err
78+ }
73 return w.app.Messages.List(ctx, sessionID)
79 return w.app.Messages.List(ctx, sessionID)
74}
80}
75
81