detached c6f9399

fix(tui): harden render cache invalidation

commit
c6f9399582c4f916b8c43931d75c6633a809e997
author
brice · · 2026-08-07 15:07 UTC
parent
89d4c91
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +358 -171
M packages/agent/modes/interactive.go +59 -54
@@ -424,23 +424,24 @@ type modelRefreshResult struct{ err error }
424424
425425 // Interactive is the TUI chat loop.
426426 type chatCacheKey struct {
427- cols int
428- agentRev uint64
429- statusOK string
430- statusErr string
431- help string
432- extNotes string
433- extStatuses string
434- extWidgets string
435- reloadErrors string
436- updateAvailable bool
437- updateCurrent string
438- updateLatest string
439- updateURL string
440- welcomeShowVer bool
441- expandAll bool
442- tailLimit int
443- viewCacheRev uint64
427+ cols int
428+ agentRev uint64
429+ statusOK string
430+ statusErr string
431+ help string
432+ extNotes string
433+ extStatuses string
434+ extWidgets string
435+ reloadErrors string
436+ updateAvailable bool
437+ updateCurrent string
438+ updateLatest string
439+ updateURL string
440+ welcomeShowVer bool
441+ expandAll bool
442+ tailLimit int
443+ renderedMessageCount int
444+ viewCacheRev uint64
444445 }
445446
446447 // QuickModelShortcut is one configured quick model switch slot.
@@ -1059,6 +1060,9 @@ func (i *Interactive) Run(ctx context.Context) error {
10591060 if req.clear {
10601061 i.rend.Clear()
10611062 }
1063+ if req.invalidate {
1064+ i.rend.Invalidate()
1065+ }
10621066 i.redraw()
10631067 })
10641068 defer renderScheduler.stop()
@@ -1066,7 +1070,7 @@ func (i *Interactive) Run(ctx context.Context) error {
10661070 // Resize and redraw share the renderer owner. The owner reads the
10671071 // latest terminal size just before its next frame.
10681072 i.renderRevision.Add(1)
1069- renderScheduler.request(false)
1073+ renderScheduler.request(false, false)
10701074 })
10711075
10721076 if i.cfg.StartupPre != "" {
@@ -1154,7 +1158,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11541158 if pendingRedraw {
11551159 pendingRedraw = false
11561160 lastRedraw = time.Now()
1157- renderScheduler.request(false)
1161+ renderScheduler.request(false, false)
11581162 }
11591163 }
11601164
@@ -1172,7 +1176,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11721176 }
11731177 pendingRedraw = false
11741178 lastRedraw = time.Now()
1175- renderScheduler.request(false)
1179+ renderScheduler.request(false, false)
11761180 return
11771181 }
11781182 if pendingRedraw {
@@ -1303,7 +1307,7 @@ func (i *Interactive) Run(ctx context.Context) error {
13031307
13041308 func (i *Interactive) requestRendererClear() {
13051309 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1306- scheduler.request(true)
1310+ scheduler.request(true, false)
13071311 return
13081312 }
13091313 if i.rend != nil {
@@ -1313,7 +1317,7 @@ func (i *Interactive) requestRendererClear() {
13131317
13141318 func (i *Interactive) requestRendererInvalidate() {
13151319 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316- scheduler.request(false)
1320+ scheduler.request(false, true)
13171321 return
13181322 }
13191323 if i.rend != nil {
@@ -1334,7 +1338,7 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) {
13341338 func (i *Interactive) invalidate() {
13351339 i.renderRevision.Add(1)
13361340 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1337- scheduler.request(false)
1341+ scheduler.request(false, false)
13381342 return
13391343 }
13401344 select {
@@ -1350,46 +1354,44 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
13501354 if i.busy || i.streamOn || i.streamFlushPending {
13511355 return i.buildChatLocked(cols)
13521356 }
1353- key, cacheable := i.chatCacheKeyLocked(cols)
1354- if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1357+ key := i.chatCacheKeyLocked(cols)
1358+ if i.chatCacheValid && i.chatCacheKey == key {
13551359 return append([]string(nil), i.chatCache...)
13561360 }
13571361 chat := i.buildChatLocked(cols)
1358- if cacheable {
1359- i.chatCache = append(i.chatCache[:0], chat...)
1360- i.chatCacheKey = key
1361- i.chatCacheValid = true
1362- } else {
1363- i.chatCacheValid = false
1364- }
1362+ key = i.chatCacheKeyLocked(cols)
1363+ i.chatCache = append(i.chatCache[:0], chat...)
1364+ i.chatCacheKey = key
1365+ i.chatCacheValid = true
13651366 return chat
13661367 }
13671368
1368-func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1369+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
13691370 var rev uint64
13701371 if i.agent != nil {
13711372 rev = i.agent.Revision()
13721373 }
13731374 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
13741375 return chatCacheKey{
1375- cols: cols,
1376- agentRev: rev,
1377- statusOK: i.statusOK,
1378- statusErr: i.statusErr,
1379- help: strings.Join(i.helpBlock, "\n"),
1380- extNotes: strings.Join(i.extNotes, "\n"),
1381- extStatuses: i.extensionStatusesKeyLocked(),
1382- extWidgets: i.extensionWidgetsKeyLocked(),
1383- reloadErrors: strings.Join(i.reloadErrors, "\n"),
1384- updateAvailable: i.updateInfo.Available,
1385- updateCurrent: i.updateInfo.Current,
1386- updateLatest: i.updateInfo.Latest,
1387- updateURL: i.updateInfo.URL,
1388- welcomeShowVer: showVer,
1389- expandAll: i.view.ExpandAll,
1390- tailLimit: i.view.TailLimit,
1391- viewCacheRev: i.view.RenderCacheRevision,
1392- }, true
1376+ cols: cols,
1377+ agentRev: rev,
1378+ statusOK: i.statusOK,
1379+ statusErr: i.statusErr,
1380+ help: strings.Join(i.helpBlock, "\n"),
1381+ extNotes: strings.Join(i.extNotes, "\n"),
1382+ extStatuses: i.extensionStatusesKeyLocked(),
1383+ extWidgets: i.extensionWidgetsKeyLocked(),
1384+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1385+ updateAvailable: i.updateInfo.Available,
1386+ updateCurrent: i.updateInfo.Current,
1387+ updateLatest: i.updateInfo.Latest,
1388+ updateURL: i.updateInfo.URL,
1389+ welcomeShowVer: showVer,
1390+ expandAll: i.view.ExpandAll,
1391+ tailLimit: i.view.TailLimit,
1392+ renderedMessageCount: len(i.view.Messages),
1393+ viewCacheRev: i.view.RenderCacheRevision,
1394+ }
13931395 }
13941396
13951397 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
@@ -1566,7 +1568,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
15661568 }
15671569
15681570 func (i *Interactive) stableChatRowsLocked(cols int) []string {
1569- key, _ := i.chatCacheKeyLocked(cols)
1571+ key := i.chatCacheKeyLocked(cols)
15701572 if i.stableChatCacheValid && i.stableChatCacheKey == key {
15711573 return append([]string(nil), i.stableChatCache...)
15721574 }
@@ -1589,7 +1591,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
15891591 i.mu.Unlock()
15901592 rows := renderView.Build(cols)
15911593 i.mu.Lock()
1592- if i.view.MessagesRevision == renderView.MessagesRevision {
1594+ if i.view.MessagesRevision == renderView.MessagesRevision &&
1595+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
15931596 i.view.AdoptRenderCacheFrom(renderView)
15941597 }
15951598
@@ -2002,7 +2005,7 @@ func (i *Interactive) redraw() {
20022005 // is repainted clean, matching what the user expects after
20032006 // dismissing a picker. Clear() is keepScrollback-aware and
20042007 // emits \x1b[3J there.
2005- i.requestRendererClear()
2008+ i.rend.Clear()
20062009 }
20072010 i.prevOverlayOpen = overlayOpen
20082011 if len(suggest) > 0 {
@@ -5586,9 +5589,11 @@ func (i *Interactive) applyThemeNow(name string) {
55865589 i.statusErr = ""
55875590 i.mu.Unlock()
55885591 }
5592+ i.mu.Lock()
55895593 i.cfg.Theme = th
55905594 i.view.Theme = th
55915595 i.view.InvalidateRenderCache()
5596+ i.mu.Unlock()
55925597 i.ed.Prompt = th.AccentBar(th.Accent)
55935598 i.applyInputCursorColor()
55945599 i.spin.Configure(th)
@@ -424,23 +424,24 @@ type modelRefreshResult struct{ err error }
424 424
425 // Interactive is the TUI chat loop.425 // Interactive is the TUI chat loop.
426 type chatCacheKey struct {426 type chatCacheKey struct {
427- cols int427+ cols int
428- agentRev uint64428+ agentRev uint64
429- statusOK string429+ statusOK string
430- statusErr string430+ statusErr string
431- help string431+ help string
432- extNotes string432+ extNotes string
433- extStatuses string433+ extStatuses string
434- extWidgets string434+ extWidgets string
435- reloadErrors string435+ reloadErrors string
436- updateAvailable bool436+ updateAvailable bool
437- updateCurrent string437+ updateCurrent string
438- updateLatest string438+ updateLatest string
439- updateURL string439+ updateURL string
440- welcomeShowVer bool440+ welcomeShowVer bool
441- expandAll bool441+ expandAll bool
442- tailLimit int442+ tailLimit int
443- viewCacheRev uint64443+ renderedMessageCount int
444+ viewCacheRev uint64
444 }445 }
445 446
446 // QuickModelShortcut is one configured quick model switch slot.447 // QuickModelShortcut is one configured quick model switch slot.
@@ -1059,6 +1060,9 @@ func (i *Interactive) Run(ctx context.Context) error {
1059 if req.clear {1060 if req.clear {
1060 i.rend.Clear()1061 i.rend.Clear()
1061 }1062 }
1063+ if req.invalidate {
1064+ i.rend.Invalidate()
1065+ }
1062 i.redraw()1066 i.redraw()
1063 })1067 })
1064 defer renderScheduler.stop()1068 defer renderScheduler.stop()
@@ -1066,7 +1070,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1066 // Resize and redraw share the renderer owner. The owner reads the1070 // Resize and redraw share the renderer owner. The owner reads the
1067 // latest terminal size just before its next frame.1071 // latest terminal size just before its next frame.
1068 i.renderRevision.Add(1)1072 i.renderRevision.Add(1)
1069- renderScheduler.request(false)1073+ renderScheduler.request(false, false)
1070 })1074 })
1071 1075
1072 if i.cfg.StartupPre != "" {1076 if i.cfg.StartupPre != "" {
@@ -1154,7 +1158,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1154 if pendingRedraw {1158 if pendingRedraw {
1155 pendingRedraw = false1159 pendingRedraw = false
1156 lastRedraw = time.Now()1160 lastRedraw = time.Now()
1157- renderScheduler.request(false)1161+ renderScheduler.request(false, false)
1158 }1162 }
1159 }1163 }
1160 1164
@@ -1172,7 +1176,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1172 }1176 }
1173 pendingRedraw = false1177 pendingRedraw = false
1174 lastRedraw = time.Now()1178 lastRedraw = time.Now()
1175- renderScheduler.request(false)1179+ renderScheduler.request(false, false)
1176 return1180 return
1177 }1181 }
1178 if pendingRedraw {1182 if pendingRedraw {
@@ -1303,7 +1307,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1303 1307
1304 func (i *Interactive) requestRendererClear() {1308 func (i *Interactive) requestRendererClear() {
1305 if scheduler := i.renderScheduler.Load(); scheduler != nil {1309 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1306- scheduler.request(true)1310+ scheduler.request(true, false)
1307 return1311 return
1308 }1312 }
1309 if i.rend != nil {1313 if i.rend != nil {
@@ -1313,7 +1317,7 @@ func (i *Interactive) requestRendererClear() {
1313 1317
1314 func (i *Interactive) requestRendererInvalidate() {1318 func (i *Interactive) requestRendererInvalidate() {
1315 if scheduler := i.renderScheduler.Load(); scheduler != nil {1319 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316- scheduler.request(false)1320+ scheduler.request(false, true)
1317 return1321 return
1318 }1322 }
1319 if i.rend != nil {1323 if i.rend != nil {
@@ -1334,7 +1338,7 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1334 func (i *Interactive) invalidate() {1338 func (i *Interactive) invalidate() {
1335 i.renderRevision.Add(1)1339 i.renderRevision.Add(1)
1336 if scheduler := i.renderScheduler.Load(); scheduler != nil {1340 if scheduler := i.renderScheduler.Load(); scheduler != nil {
1337- scheduler.request(false)1341+ scheduler.request(false, false)
1338 return1342 return
1339 }1343 }
1340 select {1344 select {
@@ -1350,46 +1354,44 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
1350 if i.busy || i.streamOn || i.streamFlushPending {1354 if i.busy || i.streamOn || i.streamFlushPending {
1351 return i.buildChatLocked(cols)1355 return i.buildChatLocked(cols)
1352 }1356 }
1353- key, cacheable := i.chatCacheKeyLocked(cols)1357+ key := i.chatCacheKeyLocked(cols)
1354- if cacheable && i.chatCacheValid && i.chatCacheKey == key {1358+ if i.chatCacheValid && i.chatCacheKey == key {
1355 return append([]string(nil), i.chatCache...)1359 return append([]string(nil), i.chatCache...)
1356 }1360 }
1357 chat := i.buildChatLocked(cols)1361 chat := i.buildChatLocked(cols)
1358- if cacheable {1362+ key = i.chatCacheKeyLocked(cols)
1359- i.chatCache = append(i.chatCache[:0], chat...)1363+ i.chatCache = append(i.chatCache[:0], chat...)
1360- i.chatCacheKey = key1364+ i.chatCacheKey = key
1361- i.chatCacheValid = true1365+ i.chatCacheValid = true
1362- } else {
1363- i.chatCacheValid = false
1364- }
1365 return chat1366 return chat
1366 }1367 }
1367 1368
1368-func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {1369+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
1369 var rev uint641370 var rev uint64
1370 if i.agent != nil {1371 if i.agent != nil {
1371 rev = i.agent.Revision()1372 rev = i.agent.Revision()
1372 }1373 }
1373 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration1374 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1374 return chatCacheKey{1375 return chatCacheKey{
1375- cols: cols,1376+ cols: cols,
1376- agentRev: rev,1377+ agentRev: rev,
1377- statusOK: i.statusOK,1378+ statusOK: i.statusOK,
1378- statusErr: i.statusErr,1379+ statusErr: i.statusErr,
1379- help: strings.Join(i.helpBlock, "\n"),1380+ help: strings.Join(i.helpBlock, "\n"),
1380- extNotes: strings.Join(i.extNotes, "\n"),1381+ extNotes: strings.Join(i.extNotes, "\n"),
1381- extStatuses: i.extensionStatusesKeyLocked(),1382+ extStatuses: i.extensionStatusesKeyLocked(),
1382- extWidgets: i.extensionWidgetsKeyLocked(),1383+ extWidgets: i.extensionWidgetsKeyLocked(),
1383- reloadErrors: strings.Join(i.reloadErrors, "\n"),1384+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1384- updateAvailable: i.updateInfo.Available,1385+ updateAvailable: i.updateInfo.Available,
1385- updateCurrent: i.updateInfo.Current,1386+ updateCurrent: i.updateInfo.Current,
1386- updateLatest: i.updateInfo.Latest,1387+ updateLatest: i.updateInfo.Latest,
1387- updateURL: i.updateInfo.URL,1388+ updateURL: i.updateInfo.URL,
1388- welcomeShowVer: showVer,1389+ welcomeShowVer: showVer,
1389- expandAll: i.view.ExpandAll,1390+ expandAll: i.view.ExpandAll,
1390- tailLimit: i.view.TailLimit,1391+ tailLimit: i.view.TailLimit,
1391- viewCacheRev: i.view.RenderCacheRevision,1392+ renderedMessageCount: len(i.view.Messages),
1392- }, true1393+ viewCacheRev: i.view.RenderCacheRevision,
1394+ }
1393 }1395 }
1394 1396
1395 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {1397 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
@@ -1566,7 +1568,7 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1566 }1568 }
1567 1569
1568 func (i *Interactive) stableChatRowsLocked(cols int) []string {1570 func (i *Interactive) stableChatRowsLocked(cols int) []string {
1569- key, _ := i.chatCacheKeyLocked(cols)1571+ key := i.chatCacheKeyLocked(cols)
1570 if i.stableChatCacheValid && i.stableChatCacheKey == key {1572 if i.stableChatCacheValid && i.stableChatCacheKey == key {
1571 return append([]string(nil), i.stableChatCache...)1573 return append([]string(nil), i.stableChatCache...)
1572 }1574 }
@@ -1589,7 +1591,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
1589 i.mu.Unlock()1591 i.mu.Unlock()
1590 rows := renderView.Build(cols)1592 rows := renderView.Build(cols)
1591 i.mu.Lock()1593 i.mu.Lock()
1592- if i.view.MessagesRevision == renderView.MessagesRevision {1594+ if i.view.MessagesRevision == renderView.MessagesRevision &&
1595+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1593 i.view.AdoptRenderCacheFrom(renderView)1596 i.view.AdoptRenderCacheFrom(renderView)
1594 }1597 }
1595 1598
@@ -2002,7 +2005,7 @@ func (i *Interactive) redraw() {
2002 // is repainted clean, matching what the user expects after2005 // is repainted clean, matching what the user expects after
2003 // dismissing a picker. Clear() is keepScrollback-aware and2006 // dismissing a picker. Clear() is keepScrollback-aware and
2004 // emits \x1b[3J there.2007 // emits \x1b[3J there.
2005- i.requestRendererClear()2008+ i.rend.Clear()
2006 }2009 }
2007 i.prevOverlayOpen = overlayOpen2010 i.prevOverlayOpen = overlayOpen
2008 if len(suggest) > 0 {2011 if len(suggest) > 0 {
@@ -5586,9 +5589,11 @@ func (i *Interactive) applyThemeNow(name string) {
5586 i.statusErr = ""5589 i.statusErr = ""
5587 i.mu.Unlock()5590 i.mu.Unlock()
5588 }5591 }
5592+ i.mu.Lock()
5589 i.cfg.Theme = th5593 i.cfg.Theme = th
5590 i.view.Theme = th5594 i.view.Theme = th
5591 i.view.InvalidateRenderCache()5595 i.view.InvalidateRenderCache()
5596+ i.mu.Unlock()
5592 i.ed.Prompt = th.AccentBar(th.Accent)5597 i.ed.Prompt = th.AccentBar(th.Accent)
5593 i.applyInputCursorColor()5598 i.applyInputCursorColor()
5594 i.spin.Configure(th)5599 i.spin.Configure(th)
M packages/agent/modes/interactive_render.go +14 -8
@@ -10,18 +10,20 @@ import (
1010 // reads the newest synchronized interactive state when it wakes, so a burst
1111 // replaces the one pending request instead of queueing stale snapshots.
1212 type renderRequest struct {
13- clear bool
14- theme *tui.Theme
13+ clear bool
14+ invalidate bool
15+ theme *tui.Theme
1516 }
1617
1718 type latestFrameScheduler struct {
1819 wake chan struct{}
1920 done chan struct{}
2021
21- mu sync.Mutex
22- clear bool
23- theme *tui.Theme
24- stopped bool
22+ mu sync.Mutex
23+ clear bool
24+ invalidate bool
25+ theme *tui.Theme
26+ stopped bool
2527 }
2628
2729 func newLatestFrameScheduler() *latestFrameScheduler {
@@ -31,13 +33,14 @@ func newLatestFrameScheduler() *latestFrameScheduler {
3133 }
3234 }
3335
34-func (s *latestFrameScheduler) request(clear bool) bool {
36+func (s *latestFrameScheduler) request(clear, invalidate bool) bool {
3537 s.mu.Lock()
3638 defer s.mu.Unlock()
3739 if s.stopped {
3840 return false
3941 }
4042 s.clear = s.clear || clear
43+ s.invalidate = s.invalidate || invalidate
4144 select {
4245 case s.wake <- struct{}{}:
4346 default:
@@ -67,8 +70,9 @@ func (s *latestFrameScheduler) next() (renderRequest, bool) {
6770 if s.stopped {
6871 return renderRequest{}, false
6972 }
70- req := renderRequest{clear: s.clear, theme: s.theme}
73+ req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme}
7174 s.clear = false
75+ s.invalidate = false
7276 s.theme = nil
7377 return req, true
7478 }
@@ -84,6 +88,8 @@ func (s *latestFrameScheduler) run(render func(renderRequest)) {
8488 }
8589 }
8690
91+// stop waits for the in-flight render callback to finish. Callers must not
92+// invoke it while holding i.mu: the callback may need to acquire i.mu.
8793 func (s *latestFrameScheduler) stop() {
8894 s.mu.Lock()
8995 if !s.stopped {
@@ -10,18 +10,20 @@ import (
10 // reads the newest synchronized interactive state when it wakes, so a burst10 // reads the newest synchronized interactive state when it wakes, so a burst
11 // replaces the one pending request instead of queueing stale snapshots.11 // replaces the one pending request instead of queueing stale snapshots.
12 type renderRequest struct {12 type renderRequest struct {
13- clear bool13+ clear bool
14- theme *tui.Theme14+ invalidate bool
15+ theme *tui.Theme
15 }16 }
16 17
17 type latestFrameScheduler struct {18 type latestFrameScheduler struct {
18 wake chan struct{}19 wake chan struct{}
19 done chan struct{}20 done chan struct{}
20 21
21- mu sync.Mutex22+ mu sync.Mutex
22- clear bool23+ clear bool
23- theme *tui.Theme24+ invalidate bool
24- stopped bool25+ theme *tui.Theme
26+ stopped bool
25 }27 }
26 28
27 func newLatestFrameScheduler() *latestFrameScheduler {29 func newLatestFrameScheduler() *latestFrameScheduler {
@@ -31,13 +33,14 @@ func newLatestFrameScheduler() *latestFrameScheduler {
31 }33 }
32 }34 }
33 35
34-func (s *latestFrameScheduler) request(clear bool) bool {36+func (s *latestFrameScheduler) request(clear, invalidate bool) bool {
35 s.mu.Lock()37 s.mu.Lock()
36 defer s.mu.Unlock()38 defer s.mu.Unlock()
37 if s.stopped {39 if s.stopped {
38 return false40 return false
39 }41 }
40 s.clear = s.clear || clear42 s.clear = s.clear || clear
43+ s.invalidate = s.invalidate || invalidate
41 select {44 select {
42 case s.wake <- struct{}{}:45 case s.wake <- struct{}{}:
43 default:46 default:
@@ -67,8 +70,9 @@ func (s *latestFrameScheduler) next() (renderRequest, bool) {
67 if s.stopped {70 if s.stopped {
68 return renderRequest{}, false71 return renderRequest{}, false
69 }72 }
70- req := renderRequest{clear: s.clear, theme: s.theme}73+ req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme}
71 s.clear = false74 s.clear = false
75+ s.invalidate = false
72 s.theme = nil76 s.theme = nil
73 return req, true77 return req, true
74 }78 }
@@ -84,6 +88,8 @@ func (s *latestFrameScheduler) run(render func(renderRequest)) {
84 }88 }
85 }89 }
86 90
91+// stop waits for the in-flight render callback to finish. Callers must not
92+// invoke it while holding i.mu: the callback may need to acquire i.mu.
87 func (s *latestFrameScheduler) stop() {93 func (s *latestFrameScheduler) stop() {
88 s.mu.Lock()94 s.mu.Lock()
89 if !s.stopped {95 if !s.stopped {
M packages/agent/modes/interactive_render_test.go +49 -9
@@ -3,6 +3,7 @@ package modes
33 import (
44 "strings"
55 "sync"
6+ "sync/atomic"
67 "testing"
78
89 "github.com/bnema/zut/packages/core"
@@ -16,23 +17,23 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
1617 release := make(chan struct{})
1718 type renderedFrame struct {
1819 req renderRequest
19- version int
20+ version int64
2021 }
2122 frames := make(chan renderedFrame, 4)
2223 done := make(chan struct{})
2324 var startedOnce sync.Once
24- latestVersion := 0
25+ var latestVersion atomic.Int64
2526 go func() {
2627 s.run(func(req renderRequest) {
27- frames <- renderedFrame{req: req, version: latestVersion}
28+ frames <- renderedFrame{req: req, version: latestVersion.Load()}
2829 startedOnce.Do(func() { close(started) })
2930 <-release
3031 })
3132 close(done)
3233 }()
3334
34- latestVersion = 1
35- if !s.request(false) {
35+ latestVersion.Store(1)
36+ if !s.request(false, false) {
3637 t.Fatal("initial render request was rejected")
3738 }
3839 first := <-frames
@@ -41,8 +42,8 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
4142 t.Fatal("ordinary request unexpectedly requested a clear")
4243 }
4344 for n := 0; n < 1000; n++ {
44- latestVersion = n + 2
45- if !s.request(false) {
45+ latestVersion.Store(int64(n + 2))
46+ if !s.request(false, false) {
4647 t.Fatal("request rejected before shutdown")
4748 }
4849 }
@@ -88,6 +89,45 @@ func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
8889 }
8990 }
9091
92+func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
93+ const finalText = "final message revealed after paced flush"
94+
95+ agent := &core.Agent{}
96+ agent.SetMessages([]provider.Message{
97+ {
98+ Role: provider.RoleUser,
99+ Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
100+ },
101+ {
102+ Role: provider.RoleAssistant,
103+ Content: []provider.Content{provider.TextBlock{Text: finalText}},
104+ },
105+ })
106+ i := &Interactive{
107+ agent: agent,
108+ view: &tui.View{Theme: tui.Dark},
109+ renderOutsideLock: true,
110+ streamFlushPending: true,
111+ }
112+
113+ revision := agent.Revision()
114+ i.mu.Lock()
115+ before := strings.Join(i.cachedChatLocked(80), "\n")
116+ i.streamFlushPending = false
117+ after := strings.Join(i.cachedChatLocked(80), "\n")
118+ i.mu.Unlock()
119+
120+ if got := agent.Revision(); got != revision {
121+ t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
122+ }
123+ if strings.Contains(before, finalText) {
124+ t.Fatal("stable cache revealed the final message while stream flush was pending")
125+ }
126+ if !strings.Contains(after, finalText) {
127+ t.Fatal("stable cache did not reveal the final message after stream flush")
128+ }
129+}
130+
91131 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
92132 i := &Interactive{
93133 dirty: make(chan struct{}, 1),
@@ -129,8 +169,8 @@ func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
129169 if got := i.toolCalls["storm"].Result; got != result {
130170 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
131171 }
132- if i.toolCalls["storm"].Revision == 0 {
133- t.Fatal("completed tool result did not advance its render revision")
172+ if got := i.toolCalls["storm"].Revision; got != 2 {
173+ t.Fatalf("completed tool result revision: got %d, want 2", got)
134174 }
135175
136176 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
@@ -3,6 +3,7 @@ package modes
3 import (3 import (
4 "strings"4 "strings"
5 "sync"5 "sync"
6+ "sync/atomic"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/zut/packages/core"9 "github.com/bnema/zut/packages/core"
@@ -16,23 +17,23 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
16 release := make(chan struct{})17 release := make(chan struct{})
17 type renderedFrame struct {18 type renderedFrame struct {
18 req renderRequest19 req renderRequest
19- version int20+ version int64
20 }21 }
21 frames := make(chan renderedFrame, 4)22 frames := make(chan renderedFrame, 4)
22 done := make(chan struct{})23 done := make(chan struct{})
23 var startedOnce sync.Once24 var startedOnce sync.Once
24- latestVersion := 025+ var latestVersion atomic.Int64
25 go func() {26 go func() {
26 s.run(func(req renderRequest) {27 s.run(func(req renderRequest) {
27- frames <- renderedFrame{req: req, version: latestVersion}28+ frames <- renderedFrame{req: req, version: latestVersion.Load()}
28 startedOnce.Do(func() { close(started) })29 startedOnce.Do(func() { close(started) })
29 <-release30 <-release
30 })31 })
31 close(done)32 close(done)
32 }()33 }()
33 34
34- latestVersion = 135+ latestVersion.Store(1)
35- if !s.request(false) {36+ if !s.request(false, false) {
36 t.Fatal("initial render request was rejected")37 t.Fatal("initial render request was rejected")
37 }38 }
38 first := <-frames39 first := <-frames
@@ -41,8 +42,8 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
41 t.Fatal("ordinary request unexpectedly requested a clear")42 t.Fatal("ordinary request unexpectedly requested a clear")
42 }43 }
43 for n := 0; n < 1000; n++ {44 for n := 0; n < 1000; n++ {
44- latestVersion = n + 245+ latestVersion.Store(int64(n + 2))
45- if !s.request(false) {46+ if !s.request(false, false) {
46 t.Fatal("request rejected before shutdown")47 t.Fatal("request rejected before shutdown")
47 }48 }
48 }49 }
@@ -88,6 +89,45 @@ func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
88 }89 }
89 }90 }
90 91
92+func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
93+ const finalText = "final message revealed after paced flush"
94+
95+ agent := &core.Agent{}
96+ agent.SetMessages([]provider.Message{
97+ {
98+ Role: provider.RoleUser,
99+ Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
100+ },
101+ {
102+ Role: provider.RoleAssistant,
103+ Content: []provider.Content{provider.TextBlock{Text: finalText}},
104+ },
105+ })
106+ i := &Interactive{
107+ agent: agent,
108+ view: &tui.View{Theme: tui.Dark},
109+ renderOutsideLock: true,
110+ streamFlushPending: true,
111+ }
112+
113+ revision := agent.Revision()
114+ i.mu.Lock()
115+ before := strings.Join(i.cachedChatLocked(80), "\n")
116+ i.streamFlushPending = false
117+ after := strings.Join(i.cachedChatLocked(80), "\n")
118+ i.mu.Unlock()
119+
120+ if got := agent.Revision(); got != revision {
121+ t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
122+ }
123+ if strings.Contains(before, finalText) {
124+ t.Fatal("stable cache revealed the final message while stream flush was pending")
125+ }
126+ if !strings.Contains(after, finalText) {
127+ t.Fatal("stable cache did not reveal the final message after stream flush")
128+ }
129+}
130+
91 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {131 func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
92 i := &Interactive{132 i := &Interactive{
93 dirty: make(chan struct{}, 1),133 dirty: make(chan struct{}, 1),
@@ -129,8 +169,8 @@ func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
129 if got := i.toolCalls["storm"].Result; got != result {169 if got := i.toolCalls["storm"].Result; got != result {
130 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))170 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
131 }171 }
132- if i.toolCalls["storm"].Revision == 0 {172+ if got := i.toolCalls["storm"].Revision; got != 2 {
133- t.Fatal("completed tool result did not advance its render revision")173+ t.Fatalf("completed tool result revision: got %d, want 2", got)
134 }174 }
135 175
136 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}176 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
M packages/tui/render.go +3 -1
@@ -45,6 +45,7 @@ type Renderer struct {
4545 logRawChat []string
4646 logRawBottom []string
4747 logRawCols int
48+ logRawRows int
4849 logRawBackground string
4950 logRawHasThemeBG bool
5051 logViewportTop int
@@ -439,7 +440,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
439440 }
440441 if r.logInit && len(r.logLines) > 0 &&
441442 cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
442- r.logRawCols == r.cols && r.logRawBackground == r.backgroundStyle &&
443+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
443444 r.logRawHasThemeBG == (r.theme.Background != nil) &&
444445 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
445446 return
@@ -825,6 +826,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
825826 r.logRawChat = append(r.logRawChat[:0], chat...)
826827 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
827828 r.logRawCols = r.cols
829+ r.logRawRows = r.rows
828830 r.logRawBackground = r.backgroundStyle
829831 r.logRawHasThemeBG = r.theme.Background != nil
830832 r.cursorRow = cursorBottomRow
@@ -45,6 +45,7 @@ type Renderer struct {
45 logRawChat []string45 logRawChat []string
46 logRawBottom []string46 logRawBottom []string
47 logRawCols int47 logRawCols int
48+ logRawRows int
48 logRawBackground string49 logRawBackground string
49 logRawHasThemeBG bool50 logRawHasThemeBG bool
50 logViewportTop int51 logViewportTop int
@@ -439,7 +440,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
439 }440 }
440 if r.logInit && len(r.logLines) > 0 &&441 if r.logInit && len(r.logLines) > 0 &&
441 cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&442 cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
442- r.logRawCols == r.cols && r.logRawBackground == r.backgroundStyle &&443+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
443 r.logRawHasThemeBG == (r.theme.Background != nil) &&444 r.logRawHasThemeBG == (r.theme.Background != nil) &&
444 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {445 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
445 return446 return
@@ -825,6 +826,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
825 r.logRawChat = append(r.logRawChat[:0], chat...)826 r.logRawChat = append(r.logRawChat[:0], chat...)
826 r.logRawBottom = append(r.logRawBottom[:0], bottom...)827 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
827 r.logRawCols = r.cols828 r.logRawCols = r.cols
829+ r.logRawRows = r.rows
828 r.logRawBackground = r.backgroundStyle830 r.logRawBackground = r.backgroundStyle
829 r.logRawHasThemeBG = r.theme.Background != nil831 r.logRawHasThemeBG = r.theme.Background != nil
830 r.cursorRow = cursorBottomRow832 r.cursorRow = cursorBottomRow
M packages/tui/render_bench_test.go +15 -41
@@ -3,57 +3,30 @@ package tui
33 import (
44 "bytes"
55 "fmt"
6+ "strings"
67 "testing"
78 )
89
910 func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
10- for _, width := range []int{48, 80, 160} {
11- for _, temperature := range []string{"cold", "warm"} {
12- name := fmt.Sprintf("width=%d/%s", width, temperature)
13- b.Run(name, func(b *testing.B) {
14- var out bytes.Buffer
15- r := NewRenderer(&out)
16- r.Resize(width, 40)
17- chat := benchmarkRendererChat(1600, width)
18- bottom := []string{"working", "▌ input"}
19- if temperature == "warm" {
20- r.DrawLog(chat, bottom, 1, 2)
21- out.Reset()
22- }
23- b.ReportAllocs()
24- b.ResetTimer()
25- for n := 0; n < b.N; n++ {
26- frame := chat
27- if temperature == "warm" {
28- // Keep most of the storm stable while exercising the
29- // renderer's row-diff path with a changing suffix.
30- frame = append([]string(nil), chat...)
31- frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
32- r.DrawLog(frame, bottom, 1, 2)
33- } else {
34- cold := NewRenderer(&out)
35- cold.Resize(width, 40)
36- cold.DrawLog(frame, bottom, 1, 2)
37- }
38- out.Reset()
39- }
40- })
41- }
42- }
43-}
44-
45-func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
4611 for _, width := range []int{48, 80, 160} {
4712 b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {
13+ var out bytes.Buffer
14+ r := NewRenderer(&out)
15+ r.Resize(width, 40)
4816 chat := benchmarkRendererChat(1600, width)
4917 bottom := []string{"working", "▌ input"}
18+ r.DrawLog(chat, bottom, 1, 2)
19+ out.Reset()
20+
5021 b.ReportAllocs()
5122 b.ResetTimer()
5223 for n := 0; n < b.N; n++ {
53- var out bytes.Buffer
54- r := NewRenderer(&out)
55- r.Resize(width, 40)
56- r.DrawLog(chat, bottom, 1, 2)
24+ // Keep most of the storm stable while exercising the
25+ // renderer's row-diff path with a changing suffix.
26+ frame := append([]string(nil), chat...)
27+ frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
28+ r.DrawLog(frame, bottom, 1, 2)
29+ out.Reset()
5730 }
5831 })
5932 }
@@ -61,8 +34,9 @@ func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
6134
6235 func benchmarkRendererChat(rows, width int) []string {
6336 chat := make([]string, rows)
37+ payload := strings.Repeat("x", width)
6438 for i := range chat {
65- chat[i] = fmt.Sprintf("│ tool-%03d result line with enough payload to exercise width truncation", i)
39+ chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload)
6640 }
6741 return chat
6842 }
@@ -3,57 +3,30 @@ package tui
3 import (3 import (
4 "bytes"4 "bytes"
5 "fmt"5 "fmt"
6+ "strings"
6 "testing"7 "testing"
7 )8 )
8 9
9 func BenchmarkRendererDrawLogToolStorm(b *testing.B) {10 func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
10- for _, width := range []int{48, 80, 160} {
11- for _, temperature := range []string{"cold", "warm"} {
12- name := fmt.Sprintf("width=%d/%s", width, temperature)
13- b.Run(name, func(b *testing.B) {
14- var out bytes.Buffer
15- r := NewRenderer(&out)
16- r.Resize(width, 40)
17- chat := benchmarkRendererChat(1600, width)
18- bottom := []string{"working", "▌ input"}
19- if temperature == "warm" {
20- r.DrawLog(chat, bottom, 1, 2)
21- out.Reset()
22- }
23- b.ReportAllocs()
24- b.ResetTimer()
25- for n := 0; n < b.N; n++ {
26- frame := chat
27- if temperature == "warm" {
28- // Keep most of the storm stable while exercising the
29- // renderer's row-diff path with a changing suffix.
30- frame = append([]string(nil), chat...)
31- frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
32- r.DrawLog(frame, bottom, 1, 2)
33- } else {
34- cold := NewRenderer(&out)
35- cold.Resize(width, 40)
36- cold.DrawLog(frame, bottom, 1, 2)
37- }
38- out.Reset()
39- }
40- })
41- }
42- }
43-}
44-
45-func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
46 for _, width := range []int{48, 80, 160} {11 for _, width := range []int{48, 80, 160} {
47 b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {12 b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {
13+ var out bytes.Buffer
14+ r := NewRenderer(&out)
15+ r.Resize(width, 40)
48 chat := benchmarkRendererChat(1600, width)16 chat := benchmarkRendererChat(1600, width)
49 bottom := []string{"working", "▌ input"}17 bottom := []string{"working", "▌ input"}
18+ r.DrawLog(chat, bottom, 1, 2)
19+ out.Reset()
20+
50 b.ReportAllocs()21 b.ReportAllocs()
51 b.ResetTimer()22 b.ResetTimer()
52 for n := 0; n < b.N; n++ {23 for n := 0; n < b.N; n++ {
53- var out bytes.Buffer24+ // Keep most of the storm stable while exercising the
54- r := NewRenderer(&out)25+ // renderer's row-diff path with a changing suffix.
55- r.Resize(width, 40)26+ frame := append([]string(nil), chat...)
56- r.DrawLog(chat, bottom, 1, 2)27+ frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
28+ r.DrawLog(frame, bottom, 1, 2)
29+ out.Reset()
57 }30 }
58 })31 })
59 }32 }
@@ -61,8 +34,9 @@ func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
61 34
62 func benchmarkRendererChat(rows, width int) []string {35 func benchmarkRendererChat(rows, width int) []string {
63 chat := make([]string, rows)36 chat := make([]string, rows)
37+ payload := strings.Repeat("x", width)
64 for i := range chat {38 for i := range chat {
65- chat[i] = fmt.Sprintf("│ tool-%03d result line with enough payload to exercise width truncation", i)39+ chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload)
66 }40 }
67 return chat41 return chat
68 }42 }
M packages/tui/view.go +162 -51
@@ -187,8 +187,11 @@ type View struct {
187187 renderCache map[msgCacheKey][]string
188188 liveRenderCache map[liveToolCacheKey][]string
189189 messageCache []messageRenderCacheEntry
190+ messageCacheStart int
190191 messageCacheRevision uint64
191192 messageCacheLen int
193+ messageCachePrefix uint64
194+ messageCachePrefixOK bool
192195 toolPathRevision uint64
193196
194197 // TailLimit caps how many messages from the END of Messages are
@@ -201,20 +204,12 @@ type View struct {
201204 TailLimit int
202205 }
203206
204-// msgCacheKey identifies a cached message render. hash is a 64-bit
205-// FNV-1a of the message's content, which is cheap to compute and
206-// unambiguous enough for the cache (collisions produce a stale frame,
207-// not wrong data, and we recompute on invalidation anyway).
208-type msgCacheKey struct {
209- hash uint64
210- width int
211- expandAll bool
212- // turnOpen is true when the previous rendered message belongs to
213- // the same agent turn (assistant tool_use, or tool result). The
214- // header ("▍ zut") is suppressed in that case so a single turn
215- // — even one that spans many assistant/tool message round-trips
216- // in the underlying API — renders under one header instead of a
217- // new one per assistant message.
207+// msgVisualKey identifies the visual configuration that affects a cached
208+// message render. Keep this shared by both cache implementations so their
209+// invalidation rules cannot drift apart.
210+type msgVisualKey struct {
211+ width int
212+ expandAll bool
218213 turnOpen bool
219214 flatTools bool
220215 compactMode bool
@@ -223,17 +218,19 @@ type msgCacheKey struct {
223218 theme uint64
224219 }
225220
221+// msgCacheKey identifies a cached message render. hash is a 64-bit
222+// FNV-1a of the message's content, which is cheap to compute and
223+// unambiguous enough for the cache (collisions produce a stale frame,
224+// not wrong data, and we recompute on invalidation anyway).
225+type msgCacheKey struct {
226+ hash uint64
227+ visual msgVisualKey
228+}
229+
226230 type messageRenderCacheEntry struct {
227- width int
228- expandAll bool
229- turnOpen bool
230- flatTools bool
231- compactMode bool
232- compactUser bool
233- imageProto ImageProtocol
234- theme uint64
235- lines []string
236- valid bool
231+ visual msgVisualKey
232+ lines []string
233+ valid bool
237234 }
238235
239236 type liveToolCacheKey struct {
@@ -256,8 +253,11 @@ func (v *View) InvalidateRenderCache() {
256253 v.renderCache = nil
257254 v.liveRenderCache = nil
258255 v.messageCache = nil
256+ v.messageCacheStart = 0
259257 v.messageCacheRevision = 0
260258 v.messageCacheLen = 0
259+ v.messageCachePrefix = 0
260+ v.messageCachePrefixOK = false
261261 v.toolPathRevision = 0
262262 }
263263
@@ -320,8 +320,11 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) {
320320 v.renderCache = snapshot.renderCache
321321 v.liveRenderCache = snapshot.liveRenderCache
322322 v.messageCache = snapshot.messageCache
323+ v.messageCacheStart = snapshot.messageCacheStart
323324 v.messageCacheRevision = snapshot.messageCacheRevision
324325 v.messageCacheLen = snapshot.messageCacheLen
326+ v.messageCachePrefix = snapshot.messageCachePrefix
327+ v.messageCachePrefixOK = snapshot.messageCachePrefixOK
325328 v.toolPaths = snapshot.toolPaths
326329 v.toolStartLines = snapshot.toolStartLines
327330 v.toolCallLabels = snapshot.toolCallLabels
@@ -488,10 +491,33 @@ func (v *View) renderStartupResources(width int) []string {
488491 // to map a message index back to a row offset.
489492 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
490493 themeKey := toolThemeKey(v.Theme)
491- if v.MessagesRevision != 0 && v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision && len(v.Messages) <= v.messageCacheLen {
494+ if v.MessagesRevision == 0 && v.messageCacheRevision != 0 {
492495 v.messageCache = nil
496+ v.messageCacheStart = 0
497+ v.messageCacheRevision = 0
498+ v.messageCacheLen = 0
499+ v.messageCachePrefix = 0
500+ v.messageCachePrefixOK = false
493501 }
494502 if v.MessagesRevision != 0 {
503+ if v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision {
504+ verifiedAppend := len(v.Messages) > v.messageCacheLen && v.messageCachePrefixOK &&
505+ hashMessagePrefix(v.Messages, v.messageCacheLen) == v.messageCachePrefix
506+ if verifiedAppend {
507+ for _, m := range v.Messages[v.messageCacheLen:] {
508+ v.messageCachePrefix = appendMessageHash(v.messageCachePrefix, hashMessage(m))
509+ }
510+ } else {
511+ v.messageCache = nil
512+ v.messageCacheStart = 0
513+ v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
514+ }
515+ v.messageCachePrefixOK = true
516+ }
517+ if v.messageCacheRevision == 0 {
518+ v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
519+ v.messageCachePrefixOK = true
520+ }
495521 v.messageCacheRevision = v.MessagesRevision
496522 v.messageCacheLen = len(v.Messages)
497523 }
@@ -537,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
537563 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {
538564 renderFrom = len(v.Messages) - v.TailLimit
539565 }
566+ v.boundMessageCache(renderFrom, len(v.Messages))
540567 for idx, m := range v.Messages {
541568 if idx < renderFrom {
542569 rendered[idx] = nil
@@ -672,28 +699,48 @@ func (v *View) refreshToolPaths() {
672699 }
673700 }
674701
702+func (v *View) boundMessageCache(renderFrom, messageCount int) {
703+ if renderFrom < 0 {
704+ renderFrom = 0
705+ }
706+ if renderFrom > messageCount {
707+ renderFrom = messageCount
708+ }
709+ if len(v.messageCache) == 0 {
710+ v.messageCacheStart = renderFrom
711+ return
712+ }
713+
714+ start := v.messageCacheStart
715+ if renderFrom > start {
716+ drop := renderFrom - start
717+ if drop >= len(v.messageCache) {
718+ v.messageCache = nil
719+ v.messageCacheStart = renderFrom
720+ return
721+ }
722+ v.messageCache = v.messageCache[drop:]
723+ start = renderFrom
724+ } else if renderFrom < start {
725+ prefix := make([]messageRenderCacheEntry, start-renderFrom)
726+ v.messageCache = append(prefix, v.messageCache...)
727+ start = renderFrom
728+ }
729+
730+ max := messageCount - renderFrom
731+ if len(v.messageCache) > max {
732+ v.messageCache = v.messageCache[:max]
733+ }
734+ v.messageCacheStart = start
735+}
736+
675737 // renderMessageCached returns the rendered line slice for m, using the
676738 // cache if the same (content hash, width, expandAll) combination has
677739 // been rendered before. The slice returned is shared — callers must
678740 // not mutate it; Build() only ever appends to its own `out` so the
679741 // shared slice is safe.
680742 func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
681- if v.MessagesRevision != 0 {
682- if idx < len(v.messageCache) {
683- entry := v.messageCache[idx]
684- if entry.valid && entry.width == width && entry.expandAll == v.ExpandAll && entry.turnOpen == turnOpen && entry.flatTools == v.FlatTools && entry.compactMode == v.CompactMode && entry.compactUser == v.CompactUser && entry.imageProto == v.ImageProto && entry.theme == themeKey {
685- return entry.lines
686- }
687- }
688- lines := v.renderMessage(m, width, turnOpen)
689- if idx >= len(v.messageCache) {
690- v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, idx-len(v.messageCache)+1)...)
691- }
692- v.messageCache[idx] = messageRenderCacheEntry{width: width, expandAll: v.ExpandAll, turnOpen: turnOpen, flatTools: v.FlatTools, compactMode: v.CompactMode, compactUser: v.CompactUser, imageProto: v.ImageProto, theme: themeKey, lines: lines, valid: true}
693- return lines
694- }
695- key := msgCacheKey{
696- hash: hashMessage(m),
743+ visual := msgVisualKey{
697744 width: width,
698745 expandAll: v.ExpandAll,
699746 turnOpen: turnOpen,
@@ -703,6 +750,25 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO
703750 imageProto: v.ImageProto,
704751 theme: themeKey,
705752 }
753+ if v.MessagesRevision != 0 {
754+ cacheIdx := idx - v.messageCacheStart
755+ if cacheIdx >= 0 && cacheIdx < len(v.messageCache) {
756+ entry := v.messageCache[cacheIdx]
757+ if entry.valid && entry.visual == visual {
758+ return entry.lines
759+ }
760+ }
761+ lines := v.renderMessage(m, width, turnOpen)
762+ if cacheIdx < 0 {
763+ return lines
764+ }
765+ if cacheIdx >= len(v.messageCache) {
766+ v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, cacheIdx-len(v.messageCache)+1)...)
767+ }
768+ v.messageCache[cacheIdx] = messageRenderCacheEntry{visual: visual, lines: lines, valid: true}
769+ return lines
770+ }
771+ key := msgCacheKey{hash: hashMessage(m), visual: visual}
706772 if v.renderCache != nil {
707773 if lines, ok := v.renderCache[key]; ok {
708774 return lines
@@ -735,6 +801,25 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO
735801 return lines
736802 }
737803
804+func appendMessageHash(h, messageHash uint64) uint64 {
805+ h = fnv64aWriteByte(h, 0x01)
806+ for shift := uint(0); shift < 64; shift += 8 {
807+ h = fnv64aWriteByte(h, byte(messageHash>>shift))
808+ }
809+ return h
810+}
811+
812+func hashMessagePrefix(messages []provider.Message, end int) uint64 {
813+ if end > len(messages) {
814+ end = len(messages)
815+ }
816+ h := fnv64aInit
817+ for i := 0; i < end; i++ {
818+ h = appendMessageHash(h, hashMessage(messages[i]))
819+ }
820+ return h
821+}
822+
738823 // hashMessage returns a 64-bit FNV-1a over the role + content blocks
739824 // of m. Serialising each block to its salient bytes is enough: two
740825 // messages with the same role and same content render identically.
@@ -808,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 {
808893 return h
809894 }
810895
896+func fnv64aWriteUint64(h, value uint64) uint64 {
897+ for shift := uint(0); shift < 64; shift += 8 {
898+ h = fnv64aWriteByte(h, byte(value>>shift))
899+ }
900+ return h
901+}
902+
811903 func fnv64aWrite(h uint64, p []byte) uint64 {
812904 for _, b := range p {
813905 h ^= uint64(b)
@@ -1089,9 +1181,15 @@ func toolThemeKey(theme Theme) uint64 {
10891181 } {
10901182 h = hashThemeColor(h, c)
10911183 }
1092- if theme.Background != nil {
1184+ if theme.Background == nil {
1185+ h = fnv64aWriteByte(h, 0)
1186+ } else {
1187+ h = fnv64aWriteByte(h, 1)
10931188 h = hashThemeColor(h, *theme.Background)
10941189 }
1190+ h = fnv64aWriteByte(h, 0xff)
1191+ h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1192+ h = fnv64aWriteByte(h, 0xff)
10951193 for _, c := range theme.Terminal.Palette {
10961194 h = hashThemeColor(h, c)
10971195 }
@@ -1255,14 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
12551353 // confirmation preview immediately gets the same colors, numbered gutters,
12561354 // and syntax highlighting as its final tool result.
12571355 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1258- if !v.ExpandAll {
1259- total := estimateToolTextLines(text, width)
1260- if total > ToolCollapseLines {
1261- lines := v.renderToolText(previewToolText(text, width), width, color, sourcePath, 1)
1262- return v.collapseToolBodyWithTotal(lines, false, total)
1263- }
1356+ full := v.renderToolText(text, width, color, sourcePath, 1)
1357+ if v.ExpandAll || len(full) <= ToolCollapseLines {
1358+ return full
12641359 }
1265- return v.renderToolText(text, width, color, sourcePath, 1)
1360+ return v.collapseToolBodyWithTotal(full, false, len(full))
12661361 }
12671362
12681363 // renderLiveToolBody renders the in-flight preview of a streaming
@@ -1769,14 +1864,15 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
17691864 if v.FlatTools || v.CompactMode {
17701865 bodyWidth = flatToolBodyRenderWidth(width)
17711866 }
1772- estimated := estimateToolTextLines(bb.Text, bodyWidth)
1867+ estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1868+ estimated := estimateToolTextLines(bb.Text, estimateWidth)
17731869 total += estimated
17741870 text := bb.Text
17751871 // A collapsed result only exposes its first ten rendered rows.
17761872 // Render a bounded source preview in that case; syntax
17771873 // highlighting and wrapping the omitted tail is wasted work.
17781874 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1779- text = previewToolText(text, bodyWidth)
1875+ text = previewToolText(text, estimateWidth)
17801876 }
17811877 body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
17821878 case provider.ImageBlock:
@@ -1816,6 +1912,21 @@ func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total in
18161912 return append(out, footer)
18171913 }
18181914
1915+func effectiveToolTextWidth(text string, width int) int {
1916+ // renderToolText prefixes ordinary rows with a four-cell gutter before
1917+ // wrapping them. Diff rows also carry a five-cell read gutter. Estimating
1918+ // against the outer body width undercounts long rows and produces an
1919+ // incorrect collapse total.
1920+ width -= 4
1921+ if looksLikeUnifiedDiff(text) {
1922+ width -= 5
1923+ }
1924+ if width < 1 {
1925+ return 1
1926+ }
1927+ return width
1928+}
1929+
18191930 func estimateToolTextLines(text string, width int) int {
18201931 if width < 1 {
18211932 width = 1
@@ -187,8 +187,11 @@ type View struct {
187 renderCache map[msgCacheKey][]string187 renderCache map[msgCacheKey][]string
188 liveRenderCache map[liveToolCacheKey][]string188 liveRenderCache map[liveToolCacheKey][]string
189 messageCache []messageRenderCacheEntry189 messageCache []messageRenderCacheEntry
190+ messageCacheStart int
190 messageCacheRevision uint64191 messageCacheRevision uint64
191 messageCacheLen int192 messageCacheLen int
193+ messageCachePrefix uint64
194+ messageCachePrefixOK bool
192 toolPathRevision uint64195 toolPathRevision uint64
193 196
194 // TailLimit caps how many messages from the END of Messages are197 // TailLimit caps how many messages from the END of Messages are
@@ -201,20 +204,12 @@ type View struct {
201 TailLimit int204 TailLimit int
202 }205 }
203 206
204-// msgCacheKey identifies a cached message render. hash is a 64-bit207+// msgVisualKey identifies the visual configuration that affects a cached
205-// FNV-1a of the message's content, which is cheap to compute and208+// message render. Keep this shared by both cache implementations so their
206-// unambiguous enough for the cache (collisions produce a stale frame,209+// invalidation rules cannot drift apart.
207-// not wrong data, and we recompute on invalidation anyway).210+type msgVisualKey struct {
208-type msgCacheKey struct {211+ width int
209- hash uint64212+ expandAll bool
210- width int
211- expandAll bool
212- // turnOpen is true when the previous rendered message belongs to
213- // the same agent turn (assistant tool_use, or tool result). The
214- // header ("▍ zut") is suppressed in that case so a single turn
215- // — even one that spans many assistant/tool message round-trips
216- // in the underlying API — renders under one header instead of a
217- // new one per assistant message.
218 turnOpen bool213 turnOpen bool
219 flatTools bool214 flatTools bool
220 compactMode bool215 compactMode bool
@@ -223,17 +218,19 @@ type msgCacheKey struct {
223 theme uint64218 theme uint64
224 }219 }
225 220
221+// msgCacheKey identifies a cached message render. hash is a 64-bit
222+// FNV-1a of the message's content, which is cheap to compute and
223+// unambiguous enough for the cache (collisions produce a stale frame,
224+// not wrong data, and we recompute on invalidation anyway).
225+type msgCacheKey struct {
226+ hash uint64
227+ visual msgVisualKey
228+}
229+
226 type messageRenderCacheEntry struct {230 type messageRenderCacheEntry struct {
227- width int231+ visual msgVisualKey
228- expandAll bool232+ lines []string
229- turnOpen bool233+ valid bool
230- flatTools bool
231- compactMode bool
232- compactUser bool
233- imageProto ImageProtocol
234- theme uint64
235- lines []string
236- valid bool
237 }234 }
238 235
239 type liveToolCacheKey struct {236 type liveToolCacheKey struct {
@@ -256,8 +253,11 @@ func (v *View) InvalidateRenderCache() {
256 v.renderCache = nil253 v.renderCache = nil
257 v.liveRenderCache = nil254 v.liveRenderCache = nil
258 v.messageCache = nil255 v.messageCache = nil
256+ v.messageCacheStart = 0
259 v.messageCacheRevision = 0257 v.messageCacheRevision = 0
260 v.messageCacheLen = 0258 v.messageCacheLen = 0
259+ v.messageCachePrefix = 0
260+ v.messageCachePrefixOK = false
261 v.toolPathRevision = 0261 v.toolPathRevision = 0
262 }262 }
263 263
@@ -320,8 +320,11 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) {
320 v.renderCache = snapshot.renderCache320 v.renderCache = snapshot.renderCache
321 v.liveRenderCache = snapshot.liveRenderCache321 v.liveRenderCache = snapshot.liveRenderCache
322 v.messageCache = snapshot.messageCache322 v.messageCache = snapshot.messageCache
323+ v.messageCacheStart = snapshot.messageCacheStart
323 v.messageCacheRevision = snapshot.messageCacheRevision324 v.messageCacheRevision = snapshot.messageCacheRevision
324 v.messageCacheLen = snapshot.messageCacheLen325 v.messageCacheLen = snapshot.messageCacheLen
326+ v.messageCachePrefix = snapshot.messageCachePrefix
327+ v.messageCachePrefixOK = snapshot.messageCachePrefixOK
325 v.toolPaths = snapshot.toolPaths328 v.toolPaths = snapshot.toolPaths
326 v.toolStartLines = snapshot.toolStartLines329 v.toolStartLines = snapshot.toolStartLines
327 v.toolCallLabels = snapshot.toolCallLabels330 v.toolCallLabels = snapshot.toolCallLabels
@@ -488,10 +491,33 @@ func (v *View) renderStartupResources(width int) []string {
488 // to map a message index back to a row offset.491 // to map a message index back to a row offset.
489 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {492 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
490 themeKey := toolThemeKey(v.Theme)493 themeKey := toolThemeKey(v.Theme)
491- if v.MessagesRevision != 0 && v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision && len(v.Messages) <= v.messageCacheLen {494+ if v.MessagesRevision == 0 && v.messageCacheRevision != 0 {
492 v.messageCache = nil495 v.messageCache = nil
496+ v.messageCacheStart = 0
497+ v.messageCacheRevision = 0
498+ v.messageCacheLen = 0
499+ v.messageCachePrefix = 0
500+ v.messageCachePrefixOK = false
493 }501 }
494 if v.MessagesRevision != 0 {502 if v.MessagesRevision != 0 {
503+ if v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision {
504+ verifiedAppend := len(v.Messages) > v.messageCacheLen && v.messageCachePrefixOK &&
505+ hashMessagePrefix(v.Messages, v.messageCacheLen) == v.messageCachePrefix
506+ if verifiedAppend {
507+ for _, m := range v.Messages[v.messageCacheLen:] {
508+ v.messageCachePrefix = appendMessageHash(v.messageCachePrefix, hashMessage(m))
509+ }
510+ } else {
511+ v.messageCache = nil
512+ v.messageCacheStart = 0
513+ v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
514+ }
515+ v.messageCachePrefixOK = true
516+ }
517+ if v.messageCacheRevision == 0 {
518+ v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
519+ v.messageCachePrefixOK = true
520+ }
495 v.messageCacheRevision = v.MessagesRevision521 v.messageCacheRevision = v.MessagesRevision
496 v.messageCacheLen = len(v.Messages)522 v.messageCacheLen = len(v.Messages)
497 }523 }
@@ -537,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
537 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {563 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {
538 renderFrom = len(v.Messages) - v.TailLimit564 renderFrom = len(v.Messages) - v.TailLimit
539 }565 }
566+ v.boundMessageCache(renderFrom, len(v.Messages))
540 for idx, m := range v.Messages {567 for idx, m := range v.Messages {
541 if idx < renderFrom {568 if idx < renderFrom {
542 rendered[idx] = nil569 rendered[idx] = nil
@@ -672,28 +699,48 @@ func (v *View) refreshToolPaths() {
672 }699 }
673 }700 }
674 701
702+func (v *View) boundMessageCache(renderFrom, messageCount int) {
703+ if renderFrom < 0 {
704+ renderFrom = 0
705+ }
706+ if renderFrom > messageCount {
707+ renderFrom = messageCount
708+ }
709+ if len(v.messageCache) == 0 {
710+ v.messageCacheStart = renderFrom
711+ return
712+ }
713+
714+ start := v.messageCacheStart
715+ if renderFrom > start {
716+ drop := renderFrom - start
717+ if drop >= len(v.messageCache) {
718+ v.messageCache = nil
719+ v.messageCacheStart = renderFrom
720+ return
721+ }
722+ v.messageCache = v.messageCache[drop:]
723+ start = renderFrom
724+ } else if renderFrom < start {
725+ prefix := make([]messageRenderCacheEntry, start-renderFrom)
726+ v.messageCache = append(prefix, v.messageCache...)
727+ start = renderFrom
728+ }
729+
730+ max := messageCount - renderFrom
731+ if len(v.messageCache) > max {
732+ v.messageCache = v.messageCache[:max]
733+ }
734+ v.messageCacheStart = start
735+}
736+
675 // renderMessageCached returns the rendered line slice for m, using the737 // renderMessageCached returns the rendered line slice for m, using the
676 // cache if the same (content hash, width, expandAll) combination has738 // cache if the same (content hash, width, expandAll) combination has
677 // been rendered before. The slice returned is shared — callers must739 // been rendered before. The slice returned is shared — callers must
678 // not mutate it; Build() only ever appends to its own `out` so the740 // not mutate it; Build() only ever appends to its own `out` so the
679 // shared slice is safe.741 // shared slice is safe.
680 func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {742 func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
681- if v.MessagesRevision != 0 {743+ visual := msgVisualKey{
682- if idx < len(v.messageCache) {
683- entry := v.messageCache[idx]
684- if entry.valid && entry.width == width && entry.expandAll == v.ExpandAll && entry.turnOpen == turnOpen && entry.flatTools == v.FlatTools && entry.compactMode == v.CompactMode && entry.compactUser == v.CompactUser && entry.imageProto == v.ImageProto && entry.theme == themeKey {
685- return entry.lines
686- }
687- }
688- lines := v.renderMessage(m, width, turnOpen)
689- if idx >= len(v.messageCache) {
690- v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, idx-len(v.messageCache)+1)...)
691- }
692- v.messageCache[idx] = messageRenderCacheEntry{width: width, expandAll: v.ExpandAll, turnOpen: turnOpen, flatTools: v.FlatTools, compactMode: v.CompactMode, compactUser: v.CompactUser, imageProto: v.ImageProto, theme: themeKey, lines: lines, valid: true}
693- return lines
694- }
695- key := msgCacheKey{
696- hash: hashMessage(m),
697 width: width,744 width: width,
698 expandAll: v.ExpandAll,745 expandAll: v.ExpandAll,
699 turnOpen: turnOpen,746 turnOpen: turnOpen,
@@ -703,6 +750,25 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO
703 imageProto: v.ImageProto,750 imageProto: v.ImageProto,
704 theme: themeKey,751 theme: themeKey,
705 }752 }
753+ if v.MessagesRevision != 0 {
754+ cacheIdx := idx - v.messageCacheStart
755+ if cacheIdx >= 0 && cacheIdx < len(v.messageCache) {
756+ entry := v.messageCache[cacheIdx]
757+ if entry.valid && entry.visual == visual {
758+ return entry.lines
759+ }
760+ }
761+ lines := v.renderMessage(m, width, turnOpen)
762+ if cacheIdx < 0 {
763+ return lines
764+ }
765+ if cacheIdx >= len(v.messageCache) {
766+ v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, cacheIdx-len(v.messageCache)+1)...)
767+ }
768+ v.messageCache[cacheIdx] = messageRenderCacheEntry{visual: visual, lines: lines, valid: true}
769+ return lines
770+ }
771+ key := msgCacheKey{hash: hashMessage(m), visual: visual}
706 if v.renderCache != nil {772 if v.renderCache != nil {
707 if lines, ok := v.renderCache[key]; ok {773 if lines, ok := v.renderCache[key]; ok {
708 return lines774 return lines
@@ -735,6 +801,25 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO
735 return lines801 return lines
736 }802 }
737 803
804+func appendMessageHash(h, messageHash uint64) uint64 {
805+ h = fnv64aWriteByte(h, 0x01)
806+ for shift := uint(0); shift < 64; shift += 8 {
807+ h = fnv64aWriteByte(h, byte(messageHash>>shift))
808+ }
809+ return h
810+}
811+
812+func hashMessagePrefix(messages []provider.Message, end int) uint64 {
813+ if end > len(messages) {
814+ end = len(messages)
815+ }
816+ h := fnv64aInit
817+ for i := 0; i < end; i++ {
818+ h = appendMessageHash(h, hashMessage(messages[i]))
819+ }
820+ return h
821+}
822+
738 // hashMessage returns a 64-bit FNV-1a over the role + content blocks823 // hashMessage returns a 64-bit FNV-1a over the role + content blocks
739 // of m. Serialising each block to its salient bytes is enough: two824 // of m. Serialising each block to its salient bytes is enough: two
740 // messages with the same role and same content render identically.825 // messages with the same role and same content render identically.
@@ -808,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 {
808 return h893 return h
809 }894 }
810 895
896+func fnv64aWriteUint64(h, value uint64) uint64 {
897+ for shift := uint(0); shift < 64; shift += 8 {
898+ h = fnv64aWriteByte(h, byte(value>>shift))
899+ }
900+ return h
901+}
902+
811 func fnv64aWrite(h uint64, p []byte) uint64 {903 func fnv64aWrite(h uint64, p []byte) uint64 {
812 for _, b := range p {904 for _, b := range p {
813 h ^= uint64(b)905 h ^= uint64(b)
@@ -1089,9 +1181,15 @@ func toolThemeKey(theme Theme) uint64 {
1089 } {1181 } {
1090 h = hashThemeColor(h, c)1182 h = hashThemeColor(h, c)
1091 }1183 }
1092- if theme.Background != nil {1184+ if theme.Background == nil {
1185+ h = fnv64aWriteByte(h, 0)
1186+ } else {
1187+ h = fnv64aWriteByte(h, 1)
1093 h = hashThemeColor(h, *theme.Background)1188 h = hashThemeColor(h, *theme.Background)
1094 }1189 }
1190+ h = fnv64aWriteByte(h, 0xff)
1191+ h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1192+ h = fnv64aWriteByte(h, 0xff)
1095 for _, c := range theme.Terminal.Palette {1193 for _, c := range theme.Terminal.Palette {
1096 h = hashThemeColor(h, c)1194 h = hashThemeColor(h, c)
1097 }1195 }
@@ -1255,14 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
1255 // confirmation preview immediately gets the same colors, numbered gutters,1353 // confirmation preview immediately gets the same colors, numbered gutters,
1256 // and syntax highlighting as its final tool result.1354 // and syntax highlighting as its final tool result.
1257 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {1355 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1258- if !v.ExpandAll {1356+ full := v.renderToolText(text, width, color, sourcePath, 1)
1259- total := estimateToolTextLines(text, width)1357+ if v.ExpandAll || len(full) <= ToolCollapseLines {
1260- if total > ToolCollapseLines {1358+ return full
1261- lines := v.renderToolText(previewToolText(text, width), width, color, sourcePath, 1)
1262- return v.collapseToolBodyWithTotal(lines, false, total)
1263- }
1264 }1359 }
1265- return v.renderToolText(text, width, color, sourcePath, 1)1360+ return v.collapseToolBodyWithTotal(full, false, len(full))
1266 }1361 }
1267 1362
1268 // renderLiveToolBody renders the in-flight preview of a streaming1363 // renderLiveToolBody renders the in-flight preview of a streaming
@@ -1769,14 +1864,15 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
1769 if v.FlatTools || v.CompactMode {1864 if v.FlatTools || v.CompactMode {
1770 bodyWidth = flatToolBodyRenderWidth(width)1865 bodyWidth = flatToolBodyRenderWidth(width)
1771 }1866 }
1772- estimated := estimateToolTextLines(bb.Text, bodyWidth)1867+ estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1868+ estimated := estimateToolTextLines(bb.Text, estimateWidth)
1773 total += estimated1869 total += estimated
1774 text := bb.Text1870 text := bb.Text
1775 // A collapsed result only exposes its first ten rendered rows.1871 // A collapsed result only exposes its first ten rendered rows.
1776 // Render a bounded source preview in that case; syntax1872 // Render a bounded source preview in that case; syntax
1777 // highlighting and wrapping the omitted tail is wasted work.1873 // highlighting and wrapping the omitted tail is wasted work.
1778 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {1874 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1779- text = previewToolText(text, bodyWidth)1875+ text = previewToolText(text, estimateWidth)
1780 }1876 }
1781 body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)1877 body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
1782 case provider.ImageBlock:1878 case provider.ImageBlock:
@@ -1816,6 +1912,21 @@ func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total in
1816 return append(out, footer)1912 return append(out, footer)
1817 }1913 }
1818 1914
1915+func effectiveToolTextWidth(text string, width int) int {
1916+ // renderToolText prefixes ordinary rows with a four-cell gutter before
1917+ // wrapping them. Diff rows also carry a five-cell read gutter. Estimating
1918+ // against the outer body width undercounts long rows and produces an
1919+ // incorrect collapse total.
1920+ width -= 4
1921+ if looksLikeUnifiedDiff(text) {
1922+ width -= 5
1923+ }
1924+ if width < 1 {
1925+ return 1
1926+ }
1927+ return width
1928+}
1929+
1819 func estimateToolTextLines(text string, width int) int {1930 func estimateToolTextLines(text string, width int) int {
1820 if width < 1 {1931 if width < 1 {
1821 width = 11932 width = 1
M packages/tui/view_bench_test.go +8 -1
@@ -24,7 +24,7 @@ func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) {
2424 for n := 0; n < b.N; n++ {
2525 view := base
2626 if temperature == "cold" {
27- view = benchmarkToolStormView(expanded)
27+ view.InvalidateRenderCache()
2828 }
2929 lines, anchors := view.BuildWithAnchors(width)
3030 if len(lines) == 0 || len(anchors) == 0 {
@@ -49,9 +49,16 @@ func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) {
4949 MessagesRevision: 1,
5050 ToolCalls: benchmarkLiveTools(8, 96),
5151 }
52+ liveResults := make([]string, len(view.ToolCalls))
53+ for i := range liveResults {
54+ liveResults[i] = benchmarkResult(96, i)
55+ }
5256 b.ReportAllocs()
5357 b.ResetTimer()
5458 for n := 0; n < b.N; n++ {
59+ call := &view.ToolCalls[n%len(view.ToolCalls)]
60+ call.Revision++
61+ call.Result = liveResults[n%len(liveResults)]
5562 lines, anchors := view.BuildWithAnchors(width)
5663 if len(lines) == 0 || len(anchors) == 0 {
5764 b.Fatal("live tool storm produced no rows or anchors")
@@ -24,7 +24,7 @@ func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) {
24 for n := 0; n < b.N; n++ {24 for n := 0; n < b.N; n++ {
25 view := base25 view := base
26 if temperature == "cold" {26 if temperature == "cold" {
27- view = benchmarkToolStormView(expanded)27+ view.InvalidateRenderCache()
28 }28 }
29 lines, anchors := view.BuildWithAnchors(width)29 lines, anchors := view.BuildWithAnchors(width)
30 if len(lines) == 0 || len(anchors) == 0 {30 if len(lines) == 0 || len(anchors) == 0 {
@@ -49,9 +49,16 @@ func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) {
49 MessagesRevision: 1,49 MessagesRevision: 1,
50 ToolCalls: benchmarkLiveTools(8, 96),50 ToolCalls: benchmarkLiveTools(8, 96),
51 }51 }
52+ liveResults := make([]string, len(view.ToolCalls))
53+ for i := range liveResults {
54+ liveResults[i] = benchmarkResult(96, i)
55+ }
52 b.ReportAllocs()56 b.ReportAllocs()
53 b.ResetTimer()57 b.ResetTimer()
54 for n := 0; n < b.N; n++ {58 for n := 0; n < b.N; n++ {
59+ call := &view.ToolCalls[n%len(view.ToolCalls)]
60+ call.Revision++
61+ call.Result = liveResults[n%len(liveResults)]
55 lines, anchors := view.BuildWithAnchors(width)62 lines, anchors := view.BuildWithAnchors(width)
56 if len(lines) == 0 || len(anchors) == 0 {63 if len(lines) == 0 || len(anchors) == 0 {
57 b.Fatal("live tool storm produced no rows or anchors")64 b.Fatal("live tool storm produced no rows or anchors")
M packages/tui/view_cache_test.go +48 -6
@@ -1,6 +1,7 @@
11 package tui
22
33 import (
4+ "fmt"
45 "strings"
56 "testing"
67
@@ -36,12 +37,10 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
3637 }
3738
3839 wide, _ := view.BuildWithAnchors(120)
39- if len(wide) >= len(expanded) {
40- // The wide render may have the same row count for this fixture, but it
41- // must still be rebuilt for the new wrapping width.
42- if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {
43- t.Fatal("resize reused rows from the old width")
44- }
40+ // The wide render may have the same row count for this fixture, but it
41+ // must still be rebuilt for the new wrapping width.
42+ if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {
43+ t.Fatal("resize reused rows from the old width")
4544 }
4645
4746 view.ExpandAll = false
@@ -49,6 +48,10 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
4948 CallID: "call-1",
5049 Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
5150 }}
51+ view.Messages = append(view.Messages, provider.Message{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "third message"}},
54+ })
5255 view.MessagesRevision = 2
5356 updated, _ := view.BuildWithAnchors(40)
5457 if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
@@ -56,6 +59,45 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
5659 }
5760 }
5861
62+func TestViewRevisionCacheIsBoundToTailLimit(t *testing.T) {
63+ messages := make([]provider.Message, 20)
64+ for i := range messages {
65+ messages[i] = provider.Message{
66+ Role: provider.RoleUser,
67+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message %d", i)}},
68+ }
69+ }
70+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1, TailLimit: 5}
71+ view.Build(80)
72+ if view.messageCacheStart != len(messages)-view.TailLimit {
73+ t.Fatalf("message cache start=%d, want %d", view.messageCacheStart, len(messages)-view.TailLimit)
74+ }
75+ if len(view.messageCache) > view.TailLimit {
76+ t.Fatalf("message cache length=%d exceeds tail limit %d", len(view.messageCache), view.TailLimit)
77+ }
78+
79+ view.Build(80)
80+ if len(view.messageCache) > view.TailLimit {
81+ t.Fatalf("message cache grew on a cache hit: length=%d", len(view.messageCache))
82+ }
83+}
84+
85+func TestRenderLiveToolResultCollapseUsesRenderedLineCount(t *testing.T) {
86+ var text strings.Builder
87+ for i := 0; i < 13; i++ {
88+ text.WriteString(strings.Repeat("x", 17))
89+ text.WriteByte('\n')
90+ }
91+ view := &View{Theme: Dark}
92+ full := view.renderToolText(text.String(), 20, Dark.ToolOut, "", 1)
93+ collapsed := view.renderLiveToolResult(text.String(), 20, Dark.ToolOut, "")
94+ joined := strings.Join(collapsed, "\n")
95+ wantTotal := fmt.Sprintf("%d total", len(full))
96+ if !strings.Contains(joined, wantTotal) {
97+ t.Fatalf("collapse footer omitted rendered total %q: %s", wantTotal, joined)
98+ }
99+}
100+
59101 func cacheTestResult(prefix string) string {
60102 var b strings.Builder
61103 for n := 0; n < 32; n++ {
@@ -1,6 +1,7 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -36,12 +37,10 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
36 }37 }
37 38
38 wide, _ := view.BuildWithAnchors(120)39 wide, _ := view.BuildWithAnchors(120)
39- if len(wide) >= len(expanded) {40+ // The wide render may have the same row count for this fixture, but it
40- // The wide render may have the same row count for this fixture, but it41+ // must still be rebuilt for the new wrapping width.
41- // must still be rebuilt for the new wrapping width.42+ if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {
42- if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {43+ t.Fatal("resize reused rows from the old width")
43- t.Fatal("resize reused rows from the old width")
44- }
45 }44 }
46 45
47 view.ExpandAll = false46 view.ExpandAll = false
@@ -49,6 +48,10 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
49 CallID: "call-1",48 CallID: "call-1",
50 Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},49 Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
51 }}50 }}
51+ view.Messages = append(view.Messages, provider.Message{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "third message"}},
54+ })
52 view.MessagesRevision = 255 view.MessagesRevision = 2
53 updated, _ := view.BuildWithAnchors(40)56 updated, _ := view.BuildWithAnchors(40)
54 if !strings.Contains(strings.Join(updated, "\n"), "new marker") {57 if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
@@ -56,6 +59,45 @@ func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
56 }59 }
57 }60 }
58 61
62+func TestViewRevisionCacheIsBoundToTailLimit(t *testing.T) {
63+ messages := make([]provider.Message, 20)
64+ for i := range messages {
65+ messages[i] = provider.Message{
66+ Role: provider.RoleUser,
67+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message %d", i)}},
68+ }
69+ }
70+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1, TailLimit: 5}
71+ view.Build(80)
72+ if view.messageCacheStart != len(messages)-view.TailLimit {
73+ t.Fatalf("message cache start=%d, want %d", view.messageCacheStart, len(messages)-view.TailLimit)
74+ }
75+ if len(view.messageCache) > view.TailLimit {
76+ t.Fatalf("message cache length=%d exceeds tail limit %d", len(view.messageCache), view.TailLimit)
77+ }
78+
79+ view.Build(80)
80+ if len(view.messageCache) > view.TailLimit {
81+ t.Fatalf("message cache grew on a cache hit: length=%d", len(view.messageCache))
82+ }
83+}
84+
85+func TestRenderLiveToolResultCollapseUsesRenderedLineCount(t *testing.T) {
86+ var text strings.Builder
87+ for i := 0; i < 13; i++ {
88+ text.WriteString(strings.Repeat("x", 17))
89+ text.WriteByte('\n')
90+ }
91+ view := &View{Theme: Dark}
92+ full := view.renderToolText(text.String(), 20, Dark.ToolOut, "", 1)
93+ collapsed := view.renderLiveToolResult(text.String(), 20, Dark.ToolOut, "")
94+ joined := strings.Join(collapsed, "\n")
95+ wantTotal := fmt.Sprintf("%d total", len(full))
96+ if !strings.Contains(joined, wantTotal) {
97+ t.Fatalf("collapse footer omitted rendered total %q: %s", wantTotal, joined)
98+ }
99+}
100+
59 func cacheTestResult(prefix string) string {101 func cacheTestResult(prefix string) string {
60 var b strings.Builder102 var b strings.Builder
61 for n := 0; n < 32; n++ {103 for n := 0; n < 32; n++ {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close