detached 89d4c91

perf(tui): improve tool-storm responsiveness

commit
89d4c9167df71fc798cabe81c67d4693418863b9
author
brice · · 2026-08-07 13:01 UTC
parent
8a8e2b3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1081 -90
M packages/agent/modes/interactive.go +179 -58
@@ -440,6 +440,7 @@ type chatCacheKey struct {
440440 welcomeShowVer bool
441441 expandAll bool
442442 tailLimit int
443+ viewCacheRev uint64
443444 }
444445
445446 // QuickModelShortcut is one configured quick model switch slot.
@@ -660,6 +661,9 @@ type Interactive struct {
660661 subagentActivityActive bool
661662 pendingIdleWork []func()
662663 dirty chan struct{}
664+ renderScheduler atomic.Pointer[latestFrameScheduler]
665+ renderRevision atomic.Uint64
666+ renderOutsideLock bool
663667 modelRefresh chan modelRefreshResult
664668 modelRefreshing bool
665669 startupPreDone chan startupPreResult
@@ -689,6 +693,13 @@ type Interactive struct {
689693 chatCacheKey chatCacheKey
690694 chatCacheValid bool
691695
696+ // stableChatCache contains only transcript/startup rows. Live streaming,
697+ // tool overlays, and errors are appended separately so spinner/progress
698+ // frames can reuse finalized chat while a turn is busy.
699+ stableChatCache []string
700+ stableChatCacheKey chatCacheKey
701+ stableChatCacheValid bool
702+
692703 // Messages typed while a turn is in flight. Each is delivered as
693704 // its own follow-up turn once the current one finishes. Rendered
694705 // above the status bar as "sliding in: ..." chips.
@@ -1037,17 +1048,26 @@ func (i *Interactive) Run(ctx context.Context) error {
10371048
10381049 cols, rows := term.Size()
10391050 i.rend.Resize(cols, rows)
1040- term.OnResize(func() {
1051+ renderScheduler := newLatestFrameScheduler()
1052+ i.renderScheduler.Store(renderScheduler)
1053+ go renderScheduler.run(func(req renderRequest) {
10411054 c, r := term.Size()
10421055 i.rend.Resize(c, r)
1043- // Force an immediate redraw on resize. The throttled invalidate
1044- // path is fine for animation, but a window resize is a discrete
1045- // user action where any visible delay (or stale frame) reads as
1046- // brokenness. redraw() is mutex-safe; the worst that happens is
1047- // a duplicate paint if the throttler is mid-flight, which is
1048- // invisible.
1056+ if req.theme != nil {
1057+ i.rend.SetTheme(*req.theme)
1058+ }
1059+ if req.clear {
1060+ i.rend.Clear()
1061+ }
10491062 i.redraw()
10501063 })
1064+ defer renderScheduler.stop()
1065+ term.OnResize(func() {
1066+ // Resize and redraw share the renderer owner. The owner reads the
1067+ // latest terminal size just before its next frame.
1068+ i.renderRevision.Add(1)
1069+ renderScheduler.request(false)
1070+ })
10511071
10521072 if i.cfg.StartupPre != "" {
10531073 i.deferredInitialInput = i.cfg.InitialInput
@@ -1134,7 +1154,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11341154 if pendingRedraw {
11351155 pendingRedraw = false
11361156 lastRedraw = time.Now()
1137- i.redraw()
1157+ renderScheduler.request(false)
11381158 }
11391159 }
11401160
@@ -1152,7 +1172,7 @@ func (i *Interactive) Run(ctx context.Context) error {
11521172 }
11531173 pendingRedraw = false
11541174 lastRedraw = time.Now()
1155- i.redraw()
1175+ renderScheduler.request(false)
11561176 return
11571177 }
11581178 if pendingRedraw {
@@ -1281,7 +1301,42 @@ func (i *Interactive) Run(ctx context.Context) error {
12811301 }
12821302 }
12831303
1304+func (i *Interactive) requestRendererClear() {
1305+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1306+ scheduler.request(true)
1307+ return
1308+ }
1309+ if i.rend != nil {
1310+ i.rend.Clear()
1311+ }
1312+}
1313+
1314+func (i *Interactive) requestRendererInvalidate() {
1315+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316+ scheduler.request(false)
1317+ return
1318+ }
1319+ if i.rend != nil {
1320+ i.rend.Invalidate()
1321+ }
1322+}
1323+
1324+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1325+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1326+ scheduler.requestTheme(theme)
1327+ return
1328+ }
1329+ if i.rend != nil {
1330+ i.rend.SetTheme(theme)
1331+ }
1332+}
1333+
12841334 func (i *Interactive) invalidate() {
1335+ i.renderRevision.Add(1)
1336+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1337+ scheduler.request(false)
1338+ return
1339+ }
12851340 select {
12861341 case i.dirty <- struct{}{}:
12871342 default:
@@ -1289,6 +1344,12 @@ func (i *Interactive) invalidate() {
12891344 }
12901345
12911346 func (i *Interactive) cachedChatLocked(cols int) []string {
1347+ // A busy frame contains mutable streaming/tool state. Keep the stable
1348+ // transcript cache, but never return a full-frame cache entry that would
1349+ // hide a newly arrived delta or completed result.
1350+ if i.busy || i.streamOn || i.streamFlushPending {
1351+ return i.buildChatLocked(cols)
1352+ }
12921353 key, cacheable := i.chatCacheKeyLocked(cols)
12931354 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
12941355 return append([]string(nil), i.chatCache...)
@@ -1305,12 +1366,6 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
13051366 }
13061367
13071368 func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1308- // Live turns mutate streaming/tool-call state at high frequency;
1309- // keep those on the old rebuild path. The cache targets the common
1310- // idle case where only the editor contents changed between redraws.
1311- if i.busy || i.streamOn || i.streamFlushPending {
1312- return chatCacheKey{}, false
1313- }
13141369 var rev uint64
13151370 if i.agent != nil {
13161371 rev = i.agent.Revision()
@@ -1333,6 +1388,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
13331388 welcomeShowVer: showVer,
13341389 expandAll: i.view.ExpandAll,
13351390 tailLimit: i.view.TailLimit,
1391+ viewCacheRev: i.view.RenderCacheRevision,
13361392 }, true
13371393 }
13381394
@@ -1509,11 +1565,47 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
15091565 return out
15101566 }
15111567
1568+func (i *Interactive) stableChatRowsLocked(cols int) []string {
1569+ key, _ := i.chatCacheKeyLocked(cols)
1570+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
1571+ return append([]string(nil), i.stableChatCache...)
1572+ }
1573+
1574+ renderView := i.view.CloneForRender()
1575+ renderView.Streaming = ""
1576+ renderView.StreamingActive = false
1577+ renderView.ToolCalls = nil
1578+ renderView.Err = ""
1579+ if !i.renderOutsideLock {
1580+ rows := renderView.Build(cols)
1581+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1582+ i.stableChatCacheKey = key
1583+ i.stableChatCacheValid = true
1584+ return append([]string(nil), rows...)
1585+ }
1586+ // Markdown, syntax highlighting, and wrapping are the expensive part of
1587+ // a frame. Build the immutable snapshot without holding the interactive
1588+ // mutex so key processing can continue while a cold transcript renders.
1589+ i.mu.Unlock()
1590+ rows := renderView.Build(cols)
1591+ i.mu.Lock()
1592+ if i.view.MessagesRevision == renderView.MessagesRevision {
1593+ i.view.AdoptRenderCacheFrom(renderView)
1594+ }
1595+
1596+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1597+ i.stableChatCacheKey = key
1598+ i.stableChatCacheValid = true
1599+ return append([]string(nil), rows...)
1600+}
1601+
15121602 func (i *Interactive) buildChatLocked(cols int) []string {
15131603 if i.agent != nil {
15141604 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1605+ i.view.MessagesRevision = i.agent.Revision()
15151606 } else {
15161607 i.view.Messages = nil
1608+ i.view.MessagesRevision = 0
15171609 }
15181610 // Pacer flush: while the streaming pacer is still draining the
15191611 // buffer (i.e. EvAssistantMessage already fired but more runes
@@ -1577,7 +1669,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
15771669 // rows update in place at the end of the buffer, instead of the
15781670 // whole bottom band shrinking and shifting chat lines around.
15791671 i.liveBlock = nil
1580- chat := i.view.Build(cols)
1672+ stable := i.stableChatRowsLocked(cols)
1673+ chat := append([]string(nil), stable...)
1674+ chat = append(chat, i.view.BuildLive(cols)...)
15811675
15821676 // Welcome banner: shown at the top of the chat area when there is
15831677 // no transcript yet. Disappears after the first message is sent.
@@ -1702,7 +1796,7 @@ func (i *Interactive) scrollBy(delta int) {
17021796 // wrapped-character fragments behind during scroll-driven
17031797 // viewport changes. Force a full repaint on scroll, but
17041798 // avoid a whole-screen clear because that visibly flickers.
1705- i.rend.Invalidate()
1799+ i.requestRendererInvalidate()
17061800 }
17071801 i.mu.Unlock()
17081802 i.invalidate()
@@ -1743,16 +1837,14 @@ func (i *Interactive) scrollToBottom() {
17431837 // after resume. See commit 43da5e5 for the same fix on new turns.
17441838 i.prevChatLen = 0
17451839 i.prevChatCols = 0
1746- if i.rend != nil {
1747- i.rend.Invalidate()
1748- }
1840+ i.requestRendererInvalidate()
17491841 i.mu.Unlock()
17501842 i.invalidate()
17511843 }
17521844
17531845 func (i *Interactive) redraw() {
17541846 i.mu.Lock()
1755- defer i.mu.Unlock()
1847+ renderRevision := i.renderRevision.Load()
17561848
17571849 cols, rows := i.cfg.Terminal.Size()
17581850 mainCols := cols
@@ -1767,7 +1859,13 @@ func (i *Interactive) redraw() {
17671859 }
17681860 }
17691861 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1862+ i.renderOutsideLock = true
17701863 chat := i.cachedChatLocked(mainCols)
1864+ i.renderOutsideLock = false
1865+ if i.renderRevision.Load() != renderRevision {
1866+ i.mu.Unlock()
1867+ return
1868+ }
17711869
17721870 // Dialogs (login or model picker) render between chat and the editor.
17731871 var dialog []string
@@ -1904,7 +2002,7 @@ func (i *Interactive) redraw() {
19042002 // is repainted clean, matching what the user expects after
19052003 // dismissing a picker. Clear() is keepScrollback-aware and
19062004 // emits \x1b[3J there.
1907- i.rend.Clear()
2005+ i.requestRendererClear()
19082006 }
19092007 i.prevOverlayOpen = overlayOpen
19102008 if len(suggest) > 0 {
@@ -2345,8 +2443,16 @@ func (i *Interactive) redraw() {
23452443 cursorCol = 0
23462444 }
23472445 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2446+ if i.renderRevision.Load() != renderRevision {
2447+ i.mu.Unlock()
2448+ return
2449+ }
2450+ theme := i.cfg.Theme
2451+ // State assembly above is synchronized; terminal output is owned by
2452+ // this renderer goroutine and must not hold the global state mutex.
2453+ i.mu.Unlock()
23482454 if rightBarActive {
2349- rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2455+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
23502456 if modalBackdrop {
23512457 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
23522458 } else {
@@ -2356,11 +2462,13 @@ func (i *Interactive) redraw() {
23562462 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
23572463 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
23582464 }
2465+ i.mu.Lock()
23592466 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
23602467 alert := *i.pendingAlert
23612468 i.pendingAlert = nil
23622469 i.emitAlertLocked(alert)
23632470 }
2471+ i.mu.Unlock()
23642472 }
23652473
23662474 func hasImageEscape(line string) bool {
@@ -2606,9 +2714,7 @@ func (i *Interactive) clearFileSuggestQuery() {
26062714 func (i *Interactive) setToolExpansion(expanded bool) {
26072715 i.mu.Lock()
26082716 i.view.ExpandAll = expanded
2609- if i.rend != nil {
2610- i.rend.Clear()
2611- }
2717+ i.requestRendererClear()
26122718 i.mu.Unlock()
26132719 i.invalidate()
26142720 }
@@ -2623,9 +2729,7 @@ func (i *Interactive) toggleToolExpansion() {
26232729 func (i *Interactive) toggleBtwToolExpansion() {
26242730 i.btwDialog.ToggleToolExpansion()
26252731 i.mu.Lock()
2626- if i.rend != nil {
2627- i.rend.Clear()
2628- }
2732+ i.requestRendererClear()
26292733 i.mu.Unlock()
26302734 i.invalidate()
26312735 }
@@ -3178,11 +3282,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31783282 i.mu.Lock()
31793283 i.rightBarHidden = !i.rightBarHidden
31803284 i.mu.Unlock()
3181- i.rend.Invalidate()
3285+ i.requestRendererInvalidate()
31823286 i.invalidate()
31833287 return false
31843288 case tui.KeyCtrlL:
3185- i.rend.Clear()
3289+ i.requestRendererClear()
31863290 i.invalidate()
31873291 return false
31883292 case tui.KeyPasteClipboard:
@@ -3912,6 +4016,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
39124016 }
39134017 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
39144018 i.view.StartupContextPaths = nil
4019+ i.view.InvalidateRenderCache()
39154020 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
39164021 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
39174022 }
@@ -4981,9 +5086,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
49815086 // change (image rendering, status copy, future toggles) lands
49825087 // immediately instead of waiting for the next diff frame.
49835088 defer func() {
4984- if i.rend != nil {
4985- i.rend.Clear()
4986- }
5089+ i.requestRendererClear()
49875090 i.invalidate()
49885091 }()
49895092 switch key {
@@ -5325,6 +5428,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
53255428 i.view.StartupContextPaths = nil
53265429 i.view.StartupExtensionNames = nil
53275430 i.view.StartupSkillNames = nil
5431+ i.view.InvalidateRenderCache()
53285432 if value {
53295433 i.view.StartupAgentName = i.cfg.StartupAgentName
53305434 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
@@ -5339,9 +5443,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
53395443
53405444 func (i *Interactive) applyTUIInputStyleSetting(style string) {
53415445 defer func() {
5342- if i.rend != nil {
5343- i.rend.Clear()
5344- }
5446+ i.requestRendererClear()
53455447 i.invalidate()
53465448 }()
53475449 style = tui.NormalizeInputStyle(style)
@@ -5363,9 +5465,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) {
53635465
53645466 func (i *Interactive) applyTUIStatusPositionSetting(position string) {
53655467 defer func() {
5366- if i.rend != nil {
5367- i.rend.Clear()
5368- }
5468+ i.requestRendererClear()
53695469 i.invalidate()
53705470 }()
53715471 position = tui.NormalizeStatusPosition(position)
@@ -5390,9 +5490,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) {
53905490
53915491 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
53925492 defer func() {
5393- if i.rend != nil {
5394- i.rend.Clear()
5395- }
5493+ i.requestRendererClear()
53965494 i.invalidate()
53975495 }()
53985496 position = tui.NormalizeWorkingPosition(position)
@@ -5417,9 +5515,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
54175515
54185516 func (i *Interactive) applyTUISubagentPositionSetting(position string) {
54195517 defer func() {
5420- if i.rend != nil {
5421- i.rend.Clear()
5422- }
5518+ i.requestRendererClear()
54235519 i.invalidate()
54245520 }()
54255521 position = tui.NormalizeSubagentPosition(position)
@@ -5496,18 +5592,14 @@ func (i *Interactive) applyThemeNow(name string) {
54965592 i.ed.Prompt = th.AccentBar(th.Accent)
54975593 i.applyInputCursorColor()
54985594 i.spin.Configure(th)
5499- if i.rend != nil {
5500- i.rend.SetTheme(th)
5501- i.rend.Clear()
5502- }
5595+ i.requestRendererTheme(th)
5596+ i.requestRendererClear()
55035597 i.invalidate()
55045598 }
55055599
55065600 func (i *Interactive) applyReasoningSetting(level string) {
55075601 defer func() {
5508- if i.rend != nil {
5509- i.rend.Clear()
5510- }
5602+ i.requestRendererClear()
55115603 i.invalidate()
55125604 }()
55135605 level = provider.NormalizeReasoning(level)
@@ -7560,8 +7652,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
75607652 lastStop = e.Stop
75617653 lastTurnErr = e.Err
75627654 }
7563- i.handleEvent(ev)
7564- i.invalidate()
7655+ i.handleEventForPresentation(ev)
75657656 }
75667657
75677658 go func() {
@@ -7996,6 +8087,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
79968087 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
79978088 }
79988089
8090+func eventAffectsPresentation(ev core.AgentEvent) bool {
8091+ switch ev.(type) {
8092+ case core.EvToolProgress:
8093+ return false
8094+ default:
8095+ return true
8096+ }
8097+}
8098+
8099+func bumpToolRevision(tc *tui.ToolCallView) {
8100+ if tc.Revision == ^uint64(0) {
8101+ tc.Revision = 1
8102+ return
8103+ }
8104+ tc.Revision++
8105+}
8106+
8107+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8108+ i.handleEvent(ev)
8109+ // Progress payloads are still delivered to the core sink and applied
8110+ // in order, but they have no interactive-visible representation.
8111+ if eventAffectsPresentation(ev) {
8112+ i.invalidate()
8113+ }
8114+}
8115+
79998116 func (i *Interactive) handleEvent(ev core.AgentEvent) {
80008117 i.mu.Lock()
80018118 defer i.mu.Unlock()
@@ -8052,6 +8169,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80528169 i.toolCalls[e.ID] = &tui.ToolCallView{
80538170 ID: e.ID,
80548171 Name: e.Name,
8172+ Revision: 1,
80558173 Streaming: true,
80568174 }
80578175 i.toolOrder = append(i.toolOrder, e.ID)
@@ -8060,6 +8178,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80608178 case core.EvToolUseArgs:
80618179 if tc, ok := i.toolCalls[e.ID]; ok {
80628180 tc.RawJSONBuf += e.Delta
8181+ bumpToolRevision(tc)
80638182 // Refresh the live path as soon as it parses; used in
80648183 // the header (write /Users/example/Desktop/demo.ts)
80658184 // while the content is still streaming.
@@ -8072,6 +8191,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80728191 case core.EvToolUseEnd:
80738192 if tc, ok := i.toolCalls[e.ID]; ok {
80748193 tc.Streaming = false
8194+ bumpToolRevision(tc)
80758195 }
80768196 case core.EvToolCall:
80778197 // If we already pre-created the view during streaming, just
@@ -8079,6 +8199,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80798199 // (non-streaming providers or legacy paths).
80808200 if tc, ok := i.toolCalls[e.ID]; ok {
80818201 tc.Args = tui.ShortArgs(e.Name, e.Args)
8202+ bumpToolRevision(tc)
80828203 if tc.RawJSONBuf == "" {
80838204 tc.RawJSONBuf = string(e.Args)
80848205 }
@@ -8087,6 +8208,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
80878208 i.toolCalls[e.ID] = &tui.ToolCallView{
80888209 ID: e.ID,
80898210 Name: e.Name,
8211+ Revision: 1,
80908212 Args: tui.ShortArgs(e.Name, e.Args),
80918213 RawJSONBuf: string(e.Args),
80928214 }
@@ -8108,6 +8230,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
81088230 }
81098231 }
81108232 tc.Result = text.String()
8233+ bumpToolRevision(tc)
81118234 }
81128235 if i.cfg.OnToolResult != nil {
81138236 i.cfg.OnToolResult(e.ID, e.Result)
@@ -9475,9 +9598,7 @@ func (i *Interactive) resetTranscriptRenderLocked() {
94759598 i.prevChatCols = 0
94769599 i.prevChatRows = 0
94779600 i.prevScrollOffset = 0
9478- if i.rend != nil {
9479- i.rend.Invalidate()
9480- }
9601+ i.requestRendererInvalidate()
94819602 }
94829603
94839604 // resetStreamingStateLocked clears every piece of streaming state
@@ -440,6 +440,7 @@ type chatCacheKey struct {
440 welcomeShowVer bool440 welcomeShowVer bool
441 expandAll bool441 expandAll bool
442 tailLimit int442 tailLimit int
443+ viewCacheRev uint64
443 }444 }
444 445
445 // QuickModelShortcut is one configured quick model switch slot.446 // QuickModelShortcut is one configured quick model switch slot.
@@ -660,6 +661,9 @@ type Interactive struct {
660 subagentActivityActive bool661 subagentActivityActive bool
661 pendingIdleWork []func()662 pendingIdleWork []func()
662 dirty chan struct{}663 dirty chan struct{}
664+ renderScheduler atomic.Pointer[latestFrameScheduler]
665+ renderRevision atomic.Uint64
666+ renderOutsideLock bool
663 modelRefresh chan modelRefreshResult667 modelRefresh chan modelRefreshResult
664 modelRefreshing bool668 modelRefreshing bool
665 startupPreDone chan startupPreResult669 startupPreDone chan startupPreResult
@@ -689,6 +693,13 @@ type Interactive struct {
689 chatCacheKey chatCacheKey693 chatCacheKey chatCacheKey
690 chatCacheValid bool694 chatCacheValid bool
691 695
696+ // stableChatCache contains only transcript/startup rows. Live streaming,
697+ // tool overlays, and errors are appended separately so spinner/progress
698+ // frames can reuse finalized chat while a turn is busy.
699+ stableChatCache []string
700+ stableChatCacheKey chatCacheKey
701+ stableChatCacheValid bool
702+
692 // Messages typed while a turn is in flight. Each is delivered as703 // Messages typed while a turn is in flight. Each is delivered as
693 // its own follow-up turn once the current one finishes. Rendered704 // its own follow-up turn once the current one finishes. Rendered
694 // above the status bar as "sliding in: ..." chips.705 // above the status bar as "sliding in: ..." chips.
@@ -1037,17 +1048,26 @@ func (i *Interactive) Run(ctx context.Context) error {
1037 1048
1038 cols, rows := term.Size()1049 cols, rows := term.Size()
1039 i.rend.Resize(cols, rows)1050 i.rend.Resize(cols, rows)
1040- term.OnResize(func() {1051+ renderScheduler := newLatestFrameScheduler()
1052+ i.renderScheduler.Store(renderScheduler)
1053+ go renderScheduler.run(func(req renderRequest) {
1041 c, r := term.Size()1054 c, r := term.Size()
1042 i.rend.Resize(c, r)1055 i.rend.Resize(c, r)
1043- // Force an immediate redraw on resize. The throttled invalidate1056+ if req.theme != nil {
1044- // path is fine for animation, but a window resize is a discrete1057+ i.rend.SetTheme(*req.theme)
1045- // user action where any visible delay (or stale frame) reads as1058+ }
1046- // brokenness. redraw() is mutex-safe; the worst that happens is1059+ if req.clear {
1047- // a duplicate paint if the throttler is mid-flight, which is1060+ i.rend.Clear()
1048- // invisible.1061+ }
1049 i.redraw()1062 i.redraw()
1050 })1063 })
1064+ defer renderScheduler.stop()
1065+ term.OnResize(func() {
1066+ // Resize and redraw share the renderer owner. The owner reads the
1067+ // latest terminal size just before its next frame.
1068+ i.renderRevision.Add(1)
1069+ renderScheduler.request(false)
1070+ })
1051 1071
1052 if i.cfg.StartupPre != "" {1072 if i.cfg.StartupPre != "" {
1053 i.deferredInitialInput = i.cfg.InitialInput1073 i.deferredInitialInput = i.cfg.InitialInput
@@ -1134,7 +1154,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1134 if pendingRedraw {1154 if pendingRedraw {
1135 pendingRedraw = false1155 pendingRedraw = false
1136 lastRedraw = time.Now()1156 lastRedraw = time.Now()
1137- i.redraw()1157+ renderScheduler.request(false)
1138 }1158 }
1139 }1159 }
1140 1160
@@ -1152,7 +1172,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1152 }1172 }
1153 pendingRedraw = false1173 pendingRedraw = false
1154 lastRedraw = time.Now()1174 lastRedraw = time.Now()
1155- i.redraw()1175+ renderScheduler.request(false)
1156 return1176 return
1157 }1177 }
1158 if pendingRedraw {1178 if pendingRedraw {
@@ -1281,7 +1301,42 @@ func (i *Interactive) Run(ctx context.Context) error {
1281 }1301 }
1282 }1302 }
1283 1303
1304+func (i *Interactive) requestRendererClear() {
1305+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1306+ scheduler.request(true)
1307+ return
1308+ }
1309+ if i.rend != nil {
1310+ i.rend.Clear()
1311+ }
1312+}
1313+
1314+func (i *Interactive) requestRendererInvalidate() {
1315+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1316+ scheduler.request(false)
1317+ return
1318+ }
1319+ if i.rend != nil {
1320+ i.rend.Invalidate()
1321+ }
1322+}
1323+
1324+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1325+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1326+ scheduler.requestTheme(theme)
1327+ return
1328+ }
1329+ if i.rend != nil {
1330+ i.rend.SetTheme(theme)
1331+ }
1332+}
1333+
1284 func (i *Interactive) invalidate() {1334 func (i *Interactive) invalidate() {
1335+ i.renderRevision.Add(1)
1336+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
1337+ scheduler.request(false)
1338+ return
1339+ }
1285 select {1340 select {
1286 case i.dirty <- struct{}{}:1341 case i.dirty <- struct{}{}:
1287 default:1342 default:
@@ -1289,6 +1344,12 @@ func (i *Interactive) invalidate() {
1289 }1344 }
1290 1345
1291 func (i *Interactive) cachedChatLocked(cols int) []string {1346 func (i *Interactive) cachedChatLocked(cols int) []string {
1347+ // A busy frame contains mutable streaming/tool state. Keep the stable
1348+ // transcript cache, but never return a full-frame cache entry that would
1349+ // hide a newly arrived delta or completed result.
1350+ if i.busy || i.streamOn || i.streamFlushPending {
1351+ return i.buildChatLocked(cols)
1352+ }
1292 key, cacheable := i.chatCacheKeyLocked(cols)1353 key, cacheable := i.chatCacheKeyLocked(cols)
1293 if cacheable && i.chatCacheValid && i.chatCacheKey == key {1354 if cacheable && i.chatCacheValid && i.chatCacheKey == key {
1294 return append([]string(nil), i.chatCache...)1355 return append([]string(nil), i.chatCache...)
@@ -1305,12 +1366,6 @@ func (i *Interactive) cachedChatLocked(cols int) []string {
1305 }1366 }
1306 1367
1307 func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {1368 func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1308- // Live turns mutate streaming/tool-call state at high frequency;
1309- // keep those on the old rebuild path. The cache targets the common
1310- // idle case where only the editor contents changed between redraws.
1311- if i.busy || i.streamOn || i.streamFlushPending {
1312- return chatCacheKey{}, false
1313- }
1314 var rev uint641369 var rev uint64
1315 if i.agent != nil {1370 if i.agent != nil {
1316 rev = i.agent.Revision()1371 rev = i.agent.Revision()
@@ -1333,6 +1388,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1333 welcomeShowVer: showVer,1388 welcomeShowVer: showVer,
1334 expandAll: i.view.ExpandAll,1389 expandAll: i.view.ExpandAll,
1335 tailLimit: i.view.TailLimit,1390 tailLimit: i.view.TailLimit,
1391+ viewCacheRev: i.view.RenderCacheRevision,
1336 }, true1392 }, true
1337 }1393 }
1338 1394
@@ -1509,11 +1565,47 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
1509 return out1565 return out
1510 }1566 }
1511 1567
1568+func (i *Interactive) stableChatRowsLocked(cols int) []string {
1569+ key, _ := i.chatCacheKeyLocked(cols)
1570+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
1571+ return append([]string(nil), i.stableChatCache...)
1572+ }
1573+
1574+ renderView := i.view.CloneForRender()
1575+ renderView.Streaming = ""
1576+ renderView.StreamingActive = false
1577+ renderView.ToolCalls = nil
1578+ renderView.Err = ""
1579+ if !i.renderOutsideLock {
1580+ rows := renderView.Build(cols)
1581+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1582+ i.stableChatCacheKey = key
1583+ i.stableChatCacheValid = true
1584+ return append([]string(nil), rows...)
1585+ }
1586+ // Markdown, syntax highlighting, and wrapping are the expensive part of
1587+ // a frame. Build the immutable snapshot without holding the interactive
1588+ // mutex so key processing can continue while a cold transcript renders.
1589+ i.mu.Unlock()
1590+ rows := renderView.Build(cols)
1591+ i.mu.Lock()
1592+ if i.view.MessagesRevision == renderView.MessagesRevision {
1593+ i.view.AdoptRenderCacheFrom(renderView)
1594+ }
1595+
1596+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
1597+ i.stableChatCacheKey = key
1598+ i.stableChatCacheValid = true
1599+ return append([]string(nil), rows...)
1600+}
1601+
1512 func (i *Interactive) buildChatLocked(cols int) []string {1602 func (i *Interactive) buildChatLocked(cols int) []string {
1513 if i.agent != nil {1603 if i.agent != nil {
1514 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())1604 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1605+ i.view.MessagesRevision = i.agent.Revision()
1515 } else {1606 } else {
1516 i.view.Messages = nil1607 i.view.Messages = nil
1608+ i.view.MessagesRevision = 0
1517 }1609 }
1518 // Pacer flush: while the streaming pacer is still draining the1610 // Pacer flush: while the streaming pacer is still draining the
1519 // buffer (i.e. EvAssistantMessage already fired but more runes1611 // buffer (i.e. EvAssistantMessage already fired but more runes
@@ -1577,7 +1669,9 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1577 // rows update in place at the end of the buffer, instead of the1669 // rows update in place at the end of the buffer, instead of the
1578 // whole bottom band shrinking and shifting chat lines around.1670 // whole bottom band shrinking and shifting chat lines around.
1579 i.liveBlock = nil1671 i.liveBlock = nil
1580- chat := i.view.Build(cols)1672+ stable := i.stableChatRowsLocked(cols)
1673+ chat := append([]string(nil), stable...)
1674+ chat = append(chat, i.view.BuildLive(cols)...)
1581 1675
1582 // Welcome banner: shown at the top of the chat area when there is1676 // Welcome banner: shown at the top of the chat area when there is
1583 // no transcript yet. Disappears after the first message is sent.1677 // no transcript yet. Disappears after the first message is sent.
@@ -1702,7 +1796,7 @@ func (i *Interactive) scrollBy(delta int) {
1702 // wrapped-character fragments behind during scroll-driven1796 // wrapped-character fragments behind during scroll-driven
1703 // viewport changes. Force a full repaint on scroll, but1797 // viewport changes. Force a full repaint on scroll, but
1704 // avoid a whole-screen clear because that visibly flickers.1798 // avoid a whole-screen clear because that visibly flickers.
1705- i.rend.Invalidate()1799+ i.requestRendererInvalidate()
1706 }1800 }
1707 i.mu.Unlock()1801 i.mu.Unlock()
1708 i.invalidate()1802 i.invalidate()
@@ -1743,16 +1837,14 @@ func (i *Interactive) scrollToBottom() {
1743 // after resume. See commit 43da5e5 for the same fix on new turns.1837 // after resume. See commit 43da5e5 for the same fix on new turns.
1744 i.prevChatLen = 01838 i.prevChatLen = 0
1745 i.prevChatCols = 01839 i.prevChatCols = 0
1746- if i.rend != nil {1840+ i.requestRendererInvalidate()
1747- i.rend.Invalidate()
1748- }
1749 i.mu.Unlock()1841 i.mu.Unlock()
1750 i.invalidate()1842 i.invalidate()
1751 }1843 }
1752 1844
1753 func (i *Interactive) redraw() {1845 func (i *Interactive) redraw() {
1754 i.mu.Lock()1846 i.mu.Lock()
1755- defer i.mu.Unlock()1847+ renderRevision := i.renderRevision.Load()
1756 1848
1757 cols, rows := i.cfg.Terminal.Size()1849 cols, rows := i.cfg.Terminal.Size()
1758 mainCols := cols1850 mainCols := cols
@@ -1767,7 +1859,13 @@ func (i *Interactive) redraw() {
1767 }1859 }
1768 }1860 }
1769 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive1861 rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1862+ i.renderOutsideLock = true
1770 chat := i.cachedChatLocked(mainCols)1863 chat := i.cachedChatLocked(mainCols)
1864+ i.renderOutsideLock = false
1865+ if i.renderRevision.Load() != renderRevision {
1866+ i.mu.Unlock()
1867+ return
1868+ }
1771 1869
1772 // Dialogs (login or model picker) render between chat and the editor.1870 // Dialogs (login or model picker) render between chat and the editor.
1773 var dialog []string1871 var dialog []string
@@ -1904,7 +2002,7 @@ func (i *Interactive) redraw() {
1904 // is repainted clean, matching what the user expects after2002 // is repainted clean, matching what the user expects after
1905 // dismissing a picker. Clear() is keepScrollback-aware and2003 // dismissing a picker. Clear() is keepScrollback-aware and
1906 // emits \x1b[3J there.2004 // emits \x1b[3J there.
1907- i.rend.Clear()2005+ i.requestRendererClear()
1908 }2006 }
1909 i.prevOverlayOpen = overlayOpen2007 i.prevOverlayOpen = overlayOpen
1910 if len(suggest) > 0 {2008 if len(suggest) > 0 {
@@ -2345,8 +2443,16 @@ func (i *Interactive) redraw() {
2345 cursorCol = 02443 cursorCol = 0
2346 }2444 }
2347 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)2445 i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2446+ if i.renderRevision.Load() != renderRevision {
2447+ i.mu.Unlock()
2448+ return
2449+ }
2450+ theme := i.cfg.Theme
2451+ // State assembly above is synchronized; terminal output is owned by
2452+ // this renderer goroutine and must not hold the global state mutex.
2453+ i.mu.Unlock()
2348 if rightBarActive {2454 if rightBarActive {
2349- rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)2455+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
2350 if modalBackdrop {2456 if modalBackdrop {
2351 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)2457 i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2352 } else {2458 } else {
@@ -2356,11 +2462,13 @@ func (i *Interactive) redraw() {
2356 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.2462 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2357 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)2463 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2358 }2464 }
2465+ i.mu.Lock()
2359 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {2466 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2360 alert := *i.pendingAlert2467 alert := *i.pendingAlert
2361 i.pendingAlert = nil2468 i.pendingAlert = nil
2362 i.emitAlertLocked(alert)2469 i.emitAlertLocked(alert)
2363 }2470 }
2471+ i.mu.Unlock()
2364 }2472 }
2365 2473
2366 func hasImageEscape(line string) bool {2474 func hasImageEscape(line string) bool {
@@ -2606,9 +2714,7 @@ func (i *Interactive) clearFileSuggestQuery() {
2606 func (i *Interactive) setToolExpansion(expanded bool) {2714 func (i *Interactive) setToolExpansion(expanded bool) {
2607 i.mu.Lock()2715 i.mu.Lock()
2608 i.view.ExpandAll = expanded2716 i.view.ExpandAll = expanded
2609- if i.rend != nil {2717+ i.requestRendererClear()
2610- i.rend.Clear()
2611- }
2612 i.mu.Unlock()2718 i.mu.Unlock()
2613 i.invalidate()2719 i.invalidate()
2614 }2720 }
@@ -2623,9 +2729,7 @@ func (i *Interactive) toggleToolExpansion() {
2623 func (i *Interactive) toggleBtwToolExpansion() {2729 func (i *Interactive) toggleBtwToolExpansion() {
2624 i.btwDialog.ToggleToolExpansion()2730 i.btwDialog.ToggleToolExpansion()
2625 i.mu.Lock()2731 i.mu.Lock()
2626- if i.rend != nil {2732+ i.requestRendererClear()
2627- i.rend.Clear()
2628- }
2629 i.mu.Unlock()2733 i.mu.Unlock()
2630 i.invalidate()2734 i.invalidate()
2631 }2735 }
@@ -3178,11 +3282,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3178 i.mu.Lock()3282 i.mu.Lock()
3179 i.rightBarHidden = !i.rightBarHidden3283 i.rightBarHidden = !i.rightBarHidden
3180 i.mu.Unlock()3284 i.mu.Unlock()
3181- i.rend.Invalidate()3285+ i.requestRendererInvalidate()
3182 i.invalidate()3286 i.invalidate()
3183 return false3287 return false
3184 case tui.KeyCtrlL:3288 case tui.KeyCtrlL:
3185- i.rend.Clear()3289+ i.requestRendererClear()
3186 i.invalidate()3290 i.invalidate()
3187 return false3291 return false
3188 case tui.KeyPasteClipboard:3292 case tui.KeyPasteClipboard:
@@ -3912,6 +4016,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3912 }4016 }
3913 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)4017 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3914 i.view.StartupContextPaths = nil4018 i.view.StartupContextPaths = nil
4019+ i.view.InvalidateRenderCache()
3915 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {4020 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3916 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)4021 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3917 }4022 }
@@ -4981,9 +5086,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4981 // change (image rendering, status copy, future toggles) lands5086 // change (image rendering, status copy, future toggles) lands
4982 // immediately instead of waiting for the next diff frame.5087 // immediately instead of waiting for the next diff frame.
4983 defer func() {5088 defer func() {
4984- if i.rend != nil {5089+ i.requestRendererClear()
4985- i.rend.Clear()
4986- }
4987 i.invalidate()5090 i.invalidate()
4988 }()5091 }()
4989 switch key {5092 switch key {
@@ -5325,6 +5428,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5325 i.view.StartupContextPaths = nil5428 i.view.StartupContextPaths = nil
5326 i.view.StartupExtensionNames = nil5429 i.view.StartupExtensionNames = nil
5327 i.view.StartupSkillNames = nil5430 i.view.StartupSkillNames = nil
5431+ i.view.InvalidateRenderCache()
5328 if value {5432 if value {
5329 i.view.StartupAgentName = i.cfg.StartupAgentName5433 i.view.StartupAgentName = i.cfg.StartupAgentName
5330 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)5434 i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
@@ -5339,9 +5443,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
5339 5443
5340 func (i *Interactive) applyTUIInputStyleSetting(style string) {5444 func (i *Interactive) applyTUIInputStyleSetting(style string) {
5341 defer func() {5445 defer func() {
5342- if i.rend != nil {5446+ i.requestRendererClear()
5343- i.rend.Clear()
5344- }
5345 i.invalidate()5447 i.invalidate()
5346 }()5448 }()
5347 style = tui.NormalizeInputStyle(style)5449 style = tui.NormalizeInputStyle(style)
@@ -5363,9 +5465,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) {
5363 5465
5364 func (i *Interactive) applyTUIStatusPositionSetting(position string) {5466 func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5365 defer func() {5467 defer func() {
5366- if i.rend != nil {5468+ i.requestRendererClear()
5367- i.rend.Clear()
5368- }
5369 i.invalidate()5469 i.invalidate()
5370 }()5470 }()
5371 position = tui.NormalizeStatusPosition(position)5471 position = tui.NormalizeStatusPosition(position)
@@ -5390,9 +5490,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5390 5490
5391 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {5491 func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5392 defer func() {5492 defer func() {
5393- if i.rend != nil {5493+ i.requestRendererClear()
5394- i.rend.Clear()
5395- }
5396 i.invalidate()5494 i.invalidate()
5397 }()5495 }()
5398 position = tui.NormalizeWorkingPosition(position)5496 position = tui.NormalizeWorkingPosition(position)
@@ -5417,9 +5515,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5417 5515
5418 func (i *Interactive) applyTUISubagentPositionSetting(position string) {5516 func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5419 defer func() {5517 defer func() {
5420- if i.rend != nil {5518+ i.requestRendererClear()
5421- i.rend.Clear()
5422- }
5423 i.invalidate()5519 i.invalidate()
5424 }()5520 }()
5425 position = tui.NormalizeSubagentPosition(position)5521 position = tui.NormalizeSubagentPosition(position)
@@ -5496,18 +5592,14 @@ func (i *Interactive) applyThemeNow(name string) {
5496 i.ed.Prompt = th.AccentBar(th.Accent)5592 i.ed.Prompt = th.AccentBar(th.Accent)
5497 i.applyInputCursorColor()5593 i.applyInputCursorColor()
5498 i.spin.Configure(th)5594 i.spin.Configure(th)
5499- if i.rend != nil {5595+ i.requestRendererTheme(th)
5500- i.rend.SetTheme(th)5596+ i.requestRendererClear()
5501- i.rend.Clear()
5502- }
5503 i.invalidate()5597 i.invalidate()
5504 }5598 }
5505 5599
5506 func (i *Interactive) applyReasoningSetting(level string) {5600 func (i *Interactive) applyReasoningSetting(level string) {
5507 defer func() {5601 defer func() {
5508- if i.rend != nil {5602+ i.requestRendererClear()
5509- i.rend.Clear()
5510- }
5511 i.invalidate()5603 i.invalidate()
5512 }()5604 }()
5513 level = provider.NormalizeReasoning(level)5605 level = provider.NormalizeReasoning(level)
@@ -7560,8 +7652,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7560 lastStop = e.Stop7652 lastStop = e.Stop
7561 lastTurnErr = e.Err7653 lastTurnErr = e.Err
7562 }7654 }
7563- i.handleEvent(ev)7655+ i.handleEventForPresentation(ev)
7564- i.invalidate()
7565 }7656 }
7566 7657
7567 go func() {7658 go func() {
@@ -7996,6 +8087,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
7996 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)8087 return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7997 }8088 }
7998 8089
8090+func eventAffectsPresentation(ev core.AgentEvent) bool {
8091+ switch ev.(type) {
8092+ case core.EvToolProgress:
8093+ return false
8094+ default:
8095+ return true
8096+ }
8097+}
8098+
8099+func bumpToolRevision(tc *tui.ToolCallView) {
8100+ if tc.Revision == ^uint64(0) {
8101+ tc.Revision = 1
8102+ return
8103+ }
8104+ tc.Revision++
8105+}
8106+
8107+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8108+ i.handleEvent(ev)
8109+ // Progress payloads are still delivered to the core sink and applied
8110+ // in order, but they have no interactive-visible representation.
8111+ if eventAffectsPresentation(ev) {
8112+ i.invalidate()
8113+ }
8114+}
8115+
7999 func (i *Interactive) handleEvent(ev core.AgentEvent) {8116 func (i *Interactive) handleEvent(ev core.AgentEvent) {
8000 i.mu.Lock()8117 i.mu.Lock()
8001 defer i.mu.Unlock()8118 defer i.mu.Unlock()
@@ -8052,6 +8169,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8052 i.toolCalls[e.ID] = &tui.ToolCallView{8169 i.toolCalls[e.ID] = &tui.ToolCallView{
8053 ID: e.ID,8170 ID: e.ID,
8054 Name: e.Name,8171 Name: e.Name,
8172+ Revision: 1,
8055 Streaming: true,8173 Streaming: true,
8056 }8174 }
8057 i.toolOrder = append(i.toolOrder, e.ID)8175 i.toolOrder = append(i.toolOrder, e.ID)
@@ -8060,6 +8178,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8060 case core.EvToolUseArgs:8178 case core.EvToolUseArgs:
8061 if tc, ok := i.toolCalls[e.ID]; ok {8179 if tc, ok := i.toolCalls[e.ID]; ok {
8062 tc.RawJSONBuf += e.Delta8180 tc.RawJSONBuf += e.Delta
8181+ bumpToolRevision(tc)
8063 // Refresh the live path as soon as it parses; used in8182 // Refresh the live path as soon as it parses; used in
8064 // the header (write /Users/example/Desktop/demo.ts)8183 // the header (write /Users/example/Desktop/demo.ts)
8065 // while the content is still streaming.8184 // while the content is still streaming.
@@ -8072,6 +8191,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8072 case core.EvToolUseEnd:8191 case core.EvToolUseEnd:
8073 if tc, ok := i.toolCalls[e.ID]; ok {8192 if tc, ok := i.toolCalls[e.ID]; ok {
8074 tc.Streaming = false8193 tc.Streaming = false
8194+ bumpToolRevision(tc)
8075 }8195 }
8076 case core.EvToolCall:8196 case core.EvToolCall:
8077 // If we already pre-created the view during streaming, just8197 // If we already pre-created the view during streaming, just
@@ -8079,6 +8199,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8079 // (non-streaming providers or legacy paths).8199 // (non-streaming providers or legacy paths).
8080 if tc, ok := i.toolCalls[e.ID]; ok {8200 if tc, ok := i.toolCalls[e.ID]; ok {
8081 tc.Args = tui.ShortArgs(e.Name, e.Args)8201 tc.Args = tui.ShortArgs(e.Name, e.Args)
8202+ bumpToolRevision(tc)
8082 if tc.RawJSONBuf == "" {8203 if tc.RawJSONBuf == "" {
8083 tc.RawJSONBuf = string(e.Args)8204 tc.RawJSONBuf = string(e.Args)
8084 }8205 }
@@ -8087,6 +8208,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8087 i.toolCalls[e.ID] = &tui.ToolCallView{8208 i.toolCalls[e.ID] = &tui.ToolCallView{
8088 ID: e.ID,8209 ID: e.ID,
8089 Name: e.Name,8210 Name: e.Name,
8211+ Revision: 1,
8090 Args: tui.ShortArgs(e.Name, e.Args),8212 Args: tui.ShortArgs(e.Name, e.Args),
8091 RawJSONBuf: string(e.Args),8213 RawJSONBuf: string(e.Args),
8092 }8214 }
@@ -8108,6 +8230,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8108 }8230 }
8109 }8231 }
8110 tc.Result = text.String()8232 tc.Result = text.String()
8233+ bumpToolRevision(tc)
8111 }8234 }
8112 if i.cfg.OnToolResult != nil {8235 if i.cfg.OnToolResult != nil {
8113 i.cfg.OnToolResult(e.ID, e.Result)8236 i.cfg.OnToolResult(e.ID, e.Result)
@@ -9475,9 +9598,7 @@ func (i *Interactive) resetTranscriptRenderLocked() {
9475 i.prevChatCols = 09598 i.prevChatCols = 0
9476 i.prevChatRows = 09599 i.prevChatRows = 0
9477 i.prevScrollOffset = 09600 i.prevScrollOffset = 0
9478- if i.rend != nil {9601+ i.requestRendererInvalidate()
9479- i.rend.Invalidate()
9480- }
9481 }9602 }
9482 9603
9483 // resetStreamingStateLocked clears every piece of streaming state9604 // resetStreamingStateLocked clears every piece of streaming state
A packages/agent/modes/interactive_render.go +95 -0
@@ -0,0 +1,95 @@
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+ theme *tui.Theme
15+}
16+
17+type latestFrameScheduler struct {
18+ wake chan struct{}
19+ done chan struct{}
20+
21+ mu sync.Mutex
22+ clear bool
23+ theme *tui.Theme
24+ stopped bool
25+}
26+
27+func newLatestFrameScheduler() *latestFrameScheduler {
28+ return &latestFrameScheduler{
29+ wake: make(chan struct{}, 1),
30+ done: make(chan struct{}),
31+ }
32+}
33+
34+func (s *latestFrameScheduler) request(clear bool) bool {
35+ s.mu.Lock()
36+ defer s.mu.Unlock()
37+ if s.stopped {
38+ return false
39+ }
40+ s.clear = s.clear || clear
41+ select {
42+ case s.wake <- struct{}{}:
43+ default:
44+ }
45+ return true
46+}
47+
48+func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
49+ s.mu.Lock()
50+ defer s.mu.Unlock()
51+ if s.stopped {
52+ return false
53+ }
54+ s.clear = true
55+ s.theme = &theme
56+ select {
57+ case s.wake <- struct{}{}:
58+ default:
59+ }
60+ return true
61+}
62+
63+func (s *latestFrameScheduler) next() (renderRequest, bool) {
64+ <-s.wake
65+ s.mu.Lock()
66+ defer s.mu.Unlock()
67+ if s.stopped {
68+ return renderRequest{}, false
69+ }
70+ req := renderRequest{clear: s.clear, theme: s.theme}
71+ s.clear = false
72+ s.theme = nil
73+ return req, true
74+}
75+
76+func (s *latestFrameScheduler) run(render func(renderRequest)) {
77+ defer close(s.done)
78+ for {
79+ req, ok := s.next()
80+ if !ok {
81+ return
82+ }
83+ render(req)
84+ }
85+}
86+
87+func (s *latestFrameScheduler) stop() {
88+ s.mu.Lock()
89+ if !s.stopped {
90+ s.stopped = true
91+ close(s.wake)
92+ }
93+ s.mu.Unlock()
94+ <-s.done
95+}
@@ -0,0 +1,95 @@
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+ theme *tui.Theme
15+}
16+
17+type latestFrameScheduler struct {
18+ wake chan struct{}
19+ done chan struct{}
20+
21+ mu sync.Mutex
22+ clear bool
23+ theme *tui.Theme
24+ stopped bool
25+}
26+
27+func newLatestFrameScheduler() *latestFrameScheduler {
28+ return &latestFrameScheduler{
29+ wake: make(chan struct{}, 1),
30+ done: make(chan struct{}),
31+ }
32+}
33+
34+func (s *latestFrameScheduler) request(clear bool) bool {
35+ s.mu.Lock()
36+ defer s.mu.Unlock()
37+ if s.stopped {
38+ return false
39+ }
40+ s.clear = s.clear || clear
41+ select {
42+ case s.wake <- struct{}{}:
43+ default:
44+ }
45+ return true
46+}
47+
48+func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
49+ s.mu.Lock()
50+ defer s.mu.Unlock()
51+ if s.stopped {
52+ return false
53+ }
54+ s.clear = true
55+ s.theme = &theme
56+ select {
57+ case s.wake <- struct{}{}:
58+ default:
59+ }
60+ return true
61+}
62+
63+func (s *latestFrameScheduler) next() (renderRequest, bool) {
64+ <-s.wake
65+ s.mu.Lock()
66+ defer s.mu.Unlock()
67+ if s.stopped {
68+ return renderRequest{}, false
69+ }
70+ req := renderRequest{clear: s.clear, theme: s.theme}
71+ s.clear = false
72+ s.theme = nil
73+ return req, true
74+}
75+
76+func (s *latestFrameScheduler) run(render func(renderRequest)) {
77+ defer close(s.done)
78+ for {
79+ req, ok := s.next()
80+ if !ok {
81+ return
82+ }
83+ render(req)
84+ }
85+}
86+
87+func (s *latestFrameScheduler) stop() {
88+ s.mu.Lock()
89+ if !s.stopped {
90+ s.stopped = true
91+ close(s.wake)
92+ }
93+ s.mu.Unlock()
94+ <-s.done
95+}
A packages/agent/modes/interactive_render_test.go +141 -0
@@ -0,0 +1,141 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
14+ s := newLatestFrameScheduler()
15+ started := make(chan struct{})
16+ release := make(chan struct{})
17+ type renderedFrame struct {
18+ req renderRequest
19+ version int
20+ }
21+ frames := make(chan renderedFrame, 4)
22+ done := make(chan struct{})
23+ var startedOnce sync.Once
24+ latestVersion := 0
25+ go func() {
26+ s.run(func(req renderRequest) {
27+ frames <- renderedFrame{req: req, version: latestVersion}
28+ startedOnce.Do(func() { close(started) })
29+ <-release
30+ })
31+ close(done)
32+ }()
33+
34+ latestVersion = 1
35+ if !s.request(false) {
36+ t.Fatal("initial render request was rejected")
37+ }
38+ first := <-frames
39+ <-started
40+ if first.req.clear {
41+ t.Fatal("ordinary request unexpectedly requested a clear")
42+ }
43+ for n := 0; n < 1000; n++ {
44+ latestVersion = n + 2
45+ if !s.request(false) {
46+ t.Fatal("request rejected before shutdown")
47+ }
48+ }
49+ close(release)
50+ second := <-frames
51+ if second.req.clear {
52+ t.Fatal("coalesced ordinary request unexpectedly requested a clear")
53+ }
54+ if second.version != 1001 {
55+ t.Fatalf("renderer did not observe newest state: got version %d", second.version)
56+ }
57+ s.stop()
58+ <-done
59+
60+ select {
61+ case <-frames:
62+ t.Fatal("scheduler retained more than one pending frame")
63+ default:
64+ }
65+}
66+
67+func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
68+ i := &Interactive{
69+ view: &tui.View{
70+ Theme: tui.Dark,
71+ Messages: []provider.Message{{
72+ Role: provider.RoleUser,
73+ Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
74+ }},
75+ MessagesRevision: 1,
76+ },
77+ }
78+
79+ i.mu.Lock()
80+ before := strings.Join(i.stableChatRowsLocked(80), "\n")
81+ i.view.Theme = tui.Light
82+ i.view.InvalidateRenderCache()
83+ after := strings.Join(i.stableChatRowsLocked(80), "\n")
84+ i.mu.Unlock()
85+
86+ if before == after {
87+ t.Fatal("stable chat cache reused rows after a theme invalidation")
88+ }
89+}
90+
91+func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
92+ i := &Interactive{
93+ dirty: make(chan struct{}, 1),
94+ toolCalls: make(map[string]*tui.ToolCallView),
95+ toolGate: make(map[string]int),
96+ }
97+ i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
98+ select {
99+ case <-i.dirty:
100+ default:
101+ t.Fatal("tool start did not invalidate the presentation")
102+ }
103+
104+ const progressEvents = 4096
105+ for n := 0; n < progressEvents; n++ {
106+ i.handleEventForPresentation(core.EvToolProgress{
107+ ID: "storm",
108+ Text: strings.Repeat("progress payload ", 256),
109+ })
110+ }
111+ select {
112+ case <-i.dirty:
113+ t.Fatal("invisible progress created presentation invalidation")
114+ default:
115+ }
116+
117+ result := strings.Repeat("completed tool output\n", 256)
118+ i.handleEventForPresentation(core.EvToolResult{
119+ ID: "storm",
120+ Result: core.ToolResult{Content: []provider.Content{
121+ provider.TextBlock{Text: result},
122+ }},
123+ })
124+ select {
125+ case <-i.dirty:
126+ default:
127+ t.Fatal("completed tool result did not invalidate the presentation")
128+ }
129+ if got := i.toolCalls["storm"].Result; got != result {
130+ t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
131+ }
132+ if i.toolCalls["storm"].Revision == 0 {
133+ t.Fatal("completed tool result did not advance its render revision")
134+ }
135+
136+ view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
137+ rows := view.BuildLive(80)
138+ if len(rows) == 0 {
139+ t.Fatal("completed tool result produced no live frame rows")
140+ }
141+}
@@ -0,0 +1,141 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
14+ s := newLatestFrameScheduler()
15+ started := make(chan struct{})
16+ release := make(chan struct{})
17+ type renderedFrame struct {
18+ req renderRequest
19+ version int
20+ }
21+ frames := make(chan renderedFrame, 4)
22+ done := make(chan struct{})
23+ var startedOnce sync.Once
24+ latestVersion := 0
25+ go func() {
26+ s.run(func(req renderRequest) {
27+ frames <- renderedFrame{req: req, version: latestVersion}
28+ startedOnce.Do(func() { close(started) })
29+ <-release
30+ })
31+ close(done)
32+ }()
33+
34+ latestVersion = 1
35+ if !s.request(false) {
36+ t.Fatal("initial render request was rejected")
37+ }
38+ first := <-frames
39+ <-started
40+ if first.req.clear {
41+ t.Fatal("ordinary request unexpectedly requested a clear")
42+ }
43+ for n := 0; n < 1000; n++ {
44+ latestVersion = n + 2
45+ if !s.request(false) {
46+ t.Fatal("request rejected before shutdown")
47+ }
48+ }
49+ close(release)
50+ second := <-frames
51+ if second.req.clear {
52+ t.Fatal("coalesced ordinary request unexpectedly requested a clear")
53+ }
54+ if second.version != 1001 {
55+ t.Fatalf("renderer did not observe newest state: got version %d", second.version)
56+ }
57+ s.stop()
58+ <-done
59+
60+ select {
61+ case <-frames:
62+ t.Fatal("scheduler retained more than one pending frame")
63+ default:
64+ }
65+}
66+
67+func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
68+ i := &Interactive{
69+ view: &tui.View{
70+ Theme: tui.Dark,
71+ Messages: []provider.Message{{
72+ Role: provider.RoleUser,
73+ Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
74+ }},
75+ MessagesRevision: 1,
76+ },
77+ }
78+
79+ i.mu.Lock()
80+ before := strings.Join(i.stableChatRowsLocked(80), "\n")
81+ i.view.Theme = tui.Light
82+ i.view.InvalidateRenderCache()
83+ after := strings.Join(i.stableChatRowsLocked(80), "\n")
84+ i.mu.Unlock()
85+
86+ if before == after {
87+ t.Fatal("stable chat cache reused rows after a theme invalidation")
88+ }
89+}
90+
91+func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
92+ i := &Interactive{
93+ dirty: make(chan struct{}, 1),
94+ toolCalls: make(map[string]*tui.ToolCallView),
95+ toolGate: make(map[string]int),
96+ }
97+ i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
98+ select {
99+ case <-i.dirty:
100+ default:
101+ t.Fatal("tool start did not invalidate the presentation")
102+ }
103+
104+ const progressEvents = 4096
105+ for n := 0; n < progressEvents; n++ {
106+ i.handleEventForPresentation(core.EvToolProgress{
107+ ID: "storm",
108+ Text: strings.Repeat("progress payload ", 256),
109+ })
110+ }
111+ select {
112+ case <-i.dirty:
113+ t.Fatal("invisible progress created presentation invalidation")
114+ default:
115+ }
116+
117+ result := strings.Repeat("completed tool output\n", 256)
118+ i.handleEventForPresentation(core.EvToolResult{
119+ ID: "storm",
120+ Result: core.ToolResult{Content: []provider.Content{
121+ provider.TextBlock{Text: result},
122+ }},
123+ })
124+ select {
125+ case <-i.dirty:
126+ default:
127+ t.Fatal("completed tool result did not invalidate the presentation")
128+ }
129+ if got := i.toolCalls["storm"].Result; got != result {
130+ t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
131+ }
132+ if i.toolCalls["storm"].Revision == 0 {
133+ t.Fatal("completed tool result did not advance its render revision")
134+ }
135+
136+ view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
137+ rows := view.BuildLive(80)
138+ if len(rows) == 0 {
139+ t.Fatal("completed tool result produced no live frame rows")
140+ }
141+}
M packages/tui/render.go +22 -3
@@ -37,9 +37,16 @@ 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+ logRawBackground string
49+ logRawHasThemeBG bool
4350 logViewportTop int
4451 logHardwareRow int
4552 logInit bool
@@ -430,6 +437,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
430437 if len(bottom) == 0 {
431438 bottom = []string{""}
432439 }
440+ if r.logInit && len(r.logLines) > 0 &&
441+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
442+ r.logRawCols == r.cols && r.logRawBackground == r.backgroundStyle &&
443+ r.logRawHasThemeBG == (r.theme.Background != nil) &&
444+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
445+ return
446+ }
433447 chatFrame := make([]string, len(chat))
434448 for i, line := range chat {
435449 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
@@ -808,6 +822,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
808822 r.logChat = append(r.logChat[:0], chatFrame...)
809823 r.logBottom = append(r.logBottom[:0], bottomFrame...)
810824 r.logLines = append(r.logLines[:0], lines...)
825+ r.logRawChat = append(r.logRawChat[:0], chat...)
826+ r.logRawBottom = append(r.logRawBottom[:0], bottom...)
827+ r.logRawCols = r.cols
828+ r.logRawBackground = r.backgroundStyle
829+ r.logRawHasThemeBG = r.theme.Background != nil
811830 r.cursorRow = cursorBottomRow
812831 r.cursorCol = cursorCol
813832 }
@@ -37,9 +37,16 @@ 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+ logRawBackground string
49+ logRawHasThemeBG bool
43 logViewportTop int50 logViewportTop int
44 logHardwareRow int51 logHardwareRow int
45 logInit bool52 logInit bool
@@ -430,6 +437,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
430 if len(bottom) == 0 {437 if len(bottom) == 0 {
431 bottom = []string{""}438 bottom = []string{""}
432 }439 }
440+ if r.logInit && len(r.logLines) > 0 &&
441+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
442+ r.logRawCols == r.cols && r.logRawBackground == r.backgroundStyle &&
443+ r.logRawHasThemeBG == (r.theme.Background != nil) &&
444+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
445+ return
446+ }
433 chatFrame := make([]string, len(chat))447 chatFrame := make([]string, len(chat))
434 for i, line := range chat {448 for i, line := range chat {
435 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)449 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
@@ -808,6 +822,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
808 r.logChat = append(r.logChat[:0], chatFrame...)822 r.logChat = append(r.logChat[:0], chatFrame...)
809 r.logBottom = append(r.logBottom[:0], bottomFrame...)823 r.logBottom = append(r.logBottom[:0], bottomFrame...)
810 r.logLines = append(r.logLines[:0], lines...)824 r.logLines = append(r.logLines[:0], lines...)
825+ r.logRawChat = append(r.logRawChat[:0], chat...)
826+ r.logRawBottom = append(r.logRawBottom[:0], bottom...)
827+ r.logRawCols = r.cols
828+ r.logRawBackground = r.backgroundStyle
829+ r.logRawHasThemeBG = r.theme.Background != nil
811 r.cursorRow = cursorBottomRow830 r.cursorRow = cursorBottomRow
812 r.cursorCol = cursorCol831 r.cursorCol = cursorCol
813 }832 }
A packages/tui/render_bench_test.go +68 -0
@@ -0,0 +1,68 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "testing"
7+)
8+
9+func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
10+ for _, width := range []int{48, 80, 160} {
11+ for _, temperature := range []string{"cold", "warm"} {
12+ name := fmt.Sprintf("width=%d/%s", width, temperature)
13+ b.Run(name, func(b *testing.B) {
14+ var out bytes.Buffer
15+ r := NewRenderer(&out)
16+ r.Resize(width, 40)
17+ chat := benchmarkRendererChat(1600, width)
18+ bottom := []string{"working", "▌ input"}
19+ if temperature == "warm" {
20+ r.DrawLog(chat, bottom, 1, 2)
21+ out.Reset()
22+ }
23+ b.ReportAllocs()
24+ b.ResetTimer()
25+ for n := 0; n < b.N; n++ {
26+ frame := chat
27+ if temperature == "warm" {
28+ // Keep most of the storm stable while exercising the
29+ // renderer's row-diff path with a changing suffix.
30+ frame = append([]string(nil), chat...)
31+ frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
32+ r.DrawLog(frame, bottom, 1, 2)
33+ } else {
34+ cold := NewRenderer(&out)
35+ cold.Resize(width, 40)
36+ cold.DrawLog(frame, bottom, 1, 2)
37+ }
38+ out.Reset()
39+ }
40+ })
41+ }
42+ }
43+}
44+
45+func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
46+ for _, width := range []int{48, 80, 160} {
47+ b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {
48+ chat := benchmarkRendererChat(1600, width)
49+ bottom := []string{"working", "▌ input"}
50+ b.ReportAllocs()
51+ b.ResetTimer()
52+ for n := 0; n < b.N; n++ {
53+ var out bytes.Buffer
54+ r := NewRenderer(&out)
55+ r.Resize(width, 40)
56+ r.DrawLog(chat, bottom, 1, 2)
57+ }
58+ })
59+ }
60+}
61+
62+func benchmarkRendererChat(rows, width int) []string {
63+ chat := make([]string, rows)
64+ for i := range chat {
65+ chat[i] = fmt.Sprintf("│ tool-%03d result line with enough payload to exercise width truncation", i)
66+ }
67+ return chat
68+}
@@ -0,0 +1,68 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "testing"
7+)
8+
9+func BenchmarkRendererDrawLogToolStorm(b *testing.B) {
10+ for _, width := range []int{48, 80, 160} {
11+ for _, temperature := range []string{"cold", "warm"} {
12+ name := fmt.Sprintf("width=%d/%s", width, temperature)
13+ b.Run(name, func(b *testing.B) {
14+ var out bytes.Buffer
15+ r := NewRenderer(&out)
16+ r.Resize(width, 40)
17+ chat := benchmarkRendererChat(1600, width)
18+ bottom := []string{"working", "▌ input"}
19+ if temperature == "warm" {
20+ r.DrawLog(chat, bottom, 1, 2)
21+ out.Reset()
22+ }
23+ b.ReportAllocs()
24+ b.ResetTimer()
25+ for n := 0; n < b.N; n++ {
26+ frame := chat
27+ if temperature == "warm" {
28+ // Keep most of the storm stable while exercising the
29+ // renderer's row-diff path with a changing suffix.
30+ frame = append([]string(nil), chat...)
31+ frame[len(frame)-1] = fmt.Sprintf("tool output %d", n)
32+ r.DrawLog(frame, bottom, 1, 2)
33+ } else {
34+ cold := NewRenderer(&out)
35+ cold.Resize(width, 40)
36+ cold.DrawLog(frame, bottom, 1, 2)
37+ }
38+ out.Reset()
39+ }
40+ })
41+ }
42+ }
43+}
44+
45+func BenchmarkRendererDrawLogToolStormCold(b *testing.B) {
46+ for _, width := range []int{48, 80, 160} {
47+ b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) {
48+ chat := benchmarkRendererChat(1600, width)
49+ bottom := []string{"working", "▌ input"}
50+ b.ReportAllocs()
51+ b.ResetTimer()
52+ for n := 0; n < b.N; n++ {
53+ var out bytes.Buffer
54+ r := NewRenderer(&out)
55+ r.Resize(width, 40)
56+ r.DrawLog(chat, bottom, 1, 2)
57+ }
58+ })
59+ }
60+}
61+
62+func benchmarkRendererChat(rows, width int) []string {
63+ chat := make([]string, rows)
64+ for i := range chat {
65+ chat[i] = fmt.Sprintf("│ tool-%03d result line with enough payload to exercise width truncation", i)
66+ }
67+ return chat
68+}
M packages/tui/view.go +382 -29
@@ -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,19 @@ 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+ messageCacheRevision uint64
191+ messageCacheLen int
192+ toolPathRevision uint64
176193
177194 // TailLimit caps how many messages from the END of Messages are
178195 // rendered. Messages older than the limit emit a zero-row slice
@@ -198,7 +215,36 @@ type msgCacheKey struct {
198215 // — even one that spans many assistant/tool message round-trips
199216 // in the underlying API — renders under one header instead of a
200217 // new one per assistant message.
201- turnOpen bool
218+ turnOpen bool
219+ flatTools bool
220+ compactMode bool
221+ compactUser bool
222+ imageProto ImageProtocol
223+ theme uint64
224+}
225+
226+type messageRenderCacheEntry struct {
227+ width int
228+ expandAll bool
229+ turnOpen bool
230+ flatTools bool
231+ compactMode bool
232+ compactUser bool
233+ imageProto ImageProtocol
234+ theme uint64
235+ lines []string
236+ valid bool
237+}
238+
239+type liveToolCacheKey struct {
240+ revision uint64
241+ content uint64
242+ width int
243+ expandAll bool
244+ flatTools bool
245+ compact bool
246+ imageProto ImageProtocol
247+ theme uint64
202248 }
203249
204250 // InvalidateRenderCache drops all cached message renders. The tui
@@ -206,7 +252,80 @@ type msgCacheKey struct {
206252 // /clear, session swap) since messages can be replaced in place and
207253 // a content-hash miss alone doesn't reclaim the old entries.
208254 func (v *View) InvalidateRenderCache() {
255+ v.RenderCacheRevision++
209256 v.renderCache = nil
257+ v.liveRenderCache = nil
258+ v.messageCache = nil
259+ v.messageCacheRevision = 0
260+ v.messageCacheLen = 0
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.messageCacheRevision = snapshot.messageCacheRevision
324+ v.messageCacheLen = snapshot.messageCacheLen
325+ v.toolPaths = snapshot.toolPaths
326+ v.toolStartLines = snapshot.toolStartLines
327+ v.toolCallLabels = snapshot.toolCallLabels
328+ v.toolPathRevision = snapshot.toolPathRevision
210329 }
211330
212331 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -219,13 +338,17 @@ const (
219338
220339 // ToolCallView is a pending tool invocation plus optional result.
221340 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
341+ ID string
342+ Name string
343+ // Revision is advanced by the owning interactive state whenever a
344+ // visible field changes. A non-zero revision lets the live render cache
345+ // avoid rescanning a large result string on every redraw.
346+ Revision uint64
347+ Args string // rendered argument summary
348+ Preview string // side-effect-free result shown before confirmation
349+ Result string // rendered result preview (truncated)
350+ Error bool
351+ Done bool
229352
230353 // Streaming is true while the model is still typing the tool
231354 // call's JSON arguments. The TUI renders a live preview of any
@@ -265,6 +388,10 @@ func (v *View) Build(width int) []string {
265388 // so native scrolling stays stable while a turn streams.
266389 func (v *View) BuildLive(width int) []string {
267390 v.prepareLiveBodyHeights()
391+ if v.liveRenderCache == nil {
392+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
393+ }
394+ themeKey := toolThemeKey(v.Theme)
268395 var out []string
269396 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
270397 const indent = " "
@@ -294,7 +421,7 @@ func (v *View) BuildLive(width int) []string {
294421 out = append(out, "")
295422 }
296423 insertedToolGap = true
297- out = append(out, v.renderToolCall(tc, width)...)
424+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
298425 }
299426 if v.Err != "" {
300427 out = append(out, v.renderErr(width)...)
@@ -360,10 +487,24 @@ func (v *View) renderStartupResources(width int) []string {
360487 // scroll to a specific turn (the /jump dialog) use the anchor slice
361488 // to map a message index back to a row offset.
362489 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
363- v.refreshToolPaths()
490+ themeKey := toolThemeKey(v.Theme)
491+ if v.MessagesRevision != 0 && v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision && len(v.Messages) <= v.messageCacheLen {
492+ v.messageCache = nil
493+ }
494+ if v.MessagesRevision != 0 {
495+ v.messageCacheRevision = v.MessagesRevision
496+ v.messageCacheLen = len(v.Messages)
497+ }
498+ if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision {
499+ v.refreshToolPaths()
500+ v.toolPathRevision = v.MessagesRevision
501+ }
364502 if v.renderCache == nil {
365503 v.renderCache = make(map[msgCacheKey][]string)
366504 }
505+ if v.liveRenderCache == nil {
506+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
507+ }
367508 v.prepareLiveBodyHeights()
368509 // Drop high-water entries for calls that are gone or finalised so
369510 // the reservation never leaks into a later, shorter tool box.
@@ -422,7 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
422563 break
423564 }
424565 }
425- lines := v.renderMessageCached(m, width, turnOpen)
566+ lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey)
426567 rendered[idx] = lines
427568 total += len(lines) + 1 // +1 for the blank separator row
428569 }
@@ -487,7 +628,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
487628 if finalised[tc.ID] {
488629 continue
489630 }
490- out = append(out, v.renderToolCall(tc, width)...)
631+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
491632 out = append(out, "")
492633 }
493634 if v.Err != "" {
@@ -536,12 +677,31 @@ func (v *View) refreshToolPaths() {
536677 // been rendered before. The slice returned is shared — callers must
537678 // not mutate it; Build() only ever appends to its own `out` so the
538679 // shared slice is safe.
539-func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string {
680+func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
681+ if v.MessagesRevision != 0 {
682+ if idx < len(v.messageCache) {
683+ entry := v.messageCache[idx]
684+ if entry.valid && entry.width == width && entry.expandAll == v.ExpandAll && entry.turnOpen == turnOpen && entry.flatTools == v.FlatTools && entry.compactMode == v.CompactMode && entry.compactUser == v.CompactUser && entry.imageProto == v.ImageProto && entry.theme == themeKey {
685+ return entry.lines
686+ }
687+ }
688+ lines := v.renderMessage(m, width, turnOpen)
689+ if idx >= len(v.messageCache) {
690+ v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, idx-len(v.messageCache)+1)...)
691+ }
692+ v.messageCache[idx] = messageRenderCacheEntry{width: width, expandAll: v.ExpandAll, turnOpen: turnOpen, flatTools: v.FlatTools, compactMode: v.CompactMode, compactUser: v.CompactUser, imageProto: v.ImageProto, theme: themeKey, lines: lines, valid: true}
693+ return lines
694+ }
540695 key := msgCacheKey{
541- hash: hashMessage(m),
542- width: width,
543- expandAll: v.ExpandAll,
544- turnOpen: turnOpen,
696+ hash: hashMessage(m),
697+ width: width,
698+ expandAll: v.ExpandAll,
699+ turnOpen: turnOpen,
700+ flatTools: v.FlatTools,
701+ compactMode: v.CompactMode,
702+ compactUser: v.CompactUser,
703+ imageProto: v.ImageProto,
704+ theme: themeKey,
545705 }
546706 if v.renderCache != nil {
547707 if lines, ok := v.renderCache[key]; ok {
@@ -842,7 +1002,123 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
8421002 if v.liveBodyHigh == nil {
8431003 v.liveBodyHigh = make(map[string]int)
8441004 }
845- return v.renderToolCall(tc, width)
1005+ return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1006+}
1007+
1008+func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1009+ if v.liveRenderCache == nil {
1010+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
1011+ }
1012+ key := liveToolCacheKey{
1013+ revision: tc.Revision,
1014+ content: hashLiveTool(tc),
1015+ width: width,
1016+ expandAll: v.ExpandAll,
1017+ flatTools: v.FlatTools,
1018+ compact: v.CompactMode,
1019+ imageProto: v.ImageProto,
1020+ theme: themeKey,
1021+ }
1022+ if lines, ok := v.liveRenderCache[key]; ok {
1023+ return lines
1024+ }
1025+ lines := v.renderToolCall(tc, width)
1026+ if len(v.liveRenderCache) > 512 {
1027+ for k := range v.liveRenderCache {
1028+ delete(v.liveRenderCache, k)
1029+ break
1030+ }
1031+ }
1032+ v.liveRenderCache[key] = lines
1033+ return lines
1034+}
1035+
1036+func hashLiveTool(tc ToolCallView) uint64 {
1037+ if tc.Revision != 0 {
1038+ return 0
1039+ }
1040+ h := fnv64aInit
1041+ for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1042+ h = fnv64aWrite(h, []byte(s))
1043+ h = fnv64aWriteByte(h, 0)
1044+ }
1045+ if tc.Error {
1046+ h = fnv64aWriteByte(h, 'e')
1047+ }
1048+ if tc.Done {
1049+ h = fnv64aWriteByte(h, 'd')
1050+ }
1051+ if tc.Streaming {
1052+ h = fnv64aWriteByte(h, 's')
1053+ }
1054+ return h
1055+}
1056+
1057+func toolThemeKey(theme Theme) uint64 {
1058+ h := fnv64aInit
1059+ for _, s := range []string{
1060+ theme.SyntaxBaseStyle,
1061+ theme.Syntax.Keyword,
1062+ theme.Syntax.KeywordConstant,
1063+ theme.Syntax.KeywordDeclaration,
1064+ theme.Syntax.KeywordNamespace,
1065+ theme.Syntax.KeywordReserved,
1066+ theme.Syntax.KeywordType,
1067+ theme.Syntax.NameBuiltin,
1068+ theme.Syntax.NameFunction,
1069+ theme.Syntax.NameClass,
1070+ theme.Syntax.NameDecorator,
1071+ theme.Syntax.LiteralString,
1072+ theme.Syntax.LiteralStringEscape,
1073+ theme.Syntax.LiteralNumber,
1074+ theme.Syntax.Comment,
1075+ theme.Syntax.CommentPreproc,
1076+ theme.Syntax.Operator,
1077+ theme.Syntax.Punctuation,
1078+ theme.Syntax.Text,
1079+ } {
1080+ h = fnv64aWrite(h, []byte(s))
1081+ h = fnv64aWriteByte(h, 0)
1082+ }
1083+ for _, c := range []TerminalColor{
1084+ theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1085+ theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1086+ theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1087+ theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1088+ theme.Terminal.Background,
1089+ } {
1090+ h = hashThemeColor(h, c)
1091+ }
1092+ if theme.Background != nil {
1093+ h = hashThemeColor(h, *theme.Background)
1094+ }
1095+ for _, c := range theme.Terminal.Palette {
1096+ h = hashThemeColor(h, c)
1097+ }
1098+ for _, value := range []bool{
1099+ theme.Inherited,
1100+ theme.Terminal.HasForeground,
1101+ theme.Terminal.HasBackground,
1102+ theme.Terminal.TrueColor,
1103+ theme.Terminal.Light,
1104+ theme.Terminal.SchemeKnown,
1105+ } {
1106+ if value {
1107+ h = fnv64aWriteByte(h, 1)
1108+ } else {
1109+ h = fnv64aWriteByte(h, 0)
1110+ }
1111+ }
1112+ return h
1113+}
1114+
1115+func hashThemeColor(h uint64, c TerminalColor) uint64 {
1116+ h = fnv64aWriteByte(h, byte(c.Mode))
1117+ h = fnv64aWriteByte(h, byte(c.Index))
1118+ h = fnv64aWriteByte(h, byte(c.R))
1119+ h = fnv64aWriteByte(h, byte(c.G))
1120+ h = fnv64aWriteByte(h, byte(c.B))
1121+ return h
8461122 }
8471123
8481124 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
@@ -979,6 +1255,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
9791255 // confirmation preview immediately gets the same colors, numbered gutters,
9801256 // and syntax highlighting as its final tool result.
9811257 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1258+ if !v.ExpandAll {
1259+ total := estimateToolTextLines(text, width)
1260+ if total > ToolCollapseLines {
1261+ lines := v.renderToolText(previewToolText(text, width), width, color, sourcePath, 1)
1262+ return v.collapseToolBodyWithTotal(lines, false, total)
1263+ }
1264+ }
9821265 return v.renderToolText(text, width, color, sourcePath, 1)
9831266 }
9841267
@@ -1472,6 +1755,13 @@ func trimLeadingSpaces(s string, n int) string {
14721755 func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
14731756 var body []string
14741757 hasImage := false
1758+ for _, b := range blocks {
1759+ if _, ok := b.(provider.ImageBlock); ok {
1760+ hasImage = true
1761+ break
1762+ }
1763+ }
1764+ total := 0
14751765 for _, b := range blocks {
14761766 switch bb := b.(type) {
14771767 case provider.TextBlock:
@@ -1479,13 +1769,21 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
14791769 if v.FlatTools || v.CompactMode {
14801770 bodyWidth = flatToolBodyRenderWidth(width)
14811771 }
1482- body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
1772+ estimated := estimateToolTextLines(bb.Text, bodyWidth)
1773+ total += estimated
1774+ text := bb.Text
1775+ // A collapsed result only exposes its first ten rendered rows.
1776+ // Render a bounded source preview in that case; syntax
1777+ // highlighting and wrapping the omitted tail is wasted work.
1778+ if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1779+ text = previewToolText(text, bodyWidth)
1780+ }
1781+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
14831782 case provider.ImageBlock:
1484- hasImage = true
14851783 body = append(body, v.renderImageBlock(bb, width)...)
14861784 }
14871785 }
1488- return v.collapseToolBody(body, hasImage)
1786+ return v.collapseToolBodyWithTotal(body, hasImage, total)
14891787 }
14901788
14911789 // collapseToolBody trims lines to the configured preview size when the
@@ -1493,15 +1791,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
14931791 // expand" footer. Image blocks never collapse — they're short in text
14941792 // rows but represent real content the user wants to see.
14951793 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1794+ return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1795+}
1796+
1797+func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
14961798 if v.ExpandAll || hasImage {
14971799 return lines
14981800 }
1499- if len(lines) <= ToolCollapseLines {
1801+ if total < len(lines) {
1802+ total = len(lines)
1803+ }
1804+ if total <= ToolCollapseLines {
15001805 return lines
15011806 }
1502- kept := lines[:ToolCollapsePreview]
1503- hidden := len(lines) - ToolCollapsePreview
1504- total := len(lines)
1807+ kept := lines
1808+ if len(kept) > ToolCollapsePreview {
1809+ kept = kept[:ToolCollapsePreview]
1810+ }
1811+ hidden := total - ToolCollapsePreview
15051812 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
15061813 footer = v.Theme.FGColor(v.Theme.Muted, footer)
15071814 out := append([]string(nil), kept...)
@@ -1509,6 +1816,52 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
15091816 return append(out, footer)
15101817 }
15111818
1819+func estimateToolTextLines(text string, width int) int {
1820+ if width < 1 {
1821+ width = 1
1822+ }
1823+ total := 0
1824+ for _, line := range strings.Split(text, "\n") {
1825+ cells := runewidth.StringWidth(line)
1826+ if cells == 0 {
1827+ total++
1828+ continue
1829+ }
1830+ total += (cells + width - 1) / width
1831+ }
1832+ if total == 0 {
1833+ return 1
1834+ }
1835+ return total
1836+}
1837+
1838+func previewToolText(text string, width int) string {
1839+ if width < 1 {
1840+ width = 1
1841+ }
1842+ maxChars := width * (ToolCollapsePreview + 2)
1843+ if maxChars < 256 {
1844+ maxChars = 256
1845+ }
1846+ if maxChars > 8192 {
1847+ maxChars = 8192
1848+ }
1849+ end := 0
1850+ for count := 0; end < len(text) && count < maxChars; count++ {
1851+ _, size := utf8.DecodeRuneInString(text[end:])
1852+ if size == 0 {
1853+ break
1854+ }
1855+ end += size
1856+ }
1857+ preview := text[:end]
1858+ lines := strings.SplitAfter(preview, "\n")
1859+ if len(lines) > ToolCollapsePreview+2 {
1860+ preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1861+ }
1862+ return preview
1863+}
1864+
15121865 // renderToolText renders a text block inside a tool result. If the
15131866 // text contains a unified-diff section (lines starting with "--- " /
15141867 // "+++ " / "+" / "-"/" "), 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,19 @@ 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+ messageCacheRevision uint64
191+ messageCacheLen int
192+ toolPathRevision uint64
176 193
177 // TailLimit caps how many messages from the END of Messages are194 // TailLimit caps how many messages from the END of Messages are
178 // rendered. Messages older than the limit emit a zero-row slice195 // rendered. Messages older than the limit emit a zero-row slice
@@ -198,7 +215,36 @@ type msgCacheKey struct {
198 // — even one that spans many assistant/tool message round-trips215 // — even one that spans many assistant/tool message round-trips
199 // in the underlying API — renders under one header instead of a216 // in the underlying API — renders under one header instead of a
200 // new one per assistant message.217 // new one per assistant message.
201- turnOpen bool218+ turnOpen bool
219+ flatTools bool
220+ compactMode bool
221+ compactUser bool
222+ imageProto ImageProtocol
223+ theme uint64
224+}
225+
226+type messageRenderCacheEntry struct {
227+ width int
228+ expandAll bool
229+ turnOpen bool
230+ flatTools bool
231+ compactMode bool
232+ compactUser bool
233+ imageProto ImageProtocol
234+ theme uint64
235+ lines []string
236+ valid bool
237+}
238+
239+type liveToolCacheKey struct {
240+ revision uint64
241+ content uint64
242+ width int
243+ expandAll bool
244+ flatTools bool
245+ compact bool
246+ imageProto ImageProtocol
247+ theme uint64
202 }248 }
203 249
204 // InvalidateRenderCache drops all cached message renders. The tui250 // InvalidateRenderCache drops all cached message renders. The tui
@@ -206,7 +252,80 @@ type msgCacheKey struct {
206 // /clear, session swap) since messages can be replaced in place and252 // /clear, session swap) since messages can be replaced in place and
207 // a content-hash miss alone doesn't reclaim the old entries.253 // a content-hash miss alone doesn't reclaim the old entries.
208 func (v *View) InvalidateRenderCache() {254 func (v *View) InvalidateRenderCache() {
255+ v.RenderCacheRevision++
209 v.renderCache = nil256 v.renderCache = nil
257+ v.liveRenderCache = nil
258+ v.messageCache = nil
259+ v.messageCacheRevision = 0
260+ v.messageCacheLen = 0
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.messageCacheRevision = snapshot.messageCacheRevision
324+ v.messageCacheLen = snapshot.messageCacheLen
325+ v.toolPaths = snapshot.toolPaths
326+ v.toolStartLines = snapshot.toolStartLines
327+ v.toolCallLabels = snapshot.toolCallLabels
328+ v.toolPathRevision = snapshot.toolPathRevision
210 }329 }
211 330
212 // ToolCollapsePreview is the number of lines shown before a long tool331 // ToolCollapsePreview is the number of lines shown before a long tool
@@ -219,13 +338,17 @@ const (
219 338
220 // ToolCallView is a pending tool invocation plus optional result.339 // ToolCallView is a pending tool invocation plus optional result.
221 type ToolCallView struct {340 type ToolCallView struct {
222- ID string341+ ID string
223- Name string342+ Name string
224- Args string // rendered argument summary343+ // Revision is advanced by the owning interactive state whenever a
225- Preview string // side-effect-free result shown before confirmation344+ // visible field changes. A non-zero revision lets the live render cache
226- Result string // rendered result preview (truncated)345+ // avoid rescanning a large result string on every redraw.
227- Error bool346+ Revision uint64
228- Done bool347+ Args string // rendered argument summary
348+ Preview string // side-effect-free result shown before confirmation
349+ Result string // rendered result preview (truncated)
350+ Error bool
351+ Done bool
229 352
230 // Streaming is true while the model is still typing the tool353 // Streaming is true while the model is still typing the tool
231 // call's JSON arguments. The TUI renders a live preview of any354 // call's JSON arguments. The TUI renders a live preview of any
@@ -265,6 +388,10 @@ func (v *View) Build(width int) []string {
265 // so native scrolling stays stable while a turn streams.388 // so native scrolling stays stable while a turn streams.
266 func (v *View) BuildLive(width int) []string {389 func (v *View) BuildLive(width int) []string {
267 v.prepareLiveBodyHeights()390 v.prepareLiveBodyHeights()
391+ if v.liveRenderCache == nil {
392+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
393+ }
394+ themeKey := toolThemeKey(v.Theme)
268 var out []string395 var out []string
269 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {396 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
270 const indent = " "397 const indent = " "
@@ -294,7 +421,7 @@ func (v *View) BuildLive(width int) []string {
294 out = append(out, "")421 out = append(out, "")
295 }422 }
296 insertedToolGap = true423 insertedToolGap = true
297- out = append(out, v.renderToolCall(tc, width)...)424+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
298 }425 }
299 if v.Err != "" {426 if v.Err != "" {
300 out = append(out, v.renderErr(width)...)427 out = append(out, v.renderErr(width)...)
@@ -360,10 +487,24 @@ func (v *View) renderStartupResources(width int) []string {
360 // scroll to a specific turn (the /jump dialog) use the anchor slice487 // scroll to a specific turn (the /jump dialog) use the anchor slice
361 // to map a message index back to a row offset.488 // to map a message index back to a row offset.
362 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {489 func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
363- v.refreshToolPaths()490+ themeKey := toolThemeKey(v.Theme)
491+ if v.MessagesRevision != 0 && v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision && len(v.Messages) <= v.messageCacheLen {
492+ v.messageCache = nil
493+ }
494+ if v.MessagesRevision != 0 {
495+ v.messageCacheRevision = v.MessagesRevision
496+ v.messageCacheLen = len(v.Messages)
497+ }
498+ if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision {
499+ v.refreshToolPaths()
500+ v.toolPathRevision = v.MessagesRevision
501+ }
364 if v.renderCache == nil {502 if v.renderCache == nil {
365 v.renderCache = make(map[msgCacheKey][]string)503 v.renderCache = make(map[msgCacheKey][]string)
366 }504 }
505+ if v.liveRenderCache == nil {
506+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
507+ }
367 v.prepareLiveBodyHeights()508 v.prepareLiveBodyHeights()
368 // Drop high-water entries for calls that are gone or finalised so509 // Drop high-water entries for calls that are gone or finalised so
369 // the reservation never leaks into a later, shorter tool box.510 // the reservation never leaks into a later, shorter tool box.
@@ -422,7 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
422 break563 break
423 }564 }
424 }565 }
425- lines := v.renderMessageCached(m, width, turnOpen)566+ lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey)
426 rendered[idx] = lines567 rendered[idx] = lines
427 total += len(lines) + 1 // +1 for the blank separator row568 total += len(lines) + 1 // +1 for the blank separator row
428 }569 }
@@ -487,7 +628,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
487 if finalised[tc.ID] {628 if finalised[tc.ID] {
488 continue629 continue
489 }630 }
490- out = append(out, v.renderToolCall(tc, width)...)631+ out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
491 out = append(out, "")632 out = append(out, "")
492 }633 }
493 if v.Err != "" {634 if v.Err != "" {
@@ -536,12 +677,31 @@ func (v *View) refreshToolPaths() {
536 // been rendered before. The slice returned is shared — callers must677 // been rendered before. The slice returned is shared — callers must
537 // not mutate it; Build() only ever appends to its own `out` so the678 // not mutate it; Build() only ever appends to its own `out` so the
538 // shared slice is safe.679 // shared slice is safe.
539-func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string {680+func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
681+ if v.MessagesRevision != 0 {
682+ if idx < len(v.messageCache) {
683+ entry := v.messageCache[idx]
684+ if entry.valid && entry.width == width && entry.expandAll == v.ExpandAll && entry.turnOpen == turnOpen && entry.flatTools == v.FlatTools && entry.compactMode == v.CompactMode && entry.compactUser == v.CompactUser && entry.imageProto == v.ImageProto && entry.theme == themeKey {
685+ return entry.lines
686+ }
687+ }
688+ lines := v.renderMessage(m, width, turnOpen)
689+ if idx >= len(v.messageCache) {
690+ v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, idx-len(v.messageCache)+1)...)
691+ }
692+ v.messageCache[idx] = messageRenderCacheEntry{width: width, expandAll: v.ExpandAll, turnOpen: turnOpen, flatTools: v.FlatTools, compactMode: v.CompactMode, compactUser: v.CompactUser, imageProto: v.ImageProto, theme: themeKey, lines: lines, valid: true}
693+ return lines
694+ }
540 key := msgCacheKey{695 key := msgCacheKey{
541- hash: hashMessage(m),696+ hash: hashMessage(m),
542- width: width,697+ width: width,
543- expandAll: v.ExpandAll,698+ expandAll: v.ExpandAll,
544- turnOpen: turnOpen,699+ turnOpen: turnOpen,
700+ flatTools: v.FlatTools,
701+ compactMode: v.CompactMode,
702+ compactUser: v.CompactUser,
703+ imageProto: v.ImageProto,
704+ theme: themeKey,
545 }705 }
546 if v.renderCache != nil {706 if v.renderCache != nil {
547 if lines, ok := v.renderCache[key]; ok {707 if lines, ok := v.renderCache[key]; ok {
@@ -842,7 +1002,123 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
842 if v.liveBodyHigh == nil {1002 if v.liveBodyHigh == nil {
843 v.liveBodyHigh = make(map[string]int)1003 v.liveBodyHigh = make(map[string]int)
844 }1004 }
845- return v.renderToolCall(tc, width)1005+ return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1006+}
1007+
1008+func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1009+ if v.liveRenderCache == nil {
1010+ v.liveRenderCache = make(map[liveToolCacheKey][]string)
1011+ }
1012+ key := liveToolCacheKey{
1013+ revision: tc.Revision,
1014+ content: hashLiveTool(tc),
1015+ width: width,
1016+ expandAll: v.ExpandAll,
1017+ flatTools: v.FlatTools,
1018+ compact: v.CompactMode,
1019+ imageProto: v.ImageProto,
1020+ theme: themeKey,
1021+ }
1022+ if lines, ok := v.liveRenderCache[key]; ok {
1023+ return lines
1024+ }
1025+ lines := v.renderToolCall(tc, width)
1026+ if len(v.liveRenderCache) > 512 {
1027+ for k := range v.liveRenderCache {
1028+ delete(v.liveRenderCache, k)
1029+ break
1030+ }
1031+ }
1032+ v.liveRenderCache[key] = lines
1033+ return lines
1034+}
1035+
1036+func hashLiveTool(tc ToolCallView) uint64 {
1037+ if tc.Revision != 0 {
1038+ return 0
1039+ }
1040+ h := fnv64aInit
1041+ for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1042+ h = fnv64aWrite(h, []byte(s))
1043+ h = fnv64aWriteByte(h, 0)
1044+ }
1045+ if tc.Error {
1046+ h = fnv64aWriteByte(h, 'e')
1047+ }
1048+ if tc.Done {
1049+ h = fnv64aWriteByte(h, 'd')
1050+ }
1051+ if tc.Streaming {
1052+ h = fnv64aWriteByte(h, 's')
1053+ }
1054+ return h
1055+}
1056+
1057+func toolThemeKey(theme Theme) uint64 {
1058+ h := fnv64aInit
1059+ for _, s := range []string{
1060+ theme.SyntaxBaseStyle,
1061+ theme.Syntax.Keyword,
1062+ theme.Syntax.KeywordConstant,
1063+ theme.Syntax.KeywordDeclaration,
1064+ theme.Syntax.KeywordNamespace,
1065+ theme.Syntax.KeywordReserved,
1066+ theme.Syntax.KeywordType,
1067+ theme.Syntax.NameBuiltin,
1068+ theme.Syntax.NameFunction,
1069+ theme.Syntax.NameClass,
1070+ theme.Syntax.NameDecorator,
1071+ theme.Syntax.LiteralString,
1072+ theme.Syntax.LiteralStringEscape,
1073+ theme.Syntax.LiteralNumber,
1074+ theme.Syntax.Comment,
1075+ theme.Syntax.CommentPreproc,
1076+ theme.Syntax.Operator,
1077+ theme.Syntax.Punctuation,
1078+ theme.Syntax.Text,
1079+ } {
1080+ h = fnv64aWrite(h, []byte(s))
1081+ h = fnv64aWriteByte(h, 0)
1082+ }
1083+ for _, c := range []TerminalColor{
1084+ theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1085+ theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1086+ theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1087+ theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1088+ theme.Terminal.Background,
1089+ } {
1090+ h = hashThemeColor(h, c)
1091+ }
1092+ if theme.Background != nil {
1093+ h = hashThemeColor(h, *theme.Background)
1094+ }
1095+ for _, c := range theme.Terminal.Palette {
1096+ h = hashThemeColor(h, c)
1097+ }
1098+ for _, value := range []bool{
1099+ theme.Inherited,
1100+ theme.Terminal.HasForeground,
1101+ theme.Terminal.HasBackground,
1102+ theme.Terminal.TrueColor,
1103+ theme.Terminal.Light,
1104+ theme.Terminal.SchemeKnown,
1105+ } {
1106+ if value {
1107+ h = fnv64aWriteByte(h, 1)
1108+ } else {
1109+ h = fnv64aWriteByte(h, 0)
1110+ }
1111+ }
1112+ return h
1113+}
1114+
1115+func hashThemeColor(h uint64, c TerminalColor) uint64 {
1116+ h = fnv64aWriteByte(h, byte(c.Mode))
1117+ h = fnv64aWriteByte(h, byte(c.Index))
1118+ h = fnv64aWriteByte(h, byte(c.R))
1119+ h = fnv64aWriteByte(h, byte(c.G))
1120+ h = fnv64aWriteByte(h, byte(c.B))
1121+ return h
846 }1122 }
847 1123
848 func (v *View) renderToolCall(tc ToolCallView, width int) []string {1124 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
@@ -979,6 +1255,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
979 // confirmation preview immediately gets the same colors, numbered gutters,1255 // confirmation preview immediately gets the same colors, numbered gutters,
980 // and syntax highlighting as its final tool result.1256 // and syntax highlighting as its final tool result.
981 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {1257 func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1258+ if !v.ExpandAll {
1259+ total := estimateToolTextLines(text, width)
1260+ if total > ToolCollapseLines {
1261+ lines := v.renderToolText(previewToolText(text, width), width, color, sourcePath, 1)
1262+ return v.collapseToolBodyWithTotal(lines, false, total)
1263+ }
1264+ }
982 return v.renderToolText(text, width, color, sourcePath, 1)1265 return v.renderToolText(text, width, color, sourcePath, 1)
983 }1266 }
984 1267
@@ -1472,6 +1755,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 {1755 func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
1473 var body []string1756 var body []string
1474 hasImage := false1757 hasImage := false
1758+ for _, b := range blocks {
1759+ if _, ok := b.(provider.ImageBlock); ok {
1760+ hasImage = true
1761+ break
1762+ }
1763+ }
1764+ total := 0
1475 for _, b := range blocks {1765 for _, b := range blocks {
1476 switch bb := b.(type) {1766 switch bb := b.(type) {
1477 case provider.TextBlock:1767 case provider.TextBlock:
@@ -1479,13 +1769,21 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
1479 if v.FlatTools || v.CompactMode {1769 if v.FlatTools || v.CompactMode {
1480 bodyWidth = flatToolBodyRenderWidth(width)1770 bodyWidth = flatToolBodyRenderWidth(width)
1481 }1771 }
1482- body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)1772+ estimated := estimateToolTextLines(bb.Text, bodyWidth)
1773+ total += estimated
1774+ text := bb.Text
1775+ // A collapsed result only exposes its first ten rendered rows.
1776+ // Render a bounded source preview in that case; syntax
1777+ // highlighting and wrapping the omitted tail is wasted work.
1778+ if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1779+ text = previewToolText(text, bodyWidth)
1780+ }
1781+ body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
1483 case provider.ImageBlock:1782 case provider.ImageBlock:
1484- hasImage = true
1485 body = append(body, v.renderImageBlock(bb, width)...)1783 body = append(body, v.renderImageBlock(bb, width)...)
1486 }1784 }
1487 }1785 }
1488- return v.collapseToolBody(body, hasImage)1786+ return v.collapseToolBodyWithTotal(body, hasImage, total)
1489 }1787 }
1490 1788
1491 // collapseToolBody trims lines to the configured preview size when the1789 // collapseToolBody trims lines to the configured preview size when the
@@ -1493,15 +1791,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col
1493 // expand" footer. Image blocks never collapse — they're short in text1791 // expand" footer. Image blocks never collapse — they're short in text
1494 // rows but represent real content the user wants to see.1792 // rows but represent real content the user wants to see.
1495 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {1793 func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1794+ return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1795+}
1796+
1797+func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
1496 if v.ExpandAll || hasImage {1798 if v.ExpandAll || hasImage {
1497 return lines1799 return lines
1498 }1800 }
1499- if len(lines) <= ToolCollapseLines {1801+ if total < len(lines) {
1802+ total = len(lines)
1803+ }
1804+ if total <= ToolCollapseLines {
1500 return lines1805 return lines
1501 }1806 }
1502- kept := lines[:ToolCollapsePreview]1807+ kept := lines
1503- hidden := len(lines) - ToolCollapsePreview1808+ if len(kept) > ToolCollapsePreview {
1504- total := len(lines)1809+ kept = kept[:ToolCollapsePreview]
1810+ }
1811+ hidden := total - ToolCollapsePreview
1505 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)1812 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1506 footer = v.Theme.FGColor(v.Theme.Muted, footer)1813 footer = v.Theme.FGColor(v.Theme.Muted, footer)
1507 out := append([]string(nil), kept...)1814 out := append([]string(nil), kept...)
@@ -1509,6 +1816,52 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1509 return append(out, footer)1816 return append(out, footer)
1510 }1817 }
1511 1818
1819+func estimateToolTextLines(text string, width int) int {
1820+ if width < 1 {
1821+ width = 1
1822+ }
1823+ total := 0
1824+ for _, line := range strings.Split(text, "\n") {
1825+ cells := runewidth.StringWidth(line)
1826+ if cells == 0 {
1827+ total++
1828+ continue
1829+ }
1830+ total += (cells + width - 1) / width
1831+ }
1832+ if total == 0 {
1833+ return 1
1834+ }
1835+ return total
1836+}
1837+
1838+func previewToolText(text string, width int) string {
1839+ if width < 1 {
1840+ width = 1
1841+ }
1842+ maxChars := width * (ToolCollapsePreview + 2)
1843+ if maxChars < 256 {
1844+ maxChars = 256
1845+ }
1846+ if maxChars > 8192 {
1847+ maxChars = 8192
1848+ }
1849+ end := 0
1850+ for count := 0; end < len(text) && count < maxChars; count++ {
1851+ _, size := utf8.DecodeRuneInString(text[end:])
1852+ if size == 0 {
1853+ break
1854+ }
1855+ end += size
1856+ }
1857+ preview := text[:end]
1858+ lines := strings.SplitAfter(preview, "\n")
1859+ if len(lines) > ToolCollapsePreview+2 {
1860+ preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1861+ }
1862+ return preview
1863+}
1864+
1512 // renderToolText renders a text block inside a tool result. If the1865 // renderToolText renders a text block inside a tool result. If the
1513 // text contains a unified-diff section (lines starting with "--- " /1866 // text contains a unified-diff section (lines starting with "--- " /
1514 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove1867 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
A packages/tui/view_bench_test.go +126 -0
@@ -0,0 +1,126 @@
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 = benchmarkToolStormView(expanded)
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+ b.ReportAllocs()
53+ b.ResetTimer()
54+ for n := 0; n < b.N; n++ {
55+ lines, anchors := view.BuildWithAnchors(width)
56+ if len(lines) == 0 || len(anchors) == 0 {
57+ b.Fatal("live tool storm produced no rows or anchors")
58+ }
59+ }
60+ })
61+ }
62+ }
63+}
64+
65+func benchmarkToolStormView(expanded bool) *View {
66+ return &View{
67+ Theme: Dark,
68+ ExpandAll: expanded,
69+ Messages: benchmarkToolStormMessages(48, 96),
70+ MessagesRevision: 1,
71+ }
72+}
73+
74+func benchmarkToolStormMessages(tools, resultLines int) []provider.Message {
75+ messages := make([]provider.Message, 0, tools*3)
76+ for i := 0; i < tools; i++ {
77+ id := fmt.Sprintf("call-%d", i)
78+ messages = append(messages,
79+ provider.Message{
80+ Role: provider.RoleUser,
81+ Content: []provider.Content{provider.TextBlock{
82+ Text: fmt.Sprintf("inspect result %d", i),
83+ }},
84+ },
85+ provider.Message{
86+ Role: provider.RoleAssistant,
87+ Content: []provider.Content{provider.ToolCallBlock{
88+ ID: id,
89+ Name: "bash",
90+ Arguments: []byte(`{"command":"printf storm"}`),
91+ }},
92+ },
93+ provider.Message{
94+ Role: provider.RoleTool,
95+ Content: []provider.Content{provider.ToolResultBlock{
96+ CallID: id,
97+ Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}},
98+ }},
99+ },
100+ )
101+ }
102+ return messages
103+}
104+
105+func benchmarkLiveTools(tools, resultLines int) []ToolCallView {
106+ out := make([]ToolCallView, 0, tools)
107+ for i := 0; i < tools; i++ {
108+ out = append(out, ToolCallView{
109+ ID: fmt.Sprintf("live-%d", i),
110+ Name: "bash",
111+ Revision: 1,
112+ Args: "printf storm",
113+ Result: benchmarkResult(resultLines, i),
114+ Done: true,
115+ })
116+ }
117+ return out
118+}
119+
120+func benchmarkResult(lines, tool int) string {
121+ var b strings.Builder
122+ for line := 0; line < lines; line++ {
123+ fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line)
124+ }
125+ return b.String()
126+}
@@ -0,0 +1,126 @@
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 = benchmarkToolStormView(expanded)
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+ b.ReportAllocs()
53+ b.ResetTimer()
54+ for n := 0; n < b.N; n++ {
55+ lines, anchors := view.BuildWithAnchors(width)
56+ if len(lines) == 0 || len(anchors) == 0 {
57+ b.Fatal("live tool storm produced no rows or anchors")
58+ }
59+ }
60+ })
61+ }
62+ }
63+}
64+
65+func benchmarkToolStormView(expanded bool) *View {
66+ return &View{
67+ Theme: Dark,
68+ ExpandAll: expanded,
69+ Messages: benchmarkToolStormMessages(48, 96),
70+ MessagesRevision: 1,
71+ }
72+}
73+
74+func benchmarkToolStormMessages(tools, resultLines int) []provider.Message {
75+ messages := make([]provider.Message, 0, tools*3)
76+ for i := 0; i < tools; i++ {
77+ id := fmt.Sprintf("call-%d", i)
78+ messages = append(messages,
79+ provider.Message{
80+ Role: provider.RoleUser,
81+ Content: []provider.Content{provider.TextBlock{
82+ Text: fmt.Sprintf("inspect result %d", i),
83+ }},
84+ },
85+ provider.Message{
86+ Role: provider.RoleAssistant,
87+ Content: []provider.Content{provider.ToolCallBlock{
88+ ID: id,
89+ Name: "bash",
90+ Arguments: []byte(`{"command":"printf storm"}`),
91+ }},
92+ },
93+ provider.Message{
94+ Role: provider.RoleTool,
95+ Content: []provider.Content{provider.ToolResultBlock{
96+ CallID: id,
97+ Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}},
98+ }},
99+ },
100+ )
101+ }
102+ return messages
103+}
104+
105+func benchmarkLiveTools(tools, resultLines int) []ToolCallView {
106+ out := make([]ToolCallView, 0, tools)
107+ for i := 0; i < tools; i++ {
108+ out = append(out, ToolCallView{
109+ ID: fmt.Sprintf("live-%d", i),
110+ Name: "bash",
111+ Revision: 1,
112+ Args: "printf storm",
113+ Result: benchmarkResult(resultLines, i),
114+ Done: true,
115+ })
116+ }
117+ return out
118+}
119+
120+func benchmarkResult(lines, tool int) string {
121+ var b strings.Builder
122+ for line := 0; line < lines; line++ {
123+ fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line)
124+ }
125+ return b.String()
126+}
A packages/tui/view_cache_test.go +68 -0
@@ -0,0 +1,68 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
11+ messages := []provider.Message{
12+ {
13+ Role: provider.RoleAssistant,
14+ Content: []provider.Content{provider.ToolCallBlock{
15+ ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`),
16+ }},
17+ },
18+ {
19+ Role: provider.RoleTool,
20+ Content: []provider.Content{provider.ToolResultBlock{
21+ CallID: "call-1",
22+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}},
23+ }},
24+ },
25+ }
26+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1}
27+ collapsed, _ := view.BuildWithAnchors(40)
28+ if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") {
29+ t.Fatal("long tool result did not use the collapsed presentation")
30+ }
31+
32+ view.ExpandAll = true
33+ expanded, _ := view.BuildWithAnchors(40)
34+ if len(expanded) <= len(collapsed) {
35+ t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded))
36+ }
37+
38+ wide, _ := view.BuildWithAnchors(120)
39+ if len(wide) >= len(expanded) {
40+ // The wide render may have the same row count for this fixture, but it
41+ // must still be rebuilt for the new wrapping width.
42+ if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {
43+ t.Fatal("resize reused rows from the old width")
44+ }
45+ }
46+
47+ view.ExpandAll = false
48+ view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{
49+ CallID: "call-1",
50+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
51+ }}
52+ view.MessagesRevision = 2
53+ updated, _ := view.BuildWithAnchors(40)
54+ if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
55+ t.Fatal("tool output revision reused stale cached rows")
56+ }
57+}
58+
59+func cacheTestResult(prefix string) string {
60+ var b strings.Builder
61+ for n := 0; n < 32; n++ {
62+ b.WriteString(prefix)
63+ b.WriteString(" line ")
64+ b.WriteString(string(rune('a' + n%26)))
65+ b.WriteByte('\n')
66+ }
67+ return b.String()
68+}
@@ -0,0 +1,68 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) {
11+ messages := []provider.Message{
12+ {
13+ Role: provider.RoleAssistant,
14+ Content: []provider.Content{provider.ToolCallBlock{
15+ ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`),
16+ }},
17+ },
18+ {
19+ Role: provider.RoleTool,
20+ Content: []provider.Content{provider.ToolResultBlock{
21+ CallID: "call-1",
22+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}},
23+ }},
24+ },
25+ }
26+ view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1}
27+ collapsed, _ := view.BuildWithAnchors(40)
28+ if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") {
29+ t.Fatal("long tool result did not use the collapsed presentation")
30+ }
31+
32+ view.ExpandAll = true
33+ expanded, _ := view.BuildWithAnchors(40)
34+ if len(expanded) <= len(collapsed) {
35+ t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded))
36+ }
37+
38+ wide, _ := view.BuildWithAnchors(120)
39+ if len(wide) >= len(expanded) {
40+ // The wide render may have the same row count for this fixture, but it
41+ // must still be rebuilt for the new wrapping width.
42+ if strings.Join(wide, "\n") == strings.Join(expanded, "\n") {
43+ t.Fatal("resize reused rows from the old width")
44+ }
45+ }
46+
47+ view.ExpandAll = false
48+ view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{
49+ CallID: "call-1",
50+ Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}},
51+ }}
52+ view.MessagesRevision = 2
53+ updated, _ := view.BuildWithAnchors(40)
54+ if !strings.Contains(strings.Join(updated, "\n"), "new marker") {
55+ t.Fatal("tool output revision reused stale cached rows")
56+ }
57+}
58+
59+func cacheTestResult(prefix string) string {
60+ var b strings.Builder
61+ for n := 0; n < 32; n++ {
62+ b.WriteString(prefix)
63+ b.WriteString(" line ")
64+ b.WriteString(string(rune('a' + n%26)))
65+ b.WriteByte('\n')
66+ }
67+ return b.String()
68+}

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