Merge pull request #70 from bnema/perf/tui-tool-storm-responsiveness
perf(tui): improve tool-storm responsiveness
8 files · +1321 -143
M packages/agent/modes/interactive.go +226 -100
| @@ -430,22 +430,24 @@ type modelRefreshResult struct{ err error } | |||
| 430 | 430 | ||
| 431 | 431 | // Interactive is the TUI chat loop. | |
| 432 | 432 | type chatCacheKey struct { | |
| 433 | - | cols int | |
| 434 | - | agentRev uint64 | |
| 435 | - | statusOK string | |
| 436 | - | statusErr string | |
| 437 | - | help string | |
| 438 | - | extNotes string | |
| 439 | - | extStatuses string | |
| 440 | - | extWidgets string | |
| 441 | - | reloadErrors string | |
| 442 | - | updateAvailable bool | |
| 443 | - | updateCurrent string | |
| 444 | - | updateLatest string | |
| 445 | - | updateURL string | |
| 446 | - | welcomeShowVer bool | |
| 447 | - | expandAll bool | |
| 448 | - | tailLimit int | |
| 433 | + | cols int | |
| 434 | + | agentRev uint64 | |
| 435 | + | statusOK string | |
| 436 | + | statusErr string | |
| 437 | + | help string | |
| 438 | + | extNotes string | |
| 439 | + | extStatuses string | |
| 440 | + | extWidgets string | |
| 441 | + | reloadErrors string | |
| 442 | + | updateAvailable bool | |
| 443 | + | updateCurrent string | |
| 444 | + | updateLatest string | |
| 445 | + | updateURL string | |
| 446 | + | welcomeShowVer bool | |
| 447 | + | expandAll bool | |
| 448 | + | tailLimit int | |
| 449 | + | renderedMessageCount int | |
| 450 | + | viewCacheRev uint64 | |
| 449 | 451 | } | |
| 450 | 452 | ||
| 451 | 453 | // QuickModelShortcut is one configured quick model switch slot. | |
| @@ -666,6 +668,9 @@ type Interactive struct { | |||
| 666 | 668 | subagentActivityActive bool | |
| 667 | 669 | pendingIdleWork []func() | |
| 668 | 670 | dirty chan struct{} | |
| 671 | + | renderScheduler atomic.Pointer[latestFrameScheduler] | |
| 672 | + | renderRevision atomic.Uint64 | |
| 673 | + | renderOutsideLock bool | |
| 669 | 674 | modelRefresh chan modelRefreshResult | |
| 670 | 675 | modelRefreshing bool | |
| 671 | 676 | startupPreDone chan startupPreResult | |
| @@ -695,6 +700,13 @@ type Interactive struct { | |||
| 695 | 700 | chatCacheKey chatCacheKey | |
| 696 | 701 | chatCacheValid bool | |
| 697 | 702 | ||
| 703 | + | // stableChatCache contains only transcript/startup rows. Live streaming, | |
| 704 | + | // tool overlays, and errors are appended separately so spinner/progress | |
| 705 | + | // frames can reuse finalized chat while a turn is busy. | |
| 706 | + | stableChatCache []string | |
| 707 | + | stableChatCacheKey chatCacheKey | |
| 708 | + | stableChatCacheValid bool | |
| 709 | + | ||
| 698 | 710 | // Messages typed while a turn is in flight. Each is delivered as | |
| 699 | 711 | // its own follow-up turn once the current one finishes. Rendered | |
| 700 | 712 | // above the status bar as "sliding in: ..." chips. | |
| @@ -1043,17 +1055,29 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1043 | 1055 | ||
| 1044 | 1056 | cols, rows := term.Size() | |
| 1045 | 1057 | i.rend.Resize(cols, rows) | |
| 1046 | - | term.OnResize(func() { | |
| 1058 | + | renderScheduler := newLatestFrameScheduler() | |
| 1059 | + | i.renderScheduler.Store(renderScheduler) | |
| 1060 | + | go renderScheduler.run(func(req renderRequest) { | |
| 1047 | 1061 | c, r := term.Size() | |
| 1048 | 1062 | i.rend.Resize(c, r) | |
| 1049 | - | // Force an immediate redraw on resize. The throttled invalidate | |
| 1050 | - | // path is fine for animation, but a window resize is a discrete | |
| 1051 | - | // user action where any visible delay (or stale frame) reads as | |
| 1052 | - | // brokenness. redraw() is mutex-safe; the worst that happens is | |
| 1053 | - | // a duplicate paint if the throttler is mid-flight, which is | |
| 1054 | - | // invisible. | |
| 1063 | + | if req.theme != nil { | |
| 1064 | + | i.rend.SetTheme(*req.theme) | |
| 1065 | + | } | |
| 1066 | + | if req.clear { | |
| 1067 | + | i.rend.Clear() | |
| 1068 | + | } | |
| 1069 | + | if req.invalidate { | |
| 1070 | + | i.rend.Invalidate() | |
| 1071 | + | } | |
| 1055 | 1072 | i.redraw() | |
| 1056 | 1073 | }) | |
| 1074 | + | defer renderScheduler.stop() | |
| 1075 | + | term.OnResize(func() { | |
| 1076 | + | // Resize and redraw share the renderer owner. The owner reads the | |
| 1077 | + | // latest terminal size just before its next frame. | |
| 1078 | + | i.renderRevision.Add(1) | |
| 1079 | + | renderScheduler.request(false, false) | |
| 1080 | + | }) | |
| 1057 | 1081 | ||
| 1058 | 1082 | if i.cfg.StartupPre != "" { | |
| 1059 | 1083 | i.deferredInitialInput = i.cfg.InitialInput | |
| @@ -1140,7 +1164,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1140 | 1164 | if pendingRedraw { | |
| 1141 | 1165 | pendingRedraw = false | |
| 1142 | 1166 | lastRedraw = time.Now() | |
| 1143 | - | i.redraw() | |
| 1167 | + | renderScheduler.request(false, false) | |
| 1144 | 1168 | } | |
| 1145 | 1169 | } | |
| 1146 | 1170 | ||
| @@ -1158,7 +1182,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1158 | 1182 | } | |
| 1159 | 1183 | pendingRedraw = false | |
| 1160 | 1184 | lastRedraw = time.Now() | |
| 1161 | - | i.redraw() | |
| 1185 | + | renderScheduler.request(false, false) | |
| 1162 | 1186 | return | |
| 1163 | 1187 | } | |
| 1164 | 1188 | if pendingRedraw { | |
| @@ -1287,7 +1311,42 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1287 | 1311 | } | |
| 1288 | 1312 | } | |
| 1289 | 1313 | ||
| 1314 | + | func (i *Interactive) requestRendererClear() { | |
| 1315 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1316 | + | scheduler.request(true, false) | |
| 1317 | + | return | |
| 1318 | + | } | |
| 1319 | + | if i.rend != nil { | |
| 1320 | + | i.rend.Clear() | |
| 1321 | + | } | |
| 1322 | + | } | |
| 1323 | + | ||
| 1324 | + | func (i *Interactive) requestRendererInvalidate() { | |
| 1325 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1326 | + | scheduler.request(false, true) | |
| 1327 | + | return | |
| 1328 | + | } | |
| 1329 | + | if i.rend != nil { | |
| 1330 | + | i.rend.Invalidate() | |
| 1331 | + | } | |
| 1332 | + | } | |
| 1333 | + | ||
| 1334 | + | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |
| 1335 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1336 | + | scheduler.requestTheme(theme) | |
| 1337 | + | return | |
| 1338 | + | } | |
| 1339 | + | if i.rend != nil { | |
| 1340 | + | i.rend.SetTheme(theme) | |
| 1341 | + | } | |
| 1342 | + | } | |
| 1343 | + | ||
| 1290 | 1344 | func (i *Interactive) invalidate() { | |
| 1345 | + | i.renderRevision.Add(1) | |
| 1346 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |
| 1347 | + | scheduler.request(false, false) | |
| 1348 | + | return | |
| 1349 | + | } | |
| 1291 | 1350 | select { | |
| 1292 | 1351 | case i.dirty <- struct{}{}: | |
| 1293 | 1352 | default: | |
| @@ -1295,51 +1354,50 @@ func (i *Interactive) invalidate() { | |||
| 1295 | 1354 | } | |
| 1296 | 1355 | ||
| 1297 | 1356 | func (i *Interactive) cachedChatLocked(cols int) []string { | |
| 1298 | - | key, cacheable := i.chatCacheKeyLocked(cols) | |
| 1299 | - | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | |
| 1357 | + | // A busy frame contains mutable streaming/tool state. Keep the stable | |
| 1358 | + | // transcript cache, but never return a full-frame cache entry that would | |
| 1359 | + | // hide a newly arrived delta or completed result. | |
| 1360 | + | if i.busy || i.streamOn || i.streamFlushPending { | |
| 1361 | + | return i.buildChatLocked(cols) | |
| 1362 | + | } | |
| 1363 | + | key := i.chatCacheKeyLocked(cols) | |
| 1364 | + | if i.chatCacheValid && i.chatCacheKey == key { | |
| 1300 | 1365 | return append([]string(nil), i.chatCache...) | |
| 1301 | 1366 | } | |
| 1302 | 1367 | chat := i.buildChatLocked(cols) | |
| 1303 | - | if cacheable { | |
| 1304 | - | i.chatCache = append(i.chatCache[:0], chat...) | |
| 1305 | - | i.chatCacheKey = key | |
| 1306 | - | i.chatCacheValid = true | |
| 1307 | - | } else { | |
| 1308 | - | i.chatCacheValid = false | |
| 1309 | - | } | |
| 1368 | + | key = i.chatCacheKeyLocked(cols) | |
| 1369 | + | i.chatCache = append(i.chatCache[:0], chat...) | |
| 1370 | + | i.chatCacheKey = key | |
| 1371 | + | i.chatCacheValid = true | |
| 1310 | 1372 | return chat | |
| 1311 | 1373 | } | |
| 1312 | 1374 | ||
| 1313 | - | func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |
| 1314 | - | // Live turns mutate streaming/tool-call state at high frequency; | |
| 1315 | - | // keep those on the old rebuild path. The cache targets the common | |
| 1316 | - | // idle case where only the editor contents changed between redraws. | |
| 1317 | - | if i.busy || i.streamOn || i.streamFlushPending { | |
| 1318 | - | return chatCacheKey{}, false | |
| 1319 | - | } | |
| 1375 | + | func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |
| 1320 | 1376 | var rev uint64 | |
| 1321 | 1377 | if i.agent != nil { | |
| 1322 | 1378 | rev = i.agent.Revision() | |
| 1323 | 1379 | } | |
| 1324 | 1380 | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 1325 | 1381 | return chatCacheKey{ | |
| 1326 | - | cols: cols, | |
| 1327 | - | agentRev: rev, | |
| 1328 | - | statusOK: i.statusOK, | |
| 1329 | - | statusErr: i.statusErr, | |
| 1330 | - | help: strings.Join(i.helpBlock, "\n"), | |
| 1331 | - | extNotes: strings.Join(i.extNotes, "\n"), | |
| 1332 | - | extStatuses: i.extensionStatusesKeyLocked(), | |
| 1333 | - | extWidgets: i.extensionWidgetsKeyLocked(), | |
| 1334 | - | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 1335 | - | updateAvailable: i.updateInfo.Available, | |
| 1336 | - | updateCurrent: i.updateInfo.Current, | |
| 1337 | - | updateLatest: i.updateInfo.Latest, | |
| 1338 | - | updateURL: i.updateInfo.URL, | |
| 1339 | - | welcomeShowVer: showVer, | |
| 1340 | - | expandAll: i.view.ExpandAll, | |
| 1341 | - | tailLimit: i.view.TailLimit, | |
| 1342 | - | }, true | |
| 1382 | + | cols: cols, | |
| 1383 | + | agentRev: rev, | |
| 1384 | + | statusOK: i.statusOK, | |
| 1385 | + | statusErr: i.statusErr, | |
| 1386 | + | help: strings.Join(i.helpBlock, "\n"), | |
| 1387 | + | extNotes: strings.Join(i.extNotes, "\n"), | |
| 1388 | + | extStatuses: i.extensionStatusesKeyLocked(), | |
| 1389 | + | extWidgets: i.extensionWidgetsKeyLocked(), | |
| 1390 | + | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 1391 | + | updateAvailable: i.updateInfo.Available, | |
| 1392 | + | updateCurrent: i.updateInfo.Current, | |
| 1393 | + | updateLatest: i.updateInfo.Latest, | |
| 1394 | + | updateURL: i.updateInfo.URL, | |
| 1395 | + | welcomeShowVer: showVer, | |
| 1396 | + | expandAll: i.view.ExpandAll, | |
| 1397 | + | tailLimit: i.view.TailLimit, | |
| 1398 | + | renderedMessageCount: len(i.view.Messages), | |
| 1399 | + | viewCacheRev: i.view.RenderCacheRevision, | |
| 1400 | + | } | |
| 1343 | 1401 | } | |
| 1344 | 1402 | ||
| 1345 | 1403 | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | |
| @@ -1515,11 +1573,48 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||
| 1515 | 1573 | return out | |
| 1516 | 1574 | } | |
| 1517 | 1575 | ||
| 1576 | + | func (i *Interactive) stableChatRowsLocked(cols int) []string { | |
| 1577 | + | key := i.chatCacheKeyLocked(cols) | |
| 1578 | + | if i.stableChatCacheValid && i.stableChatCacheKey == key { | |
| 1579 | + | return append([]string(nil), i.stableChatCache...) | |
| 1580 | + | } | |
| 1581 | + | ||
| 1582 | + | renderView := i.view.CloneForRender() | |
| 1583 | + | renderView.Streaming = "" | |
| 1584 | + | renderView.StreamingActive = false | |
| 1585 | + | renderView.ToolCalls = nil | |
| 1586 | + | renderView.Err = "" | |
| 1587 | + | if !i.renderOutsideLock { | |
| 1588 | + | rows := renderView.Build(cols) | |
| 1589 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 1590 | + | i.stableChatCacheKey = key | |
| 1591 | + | i.stableChatCacheValid = true | |
| 1592 | + | return append([]string(nil), rows...) | |
| 1593 | + | } | |
| 1594 | + | // Markdown, syntax highlighting, and wrapping are the expensive part of | |
| 1595 | + | // a frame. Build the immutable snapshot without holding the interactive | |
| 1596 | + | // mutex so key processing can continue while a cold transcript renders. | |
| 1597 | + | i.mu.Unlock() | |
| 1598 | + | rows := renderView.Build(cols) | |
| 1599 | + | i.mu.Lock() | |
| 1600 | + | if i.view.MessagesRevision == renderView.MessagesRevision && | |
| 1601 | + | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | |
| 1602 | + | i.view.AdoptRenderCacheFrom(renderView) | |
| 1603 | + | } | |
| 1604 | + | ||
| 1605 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 1606 | + | i.stableChatCacheKey = key | |
| 1607 | + | i.stableChatCacheValid = true | |
| 1608 | + | return append([]string(nil), rows...) | |
| 1609 | + | } | |
| 1610 | + | ||
| 1518 | 1611 | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 1519 | 1612 | if i.agent != nil { | |
| 1520 | 1613 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| 1614 | + | i.view.MessagesRevision = i.agent.Revision() | |
| 1521 | 1615 | } else { | |
| 1522 | 1616 | i.view.Messages = nil | |
| 1617 | + | i.view.MessagesRevision = 0 | |
| 1523 | 1618 | } | |
| 1524 | 1619 | // Pacer flush: while the streaming pacer is still draining the | |
| 1525 | 1620 | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| @@ -1583,7 +1678,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1583 | 1678 | // rows update in place at the end of the buffer, instead of the | |
| 1584 | 1679 | // whole bottom band shrinking and shifting chat lines around. | |
| 1585 | 1680 | i.liveBlock = nil | |
| 1586 | - | chat := i.view.Build(cols) | |
| 1681 | + | stable := i.stableChatRowsLocked(cols) | |
| 1682 | + | chat := append([]string(nil), stable...) | |
| 1683 | + | chat = append(chat, i.view.BuildLive(cols)...) | |
| 1587 | 1684 | ||
| 1588 | 1685 | // Welcome banner: shown at the top of the chat area when there is | |
| 1589 | 1686 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -1708,7 +1805,7 @@ func (i *Interactive) scrollBy(delta int) { | |||
| 1708 | 1805 | // wrapped-character fragments behind during scroll-driven | |
| 1709 | 1806 | // viewport changes. Force a full repaint on scroll, but | |
| 1710 | 1807 | // avoid a whole-screen clear because that visibly flickers. | |
| 1711 | - | i.rend.Invalidate() | |
| 1808 | + | i.requestRendererInvalidate() | |
| 1712 | 1809 | } | |
| 1713 | 1810 | i.mu.Unlock() | |
| 1714 | 1811 | i.invalidate() | |
| @@ -1749,16 +1846,14 @@ func (i *Interactive) scrollToBottom() { | |||
| 1749 | 1846 | // after resume. See commit 43da5e5 for the same fix on new turns. | |
| 1750 | 1847 | i.prevChatLen = 0 | |
| 1751 | 1848 | i.prevChatCols = 0 | |
| 1752 | - | if i.rend != nil { | |
| 1753 | - | i.rend.Invalidate() | |
| 1754 | - | } | |
| 1849 | + | i.requestRendererInvalidate() | |
| 1755 | 1850 | i.mu.Unlock() | |
| 1756 | 1851 | i.invalidate() | |
| 1757 | 1852 | } | |
| 1758 | 1853 | ||
| 1759 | 1854 | func (i *Interactive) redraw() { | |
| 1760 | 1855 | i.mu.Lock() | |
| 1761 | - | defer i.mu.Unlock() | |
| 1856 | + | renderRevision := i.renderRevision.Load() | |
| 1762 | 1857 | ||
| 1763 | 1858 | cols, rows := i.cfg.Terminal.Size() | |
| 1764 | 1859 | mainCols := cols | |
| @@ -1773,7 +1868,13 @@ func (i *Interactive) redraw() { | |||
| 1773 | 1868 | } | |
| 1774 | 1869 | } | |
| 1775 | 1870 | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 1871 | + | i.renderOutsideLock = true | |
| 1776 | 1872 | chat := i.cachedChatLocked(mainCols) | |
| 1873 | + | i.renderOutsideLock = false | |
| 1874 | + | if i.renderRevision.Load() != renderRevision { | |
| 1875 | + | i.mu.Unlock() | |
| 1876 | + | return | |
| 1877 | + | } | |
| 1777 | 1878 | ||
| 1778 | 1879 | // Dialogs (login or model picker) render between chat and the editor. | |
| 1779 | 1880 | var dialog []string | |
| @@ -2351,8 +2452,16 @@ func (i *Interactive) redraw() { | |||
| 2351 | 2452 | cursorCol = 0 | |
| 2352 | 2453 | } | |
| 2353 | 2454 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | |
| 2455 | + | if i.renderRevision.Load() != renderRevision { | |
| 2456 | + | i.mu.Unlock() | |
| 2457 | + | return | |
| 2458 | + | } | |
| 2459 | + | theme := i.cfg.Theme | |
| 2460 | + | // State assembly above is synchronized; terminal output is owned by | |
| 2461 | + | // this renderer goroutine and must not hold the global state mutex. | |
| 2462 | + | i.mu.Unlock() | |
| 2354 | 2463 | if rightBarActive { | |
| 2355 | - | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | |
| 2464 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |
| 2356 | 2465 | if modalBackdrop { | |
| 2357 | 2466 | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | |
| 2358 | 2467 | } else { | |
| @@ -2362,11 +2471,13 @@ func (i *Interactive) redraw() { | |||
| 2362 | 2471 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2363 | 2472 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2364 | 2473 | } | |
| 2474 | + | i.mu.Lock() | |
| 2365 | 2475 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 2366 | 2476 | alert := *i.pendingAlert | |
| 2367 | 2477 | i.pendingAlert = nil | |
| 2368 | 2478 | i.emitAlertLocked(alert) | |
| 2369 | 2479 | } | |
| 2480 | + | i.mu.Unlock() | |
| 2370 | 2481 | } | |
| 2371 | 2482 | ||
| 2372 | 2483 | func hasImageEscape(line string) bool { | |
| @@ -2612,9 +2723,7 @@ func (i *Interactive) clearFileSuggestQuery() { | |||
| 2612 | 2723 | func (i *Interactive) setToolExpansion(expanded bool) { | |
| 2613 | 2724 | i.mu.Lock() | |
| 2614 | 2725 | i.view.ExpandAll = expanded | |
| 2615 | - | if i.rend != nil { | |
| 2616 | - | i.rend.Clear() | |
| 2617 | - | } | |
| 2726 | + | i.requestRendererClear() | |
| 2618 | 2727 | i.mu.Unlock() | |
| 2619 | 2728 | i.invalidate() | |
| 2620 | 2729 | } | |
| @@ -2629,9 +2738,7 @@ func (i *Interactive) toggleToolExpansion() { | |||
| 2629 | 2738 | func (i *Interactive) toggleBtwToolExpansion() { | |
| 2630 | 2739 | i.btwDialog.ToggleToolExpansion() | |
| 2631 | 2740 | i.mu.Lock() | |
| 2632 | - | if i.rend != nil { | |
| 2633 | - | i.rend.Clear() | |
| 2634 | - | } | |
| 2741 | + | i.requestRendererClear() | |
| 2635 | 2742 | i.mu.Unlock() | |
| 2636 | 2743 | i.invalidate() | |
| 2637 | 2744 | } | |
| @@ -3184,11 +3291,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3184 | 3291 | i.mu.Lock() | |
| 3185 | 3292 | i.rightBarHidden = !i.rightBarHidden | |
| 3186 | 3293 | i.mu.Unlock() | |
| 3187 | - | i.rend.Invalidate() | |
| 3294 | + | i.requestRendererInvalidate() | |
| 3188 | 3295 | i.invalidate() | |
| 3189 | 3296 | return false | |
| 3190 | 3297 | case tui.KeyCtrlL: | |
| 3191 | - | i.rend.Clear() | |
| 3298 | + | i.requestRendererClear() | |
| 3192 | 3299 | i.invalidate() | |
| 3193 | 3300 | return false | |
| 3194 | 3301 | case tui.KeyPasteClipboard: | |
| @@ -3918,6 +4025,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3918 | 4025 | } | |
| 3919 | 4026 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 3920 | 4027 | i.view.StartupContextPaths = nil | |
| 4028 | + | i.view.InvalidateRenderCache() | |
| 3921 | 4029 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| 3922 | 4030 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | |
| 3923 | 4031 | } | |
| @@ -4987,9 +5095,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 4987 | 5095 | // change (image rendering, status copy, future toggles) lands | |
| 4988 | 5096 | // immediately instead of waiting for the next diff frame. | |
| 4989 | 5097 | defer func() { | |
| 4990 | - | if i.rend != nil { | |
| 4991 | - | i.rend.Clear() | |
| 4992 | - | } | |
| 5098 | + | i.requestRendererClear() | |
| 4993 | 5099 | i.invalidate() | |
| 4994 | 5100 | }() | |
| 4995 | 5101 | switch key { | |
| @@ -5331,6 +5437,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 5331 | 5437 | i.view.StartupContextPaths = nil | |
| 5332 | 5438 | i.view.StartupExtensionNames = nil | |
| 5333 | 5439 | i.view.StartupSkillNames = nil | |
| 5440 | + | i.view.InvalidateRenderCache() | |
| 5334 | 5441 | if value { | |
| 5335 | 5442 | i.view.StartupAgentName = i.cfg.StartupAgentName | |
| 5336 | 5443 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | |
| @@ -5345,9 +5452,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 5345 | 5452 | ||
| 5346 | 5453 | func (i *Interactive) applyTUIInputStyleSetting(style string) { | |
| 5347 | 5454 | defer func() { | |
| 5348 | - | if i.rend != nil { | |
| 5349 | - | i.rend.Clear() | |
| 5350 | - | } | |
| 5455 | + | i.requestRendererClear() | |
| 5351 | 5456 | i.invalidate() | |
| 5352 | 5457 | }() | |
| 5353 | 5458 | style = tui.NormalizeInputStyle(style) | |
| @@ -5369,9 +5474,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) { | |||
| 5369 | 5474 | ||
| 5370 | 5475 | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |
| 5371 | 5476 | defer func() { | |
| 5372 | - | if i.rend != nil { | |
| 5373 | - | i.rend.Clear() | |
| 5374 | - | } | |
| 5477 | + | i.requestRendererClear() | |
| 5375 | 5478 | i.invalidate() | |
| 5376 | 5479 | }() | |
| 5377 | 5480 | position = tui.NormalizeStatusPosition(position) | |
| @@ -5396,9 +5499,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |||
| 5396 | 5499 | ||
| 5397 | 5500 | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |
| 5398 | 5501 | defer func() { | |
| 5399 | - | if i.rend != nil { | |
| 5400 | - | i.rend.Clear() | |
| 5401 | - | } | |
| 5502 | + | i.requestRendererClear() | |
| 5402 | 5503 | i.invalidate() | |
| 5403 | 5504 | }() | |
| 5404 | 5505 | position = tui.NormalizeWorkingPosition(position) | |
| @@ -5423,9 +5524,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |||
| 5423 | 5524 | ||
| 5424 | 5525 | func (i *Interactive) applyTUISubagentPositionSetting(position string) { | |
| 5425 | 5526 | defer func() { | |
| 5426 | - | if i.rend != nil { | |
| 5427 | - | i.rend.Clear() | |
| 5428 | - | } | |
| 5527 | + | i.requestRendererClear() | |
| 5429 | 5528 | i.invalidate() | |
| 5430 | 5529 | }() | |
| 5431 | 5530 | position = tui.NormalizeSubagentPosition(position) | |
| @@ -5496,24 +5595,22 @@ func (i *Interactive) applyThemeNow(name string) { | |||
| 5496 | 5595 | i.statusErr = "" | |
| 5497 | 5596 | i.mu.Unlock() | |
| 5498 | 5597 | } | |
| 5598 | + | i.mu.Lock() | |
| 5499 | 5599 | i.cfg.Theme = th | |
| 5500 | 5600 | i.view.Theme = th | |
| 5501 | 5601 | i.view.InvalidateRenderCache() | |
| 5602 | + | i.mu.Unlock() | |
| 5502 | 5603 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 5503 | 5604 | i.applyInputCursorColor() | |
| 5504 | 5605 | i.spin.Configure(th) | |
| 5505 | - | if i.rend != nil { | |
| 5506 | - | i.rend.SetTheme(th) | |
| 5507 | - | i.rend.Clear() | |
| 5508 | - | } | |
| 5606 | + | i.requestRendererTheme(th) | |
| 5607 | + | i.requestRendererClear() | |
| 5509 | 5608 | i.invalidate() | |
| 5510 | 5609 | } | |
| 5511 | 5610 | ||
| 5512 | 5611 | func (i *Interactive) applyReasoningSetting(level string) { | |
| 5513 | 5612 | defer func() { | |
| 5514 | - | if i.rend != nil { | |
| 5515 | - | i.rend.Clear() | |
| 5516 | - | } | |
| 5613 | + | i.requestRendererClear() | |
| 5517 | 5614 | i.invalidate() | |
| 5518 | 5615 | }() | |
| 5519 | 5616 | level = provider.NormalizeReasoning(level) | |
| @@ -7566,8 +7663,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7566 | 7663 | lastStop = e.Stop | |
| 7567 | 7664 | lastTurnErr = e.Err | |
| 7568 | 7665 | } | |
| 7569 | - | i.handleEvent(ev) | |
| 7570 | - | i.invalidate() | |
| 7666 | + | i.handleEventForPresentation(ev) | |
| 7571 | 7667 | } | |
| 7572 | 7668 | ||
| 7573 | 7669 | go func() { | |
| @@ -8002,6 +8098,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||
| 8002 | 8098 | return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | |
| 8003 | 8099 | } | |
| 8004 | 8100 | ||
| 8101 | + | func eventAffectsPresentation(ev core.AgentEvent) bool { | |
| 8102 | + | switch ev.(type) { | |
| 8103 | + | case core.EvToolProgress: | |
| 8104 | + | return false | |
| 8105 | + | default: | |
| 8106 | + | return true | |
| 8107 | + | } | |
| 8108 | + | } | |
| 8109 | + | ||
| 8110 | + | func bumpToolRevision(tc *tui.ToolCallView) { | |
| 8111 | + | if tc.Revision == ^uint64(0) { | |
| 8112 | + | tc.Revision = 1 | |
| 8113 | + | return | |
| 8114 | + | } | |
| 8115 | + | tc.Revision++ | |
| 8116 | + | } | |
| 8117 | + | ||
| 8118 | + | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | |
| 8119 | + | i.handleEvent(ev) | |
| 8120 | + | // Progress payloads are still delivered to the core sink and applied | |
| 8121 | + | // in order, but they have no interactive-visible representation. | |
| 8122 | + | if eventAffectsPresentation(ev) { | |
| 8123 | + | i.invalidate() | |
| 8124 | + | } | |
| 8125 | + | } | |
| 8126 | + | ||
| 8005 | 8127 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 8006 | 8128 | i.mu.Lock() | |
| 8007 | 8129 | defer i.mu.Unlock() | |
| @@ -8058,6 +8180,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8058 | 8180 | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8059 | 8181 | ID: e.ID, | |
| 8060 | 8182 | Name: e.Name, | |
| 8183 | + | Revision: 1, | |
| 8061 | 8184 | Streaming: true, | |
| 8062 | 8185 | } | |
| 8063 | 8186 | i.toolOrder = append(i.toolOrder, e.ID) | |
| @@ -8066,6 +8189,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8066 | 8189 | case core.EvToolUseArgs: | |
| 8067 | 8190 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8068 | 8191 | tc.RawJSONBuf += e.Delta | |
| 8192 | + | bumpToolRevision(tc) | |
| 8069 | 8193 | // Refresh the live path as soon as it parses; used in | |
| 8070 | 8194 | // the header (write /Users/example/Desktop/demo.ts) | |
| 8071 | 8195 | // while the content is still streaming. | |
| @@ -8078,6 +8202,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8078 | 8202 | case core.EvToolUseEnd: | |
| 8079 | 8203 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8080 | 8204 | tc.Streaming = false | |
| 8205 | + | bumpToolRevision(tc) | |
| 8081 | 8206 | } | |
| 8082 | 8207 | case core.EvToolCall: | |
| 8083 | 8208 | // If we already pre-created the view during streaming, just | |
| @@ -8085,6 +8210,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8085 | 8210 | // (non-streaming providers or legacy paths). | |
| 8086 | 8211 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 8087 | 8212 | tc.Args = tui.ShortArgs(e.Name, e.Args) | |
| 8213 | + | bumpToolRevision(tc) | |
| 8088 | 8214 | if tc.RawJSONBuf == "" { | |
| 8089 | 8215 | tc.RawJSONBuf = string(e.Args) | |
| 8090 | 8216 | } | |
| @@ -8093,6 +8219,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8093 | 8219 | i.toolCalls[e.ID] = &tui.ToolCallView{ | |
| 8094 | 8220 | ID: e.ID, | |
| 8095 | 8221 | Name: e.Name, | |
| 8222 | + | Revision: 1, | |
| 8096 | 8223 | Args: tui.ShortArgs(e.Name, e.Args), | |
| 8097 | 8224 | RawJSONBuf: string(e.Args), | |
| 8098 | 8225 | } | |
| @@ -8114,6 +8241,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8114 | 8241 | } | |
| 8115 | 8242 | } | |
| 8116 | 8243 | tc.Result = text.String() | |
| 8244 | + | bumpToolRevision(tc) | |
| 8117 | 8245 | } | |
| 8118 | 8246 | if i.cfg.OnToolResult != nil { | |
| 8119 | 8247 | i.cfg.OnToolResult(e.ID, e.Result) | |
| @@ -9560,9 +9688,7 @@ func (i *Interactive) resetTranscriptRenderLocked() { | |||
| 9560 | 9688 | i.prevChatCols = 0 | |
| 9561 | 9689 | i.prevChatRows = 0 | |
| 9562 | 9690 | i.prevScrollOffset = 0 | |
| 9563 | - | if i.rend != nil { | |
| 9564 | - | i.rend.Invalidate() | |
| 9565 | - | } | |
| 9691 | + | i.requestRendererInvalidate() | |
| 9566 | 9692 | } | |
| 9567 | 9693 | ||
| 9568 | 9694 | // resetStreamingStateLocked clears every piece of streaming state | |
| @@ -430,22 +430,24 @@ type modelRefreshResult struct{ err error } | |||||
| 430 | 430 | ||||
| 431 | // Interactive is the TUI chat loop. | 431 | // Interactive is the TUI chat loop. | ||
| 432 | type chatCacheKey struct { | 432 | type chatCacheKey struct { | ||
| 433 | - | cols int | 433 | + | cols int |
| 434 | - | agentRev uint64 | 434 | + | agentRev uint64 |
| 435 | - | statusOK string | 435 | + | statusOK string |
| 436 | - | statusErr string | 436 | + | statusErr string |
| 437 | - | help string | 437 | + | help string |
| 438 | - | extNotes string | 438 | + | extNotes string |
| 439 | - | extStatuses string | 439 | + | extStatuses string |
| 440 | - | extWidgets string | 440 | + | extWidgets string |
| 441 | - | reloadErrors string | 441 | + | reloadErrors string |
| 442 | - | updateAvailable bool | 442 | + | updateAvailable bool |
| 443 | - | updateCurrent string | 443 | + | updateCurrent string |
| 444 | - | updateLatest string | 444 | + | updateLatest string |
| 445 | - | updateURL string | 445 | + | updateURL string |
| 446 | - | welcomeShowVer bool | 446 | + | welcomeShowVer bool |
| 447 | - | expandAll bool | 447 | + | expandAll bool |
| 448 | - | tailLimit int | 448 | + | tailLimit int |
| 449 | + | renderedMessageCount int | |||
| 450 | + | viewCacheRev uint64 | |||
| 449 | } | 451 | } | ||
| 450 | 452 | ||||
| 451 | // QuickModelShortcut is one configured quick model switch slot. | 453 | // QuickModelShortcut is one configured quick model switch slot. | ||
| @@ -666,6 +668,9 @@ type Interactive struct { | |||||
| 666 | subagentActivityActive bool | 668 | subagentActivityActive bool | ||
| 667 | pendingIdleWork []func() | 669 | pendingIdleWork []func() | ||
| 668 | dirty chan struct{} | 670 | dirty chan struct{} | ||
| 671 | + | renderScheduler atomic.Pointer[latestFrameScheduler] | |||
| 672 | + | renderRevision atomic.Uint64 | |||
| 673 | + | renderOutsideLock bool | |||
| 669 | modelRefresh chan modelRefreshResult | 674 | modelRefresh chan modelRefreshResult | ||
| 670 | modelRefreshing bool | 675 | modelRefreshing bool | ||
| 671 | startupPreDone chan startupPreResult | 676 | startupPreDone chan startupPreResult | ||
| @@ -695,6 +700,13 @@ type Interactive struct { | |||||
| 695 | chatCacheKey chatCacheKey | 700 | chatCacheKey chatCacheKey | ||
| 696 | chatCacheValid bool | 701 | chatCacheValid bool | ||
| 697 | 702 | ||||
| 703 | + | // stableChatCache contains only transcript/startup rows. Live streaming, | |||
| 704 | + | // tool overlays, and errors are appended separately so spinner/progress | |||
| 705 | + | // frames can reuse finalized chat while a turn is busy. | |||
| 706 | + | stableChatCache []string | |||
| 707 | + | stableChatCacheKey chatCacheKey | |||
| 708 | + | stableChatCacheValid bool | |||
| 709 | + | ||||
| 698 | // Messages typed while a turn is in flight. Each is delivered as | 710 | // Messages typed while a turn is in flight. Each is delivered as | ||
| 699 | // its own follow-up turn once the current one finishes. Rendered | 711 | // its own follow-up turn once the current one finishes. Rendered | ||
| 700 | // above the status bar as "sliding in: ..." chips. | 712 | // above the status bar as "sliding in: ..." chips. | ||
| @@ -1043,17 +1055,29 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1043 | 1055 | ||||
| 1044 | cols, rows := term.Size() | 1056 | cols, rows := term.Size() | ||
| 1045 | i.rend.Resize(cols, rows) | 1057 | i.rend.Resize(cols, rows) | ||
| 1046 | - | term.OnResize(func() { | 1058 | + | renderScheduler := newLatestFrameScheduler() |
| 1059 | + | i.renderScheduler.Store(renderScheduler) | |||
| 1060 | + | go renderScheduler.run(func(req renderRequest) { | |||
| 1047 | c, r := term.Size() | 1061 | c, r := term.Size() | ||
| 1048 | i.rend.Resize(c, r) | 1062 | i.rend.Resize(c, r) | ||
| 1049 | - | // Force an immediate redraw on resize. The throttled invalidate | 1063 | + | if req.theme != nil { |
| 1050 | - | // path is fine for animation, but a window resize is a discrete | 1064 | + | i.rend.SetTheme(*req.theme) |
| 1051 | - | // user action where any visible delay (or stale frame) reads as | 1065 | + | } |
| 1052 | - | // brokenness. redraw() is mutex-safe; the worst that happens is | 1066 | + | if req.clear { |
| 1053 | - | // a duplicate paint if the throttler is mid-flight, which is | 1067 | + | i.rend.Clear() |
| 1054 | - | // invisible. | 1068 | + | } |
| 1069 | + | if req.invalidate { | |||
| 1070 | + | i.rend.Invalidate() | |||
| 1071 | + | } | |||
| 1055 | i.redraw() | 1072 | i.redraw() | ||
| 1056 | }) | 1073 | }) | ||
| 1074 | + | defer renderScheduler.stop() | |||
| 1075 | + | term.OnResize(func() { | |||
| 1076 | + | // Resize and redraw share the renderer owner. The owner reads the | |||
| 1077 | + | // latest terminal size just before its next frame. | |||
| 1078 | + | i.renderRevision.Add(1) | |||
| 1079 | + | renderScheduler.request(false, false) | |||
| 1080 | + | }) | |||
| 1057 | 1081 | ||||
| 1058 | if i.cfg.StartupPre != "" { | 1082 | if i.cfg.StartupPre != "" { | ||
| 1059 | i.deferredInitialInput = i.cfg.InitialInput | 1083 | i.deferredInitialInput = i.cfg.InitialInput | ||
| @@ -1140,7 +1164,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1140 | if pendingRedraw { | 1164 | if pendingRedraw { | ||
| 1141 | pendingRedraw = false | 1165 | pendingRedraw = false | ||
| 1142 | lastRedraw = time.Now() | 1166 | lastRedraw = time.Now() | ||
| 1143 | - | i.redraw() | 1167 | + | renderScheduler.request(false, false) |
| 1144 | } | 1168 | } | ||
| 1145 | } | 1169 | } | ||
| 1146 | 1170 | ||||
| @@ -1158,7 +1182,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1158 | } | 1182 | } | ||
| 1159 | pendingRedraw = false | 1183 | pendingRedraw = false | ||
| 1160 | lastRedraw = time.Now() | 1184 | lastRedraw = time.Now() | ||
| 1161 | - | i.redraw() | 1185 | + | renderScheduler.request(false, false) |
| 1162 | return | 1186 | return | ||
| 1163 | } | 1187 | } | ||
| 1164 | if pendingRedraw { | 1188 | if pendingRedraw { | ||
| @@ -1287,7 +1311,42 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1287 | } | 1311 | } | ||
| 1288 | } | 1312 | } | ||
| 1289 | 1313 | ||||
| 1314 | + | func (i *Interactive) requestRendererClear() { | |||
| 1315 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1316 | + | scheduler.request(true, false) | |||
| 1317 | + | return | |||
| 1318 | + | } | |||
| 1319 | + | if i.rend != nil { | |||
| 1320 | + | i.rend.Clear() | |||
| 1321 | + | } | |||
| 1322 | + | } | |||
| 1323 | + | ||||
| 1324 | + | func (i *Interactive) requestRendererInvalidate() { | |||
| 1325 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1326 | + | scheduler.request(false, true) | |||
| 1327 | + | return | |||
| 1328 | + | } | |||
| 1329 | + | if i.rend != nil { | |||
| 1330 | + | i.rend.Invalidate() | |||
| 1331 | + | } | |||
| 1332 | + | } | |||
| 1333 | + | ||||
| 1334 | + | func (i *Interactive) requestRendererTheme(theme tui.Theme) { | |||
| 1335 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1336 | + | scheduler.requestTheme(theme) | |||
| 1337 | + | return | |||
| 1338 | + | } | |||
| 1339 | + | if i.rend != nil { | |||
| 1340 | + | i.rend.SetTheme(theme) | |||
| 1341 | + | } | |||
| 1342 | + | } | |||
| 1343 | + | ||||
| 1290 | func (i *Interactive) invalidate() { | 1344 | func (i *Interactive) invalidate() { | ||
| 1345 | + | i.renderRevision.Add(1) | |||
| 1346 | + | if scheduler := i.renderScheduler.Load(); scheduler != nil { | |||
| 1347 | + | scheduler.request(false, false) | |||
| 1348 | + | return | |||
| 1349 | + | } | |||
| 1291 | select { | 1350 | select { | ||
| 1292 | case i.dirty <- struct{}{}: | 1351 | case i.dirty <- struct{}{}: | ||
| 1293 | default: | 1352 | default: | ||
| @@ -1295,51 +1354,50 @@ func (i *Interactive) invalidate() { | |||||
| 1295 | } | 1354 | } | ||
| 1296 | 1355 | ||||
| 1297 | func (i *Interactive) cachedChatLocked(cols int) []string { | 1356 | func (i *Interactive) cachedChatLocked(cols int) []string { | ||
| 1298 | - | key, cacheable := i.chatCacheKeyLocked(cols) | 1357 | + | // A busy frame contains mutable streaming/tool state. Keep the stable |
| 1299 | - | if cacheable && i.chatCacheValid && i.chatCacheKey == key { | 1358 | + | // transcript cache, but never return a full-frame cache entry that would |
| 1359 | + | // hide a newly arrived delta or completed result. | |||
| 1360 | + | if i.busy || i.streamOn || i.streamFlushPending { | |||
| 1361 | + | return i.buildChatLocked(cols) | |||
| 1362 | + | } | |||
| 1363 | + | key := i.chatCacheKeyLocked(cols) | |||
| 1364 | + | if i.chatCacheValid && i.chatCacheKey == key { | |||
| 1300 | return append([]string(nil), i.chatCache...) | 1365 | return append([]string(nil), i.chatCache...) | ||
| 1301 | } | 1366 | } | ||
| 1302 | chat := i.buildChatLocked(cols) | 1367 | chat := i.buildChatLocked(cols) | ||
| 1303 | - | if cacheable { | 1368 | + | key = i.chatCacheKeyLocked(cols) |
| 1304 | - | i.chatCache = append(i.chatCache[:0], chat...) | 1369 | + | i.chatCache = append(i.chatCache[:0], chat...) |
| 1305 | - | i.chatCacheKey = key | 1370 | + | i.chatCacheKey = key |
| 1306 | - | i.chatCacheValid = true | 1371 | + | i.chatCacheValid = true |
| 1307 | - | } else { | |||
| 1308 | - | i.chatCacheValid = false | |||
| 1309 | - | } | |||
| 1310 | return chat | 1372 | return chat | ||
| 1311 | } | 1373 | } | ||
| 1312 | 1374 | ||||
| 1313 | - | func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | 1375 | + | func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { |
| 1314 | - | // Live turns mutate streaming/tool-call state at high frequency; | |||
| 1315 | - | // keep those on the old rebuild path. The cache targets the common | |||
| 1316 | - | // idle case where only the editor contents changed between redraws. | |||
| 1317 | - | if i.busy || i.streamOn || i.streamFlushPending { | |||
| 1318 | - | return chatCacheKey{}, false | |||
| 1319 | - | } | |||
| 1320 | var rev uint64 | 1376 | var rev uint64 | ||
| 1321 | if i.agent != nil { | 1377 | if i.agent != nil { | ||
| 1322 | rev = i.agent.Revision() | 1378 | rev = i.agent.Revision() | ||
| 1323 | } | 1379 | } | ||
| 1324 | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | 1380 | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | ||
| 1325 | return chatCacheKey{ | 1381 | return chatCacheKey{ | ||
| 1326 | - | cols: cols, | 1382 | + | cols: cols, |
| 1327 | - | agentRev: rev, | 1383 | + | agentRev: rev, |
| 1328 | - | statusOK: i.statusOK, | 1384 | + | statusOK: i.statusOK, |
| 1329 | - | statusErr: i.statusErr, | 1385 | + | statusErr: i.statusErr, |
| 1330 | - | help: strings.Join(i.helpBlock, "\n"), | 1386 | + | help: strings.Join(i.helpBlock, "\n"), |
| 1331 | - | extNotes: strings.Join(i.extNotes, "\n"), | 1387 | + | extNotes: strings.Join(i.extNotes, "\n"), |
| 1332 | - | extStatuses: i.extensionStatusesKeyLocked(), | 1388 | + | extStatuses: i.extensionStatusesKeyLocked(), |
| 1333 | - | extWidgets: i.extensionWidgetsKeyLocked(), | 1389 | + | extWidgets: i.extensionWidgetsKeyLocked(), |
| 1334 | - | reloadErrors: strings.Join(i.reloadErrors, "\n"), | 1390 | + | reloadErrors: strings.Join(i.reloadErrors, "\n"), |
| 1335 | - | updateAvailable: i.updateInfo.Available, | 1391 | + | updateAvailable: i.updateInfo.Available, |
| 1336 | - | updateCurrent: i.updateInfo.Current, | 1392 | + | updateCurrent: i.updateInfo.Current, |
| 1337 | - | updateLatest: i.updateInfo.Latest, | 1393 | + | updateLatest: i.updateInfo.Latest, |
| 1338 | - | updateURL: i.updateInfo.URL, | 1394 | + | updateURL: i.updateInfo.URL, |
| 1339 | - | welcomeShowVer: showVer, | 1395 | + | welcomeShowVer: showVer, |
| 1340 | - | expandAll: i.view.ExpandAll, | 1396 | + | expandAll: i.view.ExpandAll, |
| 1341 | - | tailLimit: i.view.TailLimit, | 1397 | + | tailLimit: i.view.TailLimit, |
| 1342 | - | }, true | 1398 | + | renderedMessageCount: len(i.view.Messages), |
| 1399 | + | viewCacheRev: i.view.RenderCacheRevision, | |||
| 1400 | + | } | |||
| 1343 | } | 1401 | } | ||
| 1344 | 1402 | ||||
| 1345 | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | 1403 | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | ||
| @@ -1515,11 +1573,48 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||||
| 1515 | return out | 1573 | return out | ||
| 1516 | } | 1574 | } | ||
| 1517 | 1575 | ||||
| 1576 | + | func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||
| 1577 | + | key := i.chatCacheKeyLocked(cols) | |||
| 1578 | + | if i.stableChatCacheValid && i.stableChatCacheKey == key { | |||
| 1579 | + | return append([]string(nil), i.stableChatCache...) | |||
| 1580 | + | } | |||
| 1581 | + | ||||
| 1582 | + | renderView := i.view.CloneForRender() | |||
| 1583 | + | renderView.Streaming = "" | |||
| 1584 | + | renderView.StreamingActive = false | |||
| 1585 | + | renderView.ToolCalls = nil | |||
| 1586 | + | renderView.Err = "" | |||
| 1587 | + | if !i.renderOutsideLock { | |||
| 1588 | + | rows := renderView.Build(cols) | |||
| 1589 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |||
| 1590 | + | i.stableChatCacheKey = key | |||
| 1591 | + | i.stableChatCacheValid = true | |||
| 1592 | + | return append([]string(nil), rows...) | |||
| 1593 | + | } | |||
| 1594 | + | // Markdown, syntax highlighting, and wrapping are the expensive part of | |||
| 1595 | + | // a frame. Build the immutable snapshot without holding the interactive | |||
| 1596 | + | // mutex so key processing can continue while a cold transcript renders. | |||
| 1597 | + | i.mu.Unlock() | |||
| 1598 | + | rows := renderView.Build(cols) | |||
| 1599 | + | i.mu.Lock() | |||
| 1600 | + | if i.view.MessagesRevision == renderView.MessagesRevision && | |||
| 1601 | + | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | |||
| 1602 | + | i.view.AdoptRenderCacheFrom(renderView) | |||
| 1603 | + | } | |||
| 1604 | + | ||||
| 1605 | + | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |||
| 1606 | + | i.stableChatCacheKey = key | |||
| 1607 | + | i.stableChatCacheValid = true | |||
| 1608 | + | return append([]string(nil), rows...) | |||
| 1609 | + | } | |||
| 1610 | + | ||||
| 1518 | func (i *Interactive) buildChatLocked(cols int) []string { | 1611 | func (i *Interactive) buildChatLocked(cols int) []string { | ||
| 1519 | if i.agent != nil { | 1612 | if i.agent != nil { | ||
| 1520 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | 1613 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | ||
| 1614 | + | i.view.MessagesRevision = i.agent.Revision() | |||
| 1521 | } else { | 1615 | } else { | ||
| 1522 | i.view.Messages = nil | 1616 | i.view.Messages = nil | ||
| 1617 | + | i.view.MessagesRevision = 0 | |||
| 1523 | } | 1618 | } | ||
| 1524 | // Pacer flush: while the streaming pacer is still draining the | 1619 | // Pacer flush: while the streaming pacer is still draining the | ||
| 1525 | // buffer (i.e. EvAssistantMessage already fired but more runes | 1620 | // buffer (i.e. EvAssistantMessage already fired but more runes | ||
| @@ -1583,7 +1678,9 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1583 | // rows update in place at the end of the buffer, instead of the | 1678 | // rows update in place at the end of the buffer, instead of the | ||
| 1584 | // whole bottom band shrinking and shifting chat lines around. | 1679 | // whole bottom band shrinking and shifting chat lines around. | ||
| 1585 | i.liveBlock = nil | 1680 | i.liveBlock = nil | ||
| 1586 | - | chat := i.view.Build(cols) | 1681 | + | stable := i.stableChatRowsLocked(cols) |
| 1682 | + | chat := append([]string(nil), stable...) | |||
| 1683 | + | chat = append(chat, i.view.BuildLive(cols)...) | |||
| 1587 | 1684 | ||||
| 1588 | // Welcome banner: shown at the top of the chat area when there is | 1685 | // Welcome banner: shown at the top of the chat area when there is | ||
| 1589 | // no transcript yet. Disappears after the first message is sent. | 1686 | // no transcript yet. Disappears after the first message is sent. | ||
| @@ -1708,7 +1805,7 @@ func (i *Interactive) scrollBy(delta int) { | |||||
| 1708 | // wrapped-character fragments behind during scroll-driven | 1805 | // wrapped-character fragments behind during scroll-driven | ||
| 1709 | // viewport changes. Force a full repaint on scroll, but | 1806 | // viewport changes. Force a full repaint on scroll, but | ||
| 1710 | // avoid a whole-screen clear because that visibly flickers. | 1807 | // avoid a whole-screen clear because that visibly flickers. | ||
| 1711 | - | i.rend.Invalidate() | 1808 | + | i.requestRendererInvalidate() |
| 1712 | } | 1809 | } | ||
| 1713 | i.mu.Unlock() | 1810 | i.mu.Unlock() | ||
| 1714 | i.invalidate() | 1811 | i.invalidate() | ||
| @@ -1749,16 +1846,14 @@ func (i *Interactive) scrollToBottom() { | |||||
| 1749 | // after resume. See commit 43da5e5 for the same fix on new turns. | 1846 | // after resume. See commit 43da5e5 for the same fix on new turns. | ||
| 1750 | i.prevChatLen = 0 | 1847 | i.prevChatLen = 0 | ||
| 1751 | i.prevChatCols = 0 | 1848 | i.prevChatCols = 0 | ||
| 1752 | - | if i.rend != nil { | 1849 | + | i.requestRendererInvalidate() |
| 1753 | - | i.rend.Invalidate() | |||
| 1754 | - | } | |||
| 1755 | i.mu.Unlock() | 1850 | i.mu.Unlock() | ||
| 1756 | i.invalidate() | 1851 | i.invalidate() | ||
| 1757 | } | 1852 | } | ||
| 1758 | 1853 | ||||
| 1759 | func (i *Interactive) redraw() { | 1854 | func (i *Interactive) redraw() { | ||
| 1760 | i.mu.Lock() | 1855 | i.mu.Lock() | ||
| 1761 | - | defer i.mu.Unlock() | 1856 | + | renderRevision := i.renderRevision.Load() |
| 1762 | 1857 | ||||
| 1763 | cols, rows := i.cfg.Terminal.Size() | 1858 | cols, rows := i.cfg.Terminal.Size() | ||
| 1764 | mainCols := cols | 1859 | mainCols := cols | ||
| @@ -1773,7 +1868,13 @@ func (i *Interactive) redraw() { | |||||
| 1773 | } | 1868 | } | ||
| 1774 | } | 1869 | } | ||
| 1775 | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | 1870 | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | ||
| 1871 | + | i.renderOutsideLock = true | |||
| 1776 | chat := i.cachedChatLocked(mainCols) | 1872 | chat := i.cachedChatLocked(mainCols) | ||
| 1873 | + | i.renderOutsideLock = false | |||
| 1874 | + | if i.renderRevision.Load() != renderRevision { | |||
| 1875 | + | i.mu.Unlock() | |||
| 1876 | + | return | |||
| 1877 | + | } | |||
| 1777 | 1878 | ||||
| 1778 | // Dialogs (login or model picker) render between chat and the editor. | 1879 | // Dialogs (login or model picker) render between chat and the editor. | ||
| 1779 | var dialog []string | 1880 | var dialog []string | ||
| @@ -2351,8 +2452,16 @@ func (i *Interactive) redraw() { | |||||
| 2351 | cursorCol = 0 | 2452 | cursorCol = 0 | ||
| 2352 | } | 2453 | } | ||
| 2353 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | 2454 | i.setInputCursorDimmed(modalBackdrop && !cursorInDialog) | ||
| 2455 | + | if i.renderRevision.Load() != renderRevision { | |||
| 2456 | + | i.mu.Unlock() | |||
| 2457 | + | return | |||
| 2458 | + | } | |||
| 2459 | + | theme := i.cfg.Theme | |||
| 2460 | + | // State assembly above is synchronized; terminal output is owned by | |||
| 2461 | + | // this renderer goroutine and must not hold the global state mutex. | |||
| 2462 | + | i.mu.Unlock() | |||
| 2354 | if rightBarActive { | 2463 | if rightBarActive { | ||
| 2355 | - | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | 2464 | + | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) |
| 2356 | if modalBackdrop { | 2465 | if modalBackdrop { | ||
| 2357 | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | 2466 | i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol) | ||
| 2358 | } else { | 2467 | } else { | ||
| @@ -2362,11 +2471,13 @@ func (i *Interactive) redraw() { | |||||
| 2362 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 2471 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 2363 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 2472 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| 2364 | } | 2473 | } | ||
| 2474 | + | i.mu.Lock() | |||
| 2365 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | 2475 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | ||
| 2366 | alert := *i.pendingAlert | 2476 | alert := *i.pendingAlert | ||
| 2367 | i.pendingAlert = nil | 2477 | i.pendingAlert = nil | ||
| 2368 | i.emitAlertLocked(alert) | 2478 | i.emitAlertLocked(alert) | ||
| 2369 | } | 2479 | } | ||
| 2480 | + | i.mu.Unlock() | |||
| 2370 | } | 2481 | } | ||
| 2371 | 2482 | ||||
| 2372 | func hasImageEscape(line string) bool { | 2483 | func hasImageEscape(line string) bool { | ||
| @@ -2612,9 +2723,7 @@ func (i *Interactive) clearFileSuggestQuery() { | |||||
| 2612 | func (i *Interactive) setToolExpansion(expanded bool) { | 2723 | func (i *Interactive) setToolExpansion(expanded bool) { | ||
| 2613 | i.mu.Lock() | 2724 | i.mu.Lock() | ||
| 2614 | i.view.ExpandAll = expanded | 2725 | i.view.ExpandAll = expanded | ||
| 2615 | - | if i.rend != nil { | 2726 | + | i.requestRendererClear() |
| 2616 | - | i.rend.Clear() | |||
| 2617 | - | } | |||
| 2618 | i.mu.Unlock() | 2727 | i.mu.Unlock() | ||
| 2619 | i.invalidate() | 2728 | i.invalidate() | ||
| 2620 | } | 2729 | } | ||
| @@ -2629,9 +2738,7 @@ func (i *Interactive) toggleToolExpansion() { | |||||
| 2629 | func (i *Interactive) toggleBtwToolExpansion() { | 2738 | func (i *Interactive) toggleBtwToolExpansion() { | ||
| 2630 | i.btwDialog.ToggleToolExpansion() | 2739 | i.btwDialog.ToggleToolExpansion() | ||
| 2631 | i.mu.Lock() | 2740 | i.mu.Lock() | ||
| 2632 | - | if i.rend != nil { | 2741 | + | i.requestRendererClear() |
| 2633 | - | i.rend.Clear() | |||
| 2634 | - | } | |||
| 2635 | i.mu.Unlock() | 2742 | i.mu.Unlock() | ||
| 2636 | i.invalidate() | 2743 | i.invalidate() | ||
| 2637 | } | 2744 | } | ||
| @@ -3184,11 +3291,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3184 | i.mu.Lock() | 3291 | i.mu.Lock() | ||
| 3185 | i.rightBarHidden = !i.rightBarHidden | 3292 | i.rightBarHidden = !i.rightBarHidden | ||
| 3186 | i.mu.Unlock() | 3293 | i.mu.Unlock() | ||
| 3187 | - | i.rend.Invalidate() | 3294 | + | i.requestRendererInvalidate() |
| 3188 | i.invalidate() | 3295 | i.invalidate() | ||
| 3189 | return false | 3296 | return false | ||
| 3190 | case tui.KeyCtrlL: | 3297 | case tui.KeyCtrlL: | ||
| 3191 | - | i.rend.Clear() | 3298 | + | i.requestRendererClear() |
| 3192 | i.invalidate() | 3299 | i.invalidate() | ||
| 3193 | return false | 3300 | return false | ||
| 3194 | case tui.KeyPasteClipboard: | 3301 | case tui.KeyPasteClipboard: | ||
| @@ -3918,6 +4025,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 3918 | } | 4025 | } | ||
| 3919 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | 4026 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | ||
| 3920 | i.view.StartupContextPaths = nil | 4027 | i.view.StartupContextPaths = nil | ||
| 4028 | + | i.view.InvalidateRenderCache() | |||
| 3921 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | 4029 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | ||
| 3922 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | 4030 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...) | ||
| 3923 | } | 4031 | } | ||
| @@ -4987,9 +5095,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 4987 | // change (image rendering, status copy, future toggles) lands | 5095 | // change (image rendering, status copy, future toggles) lands | ||
| 4988 | // immediately instead of waiting for the next diff frame. | 5096 | // immediately instead of waiting for the next diff frame. | ||
| 4989 | defer func() { | 5097 | defer func() { | ||
| 4990 | - | if i.rend != nil { | 5098 | + | i.requestRendererClear() |
| 4991 | - | i.rend.Clear() | |||
| 4992 | - | } | |||
| 4993 | i.invalidate() | 5099 | i.invalidate() | ||
| 4994 | }() | 5100 | }() | ||
| 4995 | switch key { | 5101 | switch key { | ||
| @@ -5331,6 +5437,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 5331 | i.view.StartupContextPaths = nil | 5437 | i.view.StartupContextPaths = nil | ||
| 5332 | i.view.StartupExtensionNames = nil | 5438 | i.view.StartupExtensionNames = nil | ||
| 5333 | i.view.StartupSkillNames = nil | 5439 | i.view.StartupSkillNames = nil | ||
| 5440 | + | i.view.InvalidateRenderCache() | |||
| 5334 | if value { | 5441 | if value { | ||
| 5335 | i.view.StartupAgentName = i.cfg.StartupAgentName | 5442 | i.view.StartupAgentName = i.cfg.StartupAgentName | ||
| 5336 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | 5443 | i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...) | ||
| @@ -5345,9 +5452,7 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 5345 | 5452 | ||||
| 5346 | func (i *Interactive) applyTUIInputStyleSetting(style string) { | 5453 | func (i *Interactive) applyTUIInputStyleSetting(style string) { | ||
| 5347 | defer func() { | 5454 | defer func() { | ||
| 5348 | - | if i.rend != nil { | 5455 | + | i.requestRendererClear() |
| 5349 | - | i.rend.Clear() | |||
| 5350 | - | } | |||
| 5351 | i.invalidate() | 5456 | i.invalidate() | ||
| 5352 | }() | 5457 | }() | ||
| 5353 | style = tui.NormalizeInputStyle(style) | 5458 | style = tui.NormalizeInputStyle(style) | ||
| @@ -5369,9 +5474,7 @@ func (i *Interactive) applyTUIInputStyleSetting(style string) { | |||||
| 5369 | 5474 | ||||
| 5370 | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | 5475 | func (i *Interactive) applyTUIStatusPositionSetting(position string) { | ||
| 5371 | defer func() { | 5476 | defer func() { | ||
| 5372 | - | if i.rend != nil { | 5477 | + | i.requestRendererClear() |
| 5373 | - | i.rend.Clear() | |||
| 5374 | - | } | |||
| 5375 | i.invalidate() | 5478 | i.invalidate() | ||
| 5376 | }() | 5479 | }() | ||
| 5377 | position = tui.NormalizeStatusPosition(position) | 5480 | position = tui.NormalizeStatusPosition(position) | ||
| @@ -5396,9 +5499,7 @@ func (i *Interactive) applyTUIStatusPositionSetting(position string) { | |||||
| 5396 | 5499 | ||||
| 5397 | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | 5500 | func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | ||
| 5398 | defer func() { | 5501 | defer func() { | ||
| 5399 | - | if i.rend != nil { | 5502 | + | i.requestRendererClear() |
| 5400 | - | i.rend.Clear() | |||
| 5401 | - | } | |||
| 5402 | i.invalidate() | 5503 | i.invalidate() | ||
| 5403 | }() | 5504 | }() | ||
| 5404 | position = tui.NormalizeWorkingPosition(position) | 5505 | position = tui.NormalizeWorkingPosition(position) | ||
| @@ -5423,9 +5524,7 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |||||
| 5423 | 5524 | ||||
| 5424 | func (i *Interactive) applyTUISubagentPositionSetting(position string) { | 5525 | func (i *Interactive) applyTUISubagentPositionSetting(position string) { | ||
| 5425 | defer func() { | 5526 | defer func() { | ||
| 5426 | - | if i.rend != nil { | 5527 | + | i.requestRendererClear() |
| 5427 | - | i.rend.Clear() | |||
| 5428 | - | } | |||
| 5429 | i.invalidate() | 5528 | i.invalidate() | ||
| 5430 | }() | 5529 | }() | ||
| 5431 | position = tui.NormalizeSubagentPosition(position) | 5530 | position = tui.NormalizeSubagentPosition(position) | ||
| @@ -5496,24 +5595,22 @@ func (i *Interactive) applyThemeNow(name string) { | |||||
| 5496 | i.statusErr = "" | 5595 | i.statusErr = "" | ||
| 5497 | i.mu.Unlock() | 5596 | i.mu.Unlock() | ||
| 5498 | } | 5597 | } | ||
| 5598 | + | i.mu.Lock() | |||
| 5499 | i.cfg.Theme = th | 5599 | i.cfg.Theme = th | ||
| 5500 | i.view.Theme = th | 5600 | i.view.Theme = th | ||
| 5501 | i.view.InvalidateRenderCache() | 5601 | i.view.InvalidateRenderCache() | ||
| 5602 | + | i.mu.Unlock() | |||
| 5502 | i.ed.Prompt = th.AccentBar(th.Accent) | 5603 | i.ed.Prompt = th.AccentBar(th.Accent) | ||
| 5503 | i.applyInputCursorColor() | 5604 | i.applyInputCursorColor() | ||
| 5504 | i.spin.Configure(th) | 5605 | i.spin.Configure(th) | ||
| 5505 | - | if i.rend != nil { | 5606 | + | i.requestRendererTheme(th) |
| 5506 | - | i.rend.SetTheme(th) | 5607 | + | i.requestRendererClear() |
| 5507 | - | i.rend.Clear() | |||
| 5508 | - | } | |||
| 5509 | i.invalidate() | 5608 | i.invalidate() | ||
| 5510 | } | 5609 | } | ||
| 5511 | 5610 | ||||
| 5512 | func (i *Interactive) applyReasoningSetting(level string) { | 5611 | func (i *Interactive) applyReasoningSetting(level string) { | ||
| 5513 | defer func() { | 5612 | defer func() { | ||
| 5514 | - | if i.rend != nil { | 5613 | + | i.requestRendererClear() |
| 5515 | - | i.rend.Clear() | |||
| 5516 | - | } | |||
| 5517 | i.invalidate() | 5614 | i.invalidate() | ||
| 5518 | }() | 5615 | }() | ||
| 5519 | level = provider.NormalizeReasoning(level) | 5616 | level = provider.NormalizeReasoning(level) | ||
| @@ -7566,8 +7663,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7566 | lastStop = e.Stop | 7663 | lastStop = e.Stop | ||
| 7567 | lastTurnErr = e.Err | 7664 | lastTurnErr = e.Err | ||
| 7568 | } | 7665 | } | ||
| 7569 | - | i.handleEvent(ev) | 7666 | + | i.handleEventForPresentation(ev) |
| 7570 | - | i.invalidate() | |||
| 7571 | } | 7667 | } | ||
| 7572 | 7668 | ||||
| 7573 | go func() { | 7669 | go func() { | ||
| @@ -8002,6 +8098,32 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||||
| 8002 | return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | 8098 | return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | ||
| 8003 | } | 8099 | } | ||
| 8004 | 8100 | ||||
| 8101 | + | func eventAffectsPresentation(ev core.AgentEvent) bool { | |||
| 8102 | + | switch ev.(type) { | |||
| 8103 | + | case core.EvToolProgress: | |||
| 8104 | + | return false | |||
| 8105 | + | default: | |||
| 8106 | + | return true | |||
| 8107 | + | } | |||
| 8108 | + | } | |||
| 8109 | + | ||||
| 8110 | + | func bumpToolRevision(tc *tui.ToolCallView) { | |||
| 8111 | + | if tc.Revision == ^uint64(0) { | |||
| 8112 | + | tc.Revision = 1 | |||
| 8113 | + | return | |||
| 8114 | + | } | |||
| 8115 | + | tc.Revision++ | |||
| 8116 | + | } | |||
| 8117 | + | ||||
| 8118 | + | func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { | |||
| 8119 | + | i.handleEvent(ev) | |||
| 8120 | + | // Progress payloads are still delivered to the core sink and applied | |||
| 8121 | + | // in order, but they have no interactive-visible representation. | |||
| 8122 | + | if eventAffectsPresentation(ev) { | |||
| 8123 | + | i.invalidate() | |||
| 8124 | + | } | |||
| 8125 | + | } | |||
| 8126 | + | ||||
| 8005 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | 8127 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | ||
| 8006 | i.mu.Lock() | 8128 | i.mu.Lock() | ||
| 8007 | defer i.mu.Unlock() | 8129 | defer i.mu.Unlock() | ||
| @@ -8058,6 +8180,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8058 | i.toolCalls[e.ID] = &tui.ToolCallView{ | 8180 | i.toolCalls[e.ID] = &tui.ToolCallView{ | ||
| 8059 | ID: e.ID, | 8181 | ID: e.ID, | ||
| 8060 | Name: e.Name, | 8182 | Name: e.Name, | ||
| 8183 | + | Revision: 1, | |||
| 8061 | Streaming: true, | 8184 | Streaming: true, | ||
| 8062 | } | 8185 | } | ||
| 8063 | i.toolOrder = append(i.toolOrder, e.ID) | 8186 | i.toolOrder = append(i.toolOrder, e.ID) | ||
| @@ -8066,6 +8189,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8066 | case core.EvToolUseArgs: | 8189 | case core.EvToolUseArgs: | ||
| 8067 | if tc, ok := i.toolCalls[e.ID]; ok { | 8190 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8068 | tc.RawJSONBuf += e.Delta | 8191 | tc.RawJSONBuf += e.Delta | ||
| 8192 | + | bumpToolRevision(tc) | |||
| 8069 | // Refresh the live path as soon as it parses; used in | 8193 | // Refresh the live path as soon as it parses; used in | ||
| 8070 | // the header (write /Users/example/Desktop/demo.ts) | 8194 | // the header (write /Users/example/Desktop/demo.ts) | ||
| 8071 | // while the content is still streaming. | 8195 | // while the content is still streaming. | ||
| @@ -8078,6 +8202,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8078 | case core.EvToolUseEnd: | 8202 | case core.EvToolUseEnd: | ||
| 8079 | if tc, ok := i.toolCalls[e.ID]; ok { | 8203 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8080 | tc.Streaming = false | 8204 | tc.Streaming = false | ||
| 8205 | + | bumpToolRevision(tc) | |||
| 8081 | } | 8206 | } | ||
| 8082 | case core.EvToolCall: | 8207 | case core.EvToolCall: | ||
| 8083 | // If we already pre-created the view during streaming, just | 8208 | // If we already pre-created the view during streaming, just | ||
| @@ -8085,6 +8210,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8085 | // (non-streaming providers or legacy paths). | 8210 | // (non-streaming providers or legacy paths). | ||
| 8086 | if tc, ok := i.toolCalls[e.ID]; ok { | 8211 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| 8087 | tc.Args = tui.ShortArgs(e.Name, e.Args) | 8212 | tc.Args = tui.ShortArgs(e.Name, e.Args) | ||
| 8213 | + | bumpToolRevision(tc) | |||
| 8088 | if tc.RawJSONBuf == "" { | 8214 | if tc.RawJSONBuf == "" { | ||
| 8089 | tc.RawJSONBuf = string(e.Args) | 8215 | tc.RawJSONBuf = string(e.Args) | ||
| 8090 | } | 8216 | } | ||
| @@ -8093,6 +8219,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8093 | i.toolCalls[e.ID] = &tui.ToolCallView{ | 8219 | i.toolCalls[e.ID] = &tui.ToolCallView{ | ||
| 8094 | ID: e.ID, | 8220 | ID: e.ID, | ||
| 8095 | Name: e.Name, | 8221 | Name: e.Name, | ||
| 8222 | + | Revision: 1, | |||
| 8096 | Args: tui.ShortArgs(e.Name, e.Args), | 8223 | Args: tui.ShortArgs(e.Name, e.Args), | ||
| 8097 | RawJSONBuf: string(e.Args), | 8224 | RawJSONBuf: string(e.Args), | ||
| 8098 | } | 8225 | } | ||
| @@ -8114,6 +8241,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8114 | } | 8241 | } | ||
| 8115 | } | 8242 | } | ||
| 8116 | tc.Result = text.String() | 8243 | tc.Result = text.String() | ||
| 8244 | + | bumpToolRevision(tc) | |||
| 8117 | } | 8245 | } | ||
| 8118 | if i.cfg.OnToolResult != nil { | 8246 | if i.cfg.OnToolResult != nil { | ||
| 8119 | i.cfg.OnToolResult(e.ID, e.Result) | 8247 | i.cfg.OnToolResult(e.ID, e.Result) | ||
| @@ -9560,9 +9688,7 @@ func (i *Interactive) resetTranscriptRenderLocked() { | |||||
| 9560 | i.prevChatCols = 0 | 9688 | i.prevChatCols = 0 | ||
| 9561 | i.prevChatRows = 0 | 9689 | i.prevChatRows = 0 | ||
| 9562 | i.prevScrollOffset = 0 | 9690 | i.prevScrollOffset = 0 | ||
| 9563 | - | if i.rend != nil { | 9691 | + | i.requestRendererInvalidate() |
| 9564 | - | i.rend.Invalidate() | |||
| 9565 | - | } | |||
| 9566 | } | 9692 | } | ||
| 9567 | 9693 | ||||
| 9568 | // resetStreamingStateLocked clears every piece of streaming state | 9694 | // resetStreamingStateLocked clears every piece of streaming state | ||
A packages/agent/modes/interactive_render.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // renderRequest is deliberately a signal, not a frame. The render owner | |
| 10 | + | // reads the newest synchronized interactive state when it wakes, so a burst | |
| 11 | + | // replaces the one pending request instead of queueing stale snapshots. | |
| 12 | + | type renderRequest struct { | |
| 13 | + | clear bool | |
| 14 | + | invalidate bool | |
| 15 | + | theme *tui.Theme | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type latestFrameScheduler struct { | |
| 19 | + | wake chan struct{} | |
| 20 | + | done chan struct{} | |
| 21 | + | ||
| 22 | + | mu sync.Mutex | |
| 23 | + | clear bool | |
| 24 | + | invalidate bool | |
| 25 | + | theme *tui.Theme | |
| 26 | + | stopped bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newLatestFrameScheduler() *latestFrameScheduler { | |
| 30 | + | return &latestFrameScheduler{ | |
| 31 | + | wake: make(chan struct{}, 1), | |
| 32 | + | done: make(chan struct{}), | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (s *latestFrameScheduler) request(clear, invalidate bool) bool { | |
| 37 | + | s.mu.Lock() | |
| 38 | + | defer s.mu.Unlock() | |
| 39 | + | if s.stopped { | |
| 40 | + | return false | |
| 41 | + | } | |
| 42 | + | s.clear = s.clear || clear | |
| 43 | + | s.invalidate = s.invalidate || invalidate | |
| 44 | + | select { | |
| 45 | + | case s.wake <- struct{}{}: | |
| 46 | + | default: | |
| 47 | + | } | |
| 48 | + | return true | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool { | |
| 52 | + | s.mu.Lock() | |
| 53 | + | defer s.mu.Unlock() | |
| 54 | + | if s.stopped { | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | s.clear = true | |
| 58 | + | s.theme = &theme | |
| 59 | + | select { | |
| 60 | + | case s.wake <- struct{}{}: | |
| 61 | + | default: | |
| 62 | + | } | |
| 63 | + | return true | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (s *latestFrameScheduler) next() (renderRequest, bool) { | |
| 67 | + | <-s.wake | |
| 68 | + | s.mu.Lock() | |
| 69 | + | defer s.mu.Unlock() | |
| 70 | + | if s.stopped { | |
| 71 | + | return renderRequest{}, false | |
| 72 | + | } | |
| 73 | + | req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme} | |
| 74 | + | s.clear = false | |
| 75 | + | s.invalidate = false | |
| 76 | + | s.theme = nil | |
| 77 | + | return req, true | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (s *latestFrameScheduler) run(render func(renderRequest)) { | |
| 81 | + | defer close(s.done) | |
| 82 | + | for { | |
| 83 | + | req, ok := s.next() | |
| 84 | + | if !ok { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | render(req) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // stop waits for the in-flight render callback to finish. Callers must not | |
| 92 | + | // invoke it while holding i.mu: the callback may need to acquire i.mu. | |
| 93 | + | func (s *latestFrameScheduler) stop() { | |
| 94 | + | s.mu.Lock() | |
| 95 | + | if !s.stopped { | |
| 96 | + | s.stopped = true | |
| 97 | + | close(s.wake) | |
| 98 | + | } | |
| 99 | + | s.mu.Unlock() | |
| 100 | + | <-s.done | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // renderRequest is deliberately a signal, not a frame. The render owner | |||
| 10 | + | // reads the newest synchronized interactive state when it wakes, so a burst | |||
| 11 | + | // replaces the one pending request instead of queueing stale snapshots. | |||
| 12 | + | type renderRequest struct { | |||
| 13 | + | clear bool | |||
| 14 | + | invalidate bool | |||
| 15 | + | theme *tui.Theme | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | type latestFrameScheduler struct { | |||
| 19 | + | wake chan struct{} | |||
| 20 | + | done chan struct{} | |||
| 21 | + | ||||
| 22 | + | mu sync.Mutex | |||
| 23 | + | clear bool | |||
| 24 | + | invalidate bool | |||
| 25 | + | theme *tui.Theme | |||
| 26 | + | stopped bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func newLatestFrameScheduler() *latestFrameScheduler { | |||
| 30 | + | return &latestFrameScheduler{ | |||
| 31 | + | wake: make(chan struct{}, 1), | |||
| 32 | + | done: make(chan struct{}), | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (s *latestFrameScheduler) request(clear, invalidate bool) bool { | |||
| 37 | + | s.mu.Lock() | |||
| 38 | + | defer s.mu.Unlock() | |||
| 39 | + | if s.stopped { | |||
| 40 | + | return false | |||
| 41 | + | } | |||
| 42 | + | s.clear = s.clear || clear | |||
| 43 | + | s.invalidate = s.invalidate || invalidate | |||
| 44 | + | select { | |||
| 45 | + | case s.wake <- struct{}{}: | |||
| 46 | + | default: | |||
| 47 | + | } | |||
| 48 | + | return true | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool { | |||
| 52 | + | s.mu.Lock() | |||
| 53 | + | defer s.mu.Unlock() | |||
| 54 | + | if s.stopped { | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | s.clear = true | |||
| 58 | + | s.theme = &theme | |||
| 59 | + | select { | |||
| 60 | + | case s.wake <- struct{}{}: | |||
| 61 | + | default: | |||
| 62 | + | } | |||
| 63 | + | return true | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (s *latestFrameScheduler) next() (renderRequest, bool) { | |||
| 67 | + | <-s.wake | |||
| 68 | + | s.mu.Lock() | |||
| 69 | + | defer s.mu.Unlock() | |||
| 70 | + | if s.stopped { | |||
| 71 | + | return renderRequest{}, false | |||
| 72 | + | } | |||
| 73 | + | req := renderRequest{clear: s.clear, invalidate: s.invalidate, theme: s.theme} | |||
| 74 | + | s.clear = false | |||
| 75 | + | s.invalidate = false | |||
| 76 | + | s.theme = nil | |||
| 77 | + | return req, true | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (s *latestFrameScheduler) run(render func(renderRequest)) { | |||
| 81 | + | defer close(s.done) | |||
| 82 | + | for { | |||
| 83 | + | req, ok := s.next() | |||
| 84 | + | if !ok { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | render(req) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // stop waits for the in-flight render callback to finish. Callers must not | |||
| 92 | + | // invoke it while holding i.mu: the callback may need to acquire i.mu. | |||
| 93 | + | func (s *latestFrameScheduler) stop() { | |||
| 94 | + | s.mu.Lock() | |||
| 95 | + | if !s.stopped { | |||
| 96 | + | s.stopped = true | |||
| 97 | + | close(s.wake) | |||
| 98 | + | } | |||
| 99 | + | s.mu.Unlock() | |||
| 100 | + | <-s.done | |||
| 101 | + | } | |||
A packages/agent/modes/interactive_render_test.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | "github.com/bnema/zut/packages/tui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |
| 15 | + | s := newLatestFrameScheduler() | |
| 16 | + | started := make(chan struct{}) | |
| 17 | + | release := make(chan struct{}) | |
| 18 | + | type renderedFrame struct { | |
| 19 | + | req renderRequest | |
| 20 | + | version int64 | |
| 21 | + | } | |
| 22 | + | frames := make(chan renderedFrame, 4) | |
| 23 | + | done := make(chan struct{}) | |
| 24 | + | var startedOnce sync.Once | |
| 25 | + | var latestVersion atomic.Int64 | |
| 26 | + | go func() { | |
| 27 | + | s.run(func(req renderRequest) { | |
| 28 | + | frames <- renderedFrame{req: req, version: latestVersion.Load()} | |
| 29 | + | startedOnce.Do(func() { close(started) }) | |
| 30 | + | <-release | |
| 31 | + | }) | |
| 32 | + | close(done) | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | latestVersion.Store(1) | |
| 36 | + | if !s.request(false, false) { | |
| 37 | + | t.Fatal("initial render request was rejected") | |
| 38 | + | } | |
| 39 | + | first := <-frames | |
| 40 | + | <-started | |
| 41 | + | if first.req.clear { | |
| 42 | + | t.Fatal("ordinary request unexpectedly requested a clear") | |
| 43 | + | } | |
| 44 | + | for n := 0; n < 1000; n++ { | |
| 45 | + | latestVersion.Store(int64(n + 2)) | |
| 46 | + | if !s.request(false, false) { | |
| 47 | + | t.Fatal("request rejected before shutdown") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | close(release) | |
| 51 | + | second := <-frames | |
| 52 | + | if second.req.clear { | |
| 53 | + | t.Fatal("coalesced ordinary request unexpectedly requested a clear") | |
| 54 | + | } | |
| 55 | + | if second.version != 1001 { | |
| 56 | + | t.Fatalf("renderer did not observe newest state: got version %d", second.version) | |
| 57 | + | } | |
| 58 | + | s.stop() | |
| 59 | + | <-done | |
| 60 | + | ||
| 61 | + | select { | |
| 62 | + | case <-frames: | |
| 63 | + | t.Fatal("scheduler retained more than one pending frame") | |
| 64 | + | default: | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestStableChatCacheTracksViewInvalidation(t *testing.T) { | |
| 69 | + | i := &Interactive{ | |
| 70 | + | view: &tui.View{ | |
| 71 | + | Theme: tui.Dark, | |
| 72 | + | Messages: []provider.Message{{ | |
| 73 | + | Role: provider.RoleUser, | |
| 74 | + | Content: []provider.Content{provider.TextBlock{Text: "cache theme"}}, | |
| 75 | + | }}, | |
| 76 | + | MessagesRevision: 1, | |
| 77 | + | }, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | i.mu.Lock() | |
| 81 | + | before := strings.Join(i.stableChatRowsLocked(80), "\n") | |
| 82 | + | i.view.Theme = tui.Light | |
| 83 | + | i.view.InvalidateRenderCache() | |
| 84 | + | after := strings.Join(i.stableChatRowsLocked(80), "\n") | |
| 85 | + | i.mu.Unlock() | |
| 86 | + | ||
| 87 | + | if before == after { | |
| 88 | + | t.Fatal("stable chat cache reused rows after a theme invalidation") | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) { | |
| 93 | + | const finalText = "final message revealed after paced flush" | |
| 94 | + | ||
| 95 | + | agent := &core.Agent{} | |
| 96 | + | agent.SetMessages([]provider.Message{ | |
| 97 | + | { | |
| 98 | + | Role: provider.RoleUser, | |
| 99 | + | Content: []provider.Content{provider.TextBlock{Text: "prompt"}}, | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | Role: provider.RoleAssistant, | |
| 103 | + | Content: []provider.Content{provider.TextBlock{Text: finalText}}, | |
| 104 | + | }, | |
| 105 | + | }) | |
| 106 | + | i := &Interactive{ | |
| 107 | + | agent: agent, | |
| 108 | + | view: &tui.View{Theme: tui.Dark}, | |
| 109 | + | renderOutsideLock: true, | |
| 110 | + | streamFlushPending: true, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | revision := agent.Revision() | |
| 114 | + | i.mu.Lock() | |
| 115 | + | before := strings.Join(i.cachedChatLocked(80), "\n") | |
| 116 | + | i.streamFlushPending = false | |
| 117 | + | after := strings.Join(i.cachedChatLocked(80), "\n") | |
| 118 | + | i.mu.Unlock() | |
| 119 | + | ||
| 120 | + | if got := agent.Revision(); got != revision { | |
| 121 | + | t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision) | |
| 122 | + | } | |
| 123 | + | if strings.Contains(before, finalText) { | |
| 124 | + | t.Fatal("stable cache revealed the final message while stream flush was pending") | |
| 125 | + | } | |
| 126 | + | if !strings.Contains(after, finalText) { | |
| 127 | + | t.Fatal("stable cache did not reveal the final message after stream flush") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) { | |
| 132 | + | i := &Interactive{ | |
| 133 | + | dirty: make(chan struct{}, 1), | |
| 134 | + | toolCalls: make(map[string]*tui.ToolCallView), | |
| 135 | + | toolGate: make(map[string]int), | |
| 136 | + | } | |
| 137 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"}) | |
| 138 | + | select { | |
| 139 | + | case <-i.dirty: | |
| 140 | + | default: | |
| 141 | + | t.Fatal("tool start did not invalidate the presentation") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | const progressEvents = 4096 | |
| 145 | + | for n := 0; n < progressEvents; n++ { | |
| 146 | + | i.handleEventForPresentation(core.EvToolProgress{ | |
| 147 | + | ID: "storm", | |
| 148 | + | Text: strings.Repeat("progress payload ", 256), | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | select { | |
| 152 | + | case <-i.dirty: | |
| 153 | + | t.Fatal("invisible progress created presentation invalidation") | |
| 154 | + | default: | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | result := strings.Repeat("completed tool output\n", 256) | |
| 158 | + | i.handleEventForPresentation(core.EvToolResult{ | |
| 159 | + | ID: "storm", | |
| 160 | + | Result: core.ToolResult{Content: []provider.Content{ | |
| 161 | + | provider.TextBlock{Text: result}, | |
| 162 | + | }}, | |
| 163 | + | }) | |
| 164 | + | select { | |
| 165 | + | case <-i.dirty: | |
| 166 | + | default: | |
| 167 | + | t.Fatal("completed tool result did not invalidate the presentation") | |
| 168 | + | } | |
| 169 | + | if got := i.toolCalls["storm"].Result; got != result { | |
| 170 | + | t.Fatalf("completed tool result was not retained: got %d bytes", len(got)) | |
| 171 | + | } | |
| 172 | + | if got := i.toolCalls["storm"].Revision; got != 2 { | |
| 173 | + | t.Fatalf("completed tool result revision: got %d, want 2", got) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}} | |
| 177 | + | rows := view.BuildLive(80) | |
| 178 | + | if len(rows) == 0 { | |
| 179 | + | t.Fatal("completed tool result produced no live frame rows") | |
| 180 | + | } | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "sync" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/core" | |||
| 10 | + | "github.com/bnema/zut/packages/provider" | |||
| 11 | + | "github.com/bnema/zut/packages/tui" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) { | |||
| 15 | + | s := newLatestFrameScheduler() | |||
| 16 | + | started := make(chan struct{}) | |||
| 17 | + | release := make(chan struct{}) | |||
| 18 | + | type renderedFrame struct { | |||
| 19 | + | req renderRequest | |||
| 20 | + | version int64 | |||
| 21 | + | } | |||
| 22 | + | frames := make(chan renderedFrame, 4) | |||
| 23 | + | done := make(chan struct{}) | |||
| 24 | + | var startedOnce sync.Once | |||
| 25 | + | var latestVersion atomic.Int64 | |||
| 26 | + | go func() { | |||
| 27 | + | s.run(func(req renderRequest) { | |||
| 28 | + | frames <- renderedFrame{req: req, version: latestVersion.Load()} | |||
| 29 | + | startedOnce.Do(func() { close(started) }) | |||
| 30 | + | <-release | |||
| 31 | + | }) | |||
| 32 | + | close(done) | |||
| 33 | + | }() | |||
| 34 | + | ||||
| 35 | + | latestVersion.Store(1) | |||
| 36 | + | if !s.request(false, false) { | |||
| 37 | + | t.Fatal("initial render request was rejected") | |||
| 38 | + | } | |||
| 39 | + | first := <-frames | |||
| 40 | + | <-started | |||
| 41 | + | if first.req.clear { | |||
| 42 | + | t.Fatal("ordinary request unexpectedly requested a clear") | |||
| 43 | + | } | |||
| 44 | + | for n := 0; n < 1000; n++ { | |||
| 45 | + | latestVersion.Store(int64(n + 2)) | |||
| 46 | + | if !s.request(false, false) { | |||
| 47 | + | t.Fatal("request rejected before shutdown") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | close(release) | |||
| 51 | + | second := <-frames | |||
| 52 | + | if second.req.clear { | |||
| 53 | + | t.Fatal("coalesced ordinary request unexpectedly requested a clear") | |||
| 54 | + | } | |||
| 55 | + | if second.version != 1001 { | |||
| 56 | + | t.Fatalf("renderer did not observe newest state: got version %d", second.version) | |||
| 57 | + | } | |||
| 58 | + | s.stop() | |||
| 59 | + | <-done | |||
| 60 | + | ||||
| 61 | + | select { | |||
| 62 | + | case <-frames: | |||
| 63 | + | t.Fatal("scheduler retained more than one pending frame") | |||
| 64 | + | default: | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestStableChatCacheTracksViewInvalidation(t *testing.T) { | |||
| 69 | + | i := &Interactive{ | |||
| 70 | + | view: &tui.View{ | |||
| 71 | + | Theme: tui.Dark, | |||
| 72 | + | Messages: []provider.Message{{ | |||
| 73 | + | Role: provider.RoleUser, | |||
| 74 | + | Content: []provider.Content{provider.TextBlock{Text: "cache theme"}}, | |||
| 75 | + | }}, | |||
| 76 | + | MessagesRevision: 1, | |||
| 77 | + | }, | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | i.mu.Lock() | |||
| 81 | + | before := strings.Join(i.stableChatRowsLocked(80), "\n") | |||
| 82 | + | i.view.Theme = tui.Light | |||
| 83 | + | i.view.InvalidateRenderCache() | |||
| 84 | + | after := strings.Join(i.stableChatRowsLocked(80), "\n") | |||
| 85 | + | i.mu.Unlock() | |||
| 86 | + | ||||
| 87 | + | if before == after { | |||
| 88 | + | t.Fatal("stable chat cache reused rows after a theme invalidation") | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) { | |||
| 93 | + | const finalText = "final message revealed after paced flush" | |||
| 94 | + | ||||
| 95 | + | agent := &core.Agent{} | |||
| 96 | + | agent.SetMessages([]provider.Message{ | |||
| 97 | + | { | |||
| 98 | + | Role: provider.RoleUser, | |||
| 99 | + | Content: []provider.Content{provider.TextBlock{Text: "prompt"}}, | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | Role: provider.RoleAssistant, | |||
| 103 | + | Content: []provider.Content{provider.TextBlock{Text: finalText}}, | |||
| 104 | + | }, | |||
| 105 | + | }) | |||
| 106 | + | i := &Interactive{ | |||
| 107 | + | agent: agent, | |||
| 108 | + | view: &tui.View{Theme: tui.Dark}, | |||
| 109 | + | renderOutsideLock: true, | |||
| 110 | + | streamFlushPending: true, | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | revision := agent.Revision() | |||
| 114 | + | i.mu.Lock() | |||
| 115 | + | before := strings.Join(i.cachedChatLocked(80), "\n") | |||
| 116 | + | i.streamFlushPending = false | |||
| 117 | + | after := strings.Join(i.cachedChatLocked(80), "\n") | |||
| 118 | + | i.mu.Unlock() | |||
| 119 | + | ||||
| 120 | + | if got := agent.Revision(); got != revision { | |||
| 121 | + | t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision) | |||
| 122 | + | } | |||
| 123 | + | if strings.Contains(before, finalText) { | |||
| 124 | + | t.Fatal("stable cache revealed the final message while stream flush was pending") | |||
| 125 | + | } | |||
| 126 | + | if !strings.Contains(after, finalText) { | |||
| 127 | + | t.Fatal("stable cache did not reveal the final message after stream flush") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) { | |||
| 132 | + | i := &Interactive{ | |||
| 133 | + | dirty: make(chan struct{}, 1), | |||
| 134 | + | toolCalls: make(map[string]*tui.ToolCallView), | |||
| 135 | + | toolGate: make(map[string]int), | |||
| 136 | + | } | |||
| 137 | + | i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"}) | |||
| 138 | + | select { | |||
| 139 | + | case <-i.dirty: | |||
| 140 | + | default: | |||
| 141 | + | t.Fatal("tool start did not invalidate the presentation") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | const progressEvents = 4096 | |||
| 145 | + | for n := 0; n < progressEvents; n++ { | |||
| 146 | + | i.handleEventForPresentation(core.EvToolProgress{ | |||
| 147 | + | ID: "storm", | |||
| 148 | + | Text: strings.Repeat("progress payload ", 256), | |||
| 149 | + | }) | |||
| 150 | + | } | |||
| 151 | + | select { | |||
| 152 | + | case <-i.dirty: | |||
| 153 | + | t.Fatal("invisible progress created presentation invalidation") | |||
| 154 | + | default: | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | result := strings.Repeat("completed tool output\n", 256) | |||
| 158 | + | i.handleEventForPresentation(core.EvToolResult{ | |||
| 159 | + | ID: "storm", | |||
| 160 | + | Result: core.ToolResult{Content: []provider.Content{ | |||
| 161 | + | provider.TextBlock{Text: result}, | |||
| 162 | + | }}, | |||
| 163 | + | }) | |||
| 164 | + | select { | |||
| 165 | + | case <-i.dirty: | |||
| 166 | + | default: | |||
| 167 | + | t.Fatal("completed tool result did not invalidate the presentation") | |||
| 168 | + | } | |||
| 169 | + | if got := i.toolCalls["storm"].Result; got != result { | |||
| 170 | + | t.Fatalf("completed tool result was not retained: got %d bytes", len(got)) | |||
| 171 | + | } | |||
| 172 | + | if got := i.toolCalls["storm"].Revision; got != 2 { | |||
| 173 | + | t.Fatalf("completed tool result revision: got %d, want 2", got) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}} | |||
| 177 | + | rows := view.BuildLive(80) | |||
| 178 | + | if len(rows) == 0 { | |||
| 179 | + | t.Fatal("completed tool result produced no live frame rows") | |||
| 180 | + | } | |||
| 181 | + | } | |||
M packages/tui/render.go +24 -3
| @@ -37,9 +37,17 @@ type Renderer struct { | |||
| 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 | + | logRawRows int | |
| 49 | + | logRawBackground string | |
| 50 | + | logRawHasThemeBG bool | |
| 43 | 51 | logViewportTop int | |
| 44 | 52 | logHardwareRow int | |
| 45 | 53 | logInit bool | |
| @@ -430,6 +438,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 430 | 438 | if len(bottom) == 0 { | |
| 431 | 439 | bottom = []string{""} | |
| 432 | 440 | } | |
| 441 | + | if r.logInit && len(r.logLines) > 0 && | |
| 442 | + | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | |
| 443 | + | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle && | |
| 444 | + | r.logRawHasThemeBG == (r.theme.Background != nil) && | |
| 445 | + | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) { | |
| 446 | + | return | |
| 447 | + | } | |
| 433 | 448 | chatFrame := make([]string, len(chat)) | |
| 434 | 449 | for i, line := range chat { | |
| 435 | 450 | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| @@ -808,6 +823,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 808 | 823 | r.logChat = append(r.logChat[:0], chatFrame...) | |
| 809 | 824 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | |
| 810 | 825 | r.logLines = append(r.logLines[:0], lines...) | |
| 826 | + | r.logRawChat = append(r.logRawChat[:0], chat...) | |
| 827 | + | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | |
| 828 | + | r.logRawCols = r.cols | |
| 829 | + | r.logRawRows = r.rows | |
| 830 | + | r.logRawBackground = r.backgroundStyle | |
| 831 | + | r.logRawHasThemeBG = r.theme.Background != nil | |
| 811 | 832 | r.cursorRow = cursorBottomRow | |
| 812 | 833 | r.cursorCol = cursorCol | |
| 813 | 834 | } | |
| @@ -37,9 +37,17 @@ 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 | + | logRawRows int | |||
| 49 | + | logRawBackground string | |||
| 50 | + | logRawHasThemeBG bool | |||
| 43 | logViewportTop int | 51 | logViewportTop int | ||
| 44 | logHardwareRow int | 52 | logHardwareRow int | ||
| 45 | logInit bool | 53 | logInit bool | ||
| @@ -430,6 +438,13 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 430 | if len(bottom) == 0 { | 438 | if len(bottom) == 0 { | ||
| 431 | bottom = []string{""} | 439 | bottom = []string{""} | ||
| 432 | } | 440 | } | ||
| 441 | + | if r.logInit && len(r.logLines) > 0 && | |||
| 442 | + | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | |||
| 443 | + | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle && | |||
| 444 | + | r.logRawHasThemeBG == (r.theme.Background != nil) && | |||
| 445 | + | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) { | |||
| 446 | + | return | |||
| 447 | + | } | |||
| 433 | chatFrame := make([]string, len(chat)) | 448 | chatFrame := make([]string, len(chat)) | ||
| 434 | for i, line := range chat { | 449 | for i, line := range chat { | ||
| 435 | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | 450 | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | ||
| @@ -808,6 +823,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 808 | r.logChat = append(r.logChat[:0], chatFrame...) | 823 | r.logChat = append(r.logChat[:0], chatFrame...) | ||
| 809 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | 824 | r.logBottom = append(r.logBottom[:0], bottomFrame...) | ||
| 810 | r.logLines = append(r.logLines[:0], lines...) | 825 | r.logLines = append(r.logLines[:0], lines...) | ||
| 826 | + | r.logRawChat = append(r.logRawChat[:0], chat...) | |||
| 827 | + | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | |||
| 828 | + | r.logRawCols = r.cols | |||
| 829 | + | r.logRawRows = r.rows | |||
| 830 | + | r.logRawBackground = r.backgroundStyle | |||
| 831 | + | r.logRawHasThemeBG = r.theme.Background != nil | |||
| 811 | r.cursorRow = cursorBottomRow | 832 | r.cursorRow = cursorBottomRow | ||
| 812 | r.cursorCol = cursorCol | 833 | r.cursorCol = cursorCol | ||
| 813 | } | 834 | } | ||
A packages/tui/render_bench_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func BenchmarkRendererDrawLogToolStorm(b *testing.B) { | |
| 11 | + | for _, width := range []int{48, 80, 160} { | |
| 12 | + | b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) { | |
| 13 | + | var out bytes.Buffer | |
| 14 | + | r := NewRenderer(&out) | |
| 15 | + | r.Resize(width, 40) | |
| 16 | + | chat := benchmarkRendererChat(1600, width) | |
| 17 | + | bottom := []string{"working", "▌ input"} | |
| 18 | + | r.DrawLog(chat, bottom, 1, 2) | |
| 19 | + | out.Reset() | |
| 20 | + | ||
| 21 | + | b.ReportAllocs() | |
| 22 | + | b.ResetTimer() | |
| 23 | + | for n := 0; n < b.N; n++ { | |
| 24 | + | // Keep most of the storm stable while exercising the | |
| 25 | + | // renderer's row-diff path with a changing suffix. | |
| 26 | + | frame := append([]string(nil), chat...) | |
| 27 | + | frame[len(frame)-1] = fmt.Sprintf("tool output %d", n) | |
| 28 | + | r.DrawLog(frame, bottom, 1, 2) | |
| 29 | + | out.Reset() | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func benchmarkRendererChat(rows, width int) []string { | |
| 36 | + | chat := make([]string, rows) | |
| 37 | + | payload := strings.Repeat("x", width) | |
| 38 | + | for i := range chat { | |
| 39 | + | chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload) | |
| 40 | + | } | |
| 41 | + | return chat | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func BenchmarkRendererDrawLogToolStorm(b *testing.B) { | |||
| 11 | + | for _, width := range []int{48, 80, 160} { | |||
| 12 | + | b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) { | |||
| 13 | + | var out bytes.Buffer | |||
| 14 | + | r := NewRenderer(&out) | |||
| 15 | + | r.Resize(width, 40) | |||
| 16 | + | chat := benchmarkRendererChat(1600, width) | |||
| 17 | + | bottom := []string{"working", "▌ input"} | |||
| 18 | + | r.DrawLog(chat, bottom, 1, 2) | |||
| 19 | + | out.Reset() | |||
| 20 | + | ||||
| 21 | + | b.ReportAllocs() | |||
| 22 | + | b.ResetTimer() | |||
| 23 | + | for n := 0; n < b.N; n++ { | |||
| 24 | + | // Keep most of the storm stable while exercising the | |||
| 25 | + | // renderer's row-diff path with a changing suffix. | |||
| 26 | + | frame := append([]string(nil), chat...) | |||
| 27 | + | frame[len(frame)-1] = fmt.Sprintf("tool output %d", n) | |||
| 28 | + | r.DrawLog(frame, bottom, 1, 2) | |||
| 29 | + | out.Reset() | |||
| 30 | + | } | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func benchmarkRendererChat(rows, width int) []string { | |||
| 36 | + | chat := make([]string, rows) | |||
| 37 | + | payload := strings.Repeat("x", width) | |||
| 38 | + | for i := range chat { | |||
| 39 | + | chat[i] = fmt.Sprintf("│ tool-%03d result %s", i, payload) | |||
| 40 | + | } | |||
| 41 | + | return chat | |||
| 42 | + | } | |||
M packages/tui/view.go +504 -40
| @@ -104,6 +104,15 @@ type View struct { | |||
| 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,22 @@ 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 | + | messageCacheStart int | |
| 191 | + | messageCacheRevision uint64 | |
| 192 | + | messageCacheLen int | |
| 193 | + | messageCachePrefix uint64 | |
| 194 | + | messageCachePrefixOK bool | |
| 195 | + | toolPathRevision uint64 | |
| 176 | 196 | ||
| 177 | 197 | // TailLimit caps how many messages from the END of Messages are | |
| 178 | 198 | // rendered. Messages older than the limit emit a zero-row slice | |
| @@ -184,21 +204,44 @@ type View struct { | |||
| 184 | 204 | TailLimit int | |
| 185 | 205 | } | |
| 186 | 206 | ||
| 207 | + | // msgVisualKey identifies the visual configuration that affects a cached | |
| 208 | + | // message render. Keep this shared by both cache implementations so their | |
| 209 | + | // invalidation rules cannot drift apart. | |
| 210 | + | type msgVisualKey struct { | |
| 211 | + | width int | |
| 212 | + | expandAll bool | |
| 213 | + | turnOpen bool | |
| 214 | + | flatTools bool | |
| 215 | + | compactMode bool | |
| 216 | + | compactUser bool | |
| 217 | + | imageProto ImageProtocol | |
| 218 | + | theme uint64 | |
| 219 | + | } | |
| 220 | + | ||
| 187 | 221 | // msgCacheKey identifies a cached message render. hash is a 64-bit | |
| 188 | 222 | // FNV-1a of the message's content, which is cheap to compute and | |
| 189 | 223 | // unambiguous enough for the cache (collisions produce a stale frame, | |
| 190 | 224 | // not wrong data, and we recompute on invalidation anyway). | |
| 191 | 225 | type msgCacheKey struct { | |
| 192 | - | hash uint64 | |
| 193 | - | width int | |
| 194 | - | expandAll bool | |
| 195 | - | // turnOpen is true when the previous rendered message belongs to | |
| 196 | - | // the same agent turn (assistant tool_use, or tool result). The | |
| 197 | - | // header ("▍ zut") is suppressed in that case so a single turn | |
| 198 | - | // — even one that spans many assistant/tool message round-trips | |
| 199 | - | // in the underlying API — renders under one header instead of a | |
| 200 | - | // new one per assistant message. | |
| 201 | - | turnOpen bool | |
| 226 | + | hash uint64 | |
| 227 | + | visual msgVisualKey | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | type messageRenderCacheEntry struct { | |
| 231 | + | visual msgVisualKey | |
| 232 | + | lines []string | |
| 233 | + | valid bool | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | type liveToolCacheKey struct { | |
| 237 | + | revision uint64 | |
| 238 | + | content uint64 | |
| 239 | + | width int | |
| 240 | + | expandAll bool | |
| 241 | + | flatTools bool | |
| 242 | + | compact bool | |
| 243 | + | imageProto ImageProtocol | |
| 244 | + | theme uint64 | |
| 202 | 245 | } | |
| 203 | 246 | ||
| 204 | 247 | // InvalidateRenderCache drops all cached message renders. The tui | |
| @@ -206,7 +249,86 @@ type msgCacheKey struct { | |||
| 206 | 249 | // /clear, session swap) since messages can be replaced in place and | |
| 207 | 250 | // a content-hash miss alone doesn't reclaim the old entries. | |
| 208 | 251 | func (v *View) InvalidateRenderCache() { | |
| 252 | + | v.RenderCacheRevision++ | |
| 209 | 253 | v.renderCache = nil | |
| 254 | + | v.liveRenderCache = nil | |
| 255 | + | v.messageCache = nil | |
| 256 | + | v.messageCacheStart = 0 | |
| 257 | + | v.messageCacheRevision = 0 | |
| 258 | + | v.messageCacheLen = 0 | |
| 259 | + | v.messageCachePrefix = 0 | |
| 260 | + | v.messageCachePrefixOK = false | |
| 261 | + | v.toolPathRevision = 0 | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // CloneForRender returns an immutable render snapshot. Its mutable cache | |
| 265 | + | // maps are copied so a renderer owner can build outside the interactive | |
| 266 | + | // state mutex without racing the next snapshot. | |
| 267 | + | func (v *View) CloneForRender() *View { | |
| 268 | + | clone := *v | |
| 269 | + | clone.Messages = append([]provider.Message(nil), v.Messages...) | |
| 270 | + | clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...) | |
| 271 | + | clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...) | |
| 272 | + | clone.StartupExtensionNames = append([]string(nil), v.StartupExtensionNames...) | |
| 273 | + | clone.StartupSkillNames = append([]string(nil), v.StartupSkillNames...) | |
| 274 | + | clone.toolPaths = cloneStringMap(v.toolPaths) | |
| 275 | + | clone.toolStartLines = cloneIntMap(v.toolStartLines) | |
| 276 | + | clone.toolCallLabels = cloneStringMap(v.toolCallLabels) | |
| 277 | + | clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh) | |
| 278 | + | if v.renderCache != nil { | |
| 279 | + | clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache)) | |
| 280 | + | for key, lines := range v.renderCache { | |
| 281 | + | clone.renderCache[key] = lines | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | if v.liveRenderCache != nil { | |
| 285 | + | clone.liveRenderCache = make(map[liveToolCacheKey][]string, len(v.liveRenderCache)) | |
| 286 | + | for key, lines := range v.liveRenderCache { | |
| 287 | + | clone.liveRenderCache[key] = lines | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | clone.messageCache = append([]messageRenderCacheEntry(nil), v.messageCache...) | |
| 291 | + | return &clone | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func cloneStringMap(src map[string]string) map[string]string { | |
| 295 | + | if src == nil { | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | dst := make(map[string]string, len(src)) | |
| 299 | + | for key, value := range src { | |
| 300 | + | dst[key] = value | |
| 301 | + | } | |
| 302 | + | return dst | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func cloneIntMap(src map[string]int) map[string]int { | |
| 306 | + | if src == nil { | |
| 307 | + | return nil | |
| 308 | + | } | |
| 309 | + | dst := make(map[string]int, len(src)) | |
| 310 | + | for key, value := range src { | |
| 311 | + | dst[key] = value | |
| 312 | + | } | |
| 313 | + | return dst | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // AdoptRenderCacheFrom transfers caches produced from a render snapshot | |
| 317 | + | // back to the owning view. Callers must ensure the transcript revision is | |
| 318 | + | // still current before adopting them. | |
| 319 | + | func (v *View) AdoptRenderCacheFrom(snapshot *View) { | |
| 320 | + | v.renderCache = snapshot.renderCache | |
| 321 | + | v.liveRenderCache = snapshot.liveRenderCache | |
| 322 | + | v.messageCache = snapshot.messageCache | |
| 323 | + | v.messageCacheStart = snapshot.messageCacheStart | |
| 324 | + | v.messageCacheRevision = snapshot.messageCacheRevision | |
| 325 | + | v.messageCacheLen = snapshot.messageCacheLen | |
| 326 | + | v.messageCachePrefix = snapshot.messageCachePrefix | |
| 327 | + | v.messageCachePrefixOK = snapshot.messageCachePrefixOK | |
| 328 | + | v.toolPaths = snapshot.toolPaths | |
| 329 | + | v.toolStartLines = snapshot.toolStartLines | |
| 330 | + | v.toolCallLabels = snapshot.toolCallLabels | |
| 331 | + | v.toolPathRevision = snapshot.toolPathRevision | |
| 210 | 332 | } | |
| 211 | 333 | ||
| 212 | 334 | // ToolCollapsePreview is the number of lines shown before a long tool | |
| @@ -219,13 +341,17 @@ const ( | |||
| 219 | 341 | ||
| 220 | 342 | // ToolCallView is a pending tool invocation plus optional result. | |
| 221 | 343 | type ToolCallView struct { | |
| 222 | - | ID string | |
| 223 | - | Name string | |
| 224 | - | Args string // rendered argument summary | |
| 225 | - | Preview string // side-effect-free result shown before confirmation | |
| 226 | - | Result string // rendered result preview (truncated) | |
| 227 | - | Error bool | |
| 228 | - | Done bool | |
| 344 | + | ID string | |
| 345 | + | Name string | |
| 346 | + | // Revision is advanced by the owning interactive state whenever a | |
| 347 | + | // visible field changes. A non-zero revision lets the live render cache | |
| 348 | + | // avoid rescanning a large result string on every redraw. | |
| 349 | + | Revision uint64 | |
| 350 | + | Args string // rendered argument summary | |
| 351 | + | Preview string // side-effect-free result shown before confirmation | |
| 352 | + | Result string // rendered result preview (truncated) | |
| 353 | + | Error bool | |
| 354 | + | Done bool | |
| 229 | 355 | ||
| 230 | 356 | // Streaming is true while the model is still typing the tool | |
| 231 | 357 | // call's JSON arguments. The TUI renders a live preview of any | |
| @@ -265,6 +391,10 @@ func (v *View) Build(width int) []string { | |||
| 265 | 391 | // so native scrolling stays stable while a turn streams. | |
| 266 | 392 | func (v *View) BuildLive(width int) []string { | |
| 267 | 393 | v.prepareLiveBodyHeights() | |
| 394 | + | if v.liveRenderCache == nil { | |
| 395 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 396 | + | } | |
| 397 | + | themeKey := toolThemeKey(v.Theme) | |
| 268 | 398 | var out []string | |
| 269 | 399 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | |
| 270 | 400 | const indent = " " | |
| @@ -294,7 +424,7 @@ func (v *View) BuildLive(width int) []string { | |||
| 294 | 424 | out = append(out, "") | |
| 295 | 425 | } | |
| 296 | 426 | insertedToolGap = true | |
| 297 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 427 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) | |
| 298 | 428 | } | |
| 299 | 429 | if v.Err != "" { | |
| 300 | 430 | out = append(out, v.renderErr(width)...) | |
| @@ -360,10 +490,47 @@ func (v *View) renderStartupResources(width int) []string { | |||
| 360 | 490 | // scroll to a specific turn (the /jump dialog) use the anchor slice | |
| 361 | 491 | // to map a message index back to a row offset. | |
| 362 | 492 | func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |
| 363 | - | v.refreshToolPaths() | |
| 493 | + | themeKey := toolThemeKey(v.Theme) | |
| 494 | + | if v.MessagesRevision == 0 && v.messageCacheRevision != 0 { | |
| 495 | + | v.messageCache = nil | |
| 496 | + | v.messageCacheStart = 0 | |
| 497 | + | v.messageCacheRevision = 0 | |
| 498 | + | v.messageCacheLen = 0 | |
| 499 | + | v.messageCachePrefix = 0 | |
| 500 | + | v.messageCachePrefixOK = false | |
| 501 | + | } | |
| 502 | + | if v.MessagesRevision != 0 { | |
| 503 | + | if v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision { | |
| 504 | + | verifiedAppend := len(v.Messages) > v.messageCacheLen && v.messageCachePrefixOK && | |
| 505 | + | hashMessagePrefix(v.Messages, v.messageCacheLen) == v.messageCachePrefix | |
| 506 | + | if verifiedAppend { | |
| 507 | + | for _, m := range v.Messages[v.messageCacheLen:] { | |
| 508 | + | v.messageCachePrefix = appendMessageHash(v.messageCachePrefix, hashMessage(m)) | |
| 509 | + | } | |
| 510 | + | } else { | |
| 511 | + | v.messageCache = nil | |
| 512 | + | v.messageCacheStart = 0 | |
| 513 | + | v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages)) | |
| 514 | + | } | |
| 515 | + | v.messageCachePrefixOK = true | |
| 516 | + | } | |
| 517 | + | if v.messageCacheRevision == 0 { | |
| 518 | + | v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages)) | |
| 519 | + | v.messageCachePrefixOK = true | |
| 520 | + | } | |
| 521 | + | v.messageCacheRevision = v.MessagesRevision | |
| 522 | + | v.messageCacheLen = len(v.Messages) | |
| 523 | + | } | |
| 524 | + | if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision { | |
| 525 | + | v.refreshToolPaths() | |
| 526 | + | v.toolPathRevision = v.MessagesRevision | |
| 527 | + | } | |
| 364 | 528 | if v.renderCache == nil { | |
| 365 | 529 | v.renderCache = make(map[msgCacheKey][]string) | |
| 366 | 530 | } | |
| 531 | + | if v.liveRenderCache == nil { | |
| 532 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 533 | + | } | |
| 367 | 534 | v.prepareLiveBodyHeights() | |
| 368 | 535 | // Drop high-water entries for calls that are gone or finalised so | |
| 369 | 536 | // the reservation never leaks into a later, shorter tool box. | |
| @@ -396,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 396 | 563 | if v.TailLimit > 0 && len(v.Messages) > v.TailLimit { | |
| 397 | 564 | renderFrom = len(v.Messages) - v.TailLimit | |
| 398 | 565 | } | |
| 566 | + | v.boundMessageCache(renderFrom, len(v.Messages)) | |
| 399 | 567 | for idx, m := range v.Messages { | |
| 400 | 568 | if idx < renderFrom { | |
| 401 | 569 | rendered[idx] = nil | |
| @@ -422,7 +590,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 422 | 590 | break | |
| 423 | 591 | } | |
| 424 | 592 | } | |
| 425 | - | lines := v.renderMessageCached(m, width, turnOpen) | |
| 593 | + | lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey) | |
| 426 | 594 | rendered[idx] = lines | |
| 427 | 595 | total += len(lines) + 1 // +1 for the blank separator row | |
| 428 | 596 | } | |
| @@ -487,7 +655,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 487 | 655 | if finalised[tc.ID] { | |
| 488 | 656 | continue | |
| 489 | 657 | } | |
| 490 | - | out = append(out, v.renderToolCall(tc, width)...) | |
| 658 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) | |
| 491 | 659 | out = append(out, "") | |
| 492 | 660 | } | |
| 493 | 661 | if v.Err != "" { | |
| @@ -531,18 +699,76 @@ func (v *View) refreshToolPaths() { | |||
| 531 | 699 | } | |
| 532 | 700 | } | |
| 533 | 701 | ||
| 702 | + | func (v *View) boundMessageCache(renderFrom, messageCount int) { | |
| 703 | + | if renderFrom < 0 { | |
| 704 | + | renderFrom = 0 | |
| 705 | + | } | |
| 706 | + | if renderFrom > messageCount { | |
| 707 | + | renderFrom = messageCount | |
| 708 | + | } | |
| 709 | + | if len(v.messageCache) == 0 { | |
| 710 | + | v.messageCacheStart = renderFrom | |
| 711 | + | return | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | start := v.messageCacheStart | |
| 715 | + | if renderFrom > start { | |
| 716 | + | drop := renderFrom - start | |
| 717 | + | if drop >= len(v.messageCache) { | |
| 718 | + | v.messageCache = nil | |
| 719 | + | v.messageCacheStart = renderFrom | |
| 720 | + | return | |
| 721 | + | } | |
| 722 | + | v.messageCache = v.messageCache[drop:] | |
| 723 | + | start = renderFrom | |
| 724 | + | } else if renderFrom < start { | |
| 725 | + | prefix := make([]messageRenderCacheEntry, start-renderFrom) | |
| 726 | + | v.messageCache = append(prefix, v.messageCache...) | |
| 727 | + | start = renderFrom | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | max := messageCount - renderFrom | |
| 731 | + | if len(v.messageCache) > max { | |
| 732 | + | v.messageCache = v.messageCache[:max] | |
| 733 | + | } | |
| 734 | + | v.messageCacheStart = start | |
| 735 | + | } | |
| 736 | + | ||
| 534 | 737 | // renderMessageCached returns the rendered line slice for m, using the | |
| 535 | 738 | // cache if the same (content hash, width, expandAll) combination has | |
| 536 | 739 | // been rendered before. The slice returned is shared — callers must | |
| 537 | 740 | // not mutate it; Build() only ever appends to its own `out` so the | |
| 538 | 741 | // shared slice is safe. | |
| 539 | - | func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string { | |
| 540 | - | key := msgCacheKey{ | |
| 541 | - | hash: hashMessage(m), | |
| 542 | - | width: width, | |
| 543 | - | expandAll: v.ExpandAll, | |
| 544 | - | turnOpen: turnOpen, | |
| 742 | + | func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string { | |
| 743 | + | visual := msgVisualKey{ | |
| 744 | + | width: width, | |
| 745 | + | expandAll: v.ExpandAll, | |
| 746 | + | turnOpen: turnOpen, | |
| 747 | + | flatTools: v.FlatTools, | |
| 748 | + | compactMode: v.CompactMode, | |
| 749 | + | compactUser: v.CompactUser, | |
| 750 | + | imageProto: v.ImageProto, | |
| 751 | + | theme: themeKey, | |
| 752 | + | } | |
| 753 | + | if v.MessagesRevision != 0 { | |
| 754 | + | cacheIdx := idx - v.messageCacheStart | |
| 755 | + | if cacheIdx >= 0 && cacheIdx < len(v.messageCache) { | |
| 756 | + | entry := v.messageCache[cacheIdx] | |
| 757 | + | if entry.valid && entry.visual == visual { | |
| 758 | + | return entry.lines | |
| 759 | + | } | |
| 760 | + | } | |
| 761 | + | lines := v.renderMessage(m, width, turnOpen) | |
| 762 | + | if cacheIdx < 0 { | |
| 763 | + | return lines | |
| 764 | + | } | |
| 765 | + | if cacheIdx >= len(v.messageCache) { | |
| 766 | + | v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, cacheIdx-len(v.messageCache)+1)...) | |
| 767 | + | } | |
| 768 | + | v.messageCache[cacheIdx] = messageRenderCacheEntry{visual: visual, lines: lines, valid: true} | |
| 769 | + | return lines | |
| 545 | 770 | } | |
| 771 | + | key := msgCacheKey{hash: hashMessage(m), visual: visual} | |
| 546 | 772 | if v.renderCache != nil { | |
| 547 | 773 | if lines, ok := v.renderCache[key]; ok { | |
| 548 | 774 | return lines | |
| @@ -575,6 +801,25 @@ func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) | |||
| 575 | 801 | return lines | |
| 576 | 802 | } | |
| 577 | 803 | ||
| 804 | + | func appendMessageHash(h, messageHash uint64) uint64 { | |
| 805 | + | h = fnv64aWriteByte(h, 0x01) | |
| 806 | + | for shift := uint(0); shift < 64; shift += 8 { | |
| 807 | + | h = fnv64aWriteByte(h, byte(messageHash>>shift)) | |
| 808 | + | } | |
| 809 | + | return h | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | func hashMessagePrefix(messages []provider.Message, end int) uint64 { | |
| 813 | + | if end > len(messages) { | |
| 814 | + | end = len(messages) | |
| 815 | + | } | |
| 816 | + | h := fnv64aInit | |
| 817 | + | for i := 0; i < end; i++ { | |
| 818 | + | h = appendMessageHash(h, hashMessage(messages[i])) | |
| 819 | + | } | |
| 820 | + | return h | |
| 821 | + | } | |
| 822 | + | ||
| 578 | 823 | // hashMessage returns a 64-bit FNV-1a over the role + content blocks | |
| 579 | 824 | // of m. Serialising each block to its salient bytes is enough: two | |
| 580 | 825 | // messages with the same role and same content render identically. | |
| @@ -648,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 { | |||
| 648 | 893 | return h | |
| 649 | 894 | } | |
| 650 | 895 | ||
| 896 | + | func fnv64aWriteUint64(h, value uint64) uint64 { | |
| 897 | + | for shift := uint(0); shift < 64; shift += 8 { | |
| 898 | + | h = fnv64aWriteByte(h, byte(value>>shift)) | |
| 899 | + | } | |
| 900 | + | return h | |
| 901 | + | } | |
| 902 | + | ||
| 651 | 903 | func fnv64aWrite(h uint64, p []byte) uint64 { | |
| 652 | 904 | for _, b := range p { | |
| 653 | 905 | h ^= uint64(b) | |
| @@ -842,7 +1094,129 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string { | |||
| 842 | 1094 | if v.liveBodyHigh == nil { | |
| 843 | 1095 | v.liveBodyHigh = make(map[string]int) | |
| 844 | 1096 | } | |
| 845 | - | return v.renderToolCall(tc, width) | |
| 1097 | + | return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme)) | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string { | |
| 1101 | + | if v.liveRenderCache == nil { | |
| 1102 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |
| 1103 | + | } | |
| 1104 | + | key := liveToolCacheKey{ | |
| 1105 | + | revision: tc.Revision, | |
| 1106 | + | content: hashLiveTool(tc), | |
| 1107 | + | width: width, | |
| 1108 | + | expandAll: v.ExpandAll, | |
| 1109 | + | flatTools: v.FlatTools, | |
| 1110 | + | compact: v.CompactMode, | |
| 1111 | + | imageProto: v.ImageProto, | |
| 1112 | + | theme: themeKey, | |
| 1113 | + | } | |
| 1114 | + | if lines, ok := v.liveRenderCache[key]; ok { | |
| 1115 | + | return lines | |
| 1116 | + | } | |
| 1117 | + | lines := v.renderToolCall(tc, width) | |
| 1118 | + | if len(v.liveRenderCache) > 512 { | |
| 1119 | + | for k := range v.liveRenderCache { | |
| 1120 | + | delete(v.liveRenderCache, k) | |
| 1121 | + | break | |
| 1122 | + | } | |
| 1123 | + | } | |
| 1124 | + | v.liveRenderCache[key] = lines | |
| 1125 | + | return lines | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | func hashLiveTool(tc ToolCallView) uint64 { | |
| 1129 | + | if tc.Revision != 0 { | |
| 1130 | + | return 0 | |
| 1131 | + | } | |
| 1132 | + | h := fnv64aInit | |
| 1133 | + | for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} { | |
| 1134 | + | h = fnv64aWrite(h, []byte(s)) | |
| 1135 | + | h = fnv64aWriteByte(h, 0) | |
| 1136 | + | } | |
| 1137 | + | if tc.Error { | |
| 1138 | + | h = fnv64aWriteByte(h, 'e') | |
| 1139 | + | } | |
| 1140 | + | if tc.Done { | |
| 1141 | + | h = fnv64aWriteByte(h, 'd') | |
| 1142 | + | } | |
| 1143 | + | if tc.Streaming { | |
| 1144 | + | h = fnv64aWriteByte(h, 's') | |
| 1145 | + | } | |
| 1146 | + | return h | |
| 1147 | + | } | |
| 1148 | + | ||
| 1149 | + | func toolThemeKey(theme Theme) uint64 { | |
| 1150 | + | h := fnv64aInit | |
| 1151 | + | for _, s := range []string{ | |
| 1152 | + | theme.SyntaxBaseStyle, | |
| 1153 | + | theme.Syntax.Keyword, | |
| 1154 | + | theme.Syntax.KeywordConstant, | |
| 1155 | + | theme.Syntax.KeywordDeclaration, | |
| 1156 | + | theme.Syntax.KeywordNamespace, | |
| 1157 | + | theme.Syntax.KeywordReserved, | |
| 1158 | + | theme.Syntax.KeywordType, | |
| 1159 | + | theme.Syntax.NameBuiltin, | |
| 1160 | + | theme.Syntax.NameFunction, | |
| 1161 | + | theme.Syntax.NameClass, | |
| 1162 | + | theme.Syntax.NameDecorator, | |
| 1163 | + | theme.Syntax.LiteralString, | |
| 1164 | + | theme.Syntax.LiteralStringEscape, | |
| 1165 | + | theme.Syntax.LiteralNumber, | |
| 1166 | + | theme.Syntax.Comment, | |
| 1167 | + | theme.Syntax.CommentPreproc, | |
| 1168 | + | theme.Syntax.Operator, | |
| 1169 | + | theme.Syntax.Punctuation, | |
| 1170 | + | theme.Syntax.Text, | |
| 1171 | + | } { | |
| 1172 | + | h = fnv64aWrite(h, []byte(s)) | |
| 1173 | + | h = fnv64aWriteByte(h, 0) | |
| 1174 | + | } | |
| 1175 | + | for _, c := range []TerminalColor{ | |
| 1176 | + | theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG, | |
| 1177 | + | theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut, | |
| 1178 | + | theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax, | |
| 1179 | + | theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground, | |
| 1180 | + | theme.Terminal.Background, | |
| 1181 | + | } { | |
| 1182 | + | h = hashThemeColor(h, c) | |
| 1183 | + | } | |
| 1184 | + | if theme.Background == nil { | |
| 1185 | + | h = fnv64aWriteByte(h, 0) | |
| 1186 | + | } else { | |
| 1187 | + | h = fnv64aWriteByte(h, 1) | |
| 1188 | + | h = hashThemeColor(h, *theme.Background) | |
| 1189 | + | } | |
| 1190 | + | h = fnv64aWriteByte(h, 0xff) | |
| 1191 | + | h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette))) | |
| 1192 | + | h = fnv64aWriteByte(h, 0xff) | |
| 1193 | + | for _, c := range theme.Terminal.Palette { | |
| 1194 | + | h = hashThemeColor(h, c) | |
| 1195 | + | } | |
| 1196 | + | for _, value := range []bool{ | |
| 1197 | + | theme.Inherited, | |
| 1198 | + | theme.Terminal.HasForeground, | |
| 1199 | + | theme.Terminal.HasBackground, | |
| 1200 | + | theme.Terminal.TrueColor, | |
| 1201 | + | theme.Terminal.Light, | |
| 1202 | + | theme.Terminal.SchemeKnown, | |
| 1203 | + | } { | |
| 1204 | + | if value { | |
| 1205 | + | h = fnv64aWriteByte(h, 1) | |
| 1206 | + | } else { | |
| 1207 | + | h = fnv64aWriteByte(h, 0) | |
| 1208 | + | } | |
| 1209 | + | } | |
| 1210 | + | return h | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | func hashThemeColor(h uint64, c TerminalColor) uint64 { | |
| 1214 | + | h = fnv64aWriteByte(h, byte(c.Mode)) | |
| 1215 | + | h = fnv64aWriteByte(h, byte(c.Index)) | |
| 1216 | + | h = fnv64aWriteByte(h, byte(c.R)) | |
| 1217 | + | h = fnv64aWriteByte(h, byte(c.G)) | |
| 1218 | + | h = fnv64aWriteByte(h, byte(c.B)) | |
| 1219 | + | return h | |
| 846 | 1220 | } | |
| 847 | 1221 | ||
| 848 | 1222 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| @@ -979,7 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 979 | 1353 | // confirmation preview immediately gets the same colors, numbered gutters, | |
| 980 | 1354 | // and syntax highlighting as its final tool result. | |
| 981 | 1355 | func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string { | |
| 982 | - | return v.renderToolText(text, width, color, sourcePath, 1) | |
| 1356 | + | full := v.renderToolText(text, width, color, sourcePath, 1) | |
| 1357 | + | if v.ExpandAll || len(full) <= ToolCollapseLines { | |
| 1358 | + | return full | |
| 1359 | + | } | |
| 1360 | + | return v.collapseToolBodyWithTotal(full, false, len(full)) | |
| 983 | 1361 | } | |
| 984 | 1362 | ||
| 985 | 1363 | // renderLiveToolBody renders the in-flight preview of a streaming | |
| @@ -1472,6 +1850,13 @@ func trimLeadingSpaces(s string, n int) string { | |||
| 1472 | 1850 | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { | |
| 1473 | 1851 | var body []string | |
| 1474 | 1852 | hasImage := false | |
| 1853 | + | for _, b := range blocks { | |
| 1854 | + | if _, ok := b.(provider.ImageBlock); ok { | |
| 1855 | + | hasImage = true | |
| 1856 | + | break | |
| 1857 | + | } | |
| 1858 | + | } | |
| 1859 | + | total := 0 | |
| 1475 | 1860 | for _, b := range blocks { | |
| 1476 | 1861 | switch bb := b.(type) { | |
| 1477 | 1862 | case provider.TextBlock: | |
| @@ -1479,13 +1864,22 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||
| 1479 | 1864 | if v.FlatTools || v.CompactMode { | |
| 1480 | 1865 | bodyWidth = flatToolBodyRenderWidth(width) | |
| 1481 | 1866 | } | |
| 1482 | - | body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) | |
| 1867 | + | estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth) | |
| 1868 | + | estimated := estimateToolTextLines(bb.Text, estimateWidth) | |
| 1869 | + | total += estimated | |
| 1870 | + | text := bb.Text | |
| 1871 | + | // A collapsed result only exposes its first ten rendered rows. | |
| 1872 | + | // Render a bounded source preview in that case; syntax | |
| 1873 | + | // highlighting and wrapping the omitted tail is wasted work. | |
| 1874 | + | if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines { | |
| 1875 | + | text = previewToolText(text, estimateWidth) | |
| 1876 | + | } | |
| 1877 | + | body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...) | |
| 1483 | 1878 | case provider.ImageBlock: | |
| 1484 | - | hasImage = true | |
| 1485 | 1879 | body = append(body, v.renderImageBlock(bb, width)...) | |
| 1486 | 1880 | } | |
| 1487 | 1881 | } | |
| 1488 | - | return v.collapseToolBody(body, hasImage) | |
| 1882 | + | return v.collapseToolBodyWithTotal(body, hasImage, total) | |
| 1489 | 1883 | } | |
| 1490 | 1884 | ||
| 1491 | 1885 | // collapseToolBody trims lines to the configured preview size when the | |
| @@ -1493,15 +1887,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||
| 1493 | 1887 | // expand" footer. Image blocks never collapse — they're short in text | |
| 1494 | 1888 | // rows but represent real content the user wants to see. | |
| 1495 | 1889 | func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |
| 1890 | + | return v.collapseToolBodyWithTotal(lines, hasImage, len(lines)) | |
| 1891 | + | } | |
| 1892 | + | ||
| 1893 | + | func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string { | |
| 1496 | 1894 | if v.ExpandAll || hasImage { | |
| 1497 | 1895 | return lines | |
| 1498 | 1896 | } | |
| 1499 | - | if len(lines) <= ToolCollapseLines { | |
| 1897 | + | if total < len(lines) { | |
| 1898 | + | total = len(lines) | |
| 1899 | + | } | |
| 1900 | + | if total <= ToolCollapseLines { | |
| 1500 | 1901 | return lines | |
| 1501 | 1902 | } | |
| 1502 | - | kept := lines[:ToolCollapsePreview] | |
| 1503 | - | hidden := len(lines) - ToolCollapsePreview | |
| 1504 | - | total := len(lines) | |
| 1903 | + | kept := lines | |
| 1904 | + | if len(kept) > ToolCollapsePreview { | |
| 1905 | + | kept = kept[:ToolCollapsePreview] | |
| 1906 | + | } | |
| 1907 | + | hidden := total - ToolCollapsePreview | |
| 1505 | 1908 | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | |
| 1506 | 1909 | footer = v.Theme.FGColor(v.Theme.Muted, footer) | |
| 1507 | 1910 | out := append([]string(nil), kept...) | |
| @@ -1509,6 +1912,67 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 1509 | 1912 | return append(out, footer) | |
| 1510 | 1913 | } | |
| 1511 | 1914 | ||
| 1915 | + | func effectiveToolTextWidth(text string, width int) int { | |
| 1916 | + | // renderToolText prefixes ordinary rows with a four-cell gutter before | |
| 1917 | + | // wrapping them. Diff rows also carry a five-cell read gutter. Estimating | |
| 1918 | + | // against the outer body width undercounts long rows and produces an | |
| 1919 | + | // incorrect collapse total. | |
| 1920 | + | width -= 4 | |
| 1921 | + | if looksLikeUnifiedDiff(text) { | |
| 1922 | + | width -= 5 | |
| 1923 | + | } | |
| 1924 | + | if width < 1 { | |
| 1925 | + | return 1 | |
| 1926 | + | } | |
| 1927 | + | return width | |
| 1928 | + | } | |
| 1929 | + | ||
| 1930 | + | func estimateToolTextLines(text string, width int) int { | |
| 1931 | + | if width < 1 { | |
| 1932 | + | width = 1 | |
| 1933 | + | } | |
| 1934 | + | total := 0 | |
| 1935 | + | for _, line := range strings.Split(text, "\n") { | |
| 1936 | + | cells := runewidth.StringWidth(line) | |
| 1937 | + | if cells == 0 { | |
| 1938 | + | total++ | |
| 1939 | + | continue | |
| 1940 | + | } | |
| 1941 | + | total += (cells + width - 1) / width | |
| 1942 | + | } | |
| 1943 | + | if total == 0 { | |
| 1944 | + | return 1 | |
| 1945 | + | } | |
| 1946 | + | return total | |
| 1947 | + | } | |
| 1948 | + | ||
| 1949 | + | func previewToolText(text string, width int) string { | |
| 1950 | + | if width < 1 { | |
| 1951 | + | width = 1 | |
| 1952 | + | } | |
| 1953 | + | maxChars := width * (ToolCollapsePreview + 2) | |
| 1954 | + | if maxChars < 256 { | |
| 1955 | + | maxChars = 256 | |
| 1956 | + | } | |
| 1957 | + | if maxChars > 8192 { | |
| 1958 | + | maxChars = 8192 | |
| 1959 | + | } | |
| 1960 | + | end := 0 | |
| 1961 | + | for count := 0; end < len(text) && count < maxChars; count++ { | |
| 1962 | + | _, size := utf8.DecodeRuneInString(text[end:]) | |
| 1963 | + | if size == 0 { | |
| 1964 | + | break | |
| 1965 | + | } | |
| 1966 | + | end += size | |
| 1967 | + | } | |
| 1968 | + | preview := text[:end] | |
| 1969 | + | lines := strings.SplitAfter(preview, "\n") | |
| 1970 | + | if len(lines) > ToolCollapsePreview+2 { | |
| 1971 | + | preview = strings.Join(lines[:ToolCollapsePreview+2], "") | |
| 1972 | + | } | |
| 1973 | + | return preview | |
| 1974 | + | } | |
| 1975 | + | ||
| 1512 | 1976 | // renderToolText renders a text block inside a tool result. If the | |
| 1513 | 1977 | // text contains a unified-diff section (lines starting with "--- " / | |
| 1514 | 1978 | // "+++ " / "+" / "-"/" "), 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,22 @@ 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 | + | messageCacheStart int | |||
| 191 | + | messageCacheRevision uint64 | |||
| 192 | + | messageCacheLen int | |||
| 193 | + | messageCachePrefix uint64 | |||
| 194 | + | messageCachePrefixOK bool | |||
| 195 | + | toolPathRevision uint64 | |||
| 176 | 196 | ||||
| 177 | // TailLimit caps how many messages from the END of Messages are | 197 | // TailLimit caps how many messages from the END of Messages are | ||
| 178 | // rendered. Messages older than the limit emit a zero-row slice | 198 | // rendered. Messages older than the limit emit a zero-row slice | ||
| @@ -184,21 +204,44 @@ type View struct { | |||||
| 184 | TailLimit int | 204 | TailLimit int | ||
| 185 | } | 205 | } | ||
| 186 | 206 | ||||
| 207 | + | // msgVisualKey identifies the visual configuration that affects a cached | |||
| 208 | + | // message render. Keep this shared by both cache implementations so their | |||
| 209 | + | // invalidation rules cannot drift apart. | |||
| 210 | + | type msgVisualKey struct { | |||
| 211 | + | width int | |||
| 212 | + | expandAll bool | |||
| 213 | + | turnOpen bool | |||
| 214 | + | flatTools bool | |||
| 215 | + | compactMode bool | |||
| 216 | + | compactUser bool | |||
| 217 | + | imageProto ImageProtocol | |||
| 218 | + | theme uint64 | |||
| 219 | + | } | |||
| 220 | + | ||||
| 187 | // msgCacheKey identifies a cached message render. hash is a 64-bit | 221 | // msgCacheKey identifies a cached message render. hash is a 64-bit | ||
| 188 | // FNV-1a of the message's content, which is cheap to compute and | 222 | // FNV-1a of the message's content, which is cheap to compute and | ||
| 189 | // unambiguous enough for the cache (collisions produce a stale frame, | 223 | // unambiguous enough for the cache (collisions produce a stale frame, | ||
| 190 | // not wrong data, and we recompute on invalidation anyway). | 224 | // not wrong data, and we recompute on invalidation anyway). | ||
| 191 | type msgCacheKey struct { | 225 | type msgCacheKey struct { | ||
| 192 | - | hash uint64 | 226 | + | hash uint64 |
| 193 | - | width int | 227 | + | visual msgVisualKey |
| 194 | - | expandAll bool | 228 | + | } |
| 195 | - | // turnOpen is true when the previous rendered message belongs to | 229 | + | |
| 196 | - | // the same agent turn (assistant tool_use, or tool result). The | 230 | + | type messageRenderCacheEntry struct { |
| 197 | - | // header ("▍ zut") is suppressed in that case so a single turn | 231 | + | visual msgVisualKey |
| 198 | - | // — even one that spans many assistant/tool message round-trips | 232 | + | lines []string |
| 199 | - | // in the underlying API — renders under one header instead of a | 233 | + | valid bool |
| 200 | - | // new one per assistant message. | 234 | + | } |
| 201 | - | turnOpen bool | 235 | + | |
| 236 | + | type liveToolCacheKey struct { | |||
| 237 | + | revision uint64 | |||
| 238 | + | content uint64 | |||
| 239 | + | width int | |||
| 240 | + | expandAll bool | |||
| 241 | + | flatTools bool | |||
| 242 | + | compact bool | |||
| 243 | + | imageProto ImageProtocol | |||
| 244 | + | theme uint64 | |||
| 202 | } | 245 | } | ||
| 203 | 246 | ||||
| 204 | // InvalidateRenderCache drops all cached message renders. The tui | 247 | // InvalidateRenderCache drops all cached message renders. The tui | ||
| @@ -206,7 +249,86 @@ type msgCacheKey struct { | |||||
| 206 | // /clear, session swap) since messages can be replaced in place and | 249 | // /clear, session swap) since messages can be replaced in place and | ||
| 207 | // a content-hash miss alone doesn't reclaim the old entries. | 250 | // a content-hash miss alone doesn't reclaim the old entries. | ||
| 208 | func (v *View) InvalidateRenderCache() { | 251 | func (v *View) InvalidateRenderCache() { | ||
| 252 | + | v.RenderCacheRevision++ | |||
| 209 | v.renderCache = nil | 253 | v.renderCache = nil | ||
| 254 | + | v.liveRenderCache = nil | |||
| 255 | + | v.messageCache = nil | |||
| 256 | + | v.messageCacheStart = 0 | |||
| 257 | + | v.messageCacheRevision = 0 | |||
| 258 | + | v.messageCacheLen = 0 | |||
| 259 | + | v.messageCachePrefix = 0 | |||
| 260 | + | v.messageCachePrefixOK = false | |||
| 261 | + | v.toolPathRevision = 0 | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | // CloneForRender returns an immutable render snapshot. Its mutable cache | |||
| 265 | + | // maps are copied so a renderer owner can build outside the interactive | |||
| 266 | + | // state mutex without racing the next snapshot. | |||
| 267 | + | func (v *View) CloneForRender() *View { | |||
| 268 | + | clone := *v | |||
| 269 | + | clone.Messages = append([]provider.Message(nil), v.Messages...) | |||
| 270 | + | clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...) | |||
| 271 | + | clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...) | |||
| 272 | + | clone.StartupExtensionNames = append([]string(nil), v.StartupExtensionNames...) | |||
| 273 | + | clone.StartupSkillNames = append([]string(nil), v.StartupSkillNames...) | |||
| 274 | + | clone.toolPaths = cloneStringMap(v.toolPaths) | |||
| 275 | + | clone.toolStartLines = cloneIntMap(v.toolStartLines) | |||
| 276 | + | clone.toolCallLabels = cloneStringMap(v.toolCallLabels) | |||
| 277 | + | clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh) | |||
| 278 | + | if v.renderCache != nil { | |||
| 279 | + | clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache)) | |||
| 280 | + | for key, lines := range v.renderCache { | |||
| 281 | + | clone.renderCache[key] = lines | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | if v.liveRenderCache != nil { | |||
| 285 | + | clone.liveRenderCache = make(map[liveToolCacheKey][]string, len(v.liveRenderCache)) | |||
| 286 | + | for key, lines := range v.liveRenderCache { | |||
| 287 | + | clone.liveRenderCache[key] = lines | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | clone.messageCache = append([]messageRenderCacheEntry(nil), v.messageCache...) | |||
| 291 | + | return &clone | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func cloneStringMap(src map[string]string) map[string]string { | |||
| 295 | + | if src == nil { | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | dst := make(map[string]string, len(src)) | |||
| 299 | + | for key, value := range src { | |||
| 300 | + | dst[key] = value | |||
| 301 | + | } | |||
| 302 | + | return dst | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func cloneIntMap(src map[string]int) map[string]int { | |||
| 306 | + | if src == nil { | |||
| 307 | + | return nil | |||
| 308 | + | } | |||
| 309 | + | dst := make(map[string]int, len(src)) | |||
| 310 | + | for key, value := range src { | |||
| 311 | + | dst[key] = value | |||
| 312 | + | } | |||
| 313 | + | return dst | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // AdoptRenderCacheFrom transfers caches produced from a render snapshot | |||
| 317 | + | // back to the owning view. Callers must ensure the transcript revision is | |||
| 318 | + | // still current before adopting them. | |||
| 319 | + | func (v *View) AdoptRenderCacheFrom(snapshot *View) { | |||
| 320 | + | v.renderCache = snapshot.renderCache | |||
| 321 | + | v.liveRenderCache = snapshot.liveRenderCache | |||
| 322 | + | v.messageCache = snapshot.messageCache | |||
| 323 | + | v.messageCacheStart = snapshot.messageCacheStart | |||
| 324 | + | v.messageCacheRevision = snapshot.messageCacheRevision | |||
| 325 | + | v.messageCacheLen = snapshot.messageCacheLen | |||
| 326 | + | v.messageCachePrefix = snapshot.messageCachePrefix | |||
| 327 | + | v.messageCachePrefixOK = snapshot.messageCachePrefixOK | |||
| 328 | + | v.toolPaths = snapshot.toolPaths | |||
| 329 | + | v.toolStartLines = snapshot.toolStartLines | |||
| 330 | + | v.toolCallLabels = snapshot.toolCallLabels | |||
| 331 | + | v.toolPathRevision = snapshot.toolPathRevision | |||
| 210 | } | 332 | } | ||
| 211 | 333 | ||||
| 212 | // ToolCollapsePreview is the number of lines shown before a long tool | 334 | // ToolCollapsePreview is the number of lines shown before a long tool | ||
| @@ -219,13 +341,17 @@ const ( | |||||
| 219 | 341 | ||||
| 220 | // ToolCallView is a pending tool invocation plus optional result. | 342 | // ToolCallView is a pending tool invocation plus optional result. | ||
| 221 | type ToolCallView struct { | 343 | type ToolCallView struct { | ||
| 222 | - | ID string | 344 | + | ID string |
| 223 | - | Name string | 345 | + | Name string |
| 224 | - | Args string // rendered argument summary | 346 | + | // Revision is advanced by the owning interactive state whenever a |
| 225 | - | Preview string // side-effect-free result shown before confirmation | 347 | + | // visible field changes. A non-zero revision lets the live render cache |
| 226 | - | Result string // rendered result preview (truncated) | 348 | + | // avoid rescanning a large result string on every redraw. |
| 227 | - | Error bool | 349 | + | Revision uint64 |
| 228 | - | Done bool | 350 | + | Args string // rendered argument summary |
| 351 | + | Preview string // side-effect-free result shown before confirmation | |||
| 352 | + | Result string // rendered result preview (truncated) | |||
| 353 | + | Error bool | |||
| 354 | + | Done bool | |||
| 229 | 355 | ||||
| 230 | // Streaming is true while the model is still typing the tool | 356 | // Streaming is true while the model is still typing the tool | ||
| 231 | // call's JSON arguments. The TUI renders a live preview of any | 357 | // call's JSON arguments. The TUI renders a live preview of any | ||
| @@ -265,6 +391,10 @@ func (v *View) Build(width int) []string { | |||||
| 265 | // so native scrolling stays stable while a turn streams. | 391 | // so native scrolling stays stable while a turn streams. | ||
| 266 | func (v *View) BuildLive(width int) []string { | 392 | func (v *View) BuildLive(width int) []string { | ||
| 267 | v.prepareLiveBodyHeights() | 393 | v.prepareLiveBodyHeights() | ||
| 394 | + | if v.liveRenderCache == nil { | |||
| 395 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |||
| 396 | + | } | |||
| 397 | + | themeKey := toolThemeKey(v.Theme) | |||
| 268 | var out []string | 398 | var out []string | ||
| 269 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | 399 | if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { | ||
| 270 | const indent = " " | 400 | const indent = " " | ||
| @@ -294,7 +424,7 @@ func (v *View) BuildLive(width int) []string { | |||||
| 294 | out = append(out, "") | 424 | out = append(out, "") | ||
| 295 | } | 425 | } | ||
| 296 | insertedToolGap = true | 426 | insertedToolGap = true | ||
| 297 | - | out = append(out, v.renderToolCall(tc, width)...) | 427 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) |
| 298 | } | 428 | } | ||
| 299 | if v.Err != "" { | 429 | if v.Err != "" { | ||
| 300 | out = append(out, v.renderErr(width)...) | 430 | out = append(out, v.renderErr(width)...) | ||
| @@ -360,10 +490,47 @@ func (v *View) renderStartupResources(width int) []string { | |||||
| 360 | // scroll to a specific turn (the /jump dialog) use the anchor slice | 490 | // scroll to a specific turn (the /jump dialog) use the anchor slice | ||
| 361 | // to map a message index back to a row offset. | 491 | // to map a message index back to a row offset. | ||
| 362 | func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | 492 | func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | ||
| 363 | - | v.refreshToolPaths() | 493 | + | themeKey := toolThemeKey(v.Theme) |
| 494 | + | if v.MessagesRevision == 0 && v.messageCacheRevision != 0 { | |||
| 495 | + | v.messageCache = nil | |||
| 496 | + | v.messageCacheStart = 0 | |||
| 497 | + | v.messageCacheRevision = 0 | |||
| 498 | + | v.messageCacheLen = 0 | |||
| 499 | + | v.messageCachePrefix = 0 | |||
| 500 | + | v.messageCachePrefixOK = false | |||
| 501 | + | } | |||
| 502 | + | if v.MessagesRevision != 0 { | |||
| 503 | + | if v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision { | |||
| 504 | + | verifiedAppend := len(v.Messages) > v.messageCacheLen && v.messageCachePrefixOK && | |||
| 505 | + | hashMessagePrefix(v.Messages, v.messageCacheLen) == v.messageCachePrefix | |||
| 506 | + | if verifiedAppend { | |||
| 507 | + | for _, m := range v.Messages[v.messageCacheLen:] { | |||
| 508 | + | v.messageCachePrefix = appendMessageHash(v.messageCachePrefix, hashMessage(m)) | |||
| 509 | + | } | |||
| 510 | + | } else { | |||
| 511 | + | v.messageCache = nil | |||
| 512 | + | v.messageCacheStart = 0 | |||
| 513 | + | v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages)) | |||
| 514 | + | } | |||
| 515 | + | v.messageCachePrefixOK = true | |||
| 516 | + | } | |||
| 517 | + | if v.messageCacheRevision == 0 { | |||
| 518 | + | v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages)) | |||
| 519 | + | v.messageCachePrefixOK = true | |||
| 520 | + | } | |||
| 521 | + | v.messageCacheRevision = v.MessagesRevision | |||
| 522 | + | v.messageCacheLen = len(v.Messages) | |||
| 523 | + | } | |||
| 524 | + | if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision { | |||
| 525 | + | v.refreshToolPaths() | |||
| 526 | + | v.toolPathRevision = v.MessagesRevision | |||
| 527 | + | } | |||
| 364 | if v.renderCache == nil { | 528 | if v.renderCache == nil { | ||
| 365 | v.renderCache = make(map[msgCacheKey][]string) | 529 | v.renderCache = make(map[msgCacheKey][]string) | ||
| 366 | } | 530 | } | ||
| 531 | + | if v.liveRenderCache == nil { | |||
| 532 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |||
| 533 | + | } | |||
| 367 | v.prepareLiveBodyHeights() | 534 | v.prepareLiveBodyHeights() | ||
| 368 | // Drop high-water entries for calls that are gone or finalised so | 535 | // Drop high-water entries for calls that are gone or finalised so | ||
| 369 | // the reservation never leaks into a later, shorter tool box. | 536 | // the reservation never leaks into a later, shorter tool box. | ||
| @@ -396,6 +563,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 396 | if v.TailLimit > 0 && len(v.Messages) > v.TailLimit { | 563 | if v.TailLimit > 0 && len(v.Messages) > v.TailLimit { | ||
| 397 | renderFrom = len(v.Messages) - v.TailLimit | 564 | renderFrom = len(v.Messages) - v.TailLimit | ||
| 398 | } | 565 | } | ||
| 566 | + | v.boundMessageCache(renderFrom, len(v.Messages)) | |||
| 399 | for idx, m := range v.Messages { | 567 | for idx, m := range v.Messages { | ||
| 400 | if idx < renderFrom { | 568 | if idx < renderFrom { | ||
| 401 | rendered[idx] = nil | 569 | rendered[idx] = nil | ||
| @@ -422,7 +590,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 422 | break | 590 | break | ||
| 423 | } | 591 | } | ||
| 424 | } | 592 | } | ||
| 425 | - | lines := v.renderMessageCached(m, width, turnOpen) | 593 | + | lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey) |
| 426 | rendered[idx] = lines | 594 | rendered[idx] = lines | ||
| 427 | total += len(lines) + 1 // +1 for the blank separator row | 595 | total += len(lines) + 1 // +1 for the blank separator row | ||
| 428 | } | 596 | } | ||
| @@ -487,7 +655,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 487 | if finalised[tc.ID] { | 655 | if finalised[tc.ID] { | ||
| 488 | continue | 656 | continue | ||
| 489 | } | 657 | } | ||
| 490 | - | out = append(out, v.renderToolCall(tc, width)...) | 658 | + | out = append(out, v.renderToolCallCached(tc, width, themeKey)...) |
| 491 | out = append(out, "") | 659 | out = append(out, "") | ||
| 492 | } | 660 | } | ||
| 493 | if v.Err != "" { | 661 | if v.Err != "" { | ||
| @@ -531,18 +699,76 @@ func (v *View) refreshToolPaths() { | |||||
| 531 | } | 699 | } | ||
| 532 | } | 700 | } | ||
| 533 | 701 | ||||
| 702 | + | func (v *View) boundMessageCache(renderFrom, messageCount int) { | |||
| 703 | + | if renderFrom < 0 { | |||
| 704 | + | renderFrom = 0 | |||
| 705 | + | } | |||
| 706 | + | if renderFrom > messageCount { | |||
| 707 | + | renderFrom = messageCount | |||
| 708 | + | } | |||
| 709 | + | if len(v.messageCache) == 0 { | |||
| 710 | + | v.messageCacheStart = renderFrom | |||
| 711 | + | return | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | start := v.messageCacheStart | |||
| 715 | + | if renderFrom > start { | |||
| 716 | + | drop := renderFrom - start | |||
| 717 | + | if drop >= len(v.messageCache) { | |||
| 718 | + | v.messageCache = nil | |||
| 719 | + | v.messageCacheStart = renderFrom | |||
| 720 | + | return | |||
| 721 | + | } | |||
| 722 | + | v.messageCache = v.messageCache[drop:] | |||
| 723 | + | start = renderFrom | |||
| 724 | + | } else if renderFrom < start { | |||
| 725 | + | prefix := make([]messageRenderCacheEntry, start-renderFrom) | |||
| 726 | + | v.messageCache = append(prefix, v.messageCache...) | |||
| 727 | + | start = renderFrom | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | max := messageCount - renderFrom | |||
| 731 | + | if len(v.messageCache) > max { | |||
| 732 | + | v.messageCache = v.messageCache[:max] | |||
| 733 | + | } | |||
| 734 | + | v.messageCacheStart = start | |||
| 735 | + | } | |||
| 736 | + | ||||
| 534 | // renderMessageCached returns the rendered line slice for m, using the | 737 | // renderMessageCached returns the rendered line slice for m, using the | ||
| 535 | // cache if the same (content hash, width, expandAll) combination has | 738 | // cache if the same (content hash, width, expandAll) combination has | ||
| 536 | // been rendered before. The slice returned is shared — callers must | 739 | // been rendered before. The slice returned is shared — callers must | ||
| 537 | // not mutate it; Build() only ever appends to its own `out` so the | 740 | // not mutate it; Build() only ever appends to its own `out` so the | ||
| 538 | // shared slice is safe. | 741 | // shared slice is safe. | ||
| 539 | - | func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) []string { | 742 | + | func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string { |
| 540 | - | key := msgCacheKey{ | 743 | + | visual := msgVisualKey{ |
| 541 | - | hash: hashMessage(m), | 744 | + | width: width, |
| 542 | - | width: width, | 745 | + | expandAll: v.ExpandAll, |
| 543 | - | expandAll: v.ExpandAll, | 746 | + | turnOpen: turnOpen, |
| 544 | - | turnOpen: turnOpen, | 747 | + | flatTools: v.FlatTools, |
| 748 | + | compactMode: v.CompactMode, | |||
| 749 | + | compactUser: v.CompactUser, | |||
| 750 | + | imageProto: v.ImageProto, | |||
| 751 | + | theme: themeKey, | |||
| 752 | + | } | |||
| 753 | + | if v.MessagesRevision != 0 { | |||
| 754 | + | cacheIdx := idx - v.messageCacheStart | |||
| 755 | + | if cacheIdx >= 0 && cacheIdx < len(v.messageCache) { | |||
| 756 | + | entry := v.messageCache[cacheIdx] | |||
| 757 | + | if entry.valid && entry.visual == visual { | |||
| 758 | + | return entry.lines | |||
| 759 | + | } | |||
| 760 | + | } | |||
| 761 | + | lines := v.renderMessage(m, width, turnOpen) | |||
| 762 | + | if cacheIdx < 0 { | |||
| 763 | + | return lines | |||
| 764 | + | } | |||
| 765 | + | if cacheIdx >= len(v.messageCache) { | |||
| 766 | + | v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, cacheIdx-len(v.messageCache)+1)...) | |||
| 767 | + | } | |||
| 768 | + | v.messageCache[cacheIdx] = messageRenderCacheEntry{visual: visual, lines: lines, valid: true} | |||
| 769 | + | return lines | |||
| 545 | } | 770 | } | ||
| 771 | + | key := msgCacheKey{hash: hashMessage(m), visual: visual} | |||
| 546 | if v.renderCache != nil { | 772 | if v.renderCache != nil { | ||
| 547 | if lines, ok := v.renderCache[key]; ok { | 773 | if lines, ok := v.renderCache[key]; ok { | ||
| 548 | return lines | 774 | return lines | ||
| @@ -575,6 +801,25 @@ func (v *View) renderMessageCached(m provider.Message, width int, turnOpen bool) | |||||
| 575 | return lines | 801 | return lines | ||
| 576 | } | 802 | } | ||
| 577 | 803 | ||||
| 804 | + | func appendMessageHash(h, messageHash uint64) uint64 { | |||
| 805 | + | h = fnv64aWriteByte(h, 0x01) | |||
| 806 | + | for shift := uint(0); shift < 64; shift += 8 { | |||
| 807 | + | h = fnv64aWriteByte(h, byte(messageHash>>shift)) | |||
| 808 | + | } | |||
| 809 | + | return h | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | func hashMessagePrefix(messages []provider.Message, end int) uint64 { | |||
| 813 | + | if end > len(messages) { | |||
| 814 | + | end = len(messages) | |||
| 815 | + | } | |||
| 816 | + | h := fnv64aInit | |||
| 817 | + | for i := 0; i < end; i++ { | |||
| 818 | + | h = appendMessageHash(h, hashMessage(messages[i])) | |||
| 819 | + | } | |||
| 820 | + | return h | |||
| 821 | + | } | |||
| 822 | + | ||||
| 578 | // hashMessage returns a 64-bit FNV-1a over the role + content blocks | 823 | // hashMessage returns a 64-bit FNV-1a over the role + content blocks | ||
| 579 | // of m. Serialising each block to its salient bytes is enough: two | 824 | // of m. Serialising each block to its salient bytes is enough: two | ||
| 580 | // messages with the same role and same content render identically. | 825 | // messages with the same role and same content render identically. | ||
| @@ -648,6 +893,13 @@ func fnv64aWriteByte(h uint64, b byte) uint64 { | |||||
| 648 | return h | 893 | return h | ||
| 649 | } | 894 | } | ||
| 650 | 895 | ||||
| 896 | + | func fnv64aWriteUint64(h, value uint64) uint64 { | |||
| 897 | + | for shift := uint(0); shift < 64; shift += 8 { | |||
| 898 | + | h = fnv64aWriteByte(h, byte(value>>shift)) | |||
| 899 | + | } | |||
| 900 | + | return h | |||
| 901 | + | } | |||
| 902 | + | ||||
| 651 | func fnv64aWrite(h uint64, p []byte) uint64 { | 903 | func fnv64aWrite(h uint64, p []byte) uint64 { | ||
| 652 | for _, b := range p { | 904 | for _, b := range p { | ||
| 653 | h ^= uint64(b) | 905 | h ^= uint64(b) | ||
| @@ -842,7 +1094,129 @@ func (v *View) RenderToolCall(tc ToolCallView, width int) []string { | |||||
| 842 | if v.liveBodyHigh == nil { | 1094 | if v.liveBodyHigh == nil { | ||
| 843 | v.liveBodyHigh = make(map[string]int) | 1095 | v.liveBodyHigh = make(map[string]int) | ||
| 844 | } | 1096 | } | ||
| 845 | - | return v.renderToolCall(tc, width) | 1097 | + | return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme)) |
| 1098 | + | } | |||
| 1099 | + | ||||
| 1100 | + | func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string { | |||
| 1101 | + | if v.liveRenderCache == nil { | |||
| 1102 | + | v.liveRenderCache = make(map[liveToolCacheKey][]string) | |||
| 1103 | + | } | |||
| 1104 | + | key := liveToolCacheKey{ | |||
| 1105 | + | revision: tc.Revision, | |||
| 1106 | + | content: hashLiveTool(tc), | |||
| 1107 | + | width: width, | |||
| 1108 | + | expandAll: v.ExpandAll, | |||
| 1109 | + | flatTools: v.FlatTools, | |||
| 1110 | + | compact: v.CompactMode, | |||
| 1111 | + | imageProto: v.ImageProto, | |||
| 1112 | + | theme: themeKey, | |||
| 1113 | + | } | |||
| 1114 | + | if lines, ok := v.liveRenderCache[key]; ok { | |||
| 1115 | + | return lines | |||
| 1116 | + | } | |||
| 1117 | + | lines := v.renderToolCall(tc, width) | |||
| 1118 | + | if len(v.liveRenderCache) > 512 { | |||
| 1119 | + | for k := range v.liveRenderCache { | |||
| 1120 | + | delete(v.liveRenderCache, k) | |||
| 1121 | + | break | |||
| 1122 | + | } | |||
| 1123 | + | } | |||
| 1124 | + | v.liveRenderCache[key] = lines | |||
| 1125 | + | return lines | |||
| 1126 | + | } | |||
| 1127 | + | ||||
| 1128 | + | func hashLiveTool(tc ToolCallView) uint64 { | |||
| 1129 | + | if tc.Revision != 0 { | |||
| 1130 | + | return 0 | |||
| 1131 | + | } | |||
| 1132 | + | h := fnv64aInit | |||
| 1133 | + | for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} { | |||
| 1134 | + | h = fnv64aWrite(h, []byte(s)) | |||
| 1135 | + | h = fnv64aWriteByte(h, 0) | |||
| 1136 | + | } | |||
| 1137 | + | if tc.Error { | |||
| 1138 | + | h = fnv64aWriteByte(h, 'e') | |||
| 1139 | + | } | |||
| 1140 | + | if tc.Done { | |||
| 1141 | + | h = fnv64aWriteByte(h, 'd') | |||
| 1142 | + | } | |||
| 1143 | + | if tc.Streaming { | |||
| 1144 | + | h = fnv64aWriteByte(h, 's') | |||
| 1145 | + | } | |||
| 1146 | + | return h | |||
| 1147 | + | } | |||
| 1148 | + | ||||
| 1149 | + | func toolThemeKey(theme Theme) uint64 { | |||
| 1150 | + | h := fnv64aInit | |||
| 1151 | + | for _, s := range []string{ | |||
| 1152 | + | theme.SyntaxBaseStyle, | |||
| 1153 | + | theme.Syntax.Keyword, | |||
| 1154 | + | theme.Syntax.KeywordConstant, | |||
| 1155 | + | theme.Syntax.KeywordDeclaration, | |||
| 1156 | + | theme.Syntax.KeywordNamespace, | |||
| 1157 | + | theme.Syntax.KeywordReserved, | |||
| 1158 | + | theme.Syntax.KeywordType, | |||
| 1159 | + | theme.Syntax.NameBuiltin, | |||
| 1160 | + | theme.Syntax.NameFunction, | |||
| 1161 | + | theme.Syntax.NameClass, | |||
| 1162 | + | theme.Syntax.NameDecorator, | |||
| 1163 | + | theme.Syntax.LiteralString, | |||
| 1164 | + | theme.Syntax.LiteralStringEscape, | |||
| 1165 | + | theme.Syntax.LiteralNumber, | |||
| 1166 | + | theme.Syntax.Comment, | |||
| 1167 | + | theme.Syntax.CommentPreproc, | |||
| 1168 | + | theme.Syntax.Operator, | |||
| 1169 | + | theme.Syntax.Punctuation, | |||
| 1170 | + | theme.Syntax.Text, | |||
| 1171 | + | } { | |||
| 1172 | + | h = fnv64aWrite(h, []byte(s)) | |||
| 1173 | + | h = fnv64aWriteByte(h, 0) | |||
| 1174 | + | } | |||
| 1175 | + | for _, c := range []TerminalColor{ | |||
| 1176 | + | theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG, | |||
| 1177 | + | theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut, | |||
| 1178 | + | theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax, | |||
| 1179 | + | theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground, | |||
| 1180 | + | theme.Terminal.Background, | |||
| 1181 | + | } { | |||
| 1182 | + | h = hashThemeColor(h, c) | |||
| 1183 | + | } | |||
| 1184 | + | if theme.Background == nil { | |||
| 1185 | + | h = fnv64aWriteByte(h, 0) | |||
| 1186 | + | } else { | |||
| 1187 | + | h = fnv64aWriteByte(h, 1) | |||
| 1188 | + | h = hashThemeColor(h, *theme.Background) | |||
| 1189 | + | } | |||
| 1190 | + | h = fnv64aWriteByte(h, 0xff) | |||
| 1191 | + | h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette))) | |||
| 1192 | + | h = fnv64aWriteByte(h, 0xff) | |||
| 1193 | + | for _, c := range theme.Terminal.Palette { | |||
| 1194 | + | h = hashThemeColor(h, c) | |||
| 1195 | + | } | |||
| 1196 | + | for _, value := range []bool{ | |||
| 1197 | + | theme.Inherited, | |||
| 1198 | + | theme.Terminal.HasForeground, | |||
| 1199 | + | theme.Terminal.HasBackground, | |||
| 1200 | + | theme.Terminal.TrueColor, | |||
| 1201 | + | theme.Terminal.Light, | |||
| 1202 | + | theme.Terminal.SchemeKnown, | |||
| 1203 | + | } { | |||
| 1204 | + | if value { | |||
| 1205 | + | h = fnv64aWriteByte(h, 1) | |||
| 1206 | + | } else { | |||
| 1207 | + | h = fnv64aWriteByte(h, 0) | |||
| 1208 | + | } | |||
| 1209 | + | } | |||
| 1210 | + | return h | |||
| 1211 | + | } | |||
| 1212 | + | ||||
| 1213 | + | func hashThemeColor(h uint64, c TerminalColor) uint64 { | |||
| 1214 | + | h = fnv64aWriteByte(h, byte(c.Mode)) | |||
| 1215 | + | h = fnv64aWriteByte(h, byte(c.Index)) | |||
| 1216 | + | h = fnv64aWriteByte(h, byte(c.R)) | |||
| 1217 | + | h = fnv64aWriteByte(h, byte(c.G)) | |||
| 1218 | + | h = fnv64aWriteByte(h, byte(c.B)) | |||
| 1219 | + | return h | |||
| 846 | } | 1220 | } | ||
| 847 | 1221 | ||||
| 848 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | 1222 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | ||
| @@ -979,7 +1353,11 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 979 | // confirmation preview immediately gets the same colors, numbered gutters, | 1353 | // confirmation preview immediately gets the same colors, numbered gutters, | ||
| 980 | // and syntax highlighting as its final tool result. | 1354 | // and syntax highlighting as its final tool result. | ||
| 981 | func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string { | 1355 | func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string { | ||
| 982 | - | return v.renderToolText(text, width, color, sourcePath, 1) | 1356 | + | full := v.renderToolText(text, width, color, sourcePath, 1) |
| 1357 | + | if v.ExpandAll || len(full) <= ToolCollapseLines { | |||
| 1358 | + | return full | |||
| 1359 | + | } | |||
| 1360 | + | return v.collapseToolBodyWithTotal(full, false, len(full)) | |||
| 983 | } | 1361 | } | ||
| 984 | 1362 | ||||
| 985 | // renderLiveToolBody renders the in-flight preview of a streaming | 1363 | // renderLiveToolBody renders the in-flight preview of a streaming | ||
| @@ -1472,6 +1850,13 @@ func trimLeadingSpaces(s string, n int) string { | |||||
| 1472 | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { | 1850 | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { | ||
| 1473 | var body []string | 1851 | var body []string | ||
| 1474 | hasImage := false | 1852 | hasImage := false | ||
| 1853 | + | for _, b := range blocks { | |||
| 1854 | + | if _, ok := b.(provider.ImageBlock); ok { | |||
| 1855 | + | hasImage = true | |||
| 1856 | + | break | |||
| 1857 | + | } | |||
| 1858 | + | } | |||
| 1859 | + | total := 0 | |||
| 1475 | for _, b := range blocks { | 1860 | for _, b := range blocks { | ||
| 1476 | switch bb := b.(type) { | 1861 | switch bb := b.(type) { | ||
| 1477 | case provider.TextBlock: | 1862 | case provider.TextBlock: | ||
| @@ -1479,13 +1864,22 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||||
| 1479 | if v.FlatTools || v.CompactMode { | 1864 | if v.FlatTools || v.CompactMode { | ||
| 1480 | bodyWidth = flatToolBodyRenderWidth(width) | 1865 | bodyWidth = flatToolBodyRenderWidth(width) | ||
| 1481 | } | 1866 | } | ||
| 1482 | - | body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...) | 1867 | + | estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth) |
| 1868 | + | estimated := estimateToolTextLines(bb.Text, estimateWidth) | |||
| 1869 | + | total += estimated | |||
| 1870 | + | text := bb.Text | |||
| 1871 | + | // A collapsed result only exposes its first ten rendered rows. | |||
| 1872 | + | // Render a bounded source preview in that case; syntax | |||
| 1873 | + | // highlighting and wrapping the omitted tail is wasted work. | |||
| 1874 | + | if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines { | |||
| 1875 | + | text = previewToolText(text, estimateWidth) | |||
| 1876 | + | } | |||
| 1877 | + | body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...) | |||
| 1483 | case provider.ImageBlock: | 1878 | case provider.ImageBlock: | ||
| 1484 | - | hasImage = true | |||
| 1485 | body = append(body, v.renderImageBlock(bb, width)...) | 1879 | body = append(body, v.renderImageBlock(bb, width)...) | ||
| 1486 | } | 1880 | } | ||
| 1487 | } | 1881 | } | ||
| 1488 | - | return v.collapseToolBody(body, hasImage) | 1882 | + | return v.collapseToolBodyWithTotal(body, hasImage, total) |
| 1489 | } | 1883 | } | ||
| 1490 | 1884 | ||||
| 1491 | // collapseToolBody trims lines to the configured preview size when the | 1885 | // collapseToolBody trims lines to the configured preview size when the | ||
| @@ -1493,15 +1887,24 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width int, col | |||||
| 1493 | // expand" footer. Image blocks never collapse — they're short in text | 1887 | // expand" footer. Image blocks never collapse — they're short in text | ||
| 1494 | // rows but represent real content the user wants to see. | 1888 | // rows but represent real content the user wants to see. | ||
| 1495 | func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | 1889 | func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | ||
| 1890 | + | return v.collapseToolBodyWithTotal(lines, hasImage, len(lines)) | |||
| 1891 | + | } | |||
| 1892 | + | ||||
| 1893 | + | func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string { | |||
| 1496 | if v.ExpandAll || hasImage { | 1894 | if v.ExpandAll || hasImage { | ||
| 1497 | return lines | 1895 | return lines | ||
| 1498 | } | 1896 | } | ||
| 1499 | - | if len(lines) <= ToolCollapseLines { | 1897 | + | if total < len(lines) { |
| 1898 | + | total = len(lines) | |||
| 1899 | + | } | |||
| 1900 | + | if total <= ToolCollapseLines { | |||
| 1500 | return lines | 1901 | return lines | ||
| 1501 | } | 1902 | } | ||
| 1502 | - | kept := lines[:ToolCollapsePreview] | 1903 | + | kept := lines |
| 1503 | - | hidden := len(lines) - ToolCollapsePreview | 1904 | + | if len(kept) > ToolCollapsePreview { |
| 1504 | - | total := len(lines) | 1905 | + | kept = kept[:ToolCollapsePreview] |
| 1906 | + | } | |||
| 1907 | + | hidden := total - ToolCollapsePreview | |||
| 1505 | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | 1908 | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | ||
| 1506 | footer = v.Theme.FGColor(v.Theme.Muted, footer) | 1909 | footer = v.Theme.FGColor(v.Theme.Muted, footer) | ||
| 1507 | out := append([]string(nil), kept...) | 1910 | out := append([]string(nil), kept...) | ||
| @@ -1509,6 +1912,67 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||||
| 1509 | return append(out, footer) | 1912 | return append(out, footer) | ||
| 1510 | } | 1913 | } | ||
| 1511 | 1914 | ||||
| 1915 | + | func effectiveToolTextWidth(text string, width int) int { | |||
| 1916 | + | // renderToolText prefixes ordinary rows with a four-cell gutter before | |||
| 1917 | + | // wrapping them. Diff rows also carry a five-cell read gutter. Estimating | |||
| 1918 | + | // against the outer body width undercounts long rows and produces an | |||
| 1919 | + | // incorrect collapse total. | |||
| 1920 | + | width -= 4 | |||
| 1921 | + | if looksLikeUnifiedDiff(text) { | |||
| 1922 | + | width -= 5 | |||
| 1923 | + | } | |||
| 1924 | + | if width < 1 { | |||
| 1925 | + | return 1 | |||
| 1926 | + | } | |||
| 1927 | + | return width | |||
| 1928 | + | } | |||
| 1929 | + | ||||
| 1930 | + | func estimateToolTextLines(text string, width int) int { | |||
| 1931 | + | if width < 1 { | |||
| 1932 | + | width = 1 | |||
| 1933 | + | } | |||
| 1934 | + | total := 0 | |||
| 1935 | + | for _, line := range strings.Split(text, "\n") { | |||
| 1936 | + | cells := runewidth.StringWidth(line) | |||
| 1937 | + | if cells == 0 { | |||
| 1938 | + | total++ | |||
| 1939 | + | continue | |||
| 1940 | + | } | |||
| 1941 | + | total += (cells + width - 1) / width | |||
| 1942 | + | } | |||
| 1943 | + | if total == 0 { | |||
| 1944 | + | return 1 | |||
| 1945 | + | } | |||
| 1946 | + | return total | |||
| 1947 | + | } | |||
| 1948 | + | ||||
| 1949 | + | func previewToolText(text string, width int) string { | |||
| 1950 | + | if width < 1 { | |||
| 1951 | + | width = 1 | |||
| 1952 | + | } | |||
| 1953 | + | maxChars := width * (ToolCollapsePreview + 2) | |||
| 1954 | + | if maxChars < 256 { | |||
| 1955 | + | maxChars = 256 | |||
| 1956 | + | } | |||
| 1957 | + | if maxChars > 8192 { | |||
| 1958 | + | maxChars = 8192 | |||
| 1959 | + | } | |||
| 1960 | + | end := 0 | |||
| 1961 | + | for count := 0; end < len(text) && count < maxChars; count++ { | |||
| 1962 | + | _, size := utf8.DecodeRuneInString(text[end:]) | |||
| 1963 | + | if size == 0 { | |||
| 1964 | + | break | |||
| 1965 | + | } | |||
| 1966 | + | end += size | |||
| 1967 | + | } | |||
| 1968 | + | preview := text[:end] | |||
| 1969 | + | lines := strings.SplitAfter(preview, "\n") | |||
| 1970 | + | if len(lines) > ToolCollapsePreview+2 { | |||
| 1971 | + | preview = strings.Join(lines[:ToolCollapsePreview+2], "") | |||
| 1972 | + | } | |||
| 1973 | + | return preview | |||
| 1974 | + | } | |||
| 1975 | + | ||||
| 1512 | // renderToolText renders a text block inside a tool result. If the | 1976 | // renderToolText renders a text block inside a tool result. If the | ||
| 1513 | // text contains a unified-diff section (lines starting with "--- " / | 1977 | // text contains a unified-diff section (lines starting with "--- " / | ||
| 1514 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | 1978 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | ||
A packages/tui/view_bench_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) { | |
| 13 | + | for _, width := range []int{48, 80, 160} { | |
| 14 | + | for _, expanded := range []bool{false, true} { | |
| 15 | + | for _, temperature := range []string{"cold", "warm"} { | |
| 16 | + | name := fmt.Sprintf("width=%d/expanded=%t/%s", width, expanded, temperature) | |
| 17 | + | b.Run(name, func(b *testing.B) { | |
| 18 | + | base := benchmarkToolStormView(expanded) | |
| 19 | + | if temperature == "warm" { | |
| 20 | + | base.BuildWithAnchors(width) | |
| 21 | + | } | |
| 22 | + | b.ReportAllocs() | |
| 23 | + | b.ResetTimer() | |
| 24 | + | for n := 0; n < b.N; n++ { | |
| 25 | + | view := base | |
| 26 | + | if temperature == "cold" { | |
| 27 | + | view.InvalidateRenderCache() | |
| 28 | + | } | |
| 29 | + | lines, anchors := view.BuildWithAnchors(width) | |
| 30 | + | if len(lines) == 0 || len(anchors) == 0 { | |
| 31 | + | b.Fatal("tool storm produced no rows or anchors") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) { | |
| 41 | + | for _, width := range []int{48, 80, 160} { | |
| 42 | + | for _, expanded := range []bool{false, true} { | |
| 43 | + | name := "width=" + strconv.Itoa(width) + "/expanded=" + strconv.FormatBool(expanded) | |
| 44 | + | b.Run(name, func(b *testing.B) { | |
| 45 | + | view := &View{ | |
| 46 | + | Theme: Dark, | |
| 47 | + | ExpandAll: expanded, | |
| 48 | + | Messages: benchmarkToolStormMessages(24, 8), | |
| 49 | + | MessagesRevision: 1, | |
| 50 | + | ToolCalls: benchmarkLiveTools(8, 96), | |
| 51 | + | } | |
| 52 | + | liveResults := make([]string, len(view.ToolCalls)) | |
| 53 | + | for i := range liveResults { | |
| 54 | + | liveResults[i] = benchmarkResult(96, i) | |
| 55 | + | } | |
| 56 | + | b.ReportAllocs() | |
| 57 | + | b.ResetTimer() | |
| 58 | + | for n := 0; n < b.N; n++ { | |
| 59 | + | call := &view.ToolCalls[n%len(view.ToolCalls)] | |
| 60 | + | call.Revision++ | |
| 61 | + | call.Result = liveResults[n%len(liveResults)] | |
| 62 | + | lines, anchors := view.BuildWithAnchors(width) | |
| 63 | + | if len(lines) == 0 || len(anchors) == 0 { | |
| 64 | + | b.Fatal("live tool storm produced no rows or anchors") | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func benchmarkToolStormView(expanded bool) *View { | |
| 73 | + | return &View{ | |
| 74 | + | Theme: Dark, | |
| 75 | + | ExpandAll: expanded, | |
| 76 | + | Messages: benchmarkToolStormMessages(48, 96), | |
| 77 | + | MessagesRevision: 1, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func benchmarkToolStormMessages(tools, resultLines int) []provider.Message { | |
| 82 | + | messages := make([]provider.Message, 0, tools*3) | |
| 83 | + | for i := 0; i < tools; i++ { | |
| 84 | + | id := fmt.Sprintf("call-%d", i) | |
| 85 | + | messages = append(messages, | |
| 86 | + | provider.Message{ | |
| 87 | + | Role: provider.RoleUser, | |
| 88 | + | Content: []provider.Content{provider.TextBlock{ | |
| 89 | + | Text: fmt.Sprintf("inspect result %d", i), | |
| 90 | + | }}, | |
| 91 | + | }, | |
| 92 | + | provider.Message{ | |
| 93 | + | Role: provider.RoleAssistant, | |
| 94 | + | Content: []provider.Content{provider.ToolCallBlock{ | |
| 95 | + | ID: id, | |
| 96 | + | Name: "bash", | |
| 97 | + | Arguments: []byte(`{"command":"printf storm"}`), | |
| 98 | + | }}, | |
| 99 | + | }, | |
| 100 | + | provider.Message{ | |
| 101 | + | Role: provider.RoleTool, | |
| 102 | + | Content: []provider.Content{provider.ToolResultBlock{ | |
| 103 | + | CallID: id, | |
| 104 | + | Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}}, | |
| 105 | + | }}, | |
| 106 | + | }, | |
| 107 | + | ) | |
| 108 | + | } | |
| 109 | + | return messages | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func benchmarkLiveTools(tools, resultLines int) []ToolCallView { | |
| 113 | + | out := make([]ToolCallView, 0, tools) | |
| 114 | + | for i := 0; i < tools; i++ { | |
| 115 | + | out = append(out, ToolCallView{ | |
| 116 | + | ID: fmt.Sprintf("live-%d", i), | |
| 117 | + | Name: "bash", | |
| 118 | + | Revision: 1, | |
| 119 | + | Args: "printf storm", | |
| 120 | + | Result: benchmarkResult(resultLines, i), | |
| 121 | + | Done: true, | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | return out | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func benchmarkResult(lines, tool int) string { | |
| 128 | + | var b strings.Builder | |
| 129 | + | for line := 0; line < lines; line++ { | |
| 130 | + | fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line) | |
| 131 | + | } | |
| 132 | + | return b.String() | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func BenchmarkViewBuildWithAnchorsToolStorm(b *testing.B) { | |||
| 13 | + | for _, width := range []int{48, 80, 160} { | |||
| 14 | + | for _, expanded := range []bool{false, true} { | |||
| 15 | + | for _, temperature := range []string{"cold", "warm"} { | |||
| 16 | + | name := fmt.Sprintf("width=%d/expanded=%t/%s", width, expanded, temperature) | |||
| 17 | + | b.Run(name, func(b *testing.B) { | |||
| 18 | + | base := benchmarkToolStormView(expanded) | |||
| 19 | + | if temperature == "warm" { | |||
| 20 | + | base.BuildWithAnchors(width) | |||
| 21 | + | } | |||
| 22 | + | b.ReportAllocs() | |||
| 23 | + | b.ResetTimer() | |||
| 24 | + | for n := 0; n < b.N; n++ { | |||
| 25 | + | view := base | |||
| 26 | + | if temperature == "cold" { | |||
| 27 | + | view.InvalidateRenderCache() | |||
| 28 | + | } | |||
| 29 | + | lines, anchors := view.BuildWithAnchors(width) | |||
| 30 | + | if len(lines) == 0 || len(anchors) == 0 { | |||
| 31 | + | b.Fatal("tool storm produced no rows or anchors") | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func BenchmarkViewBuildWithAnchorsLiveToolStorm(b *testing.B) { | |||
| 41 | + | for _, width := range []int{48, 80, 160} { | |||
| 42 | + | for _, expanded := range []bool{false, true} { | |||
| 43 | + | name := "width=" + strconv.Itoa(width) + "/expanded=" + strconv.FormatBool(expanded) | |||
| 44 | + | b.Run(name, func(b *testing.B) { | |||
| 45 | + | view := &View{ | |||
| 46 | + | Theme: Dark, | |||
| 47 | + | ExpandAll: expanded, | |||
| 48 | + | Messages: benchmarkToolStormMessages(24, 8), | |||
| 49 | + | MessagesRevision: 1, | |||
| 50 | + | ToolCalls: benchmarkLiveTools(8, 96), | |||
| 51 | + | } | |||
| 52 | + | liveResults := make([]string, len(view.ToolCalls)) | |||
| 53 | + | for i := range liveResults { | |||
| 54 | + | liveResults[i] = benchmarkResult(96, i) | |||
| 55 | + | } | |||
| 56 | + | b.ReportAllocs() | |||
| 57 | + | b.ResetTimer() | |||
| 58 | + | for n := 0; n < b.N; n++ { | |||
| 59 | + | call := &view.ToolCalls[n%len(view.ToolCalls)] | |||
| 60 | + | call.Revision++ | |||
| 61 | + | call.Result = liveResults[n%len(liveResults)] | |||
| 62 | + | lines, anchors := view.BuildWithAnchors(width) | |||
| 63 | + | if len(lines) == 0 || len(anchors) == 0 { | |||
| 64 | + | b.Fatal("live tool storm produced no rows or anchors") | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func benchmarkToolStormView(expanded bool) *View { | |||
| 73 | + | return &View{ | |||
| 74 | + | Theme: Dark, | |||
| 75 | + | ExpandAll: expanded, | |||
| 76 | + | Messages: benchmarkToolStormMessages(48, 96), | |||
| 77 | + | MessagesRevision: 1, | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func benchmarkToolStormMessages(tools, resultLines int) []provider.Message { | |||
| 82 | + | messages := make([]provider.Message, 0, tools*3) | |||
| 83 | + | for i := 0; i < tools; i++ { | |||
| 84 | + | id := fmt.Sprintf("call-%d", i) | |||
| 85 | + | messages = append(messages, | |||
| 86 | + | provider.Message{ | |||
| 87 | + | Role: provider.RoleUser, | |||
| 88 | + | Content: []provider.Content{provider.TextBlock{ | |||
| 89 | + | Text: fmt.Sprintf("inspect result %d", i), | |||
| 90 | + | }}, | |||
| 91 | + | }, | |||
| 92 | + | provider.Message{ | |||
| 93 | + | Role: provider.RoleAssistant, | |||
| 94 | + | Content: []provider.Content{provider.ToolCallBlock{ | |||
| 95 | + | ID: id, | |||
| 96 | + | Name: "bash", | |||
| 97 | + | Arguments: []byte(`{"command":"printf storm"}`), | |||
| 98 | + | }}, | |||
| 99 | + | }, | |||
| 100 | + | provider.Message{ | |||
| 101 | + | Role: provider.RoleTool, | |||
| 102 | + | Content: []provider.Content{provider.ToolResultBlock{ | |||
| 103 | + | CallID: id, | |||
| 104 | + | Content: []provider.Content{provider.TextBlock{Text: benchmarkResult(resultLines, i)}}, | |||
| 105 | + | }}, | |||
| 106 | + | }, | |||
| 107 | + | ) | |||
| 108 | + | } | |||
| 109 | + | return messages | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func benchmarkLiveTools(tools, resultLines int) []ToolCallView { | |||
| 113 | + | out := make([]ToolCallView, 0, tools) | |||
| 114 | + | for i := 0; i < tools; i++ { | |||
| 115 | + | out = append(out, ToolCallView{ | |||
| 116 | + | ID: fmt.Sprintf("live-%d", i), | |||
| 117 | + | Name: "bash", | |||
| 118 | + | Revision: 1, | |||
| 119 | + | Args: "printf storm", | |||
| 120 | + | Result: benchmarkResult(resultLines, i), | |||
| 121 | + | Done: true, | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 124 | + | return out | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func benchmarkResult(lines, tool int) string { | |||
| 128 | + | var b strings.Builder | |||
| 129 | + | for line := 0; line < lines; line++ { | |||
| 130 | + | fmt.Fprintf(&b, "tool=%d line=%03d output payload for highlighting and wrapping\n", tool, line) | |||
| 131 | + | } | |||
| 132 | + | return b.String() | |||
| 133 | + | } | |||
A packages/tui/view_cache_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) { | |
| 12 | + | messages := []provider.Message{ | |
| 13 | + | { | |
| 14 | + | Role: provider.RoleAssistant, | |
| 15 | + | Content: []provider.Content{provider.ToolCallBlock{ | |
| 16 | + | ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`), | |
| 17 | + | }}, | |
| 18 | + | }, | |
| 19 | + | { | |
| 20 | + | Role: provider.RoleTool, | |
| 21 | + | Content: []provider.Content{provider.ToolResultBlock{ | |
| 22 | + | CallID: "call-1", | |
| 23 | + | Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}}, | |
| 24 | + | }}, | |
| 25 | + | }, | |
| 26 | + | } | |
| 27 | + | view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1} | |
| 28 | + | collapsed, _ := view.BuildWithAnchors(40) | |
| 29 | + | if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") { | |
| 30 | + | t.Fatal("long tool result did not use the collapsed presentation") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | view.ExpandAll = true | |
| 34 | + | expanded, _ := view.BuildWithAnchors(40) | |
| 35 | + | if len(expanded) <= len(collapsed) { | |
| 36 | + | t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded)) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | wide, _ := view.BuildWithAnchors(120) | |
| 40 | + | // The wide render may have the same row count for this fixture, but it | |
| 41 | + | // must still be rebuilt for the new wrapping width. | |
| 42 | + | if strings.Join(wide, "\n") == strings.Join(expanded, "\n") { | |
| 43 | + | t.Fatal("resize reused rows from the old width") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | view.ExpandAll = false | |
| 47 | + | view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{ | |
| 48 | + | CallID: "call-1", | |
| 49 | + | Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}}, | |
| 50 | + | }} | |
| 51 | + | view.Messages = append(view.Messages, provider.Message{ | |
| 52 | + | Role: provider.RoleUser, | |
| 53 | + | Content: []provider.Content{provider.TextBlock{Text: "third message"}}, | |
| 54 | + | }) | |
| 55 | + | view.MessagesRevision = 2 | |
| 56 | + | updated, _ := view.BuildWithAnchors(40) | |
| 57 | + | if !strings.Contains(strings.Join(updated, "\n"), "new marker") { | |
| 58 | + | t.Fatal("tool output revision reused stale cached rows") | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestViewRevisionCacheIsBoundToTailLimit(t *testing.T) { | |
| 63 | + | messages := make([]provider.Message, 20) | |
| 64 | + | for i := range messages { | |
| 65 | + | messages[i] = provider.Message{ | |
| 66 | + | Role: provider.RoleUser, | |
| 67 | + | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message %d", i)}}, | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1, TailLimit: 5} | |
| 71 | + | view.Build(80) | |
| 72 | + | if view.messageCacheStart != len(messages)-view.TailLimit { | |
| 73 | + | t.Fatalf("message cache start=%d, want %d", view.messageCacheStart, len(messages)-view.TailLimit) | |
| 74 | + | } | |
| 75 | + | if len(view.messageCache) > view.TailLimit { | |
| 76 | + | t.Fatalf("message cache length=%d exceeds tail limit %d", len(view.messageCache), view.TailLimit) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | view.Build(80) | |
| 80 | + | if len(view.messageCache) > view.TailLimit { | |
| 81 | + | t.Fatalf("message cache grew on a cache hit: length=%d", len(view.messageCache)) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRenderLiveToolResultCollapseUsesRenderedLineCount(t *testing.T) { | |
| 86 | + | var text strings.Builder | |
| 87 | + | for i := 0; i < 13; i++ { | |
| 88 | + | text.WriteString(strings.Repeat("x", 17)) | |
| 89 | + | text.WriteByte('\n') | |
| 90 | + | } | |
| 91 | + | view := &View{Theme: Dark} | |
| 92 | + | full := view.renderToolText(text.String(), 20, Dark.ToolOut, "", 1) | |
| 93 | + | collapsed := view.renderLiveToolResult(text.String(), 20, Dark.ToolOut, "") | |
| 94 | + | joined := strings.Join(collapsed, "\n") | |
| 95 | + | wantTotal := fmt.Sprintf("%d total", len(full)) | |
| 96 | + | if !strings.Contains(joined, wantTotal) { | |
| 97 | + | t.Fatalf("collapse footer omitted rendered total %q: %s", wantTotal, joined) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func cacheTestResult(prefix string) string { | |
| 102 | + | var b strings.Builder | |
| 103 | + | for n := 0; n < 32; n++ { | |
| 104 | + | b.WriteString(prefix) | |
| 105 | + | b.WriteString(" line ") | |
| 106 | + | b.WriteString(string(rune('a' + n%26))) | |
| 107 | + | b.WriteByte('\n') | |
| 108 | + | } | |
| 109 | + | return b.String() | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestViewCacheInvalidatesToolOutputExpansionAndWidth(t *testing.T) { | |||
| 12 | + | messages := []provider.Message{ | |||
| 13 | + | { | |||
| 14 | + | Role: provider.RoleAssistant, | |||
| 15 | + | Content: []provider.Content{provider.ToolCallBlock{ | |||
| 16 | + | ID: "call-1", Name: "bash", Arguments: []byte(`{"command":"cat file"}`), | |||
| 17 | + | }}, | |||
| 18 | + | }, | |||
| 19 | + | { | |||
| 20 | + | Role: provider.RoleTool, | |||
| 21 | + | Content: []provider.Content{provider.ToolResultBlock{ | |||
| 22 | + | CallID: "call-1", | |||
| 23 | + | Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("old")}}, | |||
| 24 | + | }}, | |||
| 25 | + | }, | |||
| 26 | + | } | |||
| 27 | + | view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1} | |||
| 28 | + | collapsed, _ := view.BuildWithAnchors(40) | |||
| 29 | + | if len(collapsed) == 0 || !strings.Contains(strings.Join(collapsed, "\n"), "more lines") { | |||
| 30 | + | t.Fatal("long tool result did not use the collapsed presentation") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | view.ExpandAll = true | |||
| 34 | + | expanded, _ := view.BuildWithAnchors(40) | |||
| 35 | + | if len(expanded) <= len(collapsed) { | |||
| 36 | + | t.Fatalf("expansion did not invalidate rows: collapsed=%d expanded=%d", len(collapsed), len(expanded)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | wide, _ := view.BuildWithAnchors(120) | |||
| 40 | + | // The wide render may have the same row count for this fixture, but it | |||
| 41 | + | // must still be rebuilt for the new wrapping width. | |||
| 42 | + | if strings.Join(wide, "\n") == strings.Join(expanded, "\n") { | |||
| 43 | + | t.Fatal("resize reused rows from the old width") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | view.ExpandAll = false | |||
| 47 | + | view.Messages[1].Content = []provider.Content{provider.ToolResultBlock{ | |||
| 48 | + | CallID: "call-1", | |||
| 49 | + | Content: []provider.Content{provider.TextBlock{Text: cacheTestResult("new marker")}}, | |||
| 50 | + | }} | |||
| 51 | + | view.Messages = append(view.Messages, provider.Message{ | |||
| 52 | + | Role: provider.RoleUser, | |||
| 53 | + | Content: []provider.Content{provider.TextBlock{Text: "third message"}}, | |||
| 54 | + | }) | |||
| 55 | + | view.MessagesRevision = 2 | |||
| 56 | + | updated, _ := view.BuildWithAnchors(40) | |||
| 57 | + | if !strings.Contains(strings.Join(updated, "\n"), "new marker") { | |||
| 58 | + | t.Fatal("tool output revision reused stale cached rows") | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestViewRevisionCacheIsBoundToTailLimit(t *testing.T) { | |||
| 63 | + | messages := make([]provider.Message, 20) | |||
| 64 | + | for i := range messages { | |||
| 65 | + | messages[i] = provider.Message{ | |||
| 66 | + | Role: provider.RoleUser, | |||
| 67 | + | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("message %d", i)}}, | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | view := &View{Theme: Dark, Messages: messages, MessagesRevision: 1, TailLimit: 5} | |||
| 71 | + | view.Build(80) | |||
| 72 | + | if view.messageCacheStart != len(messages)-view.TailLimit { | |||
| 73 | + | t.Fatalf("message cache start=%d, want %d", view.messageCacheStart, len(messages)-view.TailLimit) | |||
| 74 | + | } | |||
| 75 | + | if len(view.messageCache) > view.TailLimit { | |||
| 76 | + | t.Fatalf("message cache length=%d exceeds tail limit %d", len(view.messageCache), view.TailLimit) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | view.Build(80) | |||
| 80 | + | if len(view.messageCache) > view.TailLimit { | |||
| 81 | + | t.Fatalf("message cache grew on a cache hit: length=%d", len(view.messageCache)) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestRenderLiveToolResultCollapseUsesRenderedLineCount(t *testing.T) { | |||
| 86 | + | var text strings.Builder | |||
| 87 | + | for i := 0; i < 13; i++ { | |||
| 88 | + | text.WriteString(strings.Repeat("x", 17)) | |||
| 89 | + | text.WriteByte('\n') | |||
| 90 | + | } | |||
| 91 | + | view := &View{Theme: Dark} | |||
| 92 | + | full := view.renderToolText(text.String(), 20, Dark.ToolOut, "", 1) | |||
| 93 | + | collapsed := view.renderLiveToolResult(text.String(), 20, Dark.ToolOut, "") | |||
| 94 | + | joined := strings.Join(collapsed, "\n") | |||
| 95 | + | wantTotal := fmt.Sprintf("%d total", len(full)) | |||
| 96 | + | if !strings.Contains(joined, wantTotal) { | |||
| 97 | + | t.Fatalf("collapse footer omitted rendered total %q: %s", wantTotal, joined) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func cacheTestResult(prefix string) string { | |||
| 102 | + | var b strings.Builder | |||
| 103 | + | for n := 0; n < 32; n++ { | |||
| 104 | + | b.WriteString(prefix) | |||
| 105 | + | b.WriteString(" line ") | |||
| 106 | + | b.WriteString(string(rune('a' + n%26))) | |||
| 107 | + | b.WriteByte('\n') | |||
| 108 | + | } | |||
| 109 | + | return b.String() | |||
| 110 | + | } | |||