detached ab30a57

Merge pull request #70 from bnema/perf/tui-tool-storm-responsiveness

perf(tui): improve tool-storm responsiveness
commit
ab30a576b398bcc8d827ee926c9bc0016b2858c6
author
bnema · · 2026-08-07 15:14 UTC
committer
GitHub · · 2026-08-07 15:14 UTC
merge of
e1eefc2 + c6f9399 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.11.1
8 files · +1321 -143
M packages/agent/modes/interactive.go +226 -100
@@ -430,22 +430,24 @@ type modelRefreshResult struct{ err error }
430430
431431 // Interactive is the TUI chat loop.
432432 type chatCacheKey struct {
433- cols int
434- agentRev uint64
435- statusOK string
436- statusErr string
437- help string
438- extNotes string
439- extStatuses string
440- extWidgets string
441- reloadErrors string
442- updateAvailable bool
443- updateCurrent string
444- updateLatest string
445- updateURL string
446- welcomeShowVer bool
447- expandAll bool
448- tailLimit int
433+ cols int
434+ agentRev uint64
435+ statusOK string
436+ statusErr string
437+ help string
438+ extNotes string
439+ extStatuses string
440+ extWidgets string
441+ reloadErrors string
442+ updateAvailable bool
443+ updateCurrent string
444+ updateLatest string
445+ updateURL string
446+ welcomeShowVer bool
447+ expandAll bool
448+ tailLimit int
449+ renderedMessageCount int
450+ viewCacheRev uint64
449451 }
450452
451453 // QuickModelShortcut is one configured quick model switch slot.
@@ -666,6 +668,9 @@ type Interactive struct {
666668 subagentActivityActive bool
667669 pendingIdleWork []func()
668670 dirty chan struct{}
671+ renderScheduler atomic.Pointer[latestFrameScheduler]
672+ renderRevision atomic.Uint64
673+ renderOutsideLock bool
669674 modelRefresh chan modelRefreshResult
670675 modelRefreshing bool
671676 startupPreDone chan startupPreResult
@@ -695,6 +700,13 @@ type Interactive struct {
695700 chatCacheKey chatCacheKey
696701 chatCacheValid bool
697702
703+ // stableChatCache contains only transcript/startup rows. Live streaming,
704+ // tool overlays, and errors are appended separately so spinner/progress
705+ // frames can reuse finalized chat while a turn is busy.
706+ stableChatCache []string
707+ stableChatCacheKey chatCacheKey
708+ stableChatCacheValid bool
709+
698710 // Messages typed while a turn is in flight. Each is delivered as
699711 // its own follow-up turn once the current one finishes. Rendered
700712 // above the status bar as "sliding in: ..." chips.
@@ -1043,17 +1055,29 @@ func (i *Interactive) Run(ctx context.Context) error {
10431055
10441056 cols, rows := term.Size()
10451057 i.rend.Resize(cols, rows)
1046- term.OnResize(func() {
1058+ renderScheduler := newLatestFrameScheduler()
1059+ i.renderScheduler.Store(renderScheduler)
1060+ go renderScheduler.run(func(req renderRequest) {
10471061 c, r := term.Size()
10481062 i.rend.Resize(c, r)
1049- // Force an immediate redraw on resize. The throttled invalidate
1050- // path is fine for animation, but a window resize is a discrete
1051- // user action where any visible delay (or stale frame) reads as
1052- // brokenness. redraw() is mutex-safe; the worst that happens is
1053- // a duplicate paint if the throttler is mid-flight, which is
1054- // invisible.
1063+ if req.theme != nil {
1064+ i.rend.SetTheme(*req.theme)
1065+ }
1066+ if req.clear {
1067+ i.rend.Clear()
1068+ }
1069+ if req.invalidate {
1070+ i.rend.Invalidate()
1071+ }
10551072 i.redraw()
10561073 })
1074+ defer renderScheduler.stop()
1075+ term.OnResize(func() {
1076+ // Resize and redraw share the renderer owner. The owner reads the
1077+ // latest terminal size just before its next frame.
1078+ i.renderRevision.Add(1)
1079+ renderScheduler.request(false, false)
1080+ })
10571081
10581082 if i.cfg.StartupPre != "" {
10591083 i.deferredInitialInput = i.cfg.InitialInput
@@ -1140,7 +1164,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11401164 if pendingRedraw {
11411165 pendingRedraw = false
11421166 lastRedraw = time.Now()
1143- i.redraw()
1167+ renderScheduler.request(false, false)
11441168 }
11451169 }
11461170
@@ -1158,7 +1182,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11581182 }
11591183 pendingRedraw = false
11601184 lastRedraw = time.Now()
1161- i.redraw()
1185+ renderScheduler.request(false, false)
11621186 return
11631187 }
11641188 if pendingRedraw {
@@ -1287,7 +1311,42 @@ func (i *Interactive) Run(ctx context.Context) error {
12871311 }
12881312 }
12891313
1314+func (i *Interactive) requestRendererClear() {
1315+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316+ scheduler.request(true, false)
1317+ return
1318+ }
1319+ if i.rend != nil {
1320+ i.rend.Clear()
1321+ }
1322+}
1323+
1324+func (i *Interactive) requestRendererInvalidate() {
1325+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1326+ scheduler.request(false, true)
1327+ return
1328+ }
1329+ if i.rend != nil {
1330+ i.rend.Invalidate()
1331+ }
1332+}
1333+
1334+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1335+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1336+ scheduler.requestTheme(theme)
1337+ return
1338+ }
1339+ if i.rend != nil {
1340+ i.rend.SetTheme(theme)
1341+ }
1342+}
1343+
12901344 func (i *Interactive) invalidate() {
1345+ i.renderRevision.Add(1)
1346+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1347+ scheduler.request(false, false)
1348+ return
1349+ }
12911350 select {
12921351 case i.dirty <- struct{}{}:
12931352 default:
@@ -1295,51 +1354,50 @@ func (i *Interactive) invalidate() {
12951354 }
12961355
12971356 func (i *Interactive) cachedChatLocked(cols int) []string {
1298- key, cacheable := i.chatCacheKeyLocked(cols)
1299- if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1357+ // A busy frame contains mutable streaming/tool state. Keep the stable
1358+ // transcript cache, but never return a full-frame cache entry that would
1359+ // hide a newly arrived delta or completed result.
1360+ if i.busy || i.streamOn || i.streamFlushPending {
1361+ return i.buildChatLocked(cols)
1362+ }
1363+ key := i.chatCacheKeyLocked(cols)
1364+ if i.chatCacheValid && i.chatCacheKey == key {
13001365 return append([]string(nil), i.chatCache...)
13011366 }
13021367 chat := i.buildChatLocked(cols)
1303- if cacheable {
1304- i.chatCache = append(i.chatCache[:0], chat...)
1305- i.chatCacheKey = key
1306- i.chatCacheValid = true
1307- } else {
1308- i.chatCacheValid = false
1309- }
1368+ key = i.chatCacheKeyLocked(cols)
1369+ i.chatCache = append(i.chatCache[:0], chat...)
1370+ i.chatCacheKey = key
1371+ i.chatCacheValid = true
13101372 return chat
13111373 }
13121374
1313-func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1314- // Live turns mutate streaming/tool-call state at high frequency;
1315- // keep those on the old rebuild path. The cache targets the common
1316- // idle case where only the editor contents changed between redraws.
1317- if i.busy || i.streamOn || i.streamFlushPending {
1318- return chatCacheKey{}, false
1319- }
1375+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
13201376 var rev uint64
13211377 if i.agent != nil {
13221378 rev = i.agent.Revision()
13231379 }
13241380 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
13251381 return chatCacheKey{
1326- cols: cols,
1327- agentRev: rev,
1328- statusOK: i.statusOK,
1329- statusErr: i.statusErr,
1330- help: strings.Join(i.helpBlock, "\n"),
1331- extNotes: strings.Join(i.extNotes, "\n"),
1332- extStatuses: i.extensionStatusesKeyLocked(),
1333- extWidgets: i.extensionWidgetsKeyLocked(),
1334- reloadErrors: strings.Join(i.reloadErrors, "\n"),
1335- updateAvailable: i.updateInfo.Available,
1336- updateCurrent: i.updateInfo.Current,
1337- updateLatest: i.updateInfo.Latest,
1338- updateURL: i.updateInfo.URL,
1339- welcomeShowVer: showVer,
1340- expandAll: i.view.ExpandAll,
1341- tailLimit: i.view.TailLimit,
1342- }, true
1382+ cols: cols,
1383+ agentRev: rev,
1384+ statusOK: i.statusOK,
1385+ statusErr: i.statusErr,
1386+ help: strings.Join(i.helpBlock, "\n"),
1387+ extNotes: strings.Join(i.extNotes, "\n"),
1388+ extStatuses: i.extensionStatusesKeyLocked(),
1389+ extWidgets: i.extensionWidgetsKeyLocked(),
1390+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1391+ updateAvailable: i.updateInfo.Available,
1392+ updateCurrent: i.updateInfo.Current,
1393+ updateLatest: i.updateInfo.Latest,
1394+ updateURL: i.updateInfo.URL,
1395+ welcomeShowVer: showVer,
1396+ expandAll: i.view.ExpandAll,
1397+ tailLimit: i.view.TailLimit,
1398+ renderedMessageCount: len(i.view.Messages),
1399+ viewCacheRev: i.view.RenderCacheRevision,
1400+ }
13431401 }
13441402
13451403 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
@@ -1515,11 +1573,48 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
15151573 return out
15161574 }
15171575
1576+func (i *Interactive) stableChatRowsLocked(cols int) []string {
1577+ key := i.chatCacheKeyLocked(cols)
1578+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
1579+ return append([]string(nil), i.stableChatCache...)
1580+ }
1581+
1582+ renderView := i.view.CloneForRender()
1583+ renderView.Streaming = ""
1584+ renderView.StreamingActive = false
1585+ renderView.ToolCalls = nil
1586+ renderView.Err = ""
1587+ if !i.renderOutsideLock {
1588+ rows := renderView.Build(cols)
1589+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1590+ i.stableChatCacheKey = key
1591+ i.stableChatCacheValid = true
1592+ return append([]string(nil), rows...)
1593+ }
1594+ // Markdown, syntax highlighting, and wrapping are the expensive part of
1595+ // a frame. Build the immutable snapshot without holding the interactive
1596+ // mutex so key processing can continue while a cold transcript renders.
1597+ i.mu.Unlock()
1598+ rows := renderView.Build(cols)
1599+ i.mu.Lock()
1600+ if i.view.MessagesRevision == renderView.MessagesRevision &&
1601+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1602+ i.view.AdoptRenderCacheFrom(renderView)
1603+ }
1604+
1605+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1606+ i.stableChatCacheKey = key
1607+ i.stableChatCacheValid = true
1608+ return append([]string(nil), rows...)
1609+}
1610+
15181611 func (i *Interactive) buildChatLocked(cols int) []string {
15191612 if i.agent != nil {
15201613 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1614+ i.view.MessagesRevision = i.agent.Revision()
15211615 } else {
15221616 i.view.Messages = nil
1617+ i.view.MessagesRevision = 0
15231618 }
15241619 // Pacer flush: while the streaming pacer is still draining the
15251620 // buffer (i.e. EvAssistantMessage already fired but more runes
@@ -1583,7 +1678,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
15831678 // rows update in place at the end of the buffer, instead of the
15841679 // whole bottom band shrinking and shifting chat lines around.
15851680 i.liveBlock = nil
1586- chat := i.view.Build(cols)
1681+ stable := i.stableChatRowsLocked(cols)
1682+ chat := append([]string(nil), stable...)
1683+ chat = append(chat, i.view.BuildLive(cols)...)
15871684
15881685 // Welcome banner: shown at the top of the chat area when there is
15891686 // no transcript yet. Disappears after the first message is sent.
@@ -1708,7 +1805,7 @@ func (i *Interactive) scrollBy(delta int) {
17081805 // wrapped-character fragments behind during scroll-driven
17091806 // viewport changes. Force a full repaint on scroll, but
17101807 // avoid a whole-screen clear because that visibly flickers.
1711- i.rend.Invalidate()
1808+ i.requestRendererInvalidate()
17121809 }
17131810 i.mu.Unlock()
17141811 i.invalidate()
@@ -1749,16 +1846,14 @@ func (i *Interactive) scrollToBottom() {
17491846 // after resume. See commit 43da5e5 for the same fix on new turns.
17501847 i.prevChatLen = 0
17511848 i.prevChatCols = 0
1752- if i.rend != nil {
1753- i.rend.Invalidate()
1754- }
1849+ i.requestRendererInvalidate()
17551850 i.mu.Unlock()
17561851 i.invalidate()
17571852 }
17581853
17591854 func (i *Interactive) redraw() {
17601855 i.mu.Lock()
1761- defer i.mu.Unlock()
1856+ renderRevision := i.renderRevision.Load()
17621857
17631858 cols, rows := i.cfg.Terminal.Size()
17641859 mainCols := cols
@@ -1773,7 +1868,13 @@ func (i *Interactive) redraw() {
17731868 }
17741869 }
17751870 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1871+ i.renderOutsideLock = true
17761872 chat := i.cachedChatLocked(mainCols)
1873+ i.renderOutsideLock = false
1874+ if i.renderRevision.Load() != renderRevision {
1875+ i.mu.Unlock()
1876+ return
1877+ }
17771878
17781879 // Dialogs (login or model picker) render between chat and the editor.
17791880 var dialog []string
@@ -2351,8 +2452,16 @@ func (i *Interactive) redraw() {
23512452 cursorCol = 0
23522453 }
23532454 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2455+ if i.renderRevision.Load() != renderRevision {
2456+ i.mu.Unlock()
2457+ return
2458+ }
2459+ theme := i.cfg.Theme
2460+ // State assembly above is synchronized; terminal output is owned by
2461+ // this renderer goroutine and must not hold the global state mutex.
2462+ i.mu.Unlock()
23542463 if rightBarActive {
2355- rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2464+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
23562465 if modalBackdrop {
23572466 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
23582467 } else {
@@ -2362,11 +2471,13 @@ func (i *Interactive) redraw() {
23622471 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
23632472 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
23642473 }
2474+ i.mu.Lock()
23652475 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
23662476 alert := *i.pendingAlert
23672477 i.pendingAlert = nil
23682478 i.emitAlertLocked(alert)
23692479 }
2480+ i.mu.Unlock()
23702481 }
23712482
23722483 func hasImageEscape(line string) bool {
@@ -2612,9 +2723,7 @@ func (i *Interactive) clearFileSuggestQuery() {
26122723 func (i *Interactive) setToolExpansion(expanded bool) {
26132724 i.mu.Lock()
26142725 i.view.ExpandAll = expanded
2615- if i.rend != nil {
2616- i.rend.Clear()
2617- }
2726+ i.requestRendererClear()
26182727 i.mu.Unlock()
26192728 i.invalidate()
26202729 }
@@ -2629,9 +2738,7 @@ func (i *Interactive) toggleToolExpansion() {
26292738 func (i *Interactive) toggleBtwToolExpansion() {
26302739 i.btwDialog.ToggleToolExpansion()
26312740 i.mu.Lock()
2632- if i.rend != nil {
2633- i.rend.Clear()
2634- }
2741+ i.requestRendererClear()
26352742 i.mu.Unlock()
26362743 i.invalidate()
26372744 }
@@ -3184,11 +3291,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31843291 i.mu.Lock()
31853292 i.rightBarHidden = !i.rightBarHidden
31863293 i.mu.Unlock()
3187- i.rend.Invalidate()
3294+ i.requestRendererInvalidate()
31883295 i.invalidate()
31893296 return false
31903297 case tui.KeyCtrlL:
3191- i.rend.Clear()
3298+ i.requestRendererClear()
31923299 i.invalidate()
31933300 return false
31943301 case tui.KeyPasteClipboard:
@@ -3918,6 +4025,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
39184025 }
39194026 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
39204027 i.view.StartupContextPaths = nil
4028+ i.view.InvalidateRenderCache()
39214029 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
39224030 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
39234031 }
@@ -4987,9 +5095,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
49875095 // change (image rendering, status copy, future toggles) lands
49885096 // immediately instead of waiting for the next diff frame.
49895097 defer func() {
4990- if i.rend != nil {
4991- i.rend.Clear()
4992- }
5098+ i.requestRendererClear()
49935099 i.invalidate()
49945100 }()
49955101 switch key {
@@ -5331,6 +5437,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
53315437 i.view.StartupContextPaths = nil
53325438 i.view.StartupExtensionNames = nil
53335439 i.view.StartupSkillNames = nil
5440+ i.view.InvalidateRenderCache()
53345441 if value {
53355442 i.view.StartupAgentName = i.cfg.StartupAgentName
53365443 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
@@ -5345,9 +5452,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
53455452
53465453 func (i *Interactive) applyTUIInputStyleSetting(style string) {
53475454 defer func() {
5348- if i.rend != nil {
5349- i.rend.Clear()
5350- }
5455+ i.requestRendererClear()
53515456 i.invalidate()
53525457 }()
53535458 style = tui.NormalizeInputStyle(style)
@@ -5369,9 +5474,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) {
53695474
53705475 func (i *Interactive) applyTUIStatusPositionSetting(position string) {
53715476 defer func() {
5372- if i.rend != nil {
5373- i.rend.Clear()
5374- }
5477+ i.requestRendererClear()
53755478 i.invalidate()
53765479 }()
53775480 position = tui.NormalizeStatusPosition(position)
@@ -5396,9 +5499,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) {
53965499
53975500 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
53985501 defer func() {
5399- if i.rend != nil {
5400- i.rend.Clear()
5401- }
5502+ i.requestRendererClear()
54025503 i.invalidate()
54035504 }()
54045505 position = tui.NormalizeWorkingPosition(position)
@@ -5423,9 +5524,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
54235524
54245525 func (i *Interactive) applyTUISubagentPositionSetting(position string) {
54255526 defer func() {
5426- if i.rend != nil {
5427- i.rend.Clear()
5428- }
5527+ i.requestRendererClear()
54295528 i.invalidate()
54305529 }()
54315530 position = tui.NormalizeSubagentPosition(position)
@@ -5496,24 +5595,22 @@ func (i *Interactive) applyThemeNow(name string) {
54965595 i.statusErr = ""
54975596 i.mu.Unlock()
54985597 }
5598+ i.mu.Lock()
54995599 i.cfg.Theme = th
55005600 i.view.Theme = th
55015601 i.view.InvalidateRenderCache()
5602+ i.mu.Unlock()
55025603 i.ed.Prompt = th.AccentBar(th.Accent)
55035604 i.applyInputCursorColor()
55045605 i.spin.Configure(th)
5505- if i.rend != nil {
5506- i.rend.SetTheme(th)
5507- i.rend.Clear()
5508- }
5606+ i.requestRendererTheme(th)
5607+ i.requestRendererClear()
55095608 i.invalidate()
55105609 }
55115610
55125611 func (i *Interactive) applyReasoningSetting(level string) {
55135612 defer func() {
5514- if i.rend != nil {
5515- i.rend.Clear()
5516- }
5613+ i.requestRendererClear()
55175614 i.invalidate()
55185615 }()
55195616 level = provider.NormalizeReasoning(level)
@@ -7566,8 +7663,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
75667663 lastStop = e.Stop
75677664 lastTurnErr = e.Err
75687665 }
7569- i.handleEvent(ev)
7570- i.invalidate()
7666+ i.handleEventForPresentation(ev)
75717667 }
75727668
75737669 go func() {
@@ -8002,6 +8098,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
80028098 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
80038099 }
80048100
8101+func eventAffectsPresentation(ev core.AgentEvent) bool {
8102+ switch ev.(type) {
8103+ case core.EvToolProgress:
8104+ return false
8105+ default:
8106+ return true
8107+ }
8108+}
8109+
8110+func bumpToolRevision(tc *tui.ToolCallView) {
8111+ if tc.Revision == ^uint64(0) {
8112+ tc.Revision = 1
8113+ return
8114+ }
8115+ tc.Revision++
8116+}
8117+
8118+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8119+ i.handleEvent(ev)
8120+ // Progress payloads are still delivered to the core sink and applied
8121+ // in order, but they have no interactive-visible representation.
8122+ if eventAffectsPresentation(ev) {
8123+ i.invalidate()
8124+ }
8125+}
8126+
80058127 func (i *Interactive) handleEvent(ev core.AgentEvent) {
80068128 i.mu.Lock()
80078129 defer i.mu.Unlock()
@@ -8058,6 +8180,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80588180 i.toolCalls[e.ID] = &tui.ToolCallView{
80598181 ID: e.ID,
80608182 Name: e.Name,
8183+ Revision: 1,
80618184 Streaming: true,
80628185 }
80638186 i.toolOrder = append(i.toolOrder, e.ID)
@@ -8066,6 +8189,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80668189 case core.EvToolUseArgs:
80678190 if tc, ok := i.toolCalls[e.ID]; ok {
80688191 tc.RawJSONBuf += e.Delta
8192+ bumpToolRevision(tc)
80698193 // Refresh the live path as soon as it parses; used in
80708194 // the header (write /Users/example/Desktop/demo.ts)
80718195 // while the content is still streaming.
@@ -8078,6 +8202,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80788202 case core.EvToolUseEnd:
80798203 if tc, ok := i.toolCalls[e.ID]; ok {
80808204 tc.Streaming = false
8205+ bumpToolRevision(tc)
80818206 }
80828207 case core.EvToolCall:
80838208 // If we already pre-created the view during streaming, just
@@ -8085,6 +8210,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80858210 // (non-streaming providers or legacy paths).
80868211 if tc, ok := i.toolCalls[e.ID]; ok {
80878212 tc.Args = tui.ShortArgs(e.Name, e.Args)
8213+ bumpToolRevision(tc)
80888214 if tc.RawJSONBuf == "" {
80898215 tc.RawJSONBuf = string(e.Args)
80908216 }
@@ -8093,6 +8219,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80938219 i.toolCalls[e.ID] = &tui.ToolCallView{
80948220 ID: e.ID,
80958221 Name: e.Name,
8222+ Revision: 1,
80968223 Args: tui.ShortArgs(e.Name, e.Args),
80978224 RawJSONBuf: string(e.Args),
80988225 }
@@ -8114,6 +8241,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
81148241 }
81158242 }
81168243 tc.Result = text.String()
8244+ bumpToolRevision(tc)
81178245 }
81188246 if i.cfg.OnToolResult != nil {
81198247 i.cfg.OnToolResult(e.ID, e.Result)
@@ -9560,9 +9688,7 @@ func (i *Interactive) resetTranscriptRenderLocked() {
95609688 i.prevChatCols = 0
95619689 i.prevChatRows = 0
95629690 i.prevScrollOffset = 0
9563- if i.rend != nil {
9564- i.rend.Invalidate()
9565- }
9691+ i.requestRendererInvalidate()
95669692 }
95679693
95689694 // resetStreamingStateLocked clears every piece of streaming state
@@ -430,22 +430,24 @@ type modelRefreshResult struct{ err error }
430 430
431 // Interactive is the TUI chat loop.431 // Interactive is the TUI chat loop.
432 type chatCacheKey struct {432 type chatCacheKey struct {
433- cols int433+ cols int
434- agentRev uint64434+ agentRev uint64
435- statusOK string435+ statusOK string
436- statusErr string436+ statusErr string
437- help string437+ help string
438- extNotes string438+ extNotes string
439- extStatuses string439+ extStatuses string
440- extWidgets string440+ extWidgets string
441- reloadErrors string441+ reloadErrors string
442- updateAvailable bool442+ updateAvailable bool
443- updateCurrent string443+ updateCurrent string
444- updateLatest string444+ updateLatest string
445- updateURL string445+ updateURL string
446- welcomeShowVer bool446+ welcomeShowVer bool
447- expandAll bool447+ expandAll bool
448- tailLimit int448+ tailLimit int
449+ renderedMessageCount int
450+ viewCacheRev uint64
449 }451 }
450 452
451 // QuickModelShortcut is one configured quick model switch slot.453 // QuickModelShortcut is one configured quick model switch slot.
@@ -666,6 +668,9 @@ type Interactive struct {
666 subagentActivityActive bool668 subagentActivityActive bool
667 pendingIdleWork []func()669 pendingIdleWork []func()
668 dirty chan struct{}670 dirty chan struct{}
671+ renderScheduler atomic.Pointer[latestFrameScheduler]
672+ renderRevision atomic.Uint64
673+ renderOutsideLock bool
669 modelRefresh chan modelRefreshResult674 modelRefresh chan modelRefreshResult
670 modelRefreshing bool675 modelRefreshing bool
671 startupPreDone chan startupPreResult676 startupPreDone chan startupPreResult
@@ -695,6 +700,13 @@ type Interactive struct {
695 chatCacheKey chatCacheKey700 chatCacheKey chatCacheKey
696 chatCacheValid bool701 chatCacheValid bool
697 702
703+ // stableChatCache contains only transcript/startup rows. Live streaming,
704+ // tool overlays, and errors are appended separately so spinner/progress
705+ // frames can reuse finalized chat while a turn is busy.
706+ stableChatCache []string
707+ stableChatCacheKey chatCacheKey
708+ stableChatCacheValid bool
709+
698 // Messages typed while a turn is in flight. Each is delivered as710 // Messages typed while a turn is in flight. Each is delivered as
699 // its own follow-up turn once the current one finishes. Rendered711 // its own follow-up turn once the current one finishes. Rendered
700 // above the status bar as "sliding in: ..." chips.712 // above the status bar as "sliding in: ..." chips.
@@ -1043,17 +1055,29 @@ func (i *Interactive) Run(ctx context.Context) error {
1043 1055
1044 cols, rows := term.Size()1056 cols, rows := term.Size()
1045 i.rend.Resize(cols, rows)1057 i.rend.Resize(cols, rows)
1046- term.OnResize(func() {1058+ renderScheduler := newLatestFrameScheduler()
1059+ i.renderScheduler.Store(renderScheduler)
1060+ go renderScheduler.run(func(req renderRequest) {
1047 c, r := term.Size()1061 c, r := term.Size()
1048 i.rend.Resize(c, r)1062 i.rend.Resize(c, r)
1049- // Force an immediate redraw on resize. The throttled invalidate1063+ if req.theme != nil {
1050- // path is fine for animation, but a window resize is a discrete1064+ i.rend.SetTheme(*req.theme)
1051- // user action where any visible delay (or stale frame) reads as1065+ }
1052- // brokenness. redraw() is mutex-safe; the worst that happens is1066+ if req.clear {
1053- // a duplicate paint if the throttler is mid-flight, which is1067+ i.rend.Clear()
1054- // invisible.1068+ }
1069+ if req.invalidate {
1070+ i.rend.Invalidate()
1071+ }
1055 i.redraw()1072 i.redraw()
1056 })1073 })
1074+ defer renderScheduler.stop()
1075+ term.OnResize(func() {
1076+ // Resize and redraw share the renderer owner. The owner reads the
1077+ // latest terminal size just before its next frame.
1078+ i.renderRevision.Add(1)
1079+ renderScheduler.request(false, false)
1080+ })
1057 1081
1058 if i.cfg.StartupPre != "" {1082 if i.cfg.StartupPre != "" {
1059 i.deferredInitialInput = i.cfg.InitialInput1083 i.deferredInitialInput = i.cfg.InitialInput
@@ -1140,7 +1164,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1140 if pendingRedraw {1164 if pendingRedraw {
1141 pendingRedraw = false1165 pendingRedraw = false
1142 lastRedraw = time.Now()1166 lastRedraw = time.Now()
1143- i.redraw()1167+ renderScheduler.request(false, false)
1144 }1168 }
1145 }1169 }
1146 1170
@@ -1158,7 +1182,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1158 }1182 }
1159 pendingRedraw = false1183 pendingRedraw = false
1160 lastRedraw = time.Now()1184 lastRedraw = time.Now()
1161- i.redraw()1185+ renderScheduler.request(false, false)
1162 return1186 return
1163 }1187 }
1164 if pendingRedraw {1188 if pendingRedraw {
@@ -1287,7 +1311,42 @@ func (i *Interactive) Run(ctx context.Context) error {
1287 }1311 }
1288 }1312 }
1289 1313
1314+func (i *Interactive) requestRendererClear() {
1315+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316+ scheduler.request(true, false)
1317+ return
1318+ }
1319+ if i.rend != nil {
1320+ i.rend.Clear()
1321+ }
1322+}
1323+
1324+func (i *Interactive) requestRendererInvalidate() {
1325+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1326+ scheduler.request(false, true)
1327+ return
1328+ }
1329+ if i.rend != nil {
1330+ i.rend.Invalidate()
1331+ }
1332+}
1333+
1334+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1335+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1336+ scheduler.requestTheme(theme)
1337+ return
1338+ }
1339+ if i.rend != nil {
1340+ i.rend.SetTheme(theme)
1341+ }
1342+}
1343+
1290 func (i *Interactive) invalidate() {1344 func (i *Interactive) invalidate() {
1345+ i.renderRevision.Add(1)
1346+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1347+ scheduler.request(false, false)
1348+ return
1349+ }
1291 select {1350 select {
1292 case i.dirty <- struct{}{}:1351 case i.dirty <- struct{}{}:
1293 default:1352 default:
@@ -1295,51 +1354,50 @@ func (i *Interactive) invalidate() {
1295 }1354 }
1296 1355
1297 func (i *Interactive) cachedChatLocked(cols int) []string {1356 func (i *Interactive) cachedChatLocked(cols int) []string {
1298- key, cacheable := i.chatCacheKeyLocked(cols)1357+ // A busy frame contains mutable streaming/tool state. Keep the stable
1299- if cacheable && i.chatCacheValid && i.chatCacheKey == key {1358+ // transcript cache, but never return a full-frame cache entry that would
1359+ // hide a newly arrived delta or completed result.
1360+ if i.busy || i.streamOn || i.streamFlushPending {
1361+ return i.buildChatLocked(cols)
1362+ }
1363+ key := i.chatCacheKeyLocked(cols)
1364+ if i.chatCacheValid && i.chatCacheKey == key {
1300 return append([]string(nil), i.chatCache...)1365 return append([]string(nil), i.chatCache...)
1301 }1366 }
1302 chat := i.buildChatLocked(cols)1367 chat := i.buildChatLocked(cols)
1303- if cacheable {1368+ key = i.chatCacheKeyLocked(cols)
1304- i.chatCache = append(i.chatCache[:0], chat...)1369+ i.chatCache = append(i.chatCache[:0], chat...)
1305- i.chatCacheKey = key1370+ i.chatCacheKey = key
1306- i.chatCacheValid = true1371+ i.chatCacheValid = true
1307- } else {
1308- i.chatCacheValid = false
1309- }
1310 return chat1372 return chat
1311 }1373 }
1312 1374
1313-func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {1375+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
1314- // Live turns mutate streaming/tool-call state at high frequency;
1315- // keep those on the old rebuild path. The cache targets the common
1316- // idle case where only the editor contents changed between redraws.
1317- if i.busy || i.streamOn || i.streamFlushPending {
1318- return chatCacheKey{}, false
1319- }
1320 var rev uint641376 var rev uint64
1321 if i.agent != nil {1377 if i.agent != nil {
1322 rev = i.agent.Revision()1378 rev = i.agent.Revision()
1323 }1379 }
1324 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration1380 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1325 return chatCacheKey{1381 return chatCacheKey{
1326- cols: cols,1382+ cols: cols,
1327- agentRev: rev,1383+ agentRev: rev,
1328- statusOK: i.statusOK,1384+ statusOK: i.statusOK,
1329- statusErr: i.statusErr,1385+ statusErr: i.statusErr,
1330- help: strings.Join(i.helpBlock, "\n"),1386+ help: strings.Join(i.helpBlock, "\n"),
1331- extNotes: strings.Join(i.extNotes, "\n"),1387+ extNotes: strings.Join(i.extNotes, "\n"),
1332- extStatuses: i.extensionStatusesKeyLocked(),1388+ extStatuses: i.extensionStatusesKeyLocked(),
1333- extWidgets: i.extensionWidgetsKeyLocked(),1389+ extWidgets: i.extensionWidgetsKeyLocked(),
1334- reloadErrors: strings.Join(i.reloadErrors, "\n"),1390+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1335- updateAvailable: i.updateInfo.Available,1391+ updateAvailable: i.updateInfo.Available,
1336- updateCurrent: i.updateInfo.Current,1392+ updateCurrent: i.updateInfo.Current,
1337- updateLatest: i.updateInfo.Latest,1393+ updateLatest: i.updateInfo.Latest,
1338- updateURL: i.updateInfo.URL,1394+ updateURL: i.updateInfo.URL,
1339- welcomeShowVer: showVer,1395+ welcomeShowVer: showVer,
1340- expandAll: i.view.ExpandAll,1396+ expandAll: i.view.ExpandAll,
1341- tailLimit: i.view.TailLimit,1397+ tailLimit: i.view.TailLimit,
1342- }, true1398+ renderedMessageCount: len(i.view.Messages),
1399+ viewCacheRev: i.view.RenderCacheRevision,
1400+ }
1343 }1401 }
1344 1402
1345 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {1403 func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
@@ -1515,11 +1573,48 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1515 return out1573 return out
1516 }1574 }
1517 1575
1576+func (i *Interactive) stableChatRowsLocked(cols int) []string {
1577+ key := i.chatCacheKeyLocked(cols)
1578+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
1579+ return append([]string(nil), i.stableChatCache...)
1580+ }
1581+
1582+ renderView := i.view.CloneForRender()
1583+ renderView.Streaming = ""
1584+ renderView.StreamingActive = false
1585+ renderView.ToolCalls = nil
1586+ renderView.Err = ""
1587+ if !i.renderOutsideLock {
1588+ rows := renderView.Build(cols)
1589+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1590+ i.stableChatCacheKey = key
1591+ i.stableChatCacheValid = true
1592+ return append([]string(nil), rows...)
1593+ }
1594+ // Markdown, syntax highlighting, and wrapping are the expensive part of
1595+ // a frame. Build the immutable snapshot without holding the interactive
1596+ // mutex so key processing can continue while a cold transcript renders.
1597+ i.mu.Unlock()
1598+ rows := renderView.Build(cols)
1599+ i.mu.Lock()
1600+ if i.view.MessagesRevision == renderView.MessagesRevision &&
1601+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1602+ i.view.AdoptRenderCacheFrom(renderView)
1603+ }
1604+
1605+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1606+ i.stableChatCacheKey = key
1607+ i.stableChatCacheValid = true
1608+ return append([]string(nil), rows...)
1609+}
1610+
1518 func (i *Interactive) buildChatLocked(cols int) []string {1611 func (i *Interactive) buildChatLocked(cols int) []string {
1519 if i.agent != nil {1612 if i.agent != nil {
1520 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())1613 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1614+ i.view.MessagesRevision = i.agent.Revision()
1521 } else {1615 } else {
1522 i.view.Messages = nil1616 i.view.Messages = nil
1617+ i.view.MessagesRevision = 0
1523 }1618 }
1524 // Pacer flush: while the streaming pacer is still draining the1619 // Pacer flush: while the streaming pacer is still draining the
1525 // buffer (i.e. EvAssistantMessage already fired but more runes1620 // buffer (i.e. EvAssistantMessage already fired but more runes
@@ -1583,7 +1678,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1583 // rows update in place at the end of the buffer, instead of the1678 // rows update in place at the end of the buffer, instead of the
1584 // whole bottom band shrinking and shifting chat lines around.1679 // whole bottom band shrinking and shifting chat lines around.
1585 i.liveBlock = nil1680 i.liveBlock = nil
1586- chat := i.view.Build(cols)1681+ stable := i.stableChatRowsLocked(cols)
1682+ chat := append([]string(nil), stable...)
1683+ chat = append(chat, i.view.BuildLive(cols)...)
1587 1684
1588 // Welcome banner: shown at the top of the chat area when there is1685 // Welcome banner: shown at the top of the chat area when there is
1589 // no transcript yet. Disappears after the first message is sent.1686 // no transcript yet. Disappears after the first message is sent.
@@ -1708,7 +1805,7 @@ func (i *Interactive) scrollBy(delta int) {
1708 // wrapped-character fragments behind during scroll-driven1805 // wrapped-character fragments behind during scroll-driven
1709 // viewport changes. Force a full repaint on scroll, but1806 // viewport changes. Force a full repaint on scroll, but
1710 // avoid a whole-screen clear because that visibly flickers.1807 // avoid a whole-screen clear because that visibly flickers.
1711- i.rend.Invalidate()1808+ i.requestRendererInvalidate()
1712 }1809 }
1713 i.mu.Unlock()1810 i.mu.Unlock()
1714 i.invalidate()1811 i.invalidate()
@@ -1749,16 +1846,14 @@ func (i *Interactive) scrollToBottom() {
1749 // after resume. See commit 43da5e5 for the same fix on new turns.1846 // after resume. See commit 43da5e5 for the same fix on new turns.
1750 i.prevChatLen = 01847 i.prevChatLen = 0
1751 i.prevChatCols = 01848 i.prevChatCols = 0
1752- if i.rend != nil {1849+ i.requestRendererInvalidate()
1753- i.rend.Invalidate()
1754- }
1755 i.mu.Unlock()1850 i.mu.Unlock()
1756 i.invalidate()1851 i.invalidate()
1757 }1852 }
1758 1853
1759 func (i *Interactive) redraw() {1854 func (i *Interactive) redraw() {
1760 i.mu.Lock()1855 i.mu.Lock()
1761- defer i.mu.Unlock()1856+ renderRevision := i.renderRevision.Load()
1762 1857
1763 cols, rows := i.cfg.Terminal.Size()1858 cols, rows := i.cfg.Terminal.Size()
1764 mainCols := cols1859 mainCols := cols
@@ -1773,7 +1868,13 @@ func (i *Interactive) redraw() {
1773 }1868 }
1774 }1869 }
1775 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive1870 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1871+ i.renderOutsideLock = true
1776 chat := i.cachedChatLocked(mainCols)1872 chat := i.cachedChatLocked(mainCols)
1873+ i.renderOutsideLock = false
1874+ if i.renderRevision.Load() != renderRevision {
1875+ i.mu.Unlock()
1876+ return
1877+ }
1777 1878
1778 // Dialogs (login or model picker) render between chat and the editor.1879 // Dialogs (login or model picker) render between chat and the editor.
1779 var dialog []string1880 var dialog []string
@@ -2351,8 +2452,16 @@ func (i *Interactive) redraw() {
2351 cursorCol = 02452 cursorCol = 0
2352 }2453 }
2353 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)2454 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2455+ if i.renderRevision.Load() != renderRevision {
2456+ i.mu.Unlock()
2457+ return
2458+ }
2459+ theme := i.cfg.Theme
2460+ // State assembly above is synchronized; terminal output is owned by
2461+ // this renderer goroutine and must not hold the global state mutex.
2462+ i.mu.Unlock()
2354 if rightBarActive {2463 if rightBarActive {
2355- rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)2464+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
2356 if modalBackdrop {2465 if modalBackdrop {
2357 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)2466 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2358 } else {2467 } else {
@@ -2362,11 +2471,13 @@ func (i *Interactive) redraw() {
2362 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.2471 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2363 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)2472 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2364 }2473 }
2474+ i.mu.Lock()
2365 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {2475 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2366 alert := *i.pendingAlert2476 alert := *i.pendingAlert
2367 i.pendingAlert = nil2477 i.pendingAlert = nil
2368 i.emitAlertLocked(alert)2478 i.emitAlertLocked(alert)
2369 }2479 }
2480+ i.mu.Unlock()
2370 }2481 }
2371 2482
2372 func hasImageEscape(line string) bool {2483 func hasImageEscape(line string) bool {
@@ -2612,9 +2723,7 @@ func (i *Interactive) clearFileSuggestQuery() {
2612 func (i *Interactive) setToolExpansion(expanded bool) {2723 func (i *Interactive) setToolExpansion(expanded bool) {
2613 i.mu.Lock()2724 i.mu.Lock()
2614 i.view.ExpandAll = expanded2725 i.view.ExpandAll = expanded
2615- if i.rend != nil {2726+ i.requestRendererClear()
2616- i.rend.Clear()
2617- }
2618 i.mu.Unlock()2727 i.mu.Unlock()
2619 i.invalidate()2728 i.invalidate()
2620 }2729 }
@@ -2629,9 +2738,7 @@ func (i *Interactive) toggleToolExpansion() {
2629 func (i *Interactive) toggleBtwToolExpansion() {2738 func (i *Interactive) toggleBtwToolExpansion() {
2630 i.btwDialog.ToggleToolExpansion()2739 i.btwDialog.ToggleToolExpansion()
2631 i.mu.Lock()2740 i.mu.Lock()
2632- if i.rend != nil {2741+ i.requestRendererClear()
2633- i.rend.Clear()
2634- }
2635 i.mu.Unlock()2742 i.mu.Unlock()
2636 i.invalidate()2743 i.invalidate()
2637 }2744 }
@@ -3184,11 +3291,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3184 i.mu.Lock()3291 i.mu.Lock()
3185 i.rightBarHidden = !i.rightBarHidden3292 i.rightBarHidden = !i.rightBarHidden
3186 i.mu.Unlock()3293 i.mu.Unlock()
3187- i.rend.Invalidate()3294+ i.requestRendererInvalidate()
3188 i.invalidate()3295 i.invalidate()
3189 return false3296 return false
3190 case tui.KeyCtrlL:3297 case tui.KeyCtrlL:
3191- i.rend.Clear()3298+ i.requestRendererClear()
3192 i.invalidate()3299 i.invalidate()
3193 return false3300 return false
3194 case tui.KeyPasteClipboard:3301 case tui.KeyPasteClipboard:
@@ -3918,6 +4025,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3918 }4025 }
3919 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)4026 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3920 i.view.StartupContextPaths = nil4027 i.view.StartupContextPaths = nil
4028+ i.view.InvalidateRenderCache()
3921 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {4029 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3922 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)4030 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3923 }4031 }
@@ -4987,9 +5095,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4987 // change (image rendering, status copy, future toggles) lands5095 // change (image rendering, status copy, future toggles) lands
4988 // immediately instead of waiting for the next diff frame.5096 // immediately instead of waiting for the next diff frame.
4989 defer func() {5097 defer func() {
4990- if i.rend != nil {5098+ i.requestRendererClear()
4991- i.rend.Clear()
4992- }
4993 i.invalidate()5099 i.invalidate()
4994 }()5100 }()
4995 switch key {5101 switch key {
@@ -5331,6 +5437,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5331 i.view.StartupContextPaths = nil5437 i.view.StartupContextPaths = nil
5332 i.view.StartupExtensionNames = nil5438 i.view.StartupExtensionNames = nil
5333 i.view.StartupSkillNames = nil5439 i.view.StartupSkillNames = nil
5440+ i.view.InvalidateRenderCache()
5334 if value {5441 if value {
5335 i.view.StartupAgentName = i.cfg.StartupAgentName5442 i.view.StartupAgentName = i.cfg.StartupAgentName
5336 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)5443 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
@@ -5345,9 +5452,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5345 5452
5346 func (i *Interactive) applyTUIInputStyleSetting(style string) {5453 func (i *Interactive) applyTUIInputStyleSetting(style string) {
5347 defer func() {5454 defer func() {
5348- if i.rend != nil {5455+ i.requestRendererClear()
5349- i.rend.Clear()
5350- }
5351 i.invalidate()5456 i.invalidate()
5352 }()5457 }()
5353 style = tui.NormalizeInputStyle(style)5458 style = tui.NormalizeInputStyle(style)
@@ -5369,9 +5474,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) {
5369 5474
5370 func (i *Interactive) applyTUIStatusPositionSetting(position string) {5475 func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5371 defer func() {5476 defer func() {
5372- if i.rend != nil {5477+ i.requestRendererClear()
5373- i.rend.Clear()
5374- }
5375 i.invalidate()5478 i.invalidate()
5376 }()5479 }()
5377 position = tui.NormalizeStatusPosition(position)5480 position = tui.NormalizeStatusPosition(position)
@@ -5396,9 +5499,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5396 5499
5397 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {5500 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5398 defer func() {5501 defer func() {
5399- if i.rend != nil {5502+ i.requestRendererClear()
5400- i.rend.Clear()
5401- }
5402 i.invalidate()5503 i.invalidate()
5403 }()5504 }()
5404 position = tui.NormalizeWorkingPosition(position)5505 position = tui.NormalizeWorkingPosition(position)
@@ -5423,9 +5524,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5423 5524
5424 func (i *Interactive) applyTUISubagentPositionSetting(position string) {5525 func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5425 defer func() {5526 defer func() {
5426- if i.rend != nil {5527+ i.requestRendererClear()
5427- i.rend.Clear()
5428- }
5429 i.invalidate()5528 i.invalidate()
5430 }()5529 }()
5431 position = tui.NormalizeSubagentPosition(position)5530 position = tui.NormalizeSubagentPosition(position)
@@ -5496,24 +5595,22 @@ func (i *Interactive) applyThemeNow(name string) {
5496 i.statusErr = ""5595 i.statusErr = ""
5497 i.mu.Unlock()5596 i.mu.Unlock()
5498 }5597 }
5598+ i.mu.Lock()
5499 i.cfg.Theme = th5599 i.cfg.Theme = th
5500 i.view.Theme = th5600 i.view.Theme = th
5501 i.view.InvalidateRenderCache()5601 i.view.InvalidateRenderCache()
5602+ i.mu.Unlock()
5502 i.ed.Prompt = th.AccentBar(th.Accent)5603 i.ed.Prompt = th.AccentBar(th.Accent)
5503 i.applyInputCursorColor()5604 i.applyInputCursorColor()
5504 i.spin.Configure(th)5605 i.spin.Configure(th)
5505- if i.rend != nil {5606+ i.requestRendererTheme(th)
5506- i.rend.SetTheme(th)5607+ i.requestRendererClear()
5507- i.rend.Clear()
5508- }
5509 i.invalidate()5608 i.invalidate()
5510 }5609 }
5511 5610
5512 func (i *Interactive) applyReasoningSetting(level string) {5611 func (i *Interactive) applyReasoningSetting(level string) {
5513 defer func() {5612 defer func() {
5514- if i.rend != nil {5613+ i.requestRendererClear()
5515- i.rend.Clear()
5516- }
5517 i.invalidate()5614 i.invalidate()
5518 }()5615 }()
5519 level = provider.NormalizeReasoning(level)5616 level = provider.NormalizeReasoning(level)
@@ -7566,8 +7663,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7566 lastStop = e.Stop7663 lastStop = e.Stop
7567 lastTurnErr = e.Err7664 lastTurnErr = e.Err
7568 }7665 }
7569- i.handleEvent(ev)7666+ i.handleEventForPresentation(ev)
7570- i.invalidate()
7571 }7667 }
7572 7668
7573 go func() {7669 go func() {
@@ -8002,6 +8098,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
8002 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)8098 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
8003 }8099 }
8004 8100
8101+func eventAffectsPresentation(ev core.AgentEvent) bool {
8102+ switch ev.(type) {
8103+ case core.EvToolProgress:
8104+ return false
8105+ default:
8106+ return true
8107+ }
8108+}
8109+
8110+func bumpToolRevision(tc *tui.ToolCallView) {
8111+ if tc.Revision == ^uint64(0) {
8112+ tc.Revision = 1
8113+ return
8114+ }
8115+ tc.Revision++
8116+}
8117+
8118+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8119+ i.handleEvent(ev)
8120+ // Progress payloads are still delivered to the core sink and applied
8121+ // in order, but they have no interactive-visible representation.
8122+ if eventAffectsPresentation(ev) {
8123+ i.invalidate()
8124+ }
8125+}
8126+
8005 func (i *Interactive) handleEvent(ev core.AgentEvent) {8127 func (i *Interactive) handleEvent(ev core.AgentEvent) {
8006 i.mu.Lock()8128 i.mu.Lock()
8007 defer i.mu.Unlock()8129 defer i.mu.Unlock()
@@ -8058,6 +8180,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8058 i.toolCalls[e.ID] = &tui.ToolCallView{8180 i.toolCalls[e.ID] = &tui.ToolCallView{
8059 ID: e.ID,8181 ID: e.ID,
8060 Name: e.Name,8182 Name: e.Name,
8183+ Revision: 1,
8061 Streaming: true,8184 Streaming: true,
8062 }8185 }
8063 i.toolOrder = append(i.toolOrder, e.ID)8186 i.toolOrder = append(i.toolOrder, e.ID)
@@ -8066,6 +8189,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8066 case core.EvToolUseArgs:8189 case core.EvToolUseArgs:
8067 if tc, ok := i.toolCalls[e.ID]; ok {8190 if tc, ok := i.toolCalls[e.ID]; ok {
8068 tc.RawJSONBuf += e.Delta8191 tc.RawJSONBuf += e.Delta
8192+ bumpToolRevision(tc)
8069 // Refresh the live path as soon as it parses; used in8193 // Refresh the live path as soon as it parses; used in
8070 // the header (write /Users/example/Desktop/demo.ts)8194 // the header (write /Users/example/Desktop/demo.ts)
8071 // while the content is still streaming.8195 // while the content is still streaming.
@@ -8078,6 +8202,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8078 case core.EvToolUseEnd:8202 case core.EvToolUseEnd:
8079 if tc, ok := i.toolCalls[e.ID]; ok {8203 if tc, ok := i.toolCalls[e.ID]; ok {
8080 tc.Streaming = false8204 tc.Streaming = false
8205+ bumpToolRevision(tc)
8081 }8206 }
8082 case core.EvToolCall:8207 case core.EvToolCall:
8083 // If we already pre-created the view during streaming, just8208 // If we already pre-created the view during streaming, just
@@ -8085,6 +8210,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8085 // (non-streaming providers or legacy paths).8210 // (non-streaming providers or legacy paths).
8086 if tc, ok := i.toolCalls[e.ID]; ok {8211 if tc, ok := i.toolCalls[e.ID]; ok {
8087 tc.Args = tui.ShortArgs(e.Name, e.Args)8212 tc.Args = tui.ShortArgs(e.Name, e.Args)
8213+ bumpToolRevision(tc)
8088 if tc.RawJSONBuf == "" {8214 if tc.RawJSONBuf == "" {
8089 tc.RawJSONBuf = string(e.Args)8215 tc.RawJSONBuf = string(e.Args)
8090 }8216 }
@@ -8093,6 +8219,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8093 i.toolCalls[e.ID] = &tui.ToolCallView{8219 i.toolCalls[e.ID] = &tui.ToolCallView{
8094 ID: e.ID,8220 ID: e.ID,
8095 Name: e.Name,8221 Name: e.Name,
8222+ Revision: 1,
8096 Args: tui.ShortArgs(e.Name, e.Args),8223 Args: tui.ShortArgs(e.Name, e.Args),
8097 RawJSONBuf: string(e.Args),8224 RawJSONBuf: string(e.Args),
8098 }8225 }
@@ -8114,6 +8241,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8114 }8241 }
8115 }8242 }
8116 tc.Result = text.String()8243 tc.Result = text.String()
8244+ bumpToolRevision(tc)
8117 }8245 }
8118 if i.cfg.OnToolResult != nil {8246 if i.cfg.OnToolResult != nil {
8119 i.cfg.OnToolResult(e.ID, e.Result)8247 i.cfg.OnToolResult(e.ID, e.Result)
@@ -9560,9 +9688,7 @@ func (i *Interactive) resetTranscriptRenderLocked() {
9560 i.prevChatCols = 09688 i.prevChatCols = 0
9561 i.prevChatRows = 09689 i.prevChatRows = 0
9562 i.prevScrollOffset = 09690 i.prevScrollOffset = 0
9563- if i.rend != nil {9691+ i.requestRendererInvalidate()
9564- i.rend.Invalidate()
9565- }
9566 }9692 }
9567 9693
9568 // resetStreamingStateLocked clears every piece of streaming state9694 // resetStreamingStateLocked clears every piece of streaming state
A packages/agent/modes/interactive_render.go +101 -0
@@ -0,0 +1,101 @@
1+package modes
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/zut/packages/tui"
7+)
8+
9+// renderRequest is deliberately a signal, not a frame. The render owner
10+// reads the newest synchronized interactive state when it wakes, so a burst
11+// replaces the one pending request instead of queueing stale snapshots.
12+type renderRequest struct {
13+ clear bool
14+ invalidate bool
15+ theme *tui.Theme
16+}
17+
18+type latestFrameScheduler struct {
19+ wake chan struct{}
20+ done chan struct{}
21+
22+ mu sync.Mutex
23+ clear bool
24+ invalidate bool
25+ theme *tui.Theme
26+ stopped bool
27+}
28+
29+func newLatestFrameScheduler() *latestFrameScheduler {
30+ return &latestFrameScheduler{
31+ wake: make(chan struct{}, 1),
32+ done: make(chan struct{}),
33+ }
34+}
35+
36+func (s *latestFrameScheduler) request(clear, invalidate bool) bool {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ if s.stopped {
40+ return false
41+ }
42+ s.clear = s.clear || clear
43+ s.invalidate = s.invalidate || invalidate
44+ select {
45+ case s.wake <- struct{}{}:
46+ default:
47+ }
48+ return true
49+}
50+
51+func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
52+ s.mu.Lock()
53+ defer s.mu.Unlock()
54+ if s.stopped {
55+ return false
56+ }
57+ s.clear = true
58+ s.theme = &theme
59+ select {
60+ case s.wake <- struct{}{}:
61+ default:
62+ }
63+ return true
64+}
65+
66+func (s *latestFrameScheduler) next() (renderRequest, bool) {
67+ <-s.wake
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ if s.stopped {
71+ return renderRequest{}, false
72+ }
73+ req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme}
74+ s.clear = false
75+ s.invalidate = false
76+ s.theme = nil
77+ return req, true
78+}
79+
80+func (s *latestFrameScheduler) run(render func(renderRequest)) {
81+ defer close(s.done)
82+ for {
83+ req, ok := s.next()
84+ if !ok {
85+ return
86+ }
87+ render(req)
88+ }
89+}
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.
93+func (s *latestFrameScheduler) stop() {
94+ s.mu.Lock()
95+ if !s.stopped {
96+ s.stopped = true
97+ close(s.wake)
98+ }
99+ s.mu.Unlock()
100+ <-s.done
101+}
@@ -0,0 +1,101 @@
1+package modes
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/zut/packages/tui"
7+)
8+
9+// renderRequest is deliberately a signal, not a frame. The render owner
10+// reads the newest synchronized interactive state when it wakes, so a burst
11+// replaces the one pending request instead of queueing stale snapshots.
12+type renderRequest struct {
13+ clear bool
14+ invalidate bool
15+ theme *tui.Theme
16+}
17+
18+type latestFrameScheduler struct {
19+ wake chan struct{}
20+ done chan struct{}
21+
22+ mu sync.Mutex
23+ clear bool
24+ invalidate bool
25+ theme *tui.Theme
26+ stopped bool
27+}
28+
29+func newLatestFrameScheduler() *latestFrameScheduler {
30+ return &latestFrameScheduler{
31+ wake: make(chan struct{}, 1),
32+ done: make(chan struct{}),
33+ }
34+}
35+
36+func (s *latestFrameScheduler) request(clear, invalidate bool) bool {
37+ s.mu.Lock()
38+ defer s.mu.Unlock()
39+ if s.stopped {
40+ return false
41+ }
42+ s.clear = s.clear || clear
43+ s.invalidate = s.invalidate || invalidate
44+ select {
45+ case s.wake <- struct{}{}:
46+ default:
47+ }
48+ return true
49+}
50+
51+func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
52+ s.mu.Lock()
53+ defer s.mu.Unlock()
54+ if s.stopped {
55+ return false
56+ }
57+ s.clear = true
58+ s.theme = &theme
59+ select {
60+ case s.wake <- struct{}{}:
61+ default:
62+ }
63+ return true
64+}
65+
66+func (s *latestFrameScheduler) next() (renderRequest, bool) {
67+ <-s.wake
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ if s.stopped {
71+ return renderRequest{}, false
72+ }
73+ req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme}
74+ s.clear = false
75+ s.invalidate = false
76+ s.theme = nil
77+ return req, true
78+}
79+
80+func (s *latestFrameScheduler) run(render func(renderRequest)) {
81+ defer close(s.done)
82+ for {
83+ req, ok := s.next()
84+ if !ok {
85+ return
86+ }
87+ render(req)
88+ }
89+}
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.
93+func (s *latestFrameScheduler) stop() {
94+ s.mu.Lock()
95+ if !s.stopped {
96+ s.stopped = true
97+ close(s.wake)
98+ }
99+ s.mu.Unlock()
100+ <-s.done
101+}
A packages/agent/modes/interactive_render_test.go +181 -0
@@ -0,0 +1,181 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
15+ s := newLatestFrameScheduler()
16+ started := make(chan struct{})
17+ release := make(chan struct{})
18+ type renderedFrame struct {
19+ req renderRequest
20+ version int64
21+ }
22+ frames := make(chan renderedFrame, 4)
23+ done := make(chan struct{})
24+ var startedOnce sync.Once
25+ var latestVersion atomic.Int64
26+ go func() {
27+ s.run(func(req renderRequest) {
28+ frames <- renderedFrame{req: req, version: latestVersion.Load()}
29+ startedOnce.Do(func() { close(started) })
30+ <-release
31+ })
32+ close(done)
33+ }()
34+
35+ latestVersion.Store(1)
36+ if !s.request(false, false) {
37+ t.Fatal("initial render request was rejected")
38+ }
39+ first := <-frames
40+ <-started
41+ if first.req.clear {
42+ t.Fatal("ordinary request unexpectedly requested a clear")
43+ }
44+ for n := 0; n < 1000; n++ {
45+ latestVersion.Store(int64(n + 2))
46+ if !s.request(false, false) {
47+ t.Fatal("request rejected before shutdown")
48+ }
49+ }
50+ close(release)
51+ second := <-frames
52+ if second.req.clear {
53+ t.Fatal("coalesced ordinary request unexpectedly requested a clear")
54+ }
55+ if second.version != 1001 {
56+ t.Fatalf("renderer did not observe newest state: got version %d", second.version)
57+ }
58+ s.stop()
59+ <-done
60+
61+ select {
62+ case <-frames:
63+ t.Fatal("scheduler retained more than one pending frame")
64+ default:
65+ }
66+}
67+
68+func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
69+ i := &Interactive{
70+ view: &tui.View{
71+ Theme: tui.Dark,
72+ Messages: []provider.Message{{
73+ Role: provider.RoleUser,
74+ Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
75+ }},
76+ MessagesRevision: 1,
77+ },
78+ }
79+
80+ i.mu.Lock()
81+ before := strings.Join(i.stableChatRowsLocked(80), "\n")
82+ i.view.Theme = tui.Light
83+ i.view.InvalidateRenderCache()
84+ after := strings.Join(i.stableChatRowsLocked(80), "\n")
85+ i.mu.Unlock()
86+
87+ if before == after {
88+ t.Fatal("stable chat cache reused rows after a theme invalidation")
89+ }
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+
131+func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
132+ i := &Interactive{
133+ dirty: make(chan struct{}, 1),
134+ toolCalls: make(map[string]*tui.ToolCallView),
135+ toolGate: make(map[string]int),
136+ }
137+ i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
138+ select {
139+ case <-i.dirty:
140+ default:
141+ t.Fatal("tool start did not invalidate the presentation")
142+ }
143+
144+ const progressEvents = 4096
145+ for n := 0; n < progressEvents; n++ {
146+ i.handleEventForPresentation(core.EvToolProgress{
147+ ID: "storm",
148+ Text: strings.Repeat("progress payload ", 256),
149+ })
150+ }
151+ select {
152+ case <-i.dirty:
153+ t.Fatal("invisible progress created presentation invalidation")
154+ default:
155+ }
156+
157+ result := strings.Repeat("completed tool output\n", 256)
158+ i.handleEventForPresentation(core.EvToolResult{
159+ ID: "storm",
160+ Result: core.ToolResult{Content: []provider.Content{
161+ provider.TextBlock{Text: result},
162+ }},
163+ })
164+ select {
165+ case <-i.dirty:
166+ default:
167+ t.Fatal("completed tool result did not invalidate the presentation")
168+ }
169+ if got := i.toolCalls["storm"].Result; got != result {
170+ t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
171+ }
172+ if got := i.toolCalls["storm"].Revision; got != 2 {
173+ t.Fatalf("completed tool result revision: got %d, want 2", got)
174+ }
175+
176+ view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
177+ rows := view.BuildLive(80)
178+ if len(rows) == 0 {
179+ t.Fatal("completed tool result produced no live frame rows")
180+ }
181+}
@@ -0,0 +1,181 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
15+ s := newLatestFrameScheduler()
16+ started := make(chan struct{})
17+ release := make(chan struct{})
18+ type renderedFrame struct {
19+ req renderRequest
20+ version int64
21+ }
22+ frames := make(chan renderedFrame, 4)
23+ done := make(chan struct{})
24+ var startedOnce sync.Once
25+ var latestVersion atomic.Int64
26+ go func() {
27+ s.run(func(req renderRequest) {
28+ frames <- renderedFrame{req: req, version: latestVersion.Load()}
29+ startedOnce.Do(func() { close(started) })
30+ <-release
31+ })
32+ close(done)
33+ }()
34+
35+ latestVersion.Store(1)
36+ if !s.request(false, false) {
37+ t.Fatal("initial render request was rejected")
38+ }
39+ first := <-frames
40+ <-started
41+ if first.req.clear {
42+ t.Fatal("ordinary request unexpectedly requested a clear")
43+ }
44+ for n := 0; n < 1000; n++ {
45+ latestVersion.Store(int64(n + 2))
46+ if !s.request(false, false) {
47+ t.Fatal("request rejected before shutdown")
48+ }
49+ }
50+ close(release)
51+ second := <-frames
52+ if second.req.clear {
53+ t.Fatal("coalesced ordinary request unexpectedly requested a clear")
54+ }
55+ if second.version != 1001 {
56+ t.Fatalf("renderer did not observe newest state: got version %d", second.version)
57+ }
58+ s.stop()
59+ <-done
60+
61+ select {
62+ case <-frames:
63+ t.Fatal("scheduler retained more than one pending frame")
64+ default:
65+ }
66+}
67+
68+func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
69+ i := &Interactive{
70+ view: &tui.View{
71+ Theme: tui.Dark,
72+ Messages: []provider.Message{{
73+ Role: provider.RoleUser,
74+ Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
75+ }},
76+ MessagesRevision: 1,
77+ },
78+ }
79+
80+ i.mu.Lock()
81+ before := strings.Join(i.stableChatRowsLocked(80), "\n")
82+ i.view.Theme = tui.Light
83+ i.view.InvalidateRenderCache()
84+ after := strings.Join(i.stableChatRowsLocked(80), "\n")
85+ i.mu.Unlock()
86+
87+ if before == after {
88+ t.Fatal("stable chat cache reused rows after a theme invalidation")
89+ }
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+
131+func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
132+ i := &Interactive{
133+ dirty: make(chan struct{}, 1),
134+ toolCalls: make(map[string]*tui.ToolCallView),
135+ toolGate: make(map[string]int),
136+ }
137+ i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
138+ select {
139+ case <-i.dirty:
140+ default:
141+ t.Fatal("tool start did not invalidate the presentation")
142+ }
143+
144+ const progressEvents = 4096
145+ for n := 0; n < progressEvents; n++ {
146+ i.handleEventForPresentation(core.EvToolProgress{
147+ ID: "storm",
148+ Text: strings.Repeat("progress payload ", 256),
149+ })
150+ }
151+ select {
152+ case <-i.dirty:
153+ t.Fatal("invisible progress created presentation invalidation")
154+ default:
155+ }
156+
157+ result := strings.Repeat("completed tool output\n", 256)
158+ i.handleEventForPresentation(core.EvToolResult{
159+ ID: "storm",
160+ Result: core.ToolResult{Content: []provider.Content{
161+ provider.TextBlock{Text: result},
162+ }},
163+ })
164+ select {
165+ case <-i.dirty:
166+ default:
167+ t.Fatal("completed tool result did not invalidate the presentation")
168+ }
169+ if got := i.toolCalls["storm"].Result; got != result {
170+ t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
171+ }
172+ if got := i.toolCalls["storm"].Revision; got != 2 {
173+ t.Fatalf("completed tool result revision: got %d, want 2", got)
174+ }
175+
176+ view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
177+ rows := view.BuildLive(80)
178+ if len(rows) == 0 {
179+ t.Fatal("completed tool result produced no live frame rows")
180+ }
181+}
M packages/tui/render.go +24 -3
@@ -37,9 +37,17 @@ type Renderer struct {
3737 // in the terminal's visible viewport so we can diff safely, and bail
3838 // out to clear+replay when the diff would touch rows that are no
3939 // longer addressable.
40- logChat []string
41- logBottom []string
42- logLines []string
40+ logChat []string
41+ logBottom []string
42+ logLines []string
43+ // Raw logical rows let DrawLog reject an unchanged frame before
44+ // truncating and repainting every transcript row.
45+ logRawChat []string
46+ logRawBottom []string
47+ logRawCols int
48+ logRawRows int
49+ logRawBackground string
50+ logRawHasThemeBG bool
4351 logViewportTop int
4452 logHardwareRow int
4553 logInit bool
@@ -430,6 +438,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
430438 if len(bottom) == 0 {
431439 bottom = []string{""}
432440 }
441+ if r.logInit && len(r.logLines) > 0 &&
442+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
443+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444+ r.logRawHasThemeBG == (r.theme.Background != nil) &&
445+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446+ return
447+ }
433448 chatFrame := make([]string, len(chat))
434449 for i, line := range chat {
435450 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
@@ -808,6 +823,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
808823 r.logChat = append(r.logChat[:0], chatFrame...)
809824 r.logBottom = append(r.logBottom[:0], bottomFrame...)
810825 r.logLines = append(r.logLines[:0], lines...)
826+ r.logRawChat = append(r.logRawChat[:0], chat...)
827+ r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828+ r.logRawCols = r.cols
829+ r.logRawRows = r.rows
830+ r.logRawBackground = r.backgroundStyle
831+ r.logRawHasThemeBG = r.theme.Background != nil
811832 r.cursorRow = cursorBottomRow
812833 r.cursorCol = cursorCol
813834 }
@@ -37,9 +37,17 @@ type Renderer struct {
37 // in the terminal's visible viewport so we can diff safely, and bail37 // in the terminal's visible viewport so we can diff safely, and bail
38 // out to clear+replay when the diff would touch rows that are no38 // out to clear+replay when the diff would touch rows that are no
39 // longer addressable.39 // longer addressable.
40- logChat []string40+ logChat []string
41- logBottom []string41+ logBottom []string
42- logLines []string42+ logLines []string
43+ // Raw logical rows let DrawLog reject an unchanged frame before
44+ // truncating and repainting every transcript row.
45+ logRawChat []string
46+ logRawBottom []string
47+ logRawCols int
48+ logRawRows int
49+ logRawBackground string
50+ logRawHasThemeBG bool
43 logViewportTop int51 logViewportTop int
44 logHardwareRow int52 logHardwareRow int
45 logInit bool53 logInit bool
@@ -430,6 +438,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
430 if len(bottom) == 0 {438 if len(bottom) == 0 {
431 bottom = []string{""}439 bottom = []string{""}
432 }440 }
441+ if r.logInit && len(r.logLines) > 0 &&
442+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
443+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444+ r.logRawHasThemeBG == (r.theme.Background != nil) &&
445+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446+ return
447+ }
433 chatFrame := make([]string, len(chat))448 chatFrame := make([]string, len(chat))
434 for i, line := range chat {449 for i, line := range chat {
435 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)450 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
@@ -808,6 +823,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
808 r.logChat = append(r.logChat[:0], chatFrame...)823 r.logChat = append(r.logChat[:0], chatFrame...)
809 r.logBottom = append(r.logBottom[:0], bottomFrame...)824 r.logBottom = append(r.logBottom[:0], bottomFrame...)
810 r.logLines = append(r.logLines[:0], lines...)825 r.logLines = append(r.logLines[:0], lines...)
826+ r.logRawChat = append(r.logRawChat[:0], chat...)
827+ r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828+ r.logRawCols = r.cols
829+ r.logRawRows = r.rows
830+ r.logRawBackground = r.backgroundStyle
831+ r.logRawHasThemeBG = r.theme.Background != nil
811 r.cursorRow = cursorBottomRow832 r.cursorRow = cursorBottomRow
812 r.cursorCol = cursorCol833 r.cursorCol = cursorCol
813 }834 }
A packages/tui/render_bench_test.go +42 -0
@@ -0,0 +1,42 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "strings"
7+ "testing"
8+)
9+
10+func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
11+ for _, width := range []int{48, 80, 160} {
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)
16+ chat := benchmarkRendererChat(1600, width)
17+ bottom := []string{"working", "▌ input"}
18+ r.DrawLog(chat, bottom, 1, 2)
19+ out.Reset()
20+
21+ b.ReportAllocs()
22+ b.ResetTimer()
23+ for n := 0; n < b.N; n++ {
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()
30+ }
31+ })
32+ }
33+}
34+
35+func benchmarkRendererChat(rows, width int) []string {
36+ chat := make([]string, rows)
37+ payload := strings.Repeat("x", width)
38+ for i := range chat {
39+ chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload)
40+ }
41+ return chat
42+}
@@ -0,0 +1,42 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "strings"
7+ "testing"
8+)
9+
10+func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
11+ for _, width := range []int{48, 80, 160} {
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)
16+ chat := benchmarkRendererChat(1600, width)
17+ bottom := []string{"working", "▌ input"}
18+ r.DrawLog(chat, bottom, 1, 2)
19+ out.Reset()
20+
21+ b.ReportAllocs()
22+ b.ResetTimer()
23+ for n := 0; n < b.N; n++ {
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()
30+ }
31+ })
32+ }
33+}
34+
35+func benchmarkRendererChat(rows, width int) []string {
36+ chat := make([]string, rows)
37+ payload := strings.Repeat("x", width)
38+ for i := range chat {
39+ chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload)
40+ }
41+ return chat
42+}
M packages/tui/view.go +504 -40
@@ -104,6 +104,15 @@ type View struct {
104104 Theme Theme
105105 ImageProto ImageProtocol // how to render inline images in this terminal
106106 Messages []provider.Message
107+ // MessagesRevision is supplied by the owning transcript when available.
108+ // Append-only revisions let Build reuse prior message rows without
109+ // hashing every existing payload. Zero keeps the standalone hash cache
110+ // behaviour for callers that do not have a revision source.
111+ MessagesRevision uint64
112+ // RenderCacheRevision changes whenever a visual rendering dependency
113+ // changes. Interactive mode includes it in its stable transcript key so
114+ // the outer cache cannot bypass View's own theme/layout cache checks.
115+ RenderCacheRevision uint64
107116 // toolPaths maps tool_use_id to the "path" argument of the call, if
108117 // any, so tool_result rendering can pick the right syntax language.
109118 // Rebuilt on each Build().
@@ -168,11 +177,22 @@ type View struct {
168177 // doesn't re-markdown every message on every frame. Keyed by a
169178 // struct of (content hash, width, expandAll) — any of those
170179 // changing invalidates the entry. Messages are append-only after
171- // they finalise so keeping the cache across turns is safe.
180+ // they finalise so keeping the cache across turns is safe. The
181+ // revision-indexed cache additionally carries visual configuration
182+ // dependencies so resize/theme/compact changes cannot go stale.
172183 //
173- // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
174- // cached because it changes every delta.
175- renderCache map[msgCacheKey][]string
184+ // Streaming/in-flight work is cached per tool call. Interactive mode
185+ // advances ToolCallView.Revision for each visible update, so unchanged
186+ // tool results do not need to hash their payload on every frame.
187+ renderCache map[msgCacheKey][]string
188+ liveRenderCache map[liveToolCacheKey][]string
189+ messageCache []messageRenderCacheEntry
190+ messageCacheStart int
191+ messageCacheRevision uint64
192+ messageCacheLen int
193+ messageCachePrefix uint64
194+ messageCachePrefixOK bool
195+ toolPathRevision uint64
176196
177197 // TailLimit caps how many messages from the END of Messages are
178198 // rendered. Messages older than the limit emit a zero-row slice
@@ -184,21 +204,44 @@ type View struct {
184204 TailLimit int
185205 }
186206
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
213+ turnOpen bool
214+ flatTools bool
215+ compactMode bool
216+ compactUser bool
217+ imageProto ImageProtocol
218+ theme uint64
219+}
220+
187221 // msgCacheKey identifies a cached message render. hash is a 64-bit
188222 // FNV-1a of the message's content, which is cheap to compute and
189223 // unambiguous enough for the cache (collisions produce a stale frame,
190224 // not wrong data, and we recompute on invalidation anyway).
191225 type msgCacheKey struct {
192- hash uint64
193- width int
194- expandAll bool
195- // turnOpen is true when the previous rendered message belongs to
196- // the same agent turn (assistant tool_use, or tool result). The
197- // header ("▍ zut") is suppressed in that case so a single turn
198- // — even one that spans many assistant/tool message round-trips
199- // in the underlying API — renders under one header instead of a
200- // new one per assistant message.
201- turnOpen bool
226+ hash uint64
227+ visual msgVisualKey
228+}
229+
230+type messageRenderCacheEntry struct {
231+ visual msgVisualKey
232+ lines []string
233+ valid bool
234+}
235+
236+type liveToolCacheKey struct {
237+ revision uint64
238+ content uint64
239+ width int
240+ expandAll bool
241+ flatTools bool
242+ compact bool
243+ imageProto ImageProtocol
244+ theme uint64
202245 }
203246
204247 // InvalidateRenderCache drops all cached message renders. The tui
@@ -206,7 +249,86 @@ type msgCacheKey struct {
206249 // /clear, session swap) since messages can be replaced in place and
207250 // a content-hash miss alone doesn't reclaim the old entries.
208251 func (v *View) InvalidateRenderCache() {
252+ v.RenderCacheRevision++
209253 v.renderCache = nil
254+ v.liveRenderCache = nil
255+ v.messageCache = nil
256+ v.messageCacheStart = 0
257+ v.messageCacheRevision = 0
258+ v.messageCacheLen = 0
259+ v.messageCachePrefix = 0
260+ v.messageCachePrefixOK = false
261+ v.toolPathRevision = 0
262+}
263+
264+// CloneForRender returns an immutable render snapshot. Its mutable cache
265+// maps are copied so a renderer owner can build outside the interactive
266+// state mutex without racing the next snapshot.
267+func (v *View) CloneForRender() *View {
268+ clone := *v
269+ clone.Messages = append([]provider.Message(nil), v.Messages...)
270+ clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)
271+ clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)
272+ clone.StartupExtensionNames = append([]string(nil), v.StartupExtensionNames...)
273+ clone.StartupSkillNames = append([]string(nil), v.StartupSkillNames...)
274+ clone.toolPaths = cloneStringMap(v.toolPaths)
275+ clone.toolStartLines = cloneIntMap(v.toolStartLines)
276+ clone.toolCallLabels = cloneStringMap(v.toolCallLabels)
277+ clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh)
278+ if v.renderCache != nil {
279+ clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache))
280+ for key, lines := range v.renderCache {
281+ clone.renderCache[key] = lines
282+ }
283+ }
284+ if v.liveRenderCache != nil {
285+ clone.liveRenderCache = make(map[liveToolCacheKey][]string, len(v.liveRenderCache))
286+ for key, lines := range v.liveRenderCache {
287+ clone.liveRenderCache[key] = lines
288+ }
289+ }
290+ clone.messageCache = append([]messageRenderCacheEntry(nil), v.messageCache...)
291+ return &clone
292+}
293+
294+func cloneStringMap(src map[string]string) map[string]string {
295+ if src == nil {
296+ return nil
297+ }
298+ dst := make(map[string]string, len(src))
299+ for key, value := range src {
300+ dst[key] = value
301+ }
302+ return dst
303+}
304+
305+func cloneIntMap(src map[string]int) map[string]int {
306+ if src == nil {
307+ return nil
308+ }
309+ dst := make(map[string]int, len(src))
310+ for key, value := range src {
311+ dst[key] = value
312+ }
313+ return dst
314+}
315+
316+// AdoptRenderCacheFrom transfers caches produced from a render snapshot
317+// back to the owning view. Callers must ensure the transcript revision is
318+// still current before adopting them.
319+func (v *View) AdoptRenderCacheFrom(snapshot *View) {
320+ v.renderCache = snapshot.renderCache
321+ v.liveRenderCache = snapshot.liveRenderCache
322+ v.messageCache = snapshot.messageCache
323+ v.messageCacheStart = snapshot.messageCacheStart
324+ v.messageCacheRevision = snapshot.messageCacheRevision
325+ v.messageCacheLen = snapshot.messageCacheLen
326+ v.messageCachePrefix = snapshot.messageCachePrefix
327+ v.messageCachePrefixOK = snapshot.messageCachePrefixOK
328+ v.toolPaths = snapshot.toolPaths
329+ v.toolStartLines = snapshot.toolStartLines
330+ v.toolCallLabels = snapshot.toolCallLabels
331+ v.toolPathRevision = snapshot.toolPathRevision
210332 }
211333
212334 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -219,13 +341,17 @@ const (
219341
220342 // ToolCallView is a pending tool invocation plus optional result.
221343 type ToolCallView struct {
222- ID string
223- Name string
224- Args string // rendered argument summary
225- Preview string // side-effect-free result shown before confirmation
226- Result string // rendered result preview (truncated)
227- Error bool
228- Done bool
344+ ID string
345+ Name string
346+ // Revision is advanced by the owning interactive state whenever a
347+ // visible field changes. A non-zero revision lets the live render cache
348+ // avoid rescanning a large result string on every redraw.
349+ Revision uint64
350+ Args string // rendered argument summary
351+ Preview string // side-effect-free result shown before confirmation
352+ Result string // rendered result preview (truncated)
353+ Error bool
354+ Done bool
229355
230356 // Streaming is true while the model is still typing the tool
231357 // call's JSON arguments. The TUI renders a live preview of any
@@ -265,6 +391,10 @@ func (v *View) Build(width int) []string {
265391 // so native scrolling stays stable while a turn streams.
266392 func (v *View) BuildLive(width int) []string {
267393 v.prepareLiveBodyHeights()
394+ if v.liveRenderCache == nil {
395+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
396+ }
397+ themeKey := toolThemeKey(v.Theme)
268398 var out []string
269399 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
270400 const indent = " "
@@ -294,7 +424,7 @@ func (v *View) BuildLive(width int) []string {
294424 out = append(out, "")
295425 }
296426 insertedToolGap = true
297- out = append(out, v.renderToolCall(tc, width)...)
427+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
298428 }
299429 if v.Err != "" {
300430 out = append(out, v.renderErr(width)...)
@@ -360,10 +490,47 @@ func (v *View) renderStartupResources(width int) []string {
360490 // scroll to a specific turn (the /jump dialog) use the anchor slice
361491 // to map a message index back to a row offset.
362492 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
363- v.refreshToolPaths()
493+ themeKey := toolThemeKey(v.Theme)
494+ if v.MessagesRevision == 0 && v.messageCacheRevision != 0 {
495+ v.messageCache = nil
496+ v.messageCacheStart = 0
497+ v.messageCacheRevision = 0
498+ v.messageCacheLen = 0
499+ v.messageCachePrefix = 0
500+ v.messageCachePrefixOK = false
501+ }
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+ }
521+ v.messageCacheRevision = v.MessagesRevision
522+ v.messageCacheLen = len(v.Messages)
523+ }
524+ if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision {
525+ v.refreshToolPaths()
526+ v.toolPathRevision = v.MessagesRevision
527+ }
364528 if v.renderCache == nil {
365529 v.renderCache = make(map[msgCacheKey][]string)
366530 }
531+ if v.liveRenderCache == nil {
532+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
533+ }
367534 v.prepareLiveBodyHeights()
368535 // Drop high-water entries for calls that are gone or finalised so
369536 // the reservation never leaks into a later, shorter tool box.
@@ -396,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
396563 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {
397564 renderFrom = len(v.Messages) - v.TailLimit
398565 }
566+ v.boundMessageCache(renderFrom, len(v.Messages))
399567 for idx, m := range v.Messages {
400568 if idx < renderFrom {
401569 rendered[idx] = nil
@@ -422,7 +590,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
422590 break
423591 }
424592 }
425- lines := v.renderMessageCached(m, width, turnOpen)
593+ lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey)
426594 rendered[idx] = lines
427595 total += len(lines) + 1 // +1 for the blank separator row
428596 }
@@ -487,7 +655,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
487655 if finalised[tc.ID] {
488656 continue
489657 }
490- out = append(out, v.renderToolCall(tc, width)...)
658+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
491659 out = append(out, "")
492660 }
493661 if v.Err != "" {
@@ -531,18 +699,76 @@ func (v *View) refreshToolPaths() {
531699 }
532700 }
533701
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+
534737 // renderMessageCached returns the rendered line slice for m, using the
535738 // cache if the same (content hash, width, expandAll) combination has
536739 // been rendered before. The slice returned is shared — callers must
537740 // not mutate it; Build() only ever appends to its own `out` so the
538741 // shared slice is safe.
539-func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string {
540- key := msgCacheKey{
541- hash: hashMessage(m),
542- width: width,
543- expandAll: v.ExpandAll,
544- turnOpen: turnOpen,
742+func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
743+ visual := msgVisualKey{
744+ width: width,
745+ expandAll: v.ExpandAll,
746+ turnOpen: turnOpen,
747+ flatTools: v.FlatTools,
748+ compactMode: v.CompactMode,
749+ compactUser: v.CompactUser,
750+ imageProto: v.ImageProto,
751+ theme: themeKey,
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
545770 }
771+ key := msgCacheKey{hash: hashMessage(m), visual: visual}
546772 if v.renderCache != nil {
547773 if lines, ok := v.renderCache[key]; ok {
548774 return lines
@@ -575,6 +801,25 @@ func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool)
575801 return lines
576802 }
577803
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+
578823 // hashMessage returns a 64-bit FNV-1a over the role + content blocks
579824 // of m. Serialising each block to its salient bytes is enough: two
580825 // messages with the same role and same content render identically.
@@ -648,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 {
648893 return h
649894 }
650895
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+
651903 func fnv64aWrite(h uint64, p []byte) uint64 {
652904 for _, b := range p {
653905 h ^= uint64(b)
@@ -842,7 +1094,129 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
8421094 if v.liveBodyHigh == nil {
8431095 v.liveBodyHigh = make(map[string]int)
8441096 }
845- return v.renderToolCall(tc, width)
1097+ return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1098+}
1099+
1100+func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1101+ if v.liveRenderCache == nil {
1102+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
1103+ }
1104+ key := liveToolCacheKey{
1105+ revision: tc.Revision,
1106+ content: hashLiveTool(tc),
1107+ width: width,
1108+ expandAll: v.ExpandAll,
1109+ flatTools: v.FlatTools,
1110+ compact: v.CompactMode,
1111+ imageProto: v.ImageProto,
1112+ theme: themeKey,
1113+ }
1114+ if lines, ok := v.liveRenderCache[key]; ok {
1115+ return lines
1116+ }
1117+ lines := v.renderToolCall(tc, width)
1118+ if len(v.liveRenderCache) > 512 {
1119+ for k := range v.liveRenderCache {
1120+ delete(v.liveRenderCache, k)
1121+ break
1122+ }
1123+ }
1124+ v.liveRenderCache[key] = lines
1125+ return lines
1126+}
1127+
1128+func hashLiveTool(tc ToolCallView) uint64 {
1129+ if tc.Revision != 0 {
1130+ return 0
1131+ }
1132+ h := fnv64aInit
1133+ for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1134+ h = fnv64aWrite(h, []byte(s))
1135+ h = fnv64aWriteByte(h, 0)
1136+ }
1137+ if tc.Error {
1138+ h = fnv64aWriteByte(h, 'e')
1139+ }
1140+ if tc.Done {
1141+ h = fnv64aWriteByte(h, 'd')
1142+ }
1143+ if tc.Streaming {
1144+ h = fnv64aWriteByte(h, 's')
1145+ }
1146+ return h
1147+}
1148+
1149+func toolThemeKey(theme Theme) uint64 {
1150+ h := fnv64aInit
1151+ for _, s := range []string{
1152+ theme.SyntaxBaseStyle,
1153+ theme.Syntax.Keyword,
1154+ theme.Syntax.KeywordConstant,
1155+ theme.Syntax.KeywordDeclaration,
1156+ theme.Syntax.KeywordNamespace,
1157+ theme.Syntax.KeywordReserved,
1158+ theme.Syntax.KeywordType,
1159+ theme.Syntax.NameBuiltin,
1160+ theme.Syntax.NameFunction,
1161+ theme.Syntax.NameClass,
1162+ theme.Syntax.NameDecorator,
1163+ theme.Syntax.LiteralString,
1164+ theme.Syntax.LiteralStringEscape,
1165+ theme.Syntax.LiteralNumber,
1166+ theme.Syntax.Comment,
1167+ theme.Syntax.CommentPreproc,
1168+ theme.Syntax.Operator,
1169+ theme.Syntax.Punctuation,
1170+ theme.Syntax.Text,
1171+ } {
1172+ h = fnv64aWrite(h, []byte(s))
1173+ h = fnv64aWriteByte(h, 0)
1174+ }
1175+ for _, c := range []TerminalColor{
1176+ theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1177+ theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1178+ theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1179+ theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1180+ theme.Terminal.Background,
1181+ } {
1182+ h = hashThemeColor(h, c)
1183+ }
1184+ if theme.Background == nil {
1185+ h = fnv64aWriteByte(h, 0)
1186+ } else {
1187+ h = fnv64aWriteByte(h, 1)
1188+ h = hashThemeColor(h, *theme.Background)
1189+ }
1190+ h = fnv64aWriteByte(h, 0xff)
1191+ h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1192+ h = fnv64aWriteByte(h, 0xff)
1193+ for _, c := range theme.Terminal.Palette {
1194+ h = hashThemeColor(h, c)
1195+ }
1196+ for _, value := range []bool{
1197+ theme.Inherited,
1198+ theme.Terminal.HasForeground,
1199+ theme.Terminal.HasBackground,
1200+ theme.Terminal.TrueColor,
1201+ theme.Terminal.Light,
1202+ theme.Terminal.SchemeKnown,
1203+ } {
1204+ if value {
1205+ h = fnv64aWriteByte(h, 1)
1206+ } else {
1207+ h = fnv64aWriteByte(h, 0)
1208+ }
1209+ }
1210+ return h
1211+}
1212+
1213+func hashThemeColor(h uint64, c TerminalColor) uint64 {
1214+ h = fnv64aWriteByte(h, byte(c.Mode))
1215+ h = fnv64aWriteByte(h, byte(c.Index))
1216+ h = fnv64aWriteByte(h, byte(c.R))
1217+ h = fnv64aWriteByte(h, byte(c.G))
1218+ h = fnv64aWriteByte(h, byte(c.B))
1219+ return h
8461220 }
8471221
8481222 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
@@ -979,7 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
9791353 // confirmation preview immediately gets the same colors, numbered gutters,
9801354 // and syntax highlighting as its final tool result.
9811355 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
982- return v.renderToolText(text, width, color, sourcePath, 1)
1356+ full := v.renderToolText(text, width, color, sourcePath, 1)
1357+ if v.ExpandAll || len(full) <= ToolCollapseLines {
1358+ return full
1359+ }
1360+ return v.collapseToolBodyWithTotal(full, false, len(full))
9831361 }
9841362
9851363 // renderLiveToolBody renders the in-flight preview of a streaming
@@ -1472,6 +1850,13 @@ func trimLeadingSpaces(s string, n int) string {
14721850 func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
14731851 var body []string
14741852 hasImage := false
1853+ for _, b := range blocks {
1854+ if _, ok := b.(provider.ImageBlock); ok {
1855+ hasImage = true
1856+ break
1857+ }
1858+ }
1859+ total := 0
14751860 for _, b := range blocks {
14761861 switch bb := b.(type) {
14771862 case provider.TextBlock:
@@ -1479,13 +1864,22 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
14791864 if v.FlatTools || v.CompactMode {
14801865 bodyWidth = flatToolBodyRenderWidth(width)
14811866 }
1482- body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
1867+ estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1868+ estimated := estimateToolTextLines(bb.Text, estimateWidth)
1869+ total += estimated
1870+ text := bb.Text
1871+ // A collapsed result only exposes its first ten rendered rows.
1872+ // Render a bounded source preview in that case; syntax
1873+ // highlighting and wrapping the omitted tail is wasted work.
1874+ if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1875+ text = previewToolText(text, estimateWidth)
1876+ }
1877+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
14831878 case provider.ImageBlock:
1484- hasImage = true
14851879 body = append(body, v.renderImageBlock(bb, width)...)
14861880 }
14871881 }
1488- return v.collapseToolBody(body, hasImage)
1882+ return v.collapseToolBodyWithTotal(body, hasImage, total)
14891883 }
14901884
14911885 // collapseToolBody trims lines to the configured preview size when the
@@ -1493,15 +1887,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
14931887 // expand" footer. Image blocks never collapse — they're short in text
14941888 // rows but represent real content the user wants to see.
14951889 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1890+ return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1891+}
1892+
1893+func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
14961894 if v.ExpandAll || hasImage {
14971895 return lines
14981896 }
1499- if len(lines) <= ToolCollapseLines {
1897+ if total < len(lines) {
1898+ total = len(lines)
1899+ }
1900+ if total <= ToolCollapseLines {
15001901 return lines
15011902 }
1502- kept := lines[:ToolCollapsePreview]
1503- hidden := len(lines) - ToolCollapsePreview
1504- total := len(lines)
1903+ kept := lines
1904+ if len(kept) > ToolCollapsePreview {
1905+ kept = kept[:ToolCollapsePreview]
1906+ }
1907+ hidden := total - ToolCollapsePreview
15051908 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
15061909 footer = v.Theme.FGColor(v.Theme.Muted, footer)
15071910 out := append([]string(nil), kept...)
@@ -1509,6 +1912,67 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
15091912 return append(out, footer)
15101913 }
15111914
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+
1930+func estimateToolTextLines(text string, width int) int {
1931+ if width < 1 {
1932+ width = 1
1933+ }
1934+ total := 0
1935+ for _, line := range strings.Split(text, "\n") {
1936+ cells := runewidth.StringWidth(line)
1937+ if cells == 0 {
1938+ total++
1939+ continue
1940+ }
1941+ total += (cells + width - 1) / width
1942+ }
1943+ if total == 0 {
1944+ return 1
1945+ }
1946+ return total
1947+}
1948+
1949+func previewToolText(text string, width int) string {
1950+ if width < 1 {
1951+ width = 1
1952+ }
1953+ maxChars := width * (ToolCollapsePreview + 2)
1954+ if maxChars < 256 {
1955+ maxChars = 256
1956+ }
1957+ if maxChars > 8192 {
1958+ maxChars = 8192
1959+ }
1960+ end := 0
1961+ for count := 0; end < len(text) && count < maxChars; count++ {
1962+ _, size := utf8.DecodeRuneInString(text[end:])
1963+ if size == 0 {
1964+ break
1965+ }
1966+ end += size
1967+ }
1968+ preview := text[:end]
1969+ lines := strings.SplitAfter(preview, "\n")
1970+ if len(lines) > ToolCollapsePreview+2 {
1971+ preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1972+ }
1973+ return preview
1974+}
1975+
15121976 // renderToolText renders a text block inside a tool result. If the
15131977 // text contains a unified-diff section (lines starting with "--- " /
15141978 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
@@ -104,6 +104,15 @@ type View struct {
104 Theme Theme104 Theme Theme
105 ImageProto ImageProtocol // how to render inline images in this terminal105 ImageProto ImageProtocol // how to render inline images in this terminal
106 Messages []provider.Message106 Messages []provider.Message
107+ // MessagesRevision is supplied by the owning transcript when available.
108+ // Append-only revisions let Build reuse prior message rows without
109+ // hashing every existing payload. Zero keeps the standalone hash cache
110+ // behaviour for callers that do not have a revision source.
111+ MessagesRevision uint64
112+ // RenderCacheRevision changes whenever a visual rendering dependency
113+ // changes. Interactive mode includes it in its stable transcript key so
114+ // the outer cache cannot bypass View's own theme/layout cache checks.
115+ RenderCacheRevision uint64
107 // toolPaths maps tool_use_id to the "path" argument of the call, if116 // toolPaths maps tool_use_id to the "path" argument of the call, if
108 // any, so tool_result rendering can pick the right syntax language.117 // any, so tool_result rendering can pick the right syntax language.
109 // Rebuilt on each Build().118 // Rebuilt on each Build().
@@ -168,11 +177,22 @@ type View struct {
168 // doesn't re-markdown every message on every frame. Keyed by a177 // doesn't re-markdown every message on every frame. Keyed by a
169 // struct of (content hash, width, expandAll) — any of those178 // struct of (content hash, width, expandAll) — any of those
170 // changing invalidates the entry. Messages are append-only after179 // changing invalidates the entry. Messages are append-only after
171- // they finalise so keeping the cache across turns is safe.180+ // they finalise so keeping the cache across turns is safe. The
181+ // revision-indexed cache additionally carries visual configuration
182+ // dependencies so resize/theme/compact changes cannot go stale.
172 //183 //
173- // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never184+ // Streaming/in-flight work is cached per tool call. Interactive mode
174- // cached because it changes every delta.185+ // advances ToolCallView.Revision for each visible update, so unchanged
175- renderCache map[msgCacheKey][]string186+ // tool results do not need to hash their payload on every frame.
187+ renderCache map[msgCacheKey][]string
188+ liveRenderCache map[liveToolCacheKey][]string
189+ messageCache []messageRenderCacheEntry
190+ messageCacheStart int
191+ messageCacheRevision uint64
192+ messageCacheLen int
193+ messageCachePrefix uint64
194+ messageCachePrefixOK bool
195+ toolPathRevision uint64
176 196
177 // TailLimit caps how many messages from the END of Messages are197 // TailLimit caps how many messages from the END of Messages are
178 // rendered. Messages older than the limit emit a zero-row slice198 // rendered. Messages older than the limit emit a zero-row slice
@@ -184,21 +204,44 @@ type View struct {
184 TailLimit int204 TailLimit int
185 }205 }
186 206
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
213+ turnOpen bool
214+ flatTools bool
215+ compactMode bool
216+ compactUser bool
217+ imageProto ImageProtocol
218+ theme uint64
219+}
220+
187 // msgCacheKey identifies a cached message render. hash is a 64-bit221 // msgCacheKey identifies a cached message render. hash is a 64-bit
188 // FNV-1a of the message's content, which is cheap to compute and222 // FNV-1a of the message's content, which is cheap to compute and
189 // unambiguous enough for the cache (collisions produce a stale frame,223 // unambiguous enough for the cache (collisions produce a stale frame,
190 // not wrong data, and we recompute on invalidation anyway).224 // not wrong data, and we recompute on invalidation anyway).
191 type msgCacheKey struct {225 type msgCacheKey struct {
192- hash uint64226+ hash uint64
193- width int227+ visual msgVisualKey
194- expandAll bool228+}
195- // turnOpen is true when the previous rendered message belongs to229+
196- // the same agent turn (assistant tool_use, or tool result). The230+type messageRenderCacheEntry struct {
197- // header ("▍ zut") is suppressed in that case so a single turn231+ visual msgVisualKey
198- // — even one that spans many assistant/tool message round-trips232+ lines []string
199- // in the underlying API — renders under one header instead of a233+ valid bool
200- // new one per assistant message.234+}
201- turnOpen bool235+
236+type liveToolCacheKey struct {
237+ revision uint64
238+ content uint64
239+ width int
240+ expandAll bool
241+ flatTools bool
242+ compact bool
243+ imageProto ImageProtocol
244+ theme uint64
202 }245 }
203 246
204 // InvalidateRenderCache drops all cached message renders. The tui247 // InvalidateRenderCache drops all cached message renders. The tui
@@ -206,7 +249,86 @@ type msgCacheKey struct {
206 // /clear, session swap) since messages can be replaced in place and249 // /clear, session swap) since messages can be replaced in place and
207 // a content-hash miss alone doesn't reclaim the old entries.250 // a content-hash miss alone doesn't reclaim the old entries.
208 func (v *View) InvalidateRenderCache() {251 func (v *View) InvalidateRenderCache() {
252+ v.RenderCacheRevision++
209 v.renderCache = nil253 v.renderCache = nil
254+ v.liveRenderCache = nil
255+ v.messageCache = nil
256+ v.messageCacheStart = 0
257+ v.messageCacheRevision = 0
258+ v.messageCacheLen = 0
259+ v.messageCachePrefix = 0
260+ v.messageCachePrefixOK = false
261+ v.toolPathRevision = 0
262+}
263+
264+// CloneForRender returns an immutable render snapshot. Its mutable cache
265+// maps are copied so a renderer owner can build outside the interactive
266+// state mutex without racing the next snapshot.
267+func (v *View) CloneForRender() *View {
268+ clone := *v
269+ clone.Messages = append([]provider.Message(nil), v.Messages...)
270+ clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)
271+ clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)
272+ clone.StartupExtensionNames = append([]string(nil), v.StartupExtensionNames...)
273+ clone.StartupSkillNames = append([]string(nil), v.StartupSkillNames...)
274+ clone.toolPaths = cloneStringMap(v.toolPaths)
275+ clone.toolStartLines = cloneIntMap(v.toolStartLines)
276+ clone.toolCallLabels = cloneStringMap(v.toolCallLabels)
277+ clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh)
278+ if v.renderCache != nil {
279+ clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache))
280+ for key, lines := range v.renderCache {
281+ clone.renderCache[key] = lines
282+ }
283+ }
284+ if v.liveRenderCache != nil {
285+ clone.liveRenderCache = make(map[liveToolCacheKey][]string, len(v.liveRenderCache))
286+ for key, lines := range v.liveRenderCache {
287+ clone.liveRenderCache[key] = lines
288+ }
289+ }
290+ clone.messageCache = append([]messageRenderCacheEntry(nil), v.messageCache...)
291+ return &clone
292+}
293+
294+func cloneStringMap(src map[string]string) map[string]string {
295+ if src == nil {
296+ return nil
297+ }
298+ dst := make(map[string]string, len(src))
299+ for key, value := range src {
300+ dst[key] = value
301+ }
302+ return dst
303+}
304+
305+func cloneIntMap(src map[string]int) map[string]int {
306+ if src == nil {
307+ return nil
308+ }
309+ dst := make(map[string]int, len(src))
310+ for key, value := range src {
311+ dst[key] = value
312+ }
313+ return dst
314+}
315+
316+// AdoptRenderCacheFrom transfers caches produced from a render snapshot
317+// back to the owning view. Callers must ensure the transcript revision is
318+// still current before adopting them.
319+func (v *View) AdoptRenderCacheFrom(snapshot *View) {
320+ v.renderCache = snapshot.renderCache
321+ v.liveRenderCache = snapshot.liveRenderCache
322+ v.messageCache = snapshot.messageCache
323+ v.messageCacheStart = snapshot.messageCacheStart
324+ v.messageCacheRevision = snapshot.messageCacheRevision
325+ v.messageCacheLen = snapshot.messageCacheLen
326+ v.messageCachePrefix = snapshot.messageCachePrefix
327+ v.messageCachePrefixOK = snapshot.messageCachePrefixOK
328+ v.toolPaths = snapshot.toolPaths
329+ v.toolStartLines = snapshot.toolStartLines
330+ v.toolCallLabels = snapshot.toolCallLabels
331+ v.toolPathRevision = snapshot.toolPathRevision
210 }332 }
211 333
212 // ToolCollapsePreview is the number of lines shown before a long tool334 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -219,13 +341,17 @@ const (
219 341
220 // ToolCallView is a pending tool invocation plus optional result.342 // ToolCallView is a pending tool invocation plus optional result.
221 type ToolCallView struct {343 type ToolCallView struct {
222- ID string344+ ID string
223- Name string345+ Name string
224- Args string // rendered argument summary346+ // Revision is advanced by the owning interactive state whenever a
225- Preview string // side-effect-free result shown before confirmation347+ // visible field changes. A non-zero revision lets the live render cache
226- Result string // rendered result preview (truncated)348+ // avoid rescanning a large result string on every redraw.
227- Error bool349+ Revision uint64
228- Done bool350+ Args string // rendered argument summary
351+ Preview string // side-effect-free result shown before confirmation
352+ Result string // rendered result preview (truncated)
353+ Error bool
354+ Done bool
229 355
230 // Streaming is true while the model is still typing the tool356 // Streaming is true while the model is still typing the tool
231 // call's JSON arguments. The TUI renders a live preview of any357 // call's JSON arguments. The TUI renders a live preview of any
@@ -265,6 +391,10 @@ func (v *View) Build(width int) []string {
265 // so native scrolling stays stable while a turn streams.391 // so native scrolling stays stable while a turn streams.
266 func (v *View) BuildLive(width int) []string {392 func (v *View) BuildLive(width int) []string {
267 v.prepareLiveBodyHeights()393 v.prepareLiveBodyHeights()
394+ if v.liveRenderCache == nil {
395+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
396+ }
397+ themeKey := toolThemeKey(v.Theme)
268 var out []string398 var out []string
269 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {399 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
270 const indent = " "400 const indent = " "
@@ -294,7 +424,7 @@ func (v *View) BuildLive(width int) []string {
294 out = append(out, "")424 out = append(out, "")
295 }425 }
296 insertedToolGap = true426 insertedToolGap = true
297- out = append(out, v.renderToolCall(tc, width)...)427+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
298 }428 }
299 if v.Err != "" {429 if v.Err != "" {
300 out = append(out, v.renderErr(width)...)430 out = append(out, v.renderErr(width)...)
@@ -360,10 +490,47 @@ func (v *View) renderStartupResources(width int) []string {
360 // scroll to a specific turn (the /jump dialog) use the anchor slice490 // scroll to a specific turn (the /jump dialog) use the anchor slice
361 // to map a message index back to a row offset.491 // to map a message index back to a row offset.
362 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {492 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
363- v.refreshToolPaths()493+ themeKey := toolThemeKey(v.Theme)
494+ if v.MessagesRevision == 0 && v.messageCacheRevision != 0 {
495+ v.messageCache = nil
496+ v.messageCacheStart = 0
497+ v.messageCacheRevision = 0
498+ v.messageCacheLen = 0
499+ v.messageCachePrefix = 0
500+ v.messageCachePrefixOK = false
501+ }
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+ }
521+ v.messageCacheRevision = v.MessagesRevision
522+ v.messageCacheLen = len(v.Messages)
523+ }
524+ if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision {
525+ v.refreshToolPaths()
526+ v.toolPathRevision = v.MessagesRevision
527+ }
364 if v.renderCache == nil {528 if v.renderCache == nil {
365 v.renderCache = make(map[msgCacheKey][]string)529 v.renderCache = make(map[msgCacheKey][]string)
366 }530 }
531+ if v.liveRenderCache == nil {
532+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
533+ }
367 v.prepareLiveBodyHeights()534 v.prepareLiveBodyHeights()
368 // Drop high-water entries for calls that are gone or finalised so535 // Drop high-water entries for calls that are gone or finalised so
369 // the reservation never leaks into a later, shorter tool box.536 // the reservation never leaks into a later, shorter tool box.
@@ -396,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
396 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {563 if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {
397 renderFrom = len(v.Messages) - v.TailLimit564 renderFrom = len(v.Messages) - v.TailLimit
398 }565 }
566+ v.boundMessageCache(renderFrom, len(v.Messages))
399 for idx, m := range v.Messages {567 for idx, m := range v.Messages {
400 if idx < renderFrom {568 if idx < renderFrom {
401 rendered[idx] = nil569 rendered[idx] = nil
@@ -422,7 +590,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
422 break590 break
423 }591 }
424 }592 }
425- lines := v.renderMessageCached(m, width, turnOpen)593+ lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey)
426 rendered[idx] = lines594 rendered[idx] = lines
427 total += len(lines) + 1 // +1 for the blank separator row595 total += len(lines) + 1 // +1 for the blank separator row
428 }596 }
@@ -487,7 +655,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
487 if finalised[tc.ID] {655 if finalised[tc.ID] {
488 continue656 continue
489 }657 }
490- out = append(out, v.renderToolCall(tc, width)...)658+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
491 out = append(out, "")659 out = append(out, "")
492 }660 }
493 if v.Err != "" {661 if v.Err != "" {
@@ -531,18 +699,76 @@ func (v *View) refreshToolPaths() {
531 }699 }
532 }700 }
533 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+
534 // renderMessageCached returns the rendered line slice for m, using the737 // renderMessageCached returns the rendered line slice for m, using the
535 // cache if the same (content hash, width, expandAll) combination has738 // cache if the same (content hash, width, expandAll) combination has
536 // been rendered before. The slice returned is shared — callers must739 // been rendered before. The slice returned is shared — callers must
537 // 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
538 // shared slice is safe.741 // shared slice is safe.
539-func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string {742+func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
540- key := msgCacheKey{743+ visual := msgVisualKey{
541- hash: hashMessage(m),744+ width: width,
542- width: width,745+ expandAll: v.ExpandAll,
543- expandAll: v.ExpandAll,746+ turnOpen: turnOpen,
544- turnOpen: turnOpen,747+ flatTools: v.FlatTools,
748+ compactMode: v.CompactMode,
749+ compactUser: v.CompactUser,
750+ imageProto: v.ImageProto,
751+ theme: themeKey,
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
545 }770 }
771+ key := msgCacheKey{hash: hashMessage(m), visual: visual}
546 if v.renderCache != nil {772 if v.renderCache != nil {
547 if lines, ok := v.renderCache[key]; ok {773 if lines, ok := v.renderCache[key]; ok {
548 return lines774 return lines
@@ -575,6 +801,25 @@ func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool)
575 return lines801 return lines
576 }802 }
577 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+
578 // hashMessage returns a 64-bit FNV-1a over the role + content blocks823 // hashMessage returns a 64-bit FNV-1a over the role + content blocks
579 // of m. Serialising each block to its salient bytes is enough: two824 // of m. Serialising each block to its salient bytes is enough: two
580 // messages with the same role and same content render identically.825 // messages with the same role and same content render identically.
@@ -648,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 {
648 return h893 return h
649 }894 }
650 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+
651 func fnv64aWrite(h uint64, p []byte) uint64 {903 func fnv64aWrite(h uint64, p []byte) uint64 {
652 for _, b := range p {904 for _, b := range p {
653 h ^= uint64(b)905 h ^= uint64(b)
@@ -842,7 +1094,129 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
842 if v.liveBodyHigh == nil {1094 if v.liveBodyHigh == nil {
843 v.liveBodyHigh = make(map[string]int)1095 v.liveBodyHigh = make(map[string]int)
844 }1096 }
845- return v.renderToolCall(tc, width)1097+ return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1098+}
1099+
1100+func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1101+ if v.liveRenderCache == nil {
1102+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
1103+ }
1104+ key := liveToolCacheKey{
1105+ revision: tc.Revision,
1106+ content: hashLiveTool(tc),
1107+ width: width,
1108+ expandAll: v.ExpandAll,
1109+ flatTools: v.FlatTools,
1110+ compact: v.CompactMode,
1111+ imageProto: v.ImageProto,
1112+ theme: themeKey,
1113+ }
1114+ if lines, ok := v.liveRenderCache[key]; ok {
1115+ return lines
1116+ }
1117+ lines := v.renderToolCall(tc, width)
1118+ if len(v.liveRenderCache) > 512 {
1119+ for k := range v.liveRenderCache {
1120+ delete(v.liveRenderCache, k)
1121+ break
1122+ }
1123+ }
1124+ v.liveRenderCache[key] = lines
1125+ return lines
1126+}
1127+
1128+func hashLiveTool(tc ToolCallView) uint64 {
1129+ if tc.Revision != 0 {
1130+ return 0
1131+ }
1132+ h := fnv64aInit
1133+ for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1134+ h = fnv64aWrite(h, []byte(s))
1135+ h = fnv64aWriteByte(h, 0)
1136+ }
1137+ if tc.Error {
1138+ h = fnv64aWriteByte(h, 'e')
1139+ }
1140+ if tc.Done {
1141+ h = fnv64aWriteByte(h, 'd')
1142+ }
1143+ if tc.Streaming {
1144+ h = fnv64aWriteByte(h, 's')
1145+ }
1146+ return h
1147+}
1148+
1149+func toolThemeKey(theme Theme) uint64 {
1150+ h := fnv64aInit
1151+ for _, s := range []string{
1152+ theme.SyntaxBaseStyle,
1153+ theme.Syntax.Keyword,
1154+ theme.Syntax.KeywordConstant,
1155+ theme.Syntax.KeywordDeclaration,
1156+ theme.Syntax.KeywordNamespace,
1157+ theme.Syntax.KeywordReserved,
1158+ theme.Syntax.KeywordType,
1159+ theme.Syntax.NameBuiltin,
1160+ theme.Syntax.NameFunction,
1161+ theme.Syntax.NameClass,
1162+ theme.Syntax.NameDecorator,
1163+ theme.Syntax.LiteralString,
1164+ theme.Syntax.LiteralStringEscape,
1165+ theme.Syntax.LiteralNumber,
1166+ theme.Syntax.Comment,
1167+ theme.Syntax.CommentPreproc,
1168+ theme.Syntax.Operator,
1169+ theme.Syntax.Punctuation,
1170+ theme.Syntax.Text,
1171+ } {
1172+ h = fnv64aWrite(h, []byte(s))
1173+ h = fnv64aWriteByte(h, 0)
1174+ }
1175+ for _, c := range []TerminalColor{
1176+ theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1177+ theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1178+ theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1179+ theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1180+ theme.Terminal.Background,
1181+ } {
1182+ h = hashThemeColor(h, c)
1183+ }
1184+ if theme.Background == nil {
1185+ h = fnv64aWriteByte(h, 0)
1186+ } else {
1187+ h = fnv64aWriteByte(h, 1)
1188+ h = hashThemeColor(h, *theme.Background)
1189+ }
1190+ h = fnv64aWriteByte(h, 0xff)
1191+ h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1192+ h = fnv64aWriteByte(h, 0xff)
1193+ for _, c := range theme.Terminal.Palette {
1194+ h = hashThemeColor(h, c)
1195+ }
1196+ for _, value := range []bool{
1197+ theme.Inherited,
1198+ theme.Terminal.HasForeground,
1199+ theme.Terminal.HasBackground,
1200+ theme.Terminal.TrueColor,
1201+ theme.Terminal.Light,
1202+ theme.Terminal.SchemeKnown,
1203+ } {
1204+ if value {
1205+ h = fnv64aWriteByte(h, 1)
1206+ } else {
1207+ h = fnv64aWriteByte(h, 0)
1208+ }
1209+ }
1210+ return h
1211+}
1212+
1213+func hashThemeColor(h uint64, c TerminalColor) uint64 {
1214+ h = fnv64aWriteByte(h, byte(c.Mode))
1215+ h = fnv64aWriteByte(h, byte(c.Index))
1216+ h = fnv64aWriteByte(h, byte(c.R))
1217+ h = fnv64aWriteByte(h, byte(c.G))
1218+ h = fnv64aWriteByte(h, byte(c.B))
1219+ return h
846 }1220 }
847 1221
848 func (v *View) renderToolCall(tc ToolCallView, width int) []string {1222 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
@@ -979,7 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
979 // confirmation preview immediately gets the same colors, numbered gutters,1353 // confirmation preview immediately gets the same colors, numbered gutters,
980 // and syntax highlighting as its final tool result.1354 // and syntax highlighting as its final tool result.
981 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 {
982- return v.renderToolText(text, width, color, sourcePath, 1)1356+ full := v.renderToolText(text, width, color, sourcePath, 1)
1357+ if v.ExpandAll || len(full) <= ToolCollapseLines {
1358+ return full
1359+ }
1360+ return v.collapseToolBodyWithTotal(full, false, len(full))
983 }1361 }
984 1362
985 // renderLiveToolBody renders the in-flight preview of a streaming1363 // renderLiveToolBody renders the in-flight preview of a streaming
@@ -1472,6 +1850,13 @@ func trimLeadingSpaces(s string, n int) string {
1472 func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {1850 func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
1473 var body []string1851 var body []string
1474 hasImage := false1852 hasImage := false
1853+ for _, b := range blocks {
1854+ if _, ok := b.(provider.ImageBlock); ok {
1855+ hasImage = true
1856+ break
1857+ }
1858+ }
1859+ total := 0
1475 for _, b := range blocks {1860 for _, b := range blocks {
1476 switch bb := b.(type) {1861 switch bb := b.(type) {
1477 case provider.TextBlock:1862 case provider.TextBlock:
@@ -1479,13 +1864,22 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
1479 if v.FlatTools || v.CompactMode {1864 if v.FlatTools || v.CompactMode {
1480 bodyWidth = flatToolBodyRenderWidth(width)1865 bodyWidth = flatToolBodyRenderWidth(width)
1481 }1866 }
1482- body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)1867+ estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1868+ estimated := estimateToolTextLines(bb.Text, estimateWidth)
1869+ total += estimated
1870+ text := bb.Text
1871+ // A collapsed result only exposes its first ten rendered rows.
1872+ // Render a bounded source preview in that case; syntax
1873+ // highlighting and wrapping the omitted tail is wasted work.
1874+ if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1875+ text = previewToolText(text, estimateWidth)
1876+ }
1877+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
1483 case provider.ImageBlock:1878 case provider.ImageBlock:
1484- hasImage = true
1485 body = append(body, v.renderImageBlock(bb, width)...)1879 body = append(body, v.renderImageBlock(bb, width)...)
1486 }1880 }
1487 }1881 }
1488- return v.collapseToolBody(body, hasImage)1882+ return v.collapseToolBodyWithTotal(body, hasImage, total)
1489 }1883 }
1490 1884
1491 // collapseToolBody trims lines to the configured preview size when the1885 // collapseToolBody trims lines to the configured preview size when the
@@ -1493,15 +1887,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
1493 // expand" footer. Image blocks never collapse — they're short in text1887 // expand" footer. Image blocks never collapse — they're short in text
1494 // rows but represent real content the user wants to see.1888 // rows but represent real content the user wants to see.
1495 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {1889 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1890+ return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1891+}
1892+
1893+func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
1496 if v.ExpandAll || hasImage {1894 if v.ExpandAll || hasImage {
1497 return lines1895 return lines
1498 }1896 }
1499- if len(lines) <= ToolCollapseLines {1897+ if total < len(lines) {
1898+ total = len(lines)
1899+ }
1900+ if total <= ToolCollapseLines {
1500 return lines1901 return lines
1501 }1902 }
1502- kept := lines[:ToolCollapsePreview]1903+ kept := lines
1503- hidden := len(lines) - ToolCollapsePreview1904+ if len(kept) > ToolCollapsePreview {
1504- total := len(lines)1905+ kept = kept[:ToolCollapsePreview]
1906+ }
1907+ hidden := total - ToolCollapsePreview
1505 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)1908 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1506 footer = v.Theme.FGColor(v.Theme.Muted, footer)1909 footer = v.Theme.FGColor(v.Theme.Muted, footer)
1507 out := append([]string(nil), kept...)1910 out := append([]string(nil), kept...)
@@ -1509,6 +1912,67 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1509 return append(out, footer)1912 return append(out, footer)
1510 }1913 }
1511 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+
1930+func estimateToolTextLines(text string, width int) int {
1931+ if width < 1 {
1932+ width = 1
1933+ }
1934+ total := 0
1935+ for _, line := range strings.Split(text, "\n") {
1936+ cells := runewidth.StringWidth(line)
1937+ if cells == 0 {
1938+ total++
1939+ continue
1940+ }
1941+ total += (cells + width - 1) / width
1942+ }
1943+ if total == 0 {
1944+ return 1
1945+ }
1946+ return total
1947+}
1948+
1949+func previewToolText(text string, width int) string {
1950+ if width < 1 {
1951+ width = 1
1952+ }
1953+ maxChars := width * (ToolCollapsePreview + 2)
1954+ if maxChars < 256 {
1955+ maxChars = 256
1956+ }
1957+ if maxChars > 8192 {
1958+ maxChars = 8192
1959+ }
1960+ end := 0
1961+ for count := 0; end < len(text) && count < maxChars; count++ {
1962+ _, size := utf8.DecodeRuneInString(text[end:])
1963+ if size == 0 {
1964+ break
1965+ }
1966+ end += size
1967+ }
1968+ preview := text[:end]
1969+ lines := strings.SplitAfter(preview, "\n")
1970+ if len(lines) > ToolCollapsePreview+2 {
1971+ preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1972+ }
1973+ return preview
1974+}
1975+
1512 // renderToolText renders a text block inside a tool result. If the1976 // renderToolText renders a text block inside a tool result. If the
1513 // text contains a unified-diff section (lines starting with "--- " /1977 // text contains a unified-diff section (lines starting with "--- " /
1514 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove1978 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
A packages/tui/view_bench_test.go +133 -0
@@ -0,0 +1,133 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) {
13+ for _, width := range []int{48, 80, 160} {
14+ for _, expanded := range []bool{false, true} {
15+ for _, temperature := range []string{"cold", "warm"} {
16+ name := fmt.Sprintf("width=%d/expanded=%t/%s", width, expanded, temperature)
17+ b.Run(name, func(b *testing.B) {
18+ base := benchmarkToolStormView(expanded)
19+ if temperature == "warm" {
20+ base.BuildWithAnchors(width)
21+ }
22+ b.ReportAllocs()
23+ b.ResetTimer()
24+ for n := 0; n < b.N; n++ {
25+ view := base
26+ if temperature == "cold" {
27+ view.InvalidateRenderCache()
28+ }
29+ lines, anchors := view.BuildWithAnchors(width)
30+ if len(lines) == 0 || len(anchors) == 0 {
31+ b.Fatal("tool storm produced no rows or anchors")
32+ }
33+ }
34+ })
35+ }
36+ }
37+ }
38+}
39+
40+func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) {
41+ for _, width := range []int{48, 80, 160} {
42+ for _, expanded := range []bool{false, true} {
43+ name := "width=" + strconv.Itoa(width) + "/expanded=" + strconv.FormatBool(expanded)
44+ b.Run(name, func(b *testing.B) {
45+ view := &View{
46+ Theme: Dark,
47+ ExpandAll: expanded,
48+ Messages: benchmarkToolStormMessages(24, 8),
49+ MessagesRevision: 1,
50+ ToolCalls: benchmarkLiveTools(8, 96),
51+ }
52+ liveResults := make([]string, len(view.ToolCalls))
53+ for i := range liveResults {
54+ liveResults[i] = benchmarkResult(96, i)
55+ }
56+ b.ReportAllocs()
57+ b.ResetTimer()
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)]
62+ lines, anchors := view.BuildWithAnchors(width)
63+ if len(lines) == 0 || len(anchors) == 0 {
64+ b.Fatal("live tool storm produced no rows or anchors")
65+ }
66+ }
67+ })
68+ }
69+ }
70+}
71+
72+func benchmarkToolStormView(expanded bool) *View {
73+ return &View{
74+ Theme: Dark,
75+ ExpandAll: expanded,
76+ Messages: benchmarkToolStormMessages(48, 96),
77+ MessagesRevision: 1,
78+ }
79+}
80+
81+func benchmarkToolStormMessages(tools, resultLines int) []provider.Message {
82+ messages := make([]provider.Message, 0, tools*3)
83+ for i := 0; i < tools; i++ {
84+ id := fmt.Sprintf("call-%d", i)
85+ messages = append(messages,
86+ provider.Message{
87+ Role: provider.RoleUser,
88+ Content: []provider.Content{provider.TextBlock{
89+ Text: fmt.Sprintf("inspect result %d", i),
90+ }},
91+ },
92+ provider.Message{
93+ Role: provider.RoleAssistant,
94+ Content: []provider.Content{provider.ToolCallBlock{
95+ ID: id,
96+ Name: "bash",
97+ Arguments: []byte(`{"command":"printf storm"}`),
98+ }},
99+ },
100+ provider.Message{
101+ Role: provider.RoleTool,
102+ Content: []provider.Content{provider.ToolResultBlock{
103+ CallID: id,
104+ Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}},
105+ }},
106+ },
107+ )
108+ }
109+ return messages
110+}
111+
112+func benchmarkLiveTools(tools, resultLines int) []ToolCallView {
113+ out := make([]ToolCallView, 0, tools)
114+ for i := 0; i < tools; i++ {
115+ out = append(out, ToolCallView{
116+ ID: fmt.Sprintf("live-%d", i),
117+ Name: "bash",
118+ Revision: 1,
119+ Args: "printf storm",
120+ Result: benchmarkResult(resultLines, i),
121+ Done: true,
122+ })
123+ }
124+ return out
125+}
126+
127+func benchmarkResult(lines, tool int) string {
128+ var b strings.Builder
129+ for line := 0; line < lines; line++ {
130+ fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line)
131+ }
132+ return b.String()
133+}
@@ -0,0 +1,133 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) {
13+ for _, width := range []int{48, 80, 160} {
14+ for _, expanded := range []bool{false, true} {
15+ for _, temperature := range []string{"cold", "warm"} {
16+ name := fmt.Sprintf("width=%d/expanded=%t/%s", width, expanded, temperature)
17+ b.Run(name, func(b *testing.B) {
18+ base := benchmarkToolStormView(expanded)
19+ if temperature == "warm" {
20+ base.BuildWithAnchors(width)
21+ }
22+ b.ReportAllocs()
23+ b.ResetTimer()
24+ for n := 0; n < b.N; n++ {
25+ view := base
26+ if temperature == "cold" {
27+ view.InvalidateRenderCache()
28+ }
29+ lines, anchors := view.BuildWithAnchors(width)
30+ if len(lines) == 0 || len(anchors) == 0 {
31+ b.Fatal("tool storm produced no rows or anchors")
32+ }
33+ }
34+ })
35+ }
36+ }
37+ }
38+}
39+
40+func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) {
41+ for _, width := range []int{48, 80, 160} {
42+ for _, expanded := range []bool{false, true} {
43+ name := "width=" + strconv.Itoa(width) + "/expanded=" + strconv.FormatBool(expanded)
44+ b.Run(name, func(b *testing.B) {
45+ view := &View{
46+ Theme: Dark,
47+ ExpandAll: expanded,
48+ Messages: benchmarkToolStormMessages(24, 8),
49+ MessagesRevision: 1,
50+ ToolCalls: benchmarkLiveTools(8, 96),
51+ }
52+ liveResults := make([]string, len(view.ToolCalls))
53+ for i := range liveResults {
54+ liveResults[i] = benchmarkResult(96, i)
55+ }
56+ b.ReportAllocs()
57+ b.ResetTimer()
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)]
62+ lines, anchors := view.BuildWithAnchors(width)
63+ if len(lines) == 0 || len(anchors) == 0 {
64+ b.Fatal("live tool storm produced no rows or anchors")
65+ }
66+ }
67+ })
68+ }
69+ }
70+}
71+
72+func benchmarkToolStormView(expanded bool) *View {
73+ return &View{
74+ Theme: Dark,
75+ ExpandAll: expanded,
76+ Messages: benchmarkToolStormMessages(48, 96),
77+ MessagesRevision: 1,
78+ }
79+}
80+
81+func benchmarkToolStormMessages(tools, resultLines int) []provider.Message {
82+ messages := make([]provider.Message, 0, tools*3)
83+ for i := 0; i < tools; i++ {
84+ id := fmt.Sprintf("call-%d", i)
85+ messages = append(messages,
86+ provider.Message{
87+ Role: provider.RoleUser,
88+ Content: []provider.Content{provider.TextBlock{
89+ Text: fmt.Sprintf("inspect result %d", i),
90+ }},
91+ },
92+ provider.Message{
93+ Role: provider.RoleAssistant,
94+ Content: []provider.Content{provider.ToolCallBlock{
95+ ID: id,
96+ Name: "bash",
97+ Arguments: []byte(`{"command":"printf storm"}`),
98+ }},
99+ },
100+ provider.Message{
101+ Role: provider.RoleTool,
102+ Content: []provider.Content{provider.ToolResultBlock{
103+ CallID: id,
104+ Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}},
105+ }},
106+ },
107+ )
108+ }
109+ return messages
110+}
111+
112+func benchmarkLiveTools(tools, resultLines int) []ToolCallView {
113+ out := make([]ToolCallView, 0, tools)
114+ for i := 0; i < tools; i++ {
115+ out = append(out, ToolCallView{
116+ ID: fmt.Sprintf("live-%d", i),
117+ Name: "bash",
118+ Revision: 1,
119+ Args: "printf storm",
120+ Result: benchmarkResult(resultLines, i),
121+ Done: true,
122+ })
123+ }
124+ return out
125+}
126+
127+func benchmarkResult(lines, tool int) string {
128+ var b strings.Builder
129+ for line := 0; line < lines; line++ {
130+ fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line)
131+ }
132+ return b.String()
133+}
A packages/tui/view_cache_test.go +110 -0
@@ -0,0 +1,110 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
12+ messages := []provider.Message{
13+ {
14+ Role: provider.RoleAssistant,
15+ Content: []provider.Content{provider.ToolCallBlock{
16+ ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`),
17+ }},
18+ },
19+ {
20+ Role: provider.RoleTool,
21+ Content: []provider.Content{provider.ToolResultBlock{
22+ CallID: "call-1",
23+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}},
24+ }},
25+ },
26+ }
27+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1}
28+ collapsed, _ := view.BuildWithAnchors(40)
29+ if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") {
30+ t.Fatal("long tool result did not use the collapsed presentation")
31+ }
32+
33+ view.ExpandAll = true
34+ expanded, _ := view.BuildWithAnchors(40)
35+ if len(expanded) <= len(collapsed) {
36+ t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded))
37+ }
38+
39+ wide, _ := view.BuildWithAnchors(120)
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+ }
45+
46+ view.ExpandAll = false
47+ view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{
48+ CallID: "call-1",
49+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
50+ }}
51+ view.Messages = append(view.Messages, provider.Message{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "third message"}},
54+ })
55+ view.MessagesRevision = 2
56+ updated, _ := view.BuildWithAnchors(40)
57+ if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
58+ t.Fatal("tool output revision reused stale cached rows")
59+ }
60+}
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+
101+func cacheTestResult(prefix string) string {
102+ var b strings.Builder
103+ for n := 0; n < 32; n++ {
104+ b.WriteString(prefix)
105+ b.WriteString(" line ")
106+ b.WriteString(string(rune('a' + n%26)))
107+ b.WriteByte('\n')
108+ }
109+ return b.String()
110+}
@@ -0,0 +1,110 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
12+ messages := []provider.Message{
13+ {
14+ Role: provider.RoleAssistant,
15+ Content: []provider.Content{provider.ToolCallBlock{
16+ ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`),
17+ }},
18+ },
19+ {
20+ Role: provider.RoleTool,
21+ Content: []provider.Content{provider.ToolResultBlock{
22+ CallID: "call-1",
23+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}},
24+ }},
25+ },
26+ }
27+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1}
28+ collapsed, _ := view.BuildWithAnchors(40)
29+ if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") {
30+ t.Fatal("long tool result did not use the collapsed presentation")
31+ }
32+
33+ view.ExpandAll = true
34+ expanded, _ := view.BuildWithAnchors(40)
35+ if len(expanded) <= len(collapsed) {
36+ t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded))
37+ }
38+
39+ wide, _ := view.BuildWithAnchors(120)
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+ }
45+
46+ view.ExpandAll = false
47+ view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{
48+ CallID: "call-1",
49+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
50+ }}
51+ view.Messages = append(view.Messages, provider.Message{
52+ Role: provider.RoleUser,
53+ Content: []provider.Content{provider.TextBlock{Text: "third message"}},
54+ })
55+ view.MessagesRevision = 2
56+ updated, _ := view.BuildWithAnchors(40)
57+ if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
58+ t.Fatal("tool output revision reused stale cached rows")
59+ }
60+}
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+
101+func cacheTestResult(prefix string) string {
102+ var b strings.Builder
103+ for n := 0; n < 32; n++ {
104+ b.WriteString(prefix)
105+ b.WriteString(" line ")
106+ b.WriteString(string(rune('a' + n%26)))
107+ b.WriteByte('\n')
108+ }
109+ return b.String()
110+}

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