perf(tui): improve tool-storm responsiveness
- commit
- 89d4c9167df71fc798cabe81c67d4693418863b9
- author
- brice · · 2026-08-07 13:01 UTC
- parent
- 8a8e2b3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 440 | 440 | welcomeShowVer bool | |
| 441 | 441 | expandAll bool | |
| 442 | 442 | tailLimit int | |
| 443 | + | viewCacheRev uint64 | |
| 443 | 444 | } | |
| 444 | 445 | ||
| 445 | 446 | // QuickModelShortcut is one configured quick model switch slot. | |
| @@ -660,6 +661,9 @@ type Interactive struct { | |||
| 660 | 661 | subagentActivityActive bool | |
| 661 | 662 | pendingIdleWork []func() | |
| 662 | 663 | dirty chan struct{} | |
| 664 | + | renderScheduler atomic.Pointer[latestFrameScheduler] | |
| 665 | + | renderRevision atomic.Uint64 | |
| 666 | + | renderOutsideLock bool | |
| 663 | 667 | modelRefresh chan modelRefreshResult | |
| 664 | 668 | modelRefreshing bool | |
| 665 | 669 | startupPreDone chan startupPreResult | |
| @@ -689,6 +693,13 @@ type Interactive struct { | |||
| 689 | 693 | chatCacheKey chatCacheKey | |
| 690 | 694 | 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 | 703 | // Messages typed while a turn is in flight. Each is delivered as | |
| 693 | 704 | // its own follow-up turn once the current one finishes. Rendered | |
| 694 | 705 | // above the status bar as "sliding in: ..." chips. | |
| @@ -1037,17 +1048,26 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1037 | 1048 | ||
| 1038 | 1049 | cols, rows := term.Size() | |
| 1039 | 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 | 1054 | c, r := term.Size() | |
| 1042 | 1055 | 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 | + | } | |
| 1049 | 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 | 1072 | if i.cfg.StartupPre != "" { | |
| 1053 | 1073 | i.deferredInitialInput = i.cfg.InitialInput | |
| @@ -1134,7 +1154,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1134 | 1154 | if pendingRedraw { | |
| 1135 | 1155 | pendingRedraw = false | |
| 1136 | 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 | 1173 | pendingRedraw = false | |
| 1154 | 1174 | lastRedraw = time.Now() | |
| 1155 | - | i.redraw() | |
| 1175 | + | renderScheduler.request(false) | |
| 1156 | 1176 | return | |
| 1157 | 1177 | } | |
| 1158 | 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 | 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 | 1340 | select { | |
| 1286 | 1341 | case i.dirty <- struct{}{}: | |
| 1287 | 1342 | default: | |
| @@ -1289,6 +1344,12 @@ func (i *Interactive) invalidate() { | |||
| 1289 | 1344 | } | |
| 1290 | 1345 | ||
| 1291 | 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 | 1353 | key, cacheable := i.chatCacheKeyLocked(cols) | |
| 1293 | 1354 | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | |
| 1294 | 1355 | return append([]string(nil), i.chatCache...) | |
| @@ -1305,12 +1366,6 @@ func (i *Interactive) cachedChatLocked(cols int) []string { | |||
| 1305 | 1366 | } | |
| 1306 | 1367 | ||
| 1307 | 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 | 1369 | var rev uint64 | |
| 1315 | 1370 | if i.agent != nil { | |
| 1316 | 1371 | rev = i.agent.Revision() | |
| @@ -1333,6 +1388,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 1333 | 1388 | welcomeShowVer: showVer, | |
| 1334 | 1389 | expandAll: i.view.ExpandAll, | |
| 1335 | 1390 | tailLimit: i.view.TailLimit, | |
| 1391 | + | viewCacheRev: i.view.RenderCacheRevision, | |
| 1336 | 1392 | }, true | |
| 1337 | 1393 | } | |
| 1338 | 1394 | ||
| @@ -1509,11 +1565,47 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1509 | 1565 | 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 | 1602 | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 1513 | 1603 | if i.agent != nil { | |
| 1514 | 1604 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 1605 | + | i.view.MessagesRevision = i.agent.Revision() | |
| 1515 | 1606 | } else { | |
| 1516 | 1607 | i.view.Messages = nil | |
| 1608 | + | i.view.MessagesRevision = 0 | |
| 1517 | 1609 | } | |
| 1518 | 1610 | // Pacer flush: while the streaming pacer is still draining the | |
| 1519 | 1611 | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| @@ -1577,7 +1669,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1577 | 1669 | // rows update in place at the end of the buffer, instead of the | |
| 1578 | 1670 | // whole bottom band shrinking and shifting chat lines around. | |
| 1579 | 1671 | 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 | 1676 | // Welcome banner: shown at the top of the chat area when there is | |
| 1583 | 1677 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -1702,7 +1796,7 @@ func (i *Interactive) scrollBy(delta int) { | |||
| 1702 | 1796 | // wrapped-character fragments behind during scroll-driven | |
| 1703 | 1797 | // viewport changes. Force a full repaint on scroll, but | |
| 1704 | 1798 | // avoid a whole-screen clear because that visibly flickers. | |
| 1705 | - | i.rend.Invalidate() | |
| 1799 | + | i.requestRendererInvalidate() | |
| 1706 | 1800 | } | |
| 1707 | 1801 | i.mu.Unlock() | |
| 1708 | 1802 | i.invalidate() | |
| @@ -1743,16 +1837,14 @@ func (i *Interactive) scrollToBottom() { | |||
| 1743 | 1837 | // after resume. See commit 43da5e5 for the same fix on new turns. | |
| 1744 | 1838 | i.prevChatLen = 0 | |
| 1745 | 1839 | i.prevChatCols = 0 | |
| 1746 | - | if i.rend != nil { | |
| 1747 | - | i.rend.Invalidate() | |
| 1748 | - | } | |
| 1840 | + | i.requestRendererInvalidate() | |
| 1749 | 1841 | i.mu.Unlock() | |
| 1750 | 1842 | i.invalidate() | |
| 1751 | 1843 | } | |
| 1752 | 1844 | ||
| 1753 | 1845 | func (i *Interactive) redraw() { | |
| 1754 | 1846 | i.mu.Lock() | |
| 1755 | - | defer i.mu.Unlock() | |
| 1847 | + | renderRevision := i.renderRevision.Load() | |
| 1756 | 1848 | ||
| 1757 | 1849 | cols, rows := i.cfg.Terminal.Size() | |
| 1758 | 1850 | mainCols := cols | |
| @@ -1767,7 +1859,13 @@ func (i *Interactive) redraw() { | |||
| 1767 | 1859 | } | |
| 1768 | 1860 | } | |
| 1769 | 1861 | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 1862 | + | i.renderOutsideLock = true | |
| 1770 | 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 | 1870 | // Dialogs (login or model picker) render between chat and the editor. | |
| 1773 | 1871 | var dialog []string | |
| @@ -1904,7 +2002,7 @@ func (i *Interactive) redraw() { | |||
| 1904 | 2002 | // is repainted clean, matching what the user expects after | |
| 1905 | 2003 | // dismissing a picker. Clear() is keepScrollback-aware and | |
| 1906 | 2004 | // emits \x1b[3J there. | |
| 1907 | - | i.rend.Clear() | |
| 2005 | + | i.requestRendererClear() | |
| 1908 | 2006 | } | |
| 1909 | 2007 | i.prevOverlayOpen = overlayOpen | |
| 1910 | 2008 | if len(suggest) > 0 { | |
| @@ -2345,8 +2443,16 @@ func (i *Interactive) redraw() { | |||
| 2345 | 2443 | cursorCol = 0 | |
| 2346 | 2444 | } | |
| 2347 | 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 | 2454 | if rightBarActive { | |
| 2349 | - | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | |
| 2455 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 2350 | 2456 | if modalBackdrop { | |
| 2351 | 2457 | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 2352 | 2458 | } else { | |
| @@ -2356,11 +2462,13 @@ func (i *Interactive) redraw() { | |||
| 2356 | 2462 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2357 | 2463 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2358 | 2464 | } | |
| 2465 | + | i.mu.Lock() | |
| 2359 | 2466 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 2360 | 2467 | alert := *i.pendingAlert | |
| 2361 | 2468 | i.pendingAlert = nil | |
| 2362 | 2469 | i.emitAlertLocked(alert) | |
| 2363 | 2470 | } | |
| 2471 | + | i.mu.Unlock() | |
| 2364 | 2472 | } | |
| 2365 | 2473 | ||
| 2366 | 2474 | func hasImageEscape(line string) bool { | |
| @@ -2606,9 +2714,7 @@ func (i *Interactive) clearFileSuggestQuery() { | |||
| 2606 | 2714 | func (i *Interactive) setToolExpansion(expanded bool) { | |
| 2607 | 2715 | i.mu.Lock() | |
| 2608 | 2716 | i.view.ExpandAll = expanded | |
| 2609 | - | if i.rend != nil { | |
| 2610 | - | i.rend.Clear() | |
| 2611 | - | } | |
| 2717 | + | i.requestRendererClear() | |
| 2612 | 2718 | i.mu.Unlock() | |
| 2613 | 2719 | i.invalidate() | |
| 2614 | 2720 | } | |
| @@ -2623,9 +2729,7 @@ func (i *Interactive) toggleToolExpansion() { | |||
| 2623 | 2729 | func (i *Interactive) toggleBtwToolExpansion() { | |
| 2624 | 2730 | i.btwDialog.ToggleToolExpansion() | |
| 2625 | 2731 | i.mu.Lock() | |
| 2626 | - | if i.rend != nil { | |
| 2627 | - | i.rend.Clear() | |
| 2628 | - | } | |
| 2732 | + | i.requestRendererClear() | |
| 2629 | 2733 | i.mu.Unlock() | |
| 2630 | 2734 | i.invalidate() | |
| 2631 | 2735 | } | |
| @@ -3178,11 +3282,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3178 | 3282 | i.mu.Lock() | |
| 3179 | 3283 | i.rightBarHidden = !i.rightBarHidden | |
| 3180 | 3284 | i.mu.Unlock() | |
| 3181 | - | i.rend.Invalidate() | |
| 3285 | + | i.requestRendererInvalidate() | |
| 3182 | 3286 | i.invalidate() | |
| 3183 | 3287 | return false | |
| 3184 | 3288 | case tui.KeyCtrlL: | |
| 3185 | - | i.rend.Clear() | |
| 3289 | + | i.requestRendererClear() | |
| 3186 | 3290 | i.invalidate() | |
| 3187 | 3291 | return false | |
| 3188 | 3292 | case tui.KeyPasteClipboard: | |
| @@ -3912,6 +4016,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3912 | 4016 | } | |
| 3913 | 4017 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 3914 | 4018 | i.view.StartupContextPaths = nil | |
| 4019 | + | i.view.InvalidateRenderCache() | |
| 3915 | 4020 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 3916 | 4021 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| 3917 | 4022 | } | |
| @@ -4981,9 +5086,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4981 | 5086 | // change (image rendering, status copy, future toggles) lands | |
| 4982 | 5087 | // immediately instead of waiting for the next diff frame. | |
| 4983 | 5088 | defer func() { | |
| 4984 | - | if i.rend != nil { | |
| 4985 | - | i.rend.Clear() | |
| 4986 | - | } | |
| 5089 | + | i.requestRendererClear() | |
| 4987 | 5090 | i.invalidate() | |
| 4988 | 5091 | }() | |
| 4989 | 5092 | switch key { | |
| @@ -5325,6 +5428,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 5325 | 5428 | i.view.StartupContextPaths = nil | |
| 5326 | 5429 | i.view.StartupExtensionNames = nil | |
| 5327 | 5430 | i.view.StartupSkillNames = nil | |
| 5431 | + | i.view.InvalidateRenderCache() | |
| 5328 | 5432 | if value { | |
| 5329 | 5433 | i.view.StartupAgentName = i.cfg.StartupAgentName | |
| 5330 | 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 | 5444 | func (i *Interactive) applyTUIInputStyleSetting(style string) { | |
| 5341 | 5445 | defer func() { | |
| 5342 | - | if i.rend != nil { | |
| 5343 | - | i.rend.Clear() | |
| 5344 | - | } | |
| 5446 | + | i.requestRendererClear() | |
| 5345 | 5447 | i.invalidate() | |
| 5346 | 5448 | }() | |
| 5347 | 5449 | style = tui.NormalizeInputStyle(style) | |
| @@ -5363,9 +5465,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) { | |||
| 5363 | 5465 | ||
| 5364 | 5466 | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |
| 5365 | 5467 | defer func() { | |
| 5366 | - | if i.rend != nil { | |
| 5367 | - | i.rend.Clear() | |
| 5368 | - | } | |
| 5468 | + | i.requestRendererClear() | |
| 5369 | 5469 | i.invalidate() | |
| 5370 | 5470 | }() | |
| 5371 | 5471 | position = tui.NormalizeStatusPosition(position) | |
| @@ -5390,9 +5490,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |||
| 5390 | 5490 | ||
| 5391 | 5491 | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |
| 5392 | 5492 | defer func() { | |
| 5393 | - | if i.rend != nil { | |
| 5394 | - | i.rend.Clear() | |
| 5395 | - | } | |
| 5493 | + | i.requestRendererClear() | |
| 5396 | 5494 | i.invalidate() | |
| 5397 | 5495 | }() | |
| 5398 | 5496 | position = tui.NormalizeWorkingPosition(position) | |
| @@ -5417,9 +5515,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |||
| 5417 | 5515 | ||
| 5418 | 5516 | func (i *Interactive) applyTUISubagentPositionSetting(position string) { | |
| 5419 | 5517 | defer func() { | |
| 5420 | - | if i.rend != nil { | |
| 5421 | - | i.rend.Clear() | |
| 5422 | - | } | |
| 5518 | + | i.requestRendererClear() | |
| 5423 | 5519 | i.invalidate() | |
| 5424 | 5520 | }() | |
| 5425 | 5521 | position = tui.NormalizeSubagentPosition(position) | |
| @@ -5496,18 +5592,14 @@ func (i *Interactive) applyThemeNow(name string) { | |||
| 5496 | 5592 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 5497 | 5593 | i.applyInputCursorColor() | |
| 5498 | 5594 | 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() | |
| 5503 | 5597 | i.invalidate() | |
| 5504 | 5598 | } | |
| 5505 | 5599 | ||
| 5506 | 5600 | func (i *Interactive) applyReasoningSetting(level string) { | |
| 5507 | 5601 | defer func() { | |
| 5508 | - | if i.rend != nil { | |
| 5509 | - | i.rend.Clear() | |
| 5510 | - | } | |
| 5602 | + | i.requestRendererClear() | |
| 5511 | 5603 | i.invalidate() | |
| 5512 | 5604 | }() | |
| 5513 | 5605 | level = provider.NormalizeReasoning(level) | |
| @@ -7560,8 +7652,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7560 | 7652 | lastStop = e.Stop | |
| 7561 | 7653 | lastTurnErr = e.Err | |
| 7562 | 7654 | } | |
| 7563 | - | i.handleEvent(ev) | |
| 7564 | - | i.invalidate() | |
| 7655 | + | i.handleEventForPresentation(ev) | |
| 7565 | 7656 | } | |
| 7566 | 7657 | ||
| 7567 | 7658 | go func() { | |
| @@ -7996,6 +8087,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||
| 7996 | 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 | 8116 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 8000 | 8117 | i.mu.Lock() | |
| 8001 | 8118 | defer i.mu.Unlock() | |
| @@ -8052,6 +8169,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8052 | 8169 | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8053 | 8170 | ID: e.ID, | |
| 8054 | 8171 | Name: e.Name, | |
| 8172 | + | Revision: 1, | |
| 8055 | 8173 | Streaming: true, | |
| 8056 | 8174 | } | |
| 8057 | 8175 | i.toolOrder = append(i.toolOrder, e.ID) | |
| @@ -8060,6 +8178,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8060 | 8178 | case core.EvToolUseArgs: | |
| 8061 | 8179 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8062 | 8180 | tc.RawJSONBuf += e.Delta | |
| 8181 | + | bumpToolRevision(tc) | |
| 8063 | 8182 | // Refresh the live path as soon as it parses; used in | |
| 8064 | 8183 | // the header (write /Users/example/Desktop/demo.ts) | |
| 8065 | 8184 | // while the content is still streaming. | |
| @@ -8072,6 +8191,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8072 | 8191 | case core.EvToolUseEnd: | |
| 8073 | 8192 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8074 | 8193 | tc.Streaming = false | |
| 8194 | + | bumpToolRevision(tc) | |
| 8075 | 8195 | } | |
| 8076 | 8196 | case core.EvToolCall: | |
| 8077 | 8197 | // If we already pre-created the view during streaming, just | |
| @@ -8079,6 +8199,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8079 | 8199 | // (non-streaming providers or legacy paths). | |
| 8080 | 8200 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8081 | 8201 | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 8202 | + | bumpToolRevision(tc) | |
| 8082 | 8203 | if tc.RawJSONBuf == "" { | |
| 8083 | 8204 | tc.RawJSONBuf = string(e.Args) | |
| 8084 | 8205 | } | |
| @@ -8087,6 +8208,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8087 | 8208 | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8088 | 8209 | ID: e.ID, | |
| 8089 | 8210 | Name: e.Name, | |
| 8211 | + | Revision: 1, | |
| 8090 | 8212 | Args: tui.ShortArgs(e.Name, e.Args), | |
| 8091 | 8213 | RawJSONBuf: string(e.Args), | |
| 8092 | 8214 | } | |
| @@ -8108,6 +8230,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8108 | 8230 | } | |
| 8109 | 8231 | } | |
| 8110 | 8232 | tc.Result = text.String() | |
| 8233 | + | bumpToolRevision(tc) | |
| 8111 | 8234 | } | |
| 8112 | 8235 | if i.cfg.OnToolResult != nil { | |
| 8113 | 8236 | i.cfg.OnToolResult(e.ID, e.Result) | |
| @@ -9475,9 +9598,7 @@ func (i *Interactive) resetTranscriptRenderLocked() { | |||
| 9475 | 9598 | i.prevChatCols = 0 | |
| 9476 | 9599 | i.prevChatRows = 0 | |
| 9477 | 9600 | i.prevScrollOffset = 0 | |
| 9478 | - | if i.rend != nil { | |
| 9479 | - | i.rend.Invalidate() | |
| 9480 | - | } | |
| 9601 | + | i.requestRendererInvalidate() | |
| 9481 | 9602 | } | |
| 9482 | 9603 | ||
| 9483 | 9604 | // resetStreamingStateLocked clears every piece of streaming state | |
| @@ -440,6 +440,7 @@ type chatCacheKey struct { | |||||
| 440 | welcomeShowVer bool | 440 | welcomeShowVer bool | ||
| 441 | expandAll bool | 441 | expandAll bool | ||
| 442 | tailLimit int | 442 | 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 bool | 661 | 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 modelRefreshResult | 667 | modelRefresh chan modelRefreshResult | ||
| 664 | modelRefreshing bool | 668 | modelRefreshing bool | ||
| 665 | startupPreDone chan startupPreResult | 669 | startupPreDone chan startupPreResult | ||
| @@ -689,6 +693,13 @@ type Interactive struct { | |||||
| 689 | chatCacheKey chatCacheKey | 693 | chatCacheKey chatCacheKey | ||
| 690 | chatCacheValid bool | 694 | 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 as | 703 | // Messages typed while a turn is in flight. Each is delivered as | ||
| 693 | // its own follow-up turn once the current one finishes. Rendered | 704 | // 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 invalidate | 1056 | + | if req.theme != nil { |
| 1044 | - | // path is fine for animation, but a window resize is a discrete | 1057 | + | i.rend.SetTheme(*req.theme) |
| 1045 | - | // user action where any visible delay (or stale frame) reads as | 1058 | + | } |
| 1046 | - | // brokenness. redraw() is mutex-safe; the worst that happens is | 1059 | + | if req.clear { |
| 1047 | - | // a duplicate paint if the throttler is mid-flight, which is | 1060 | + | 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.InitialInput | 1073 | 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 = false | 1155 | 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 = false | 1173 | pendingRedraw = false | ||
| 1154 | lastRedraw = time.Now() | 1174 | lastRedraw = time.Now() | ||
| 1155 | - | i.redraw() | 1175 | + | renderScheduler.request(false) |
| 1156 | return | 1176 | 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 uint64 | 1369 | 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 | }, true | 1392 | }, true | ||
| 1337 | } | 1393 | } | ||
| 1338 | 1394 | ||||
| @@ -1509,11 +1565,47 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||||
| 1509 | return out | 1565 | 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 = nil | 1607 | i.view.Messages = nil | ||
| 1608 | + | i.view.MessagesRevision = 0 | |||
| 1517 | } | 1609 | } | ||
| 1518 | // Pacer flush: while the streaming pacer is still draining the | 1610 | // Pacer flush: while the streaming pacer is still draining the | ||
| 1519 | // buffer (i.e. EvAssistantMessage already fired but more runes | 1611 | // 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 the | 1669 | // 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 = nil | 1671 | 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 is | 1676 | // 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-driven | 1796 | // wrapped-character fragments behind during scroll-driven | ||
| 1703 | // viewport changes. Force a full repaint on scroll, but | 1797 | // 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 = 0 | 1838 | i.prevChatLen = 0 | ||
| 1745 | i.prevChatCols = 0 | 1839 | 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 := cols | 1850 | mainCols := cols | ||
| @@ -1767,7 +1859,13 @@ func (i *Interactive) redraw() { | |||||
| 1767 | } | 1859 | } | ||
| 1768 | } | 1860 | } | ||
| 1769 | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | 1861 | 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 []string | 1871 | var dialog []string | ||
| @@ -1904,7 +2002,7 @@ func (i *Interactive) redraw() { | |||||
| 1904 | // is repainted clean, matching what the user expects after | 2002 | // is repainted clean, matching what the user expects after | ||
| 1905 | // dismissing a picker. Clear() is keepScrollback-aware and | 2003 | // 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 = overlayOpen | 2007 | i.prevOverlayOpen = overlayOpen | ||
| 1910 | if len(suggest) > 0 { | 2008 | if len(suggest) > 0 { | ||
| @@ -2345,8 +2443,16 @@ func (i *Interactive) redraw() { | |||||
| 2345 | cursorCol = 0 | 2443 | 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.pendingAlert | 2467 | alert := *i.pendingAlert | ||
| 2361 | i.pendingAlert = nil | 2468 | 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 = expanded | 2716 | 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.rightBarHidden | 3283 | 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 false | 3287 | 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 false | 3291 | 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 = nil | 4018 | 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) lands | 5086 | // 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 = nil | 5428 | i.view.StartupContextPaths = nil | ||
| 5326 | i.view.StartupExtensionNames = nil | 5429 | i.view.StartupExtensionNames = nil | ||
| 5327 | i.view.StartupSkillNames = nil | 5430 | i.view.StartupSkillNames = nil | ||
| 5431 | + | i.view.InvalidateRenderCache() | |||
| 5328 | if value { | 5432 | if value { | ||
| 5329 | i.view.StartupAgentName = i.cfg.StartupAgentName | 5433 | 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.Stop | 7652 | lastStop = e.Stop | ||
| 7561 | lastTurnErr = e.Err | 7653 | 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.Delta | 8180 | tc.RawJSONBuf += e.Delta | ||
| 8181 | + | bumpToolRevision(tc) | |||
| 8063 | // Refresh the live path as soon as it parses; used in | 8182 | // 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 = false | 8193 | 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, just | 8197 | // 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 = 0 | 9598 | i.prevChatCols = 0 | ||
| 9476 | i.prevChatRows = 0 | 9599 | i.prevChatRows = 0 | ||
| 9477 | i.prevScrollOffset = 0 | 9600 | 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 state | 9604 | // 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 { | |||
| 37 | 37 | // in the terminal's visible viewport so we can diff safely, and bail | |
| 38 | 38 | // out to clear+replay when the diff would touch rows that are no | |
| 39 | 39 | // 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 | |
| 43 | 50 | logViewportTop int | |
| 44 | 51 | logHardwareRow int | |
| 45 | 52 | logInit bool | |
| @@ -430,6 +437,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 430 | 437 | if len(bottom) == 0 { | |
| 431 | 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 | 447 | chatFrame := make([]string, len(chat)) | |
| 434 | 448 | for i, line := range chat { | |
| 435 | 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 | 822 | r.logChat = append(r.logChat[:0], chatFrame...) | |
| 809 | 823 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | |
| 810 | 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 | 830 | r.cursorRow = cursorBottomRow | |
| 812 | 831 | r.cursorCol = cursorCol | |
| 813 | 832 | } | |
| @@ -37,9 +37,16 @@ type Renderer struct { | |||||
| 37 | // in the terminal's visible viewport so we can diff safely, and bail | 37 | // 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 no | 38 | // out to clear+replay when the diff would touch rows that are no | ||
| 39 | // longer addressable. | 39 | // longer addressable. | ||
| 40 | - | logChat []string | 40 | + | logChat []string |
| 41 | - | logBottom []string | 41 | + | logBottom []string |
| 42 | - | logLines []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 | |||
| 43 | logViewportTop int | 50 | logViewportTop int | ||
| 44 | logHardwareRow int | 51 | logHardwareRow int | ||
| 45 | logInit bool | 52 | 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 = cursorBottomRow | 830 | r.cursorRow = cursorBottomRow | ||
| 812 | r.cursorCol = cursorCol | 831 | 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 { | |||
| 104 | 104 | Theme Theme | |
| 105 | 105 | ImageProto ImageProtocol // how to render inline images in this terminal | |
| 106 | 106 | 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 | 116 | // toolPaths maps tool_use_id to the "path" argument of the call, if | |
| 108 | 117 | // any, so tool_result rendering can pick the right syntax language. | |
| 109 | 118 | // Rebuilt on each Build(). | |
| @@ -168,11 +177,19 @@ type View struct { | |||
| 168 | 177 | // doesn't re-markdown every message on every frame. Keyed by a | |
| 169 | 178 | // struct of (content hash, width, expandAll) — any of those | |
| 170 | 179 | // 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 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 | |
| 176 | 193 | ||
| 177 | 194 | // TailLimit caps how many messages from the END of Messages are | |
| 178 | 195 | // rendered. Messages older than the limit emit a zero-row slice | |
| @@ -198,7 +215,36 @@ type msgCacheKey struct { | |||
| 198 | 215 | // — even one that spans many assistant/tool message round-trips | |
| 199 | 216 | // in the underlying API — renders under one header instead of a | |
| 200 | 217 | // 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 | |
| 202 | 248 | } | |
| 203 | 249 | ||
| 204 | 250 | // InvalidateRenderCache drops all cached message renders. The tui | |
| @@ -206,7 +252,80 @@ type msgCacheKey struct { | |||
| 206 | 252 | // /clear, session swap) since messages can be replaced in place and | |
| 207 | 253 | // a content-hash miss alone doesn't reclaim the old entries. | |
| 208 | 254 | func (v *View) InvalidateRenderCache() { | |
| 255 | + | v.RenderCacheRevision++ | |
| 209 | 256 | 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 | 331 | // ToolCollapsePreview is the number of lines shown before a long tool | |
| @@ -219,13 +338,17 @@ const ( | |||
| 219 | 338 | ||
| 220 | 339 | // ToolCallView is a pending tool invocation plus optional result. | |
| 221 | 340 | 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 | |
| 229 | 352 | ||
| 230 | 353 | // Streaming is true while the model is still typing the tool | |
| 231 | 354 | // call's JSON arguments. The TUI renders a live preview of any | |
| @@ -265,6 +388,10 @@ func (v *View) Build(width int) []string { | |||
| 265 | 388 | // so native scrolling stays stable while a turn streams. | |
| 266 | 389 | func (v *View) BuildLive(width int) []string { | |
| 267 | 390 | v.prepareLiveBodyHeights() | |
| 391 | + | if v.liveRenderCache == nil { | |
| 392 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 393 | + | } | |
| 394 | + | themeKey := toolThemeKey(v.Theme) | |
| 268 | 395 | var out []string | |
| 269 | 396 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |
| 270 | 397 | const indent = " " | |
| @@ -294,7 +421,7 @@ func (v *View) BuildLive(width int) []string { | |||
| 294 | 421 | out = append(out, "") | |
| 295 | 422 | } | |
| 296 | 423 | insertedToolGap = true | |
| 297 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 424 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) | |
| 298 | 425 | } | |
| 299 | 426 | if v.Err != "" { | |
| 300 | 427 | out = append(out, v.renderErr(width)...) | |
| @@ -360,10 +487,24 @@ func (v *View) renderStartupResources(width int) []string { | |||
| 360 | 487 | // scroll to a specific turn (the /jump dialog) use the anchor slice | |
| 361 | 488 | // to map a message index back to a row offset. | |
| 362 | 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 | 502 | if v.renderCache == nil { | |
| 365 | 503 | v.renderCache = make(map[msgCacheKey][]string) | |
| 366 | 504 | } | |
| 505 | + | if v.liveRenderCache == nil { | |
| 506 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 507 | + | } | |
| 367 | 508 | v.prepareLiveBodyHeights() | |
| 368 | 509 | // Drop high-water entries for calls that are gone or finalised so | |
| 369 | 510 | // the reservation never leaks into a later, shorter tool box. | |
| @@ -422,7 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 422 | 563 | break | |
| 423 | 564 | } | |
| 424 | 565 | } | |
| 425 | - | lines := v.renderMessageCached(m, width, turnOpen) | |
| 566 | + | lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey) | |
| 426 | 567 | rendered[idx] = lines | |
| 427 | 568 | 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 | 628 | if finalised[tc.ID] { | |
| 488 | 629 | continue | |
| 489 | 630 | } | |
| 490 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 631 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) | |
| 491 | 632 | out = append(out, "") | |
| 492 | 633 | } | |
| 493 | 634 | if v.Err != "" { | |
| @@ -536,12 +677,31 @@ func (v *View) refreshToolPaths() { | |||
| 536 | 677 | // been rendered before. The slice returned is shared — callers must | |
| 537 | 678 | // not mutate it; Build() only ever appends to its own `out` so the | |
| 538 | 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 | 695 | 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, | |
| 545 | 705 | } | |
| 546 | 706 | if v.renderCache != nil { | |
| 547 | 707 | if lines, ok := v.renderCache[key]; ok { | |
| @@ -842,7 +1002,123 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string { | |||
| 842 | 1002 | if v.liveBodyHigh == nil { | |
| 843 | 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 | 1124 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| @@ -979,6 +1255,13 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 979 | 1255 | // confirmation preview immediately gets the same colors, numbered gutters, | |
| 980 | 1256 | // and syntax highlighting as its final tool result. | |
| 981 | 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 | 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 | 1755 | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { | |
| 1473 | 1756 | var body []string | |
| 1474 | 1757 | 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 | 1765 | for _, b := range blocks { | |
| 1476 | 1766 | switch bb := b.(type) { | |
| 1477 | 1767 | case provider.TextBlock: | |
| @@ -1479,13 +1769,21 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||
| 1479 | 1769 | if v.FlatTools || v.CompactMode { | |
| 1480 | 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 | 1782 | case provider.ImageBlock: | |
| 1484 | - | hasImage = true | |
| 1485 | 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 | 1789 | // 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 | 1791 | // expand" footer. Image blocks never collapse — they're short in text | |
| 1494 | 1792 | // rows but represent real content the user wants to see. | |
| 1495 | 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 | 1798 | if v.ExpandAll || hasImage { | |
| 1497 | 1799 | return lines | |
| 1498 | 1800 | } | |
| 1499 | - | if len(lines) <= ToolCollapseLines { | |
| 1801 | + | if total < len(lines) { | |
| 1802 | + | total = len(lines) | |
| 1803 | + | } | |
| 1804 | + | if total <= ToolCollapseLines { | |
| 1500 | 1805 | return lines | |
| 1501 | 1806 | } | |
| 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 | |
| 1505 | 1812 | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | |
| 1506 | 1813 | footer = v.Theme.FGColor(v.Theme.Muted, footer) | |
| 1507 | 1814 | out := append([]string(nil), kept...) | |
| @@ -1509,6 +1816,52 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 1509 | 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 | 1865 | // renderToolText renders a text block inside a tool result. If the | |
| 1513 | 1866 | // text contains a unified-diff section (lines starting with "--- " / | |
| 1514 | 1867 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| @@ -104,6 +104,15 @@ type View struct { | |||||
| 104 | Theme Theme | 104 | Theme Theme | ||
| 105 | ImageProto ImageProtocol // how to render inline images in this terminal | 105 | ImageProto ImageProtocol // how to render inline images in this terminal | ||
| 106 | Messages []provider.Message | 106 | 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, if | 116 | // 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 a | 177 | // doesn't re-markdown every message on every frame. Keyed by a | ||
| 169 | // struct of (content hash, width, expandAll) — any of those | 178 | // struct of (content hash, width, expandAll) — any of those | ||
| 170 | // changing invalidates the entry. Messages are append-only after | 179 | // 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 never | 184 | + | // 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][]string | 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 | |||
| 176 | 193 | ||||
| 177 | // TailLimit caps how many messages from the END of Messages are | 194 | // TailLimit caps how many messages from the END of Messages are | ||
| 178 | // rendered. Messages older than the limit emit a zero-row slice | 195 | // 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-trips | 215 | // — even one that spans many assistant/tool message round-trips | ||
| 199 | // in the underlying API — renders under one header instead of a | 216 | // 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 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 | |||
| 202 | } | 248 | } | ||
| 203 | 249 | ||||
| 204 | // InvalidateRenderCache drops all cached message renders. The tui | 250 | // 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 and | 252 | // /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 = nil | 256 | 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 tool | 331 | // 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 string | 341 | + | ID string |
| 223 | - | Name string | 342 | + | Name string |
| 224 | - | Args string // rendered argument summary | 343 | + | // Revision is advanced by the owning interactive state whenever a |
| 225 | - | Preview string // side-effect-free result shown before confirmation | 344 | + | // 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 bool | 346 | + | Revision uint64 |
| 228 | - | Done bool | 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 | |||
| 229 | 352 | ||||
| 230 | // Streaming is true while the model is still typing the tool | 353 | // Streaming is true while the model is still typing the tool | ||
| 231 | // call's JSON arguments. The TUI renders a live preview of any | 354 | // 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 []string | 395 | 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 = true | 423 | 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 slice | 487 | // 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 so | 509 | // 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 | break | 563 | break | ||
| 423 | } | 564 | } | ||
| 424 | } | 565 | } | ||
| 425 | - | lines := v.renderMessageCached(m, width, turnOpen) | 566 | + | lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey) |
| 426 | rendered[idx] = lines | 567 | rendered[idx] = lines | ||
| 427 | total += len(lines) + 1 // +1 for the blank separator row | 568 | 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 | continue | 629 | 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 must | 677 | // been rendered before. The slice returned is shared — callers must | ||
| 537 | // not mutate it; Build() only ever appends to its own `out` so the | 678 | // 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 []string | 1756 | var body []string | ||
| 1474 | hasImage := false | 1757 | 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 the | 1789 | // 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 text | 1791 | // 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 lines | 1799 | 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 lines | 1805 | return lines | ||
| 1501 | } | 1806 | } | ||
| 1502 | - | kept := lines[:ToolCollapsePreview] | 1807 | + | kept := lines |
| 1503 | - | hidden := len(lines) - ToolCollapsePreview | 1808 | + | 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 the | 1865 | // 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/remove | 1867 | // "+++ " / "+" / "-"/" "), 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 | + | } | |||