diff --git a/internal/providercatalog/catalog.go b/internal/providercatalog/catalog.go index 97fec2185..922be1a08 100644 --- a/internal/providercatalog/catalog.go +++ b/internal/providercatalog/catalog.go @@ -215,6 +215,18 @@ func All() []Descriptor { return copied } +// Find returns the first catalog entry matching pred. The predicate reads the +// internal (uncloned) descriptors, so scanning on a hot path costs no +// allocations; only a match pays the single clone that Get already pays. +func Find(pred func(Descriptor) bool) (Descriptor, bool) { + for _, descriptor := range descriptors { + if pred(descriptor) { + return cloneDescriptor(descriptor), true + } + } + return Descriptor{}, false +} + func Get(id string) (Descriptor, bool) { normalized := NormalizeID(id) for _, descriptor := range descriptors { diff --git a/internal/providercatalog/catalog_test.go b/internal/providercatalog/catalog_test.go index 1648aba9c..ccde8e887 100644 --- a/internal/providercatalog/catalog_test.go +++ b/internal/providercatalog/catalog_test.go @@ -74,6 +74,31 @@ func TestAllHasStableUniqueIDs(t *testing.T) { } } +func TestFindReturnsMatchingDescriptorAsClone(t *testing.T) { + descriptor, ok := Find(func(candidate Descriptor) bool { + return candidate.DefaultBaseURL == "https://api.openai.com/v1" + }) + if !ok { + t.Fatal("Find() did not locate the OpenAI catalog entry by base URL") + } + if descriptor.ID != "openai" { + t.Fatalf("Find() returned ID %q, want %q", descriptor.ID, "openai") + } + // Find must hand back a clone like Get: mutating the returned slices must + // not corrupt the internal catalog for later lookups. + if len(descriptor.AuthEnvVars) > 0 { + descriptor.AuthEnvVars[0] = "MUTATED" + } + if again, ok := Find(func(candidate Descriptor) bool { return candidate.ID == "openai" }); !ok { + t.Fatal("Find() lost the OpenAI entry after the first result was mutated") + } else if len(again.AuthEnvVars) > 0 && again.AuthEnvVars[0] == "MUTATED" { + t.Fatal("Find() returned an internal catalog slice without cloning") + } + if _, ok := Find(func(Descriptor) bool { return false }); ok { + t.Fatal("Find() reported a match for a predicate that never holds") + } +} + func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) { descriptors := All() // The recommended providers are badged and pinned to the top of the catalog, diff --git a/internal/tui/flush.go b/internal/tui/flush.go index 357456946..cdb06717b 100644 --- a/internal/tui/flush.go +++ b/internal/tui/flush.go @@ -159,6 +159,10 @@ func (m *model) resetFlushFrontier(divider string) { m.altScreenSettledItems = nil m.altScreenSettledWidth = 0 m.altScreenSettledFrontier = 0 + // The rebuilt transcript shares no working set with the old one; release the + // caches (and any auto-growth they earned) instead of holding dead entries. + m.transcriptBodyHeights.reset() + defaultTranscriptBodyRenders.reset() // Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind, // /compact); a transcript-hover target's bodyY would otherwise risk // coincidentally matching an unrelated row in the rebuilt transcript. diff --git a/internal/tui/model.go b/internal/tui/model.go index 119db77b4..4701bf618 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3972,8 +3972,16 @@ func (m model) liveReasoningBodyCap() int { } func (m model) interimBlock(width int) string { + return m.interimBlockWithReasoning(width, m.streamingReasoningString()) +} + +// interimBlockWithReasoning renders the live interim block from an already +// converted reasoning string, so a render pass that also builds the streaming +// reasoning's selectable lines converts the accumulated bytes once per frame +// instead of once per consumer. +func (m model) interimBlockWithReasoning(width int, reasoningRaw string) string { text := strings.TrimRight(m.streamingTextString(), "\n") - reasoning := strings.TrimRight(m.streamingReasoningString(), "\n") + reasoning := strings.TrimRight(reasoningRaw, "\n") blocks := []string{} if strings.TrimSpace(reasoning) != "" { blocks = append(blocks, renderReasoningBlock(reasoning, m.streamingReasoningExpanded, width, true, 0, m.liveReasoningBodyCap())) @@ -4068,7 +4076,7 @@ func (m model) activeToolCall() (transcriptRow, bool) { if !m.pending || m.activeRunID == 0 { return transcriptRow{}, false } - var resolved map[string]struct{} + var resolved map[rowKey]struct{} for i, scanned := len(m.transcript)-1, 0; i >= 0 && scanned < activeToolCallScanLimit; i, scanned = i-1, scanned+1 { row := m.transcript[i] if row.runID != m.activeRunID || row.id == "" { @@ -4078,7 +4086,7 @@ func (m model) activeToolCall() (transcriptRow, bool) { switch row.kind { case rowToolResult: if resolved == nil { - resolved = make(map[string]struct{}) + resolved = make(map[rowKey]struct{}) } resolved[key] = struct{}{} case rowToolCall: diff --git a/internal/tui/picker.go b/internal/tui/picker.go index eb012f391..0490ff751 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -835,17 +835,17 @@ func providerDescriptorByBaseURL(baseURL string) (providercatalog.Descriptor, bo if normalized == "" { return providercatalog.Descriptor{}, false } - for _, descriptor := range providercatalog.All() { + // Find scans the internal catalog without cloning. This runs on render-adjacent + // paths (see modelContextWindow), where the previous All() scan cloned every + // descriptor on every call. + return providercatalog.Find(func(descriptor providercatalog.Descriptor) bool { if genericProviderCatalogID(descriptor.ID) { - continue + return false } // Includes legacy catalog hosts, so a profile saved against a provider's // earlier default base URL still maps to its catalog entry. - if providercatalog.IsCatalogBaseURL(descriptor, normalized) { - return descriptor, true - } - } - return providercatalog.Descriptor{}, false + return providercatalog.IsCatalogBaseURL(descriptor, normalized) + }) } func normalizeProviderBaseURL(baseURL string) string { diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index 4dd9efea9..291ce2f2e 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -1854,3 +1854,54 @@ func TestProviderDescriptorByBaseURLMapsLegacyOpenGatewayHost(t *testing.T) { t.Fatal("an unknown host must not map to a catalog entry") } } + +func TestModelContextWindowResolvesBaseURLOnlyProfilePerFrame(t *testing.T) { + // view.go calls modelContextWindow on every render, so its active-provider + // lookup must resolve a catalog-less, base-URL-only profile through the + // providercatalog.Find scan added for #1196. A model ID carried by both the + // active provider's discovered list and another provider's pins that the + // active-provider result wins, not the random-order fallback map scan. + registry, err := modelregistry.DefaultRegistry() + if err != nil { + t.Fatalf("load default registry: %v", err) + } + const unknownModel = "totally-unknown-proxy-model" + if _, ok := registry.Resolve(unknownModel); ok { + t.Fatalf("%q unexpectedly resolves in the curated registry; pick another ID", unknownModel) + } + m := model{ + modelCatalog: registry, + providerProfile: config.ProviderProfile{ + Name: "proxy", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: config.OpenAIBaseURL, + }, + modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{ + "openai": {{ID: unknownModel, ContextWindow: 246810}}, + "xai": {{ID: unknownModel, ContextWindow: 1}}, + }, + } + descriptor, ok := m.activeProviderDescriptor() + if !ok || descriptor.ID != "openai" { + t.Fatalf("activeProviderDescriptor for a base-URL-only profile = (%q, %v), want openai", descriptor.ID, ok) + } + // The per-frame lookup hands back clones: mutating the returned descriptor's + // shared-state fields must not poison the catalog for the next frame. + if len(descriptor.AuthEnvVars) > 0 { + descriptor.AuthEnvVars[0] = "MUTATED" + } + again, ok := m.activeProviderDescriptor() + if !ok || again.ID != "openai" { + t.Fatalf("per-frame lookup lost the openai entry after the first result was mutated: (%q, %v)", again.ID, ok) + } + if len(again.AuthEnvVars) > 0 && again.AuthEnvVars[0] == "MUTATED" { + t.Fatal("per-frame lookup returned an internal catalog slice without cloning") + } + // Drive the per-frame entry point itself. Map iteration order is random, so + // repeat enough that a fallback-order leak would fail long before this loop. + for i := 0; i < 16; i++ { + if got := m.modelContextWindow(unknownModel); got != 246810 { + t.Fatalf("modelContextWindow per-frame lookup #%d = %d, want the active provider's 246810", i, got) + } + } +} diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 27acf659e..0a8441f3a 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -55,15 +55,24 @@ func helpText() string { // across turns (e.g. Gemini's gemini_tool_N), so a bare id could attribute a // decision or a result to a different run's call. type rowContext struct { - resolved map[string]bool - hints map[string]string - args map[string]string - auto map[string]bool - decided map[string]bool + resolved map[rowKey]bool + hints map[rowKey]string + args map[rowKey]string + auto map[rowKey]bool + decided map[rowKey]bool } -func rcKey(runID int, id string) string { - return strconv.Itoa(runID) + ":" + id +// rowKey identifies a tool call within a run for the rowContext maps. A +// comparable struct avoids the strconv.Itoa + concatenation that the previous +// string key paid on every lookup; the render path built millions of these +// keys per session purely to hash them. +type rowKey struct { + runID int + id string +} + +func rcKey(runID int, id string) rowKey { + return rowKey{runID: runID, id: id} } func buildRowContext(rows []transcriptRow) rowContext { @@ -73,13 +82,13 @@ func buildRowContext(rows []transcriptRow) rowContext { return rowContext{} } rc := rowContext{ - resolved: map[string]bool{}, - hints: map[string]string{}, - args: map[string]string{}, - auto: map[string]bool{}, - decided: map[string]bool{}, + resolved: map[rowKey]bool{}, + hints: map[rowKey]string{}, + args: map[rowKey]string{}, + auto: map[rowKey]bool{}, + decided: map[rowKey]bool{}, } - prompted := map[string]bool{} + prompted := map[rowKey]bool{} for _, row := range rows { switch row.kind { case rowToolCall: diff --git a/internal/tui/theme_select.go b/internal/tui/theme_select.go index ea4bb6222..a7392adc0 100644 --- a/internal/tui/theme_select.go +++ b/internal/tui/theme_select.go @@ -90,7 +90,8 @@ func applyTheme(mode themeMode, terminalDark bool) themeMode { zeroTheme = theme rebuildStreamingFadePalette() if defaultRenderCache != nil { - defaultRenderCache.clear() // old-palette entries must not be reused + defaultRenderCache.clear() // old-palette entries must not be reused + defaultTranscriptBodyRenders.reset() // ...nor the row-level body renders } if defaultFileViewCache != nil { defaultFileViewCache.clear() diff --git a/internal/tui/transcript_body_cache.go b/internal/tui/transcript_body_cache.go index 3b60a25c3..825ce807e 100644 --- a/internal/tui/transcript_body_cache.go +++ b/internal/tui/transcript_body_cache.go @@ -5,7 +5,14 @@ import ( "sync" ) -const defaultTranscriptBodyHeightCacheMaxEntries = 512 +const defaultTranscriptBodyHeightCacheMaxEntries = 2048 + +// transcriptBodyHeightCacheHardCap bounds the cache's auto-growth. Height +// entries are content-addressed (the key embeds row text, status, width, +// theme-relevant hints) so they stay valid forever and cost ~200B each; the +// cap only exists to keep a marathon session's stale variants from +// accumulating unboundedly. +const transcriptBodyHeightCacheHardCap = 65536 type transcriptBodyHeightCache struct { mu sync.Mutex @@ -27,6 +34,21 @@ func newTranscriptBodyHeightCache(maxEntries int) *transcriptBodyHeightCache { } } +// reset shrinks the ceiling back to the default after a full transcript +// rebuild (theme change, /clear, /resume): the old working set is gone, and +// stale entries can never be hit again with different content because keys +// are content-addressed, but releasing the memory is the honest thing to do. +func (c *transcriptBodyHeightCache) reset() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.items = map[string]*list.Element{} + c.lru.Init() + c.maxEntries = defaultTranscriptBodyHeightCacheMaxEntries +} + func (c *transcriptBodyHeightCache) get(key string) (int, bool) { if c == nil || key == "" { return 0, false @@ -41,6 +63,169 @@ func (c *transcriptBodyHeightCache) get(key string) (int, bool) { return element.Value.(*transcriptBodyHeightCacheEntry).height, true } +// transcriptBodyRenderCache memoises the fully rendered (display string + +// selectable geometry at bodyY origin 0) result of a transcript body item. +// layoutVisibleTranscriptBodyItems re-renders every visible item on EVERY +// frame — including idle throbber ticks — and the render closures re-wrap +// markdown, ansi-strip every line, and re-derive tool-card toggle state each +// time. Settled rows are content-addressed by the same key the height cache +// already computes (renderRowCacheKey covers row text, status, rc hints/args, +// width, bodyCap, theme-relevant fields), so a hit skips all of that work. +// Selection highlight and hover are painted fresh after the hit; the stored +// selectable slice is copied on every hit so callers can shift/extend it. +type transcriptBodyRenderCache struct { + mu sync.Mutex + maxEntries int + maxCharacters int + retained int + items map[string]*list.Element + lru *list.List + hits int + misses int +} + +type transcriptBodyRenderedEntry struct { + key string + rendered string + selectable []transcriptSelectableLine + chars int +} + +func newTranscriptBodyRenderCache(maxEntries int, maxCharacters int) *transcriptBodyRenderCache { + return &transcriptBodyRenderCache{ + maxEntries: maxEntries, + maxCharacters: maxCharacters, + items: map[string]*list.Element{}, + lru: list.New(), + } +} + +const ( + defaultTranscriptBodyRenderCacheMaxEntries = 256 + defaultTranscriptBodyRenderCacheMaxCharacters = 512 * 1024 + // transcriptBodyRenderCacheHardEntries bounds auto-growth of the render + // cache. Like the height cache, a live tail larger than the static default + // thrashes the LRU every frame; entries are content-addressed, so growth + // trades bounded resident render text for eliminating full re-renders. + transcriptBodyRenderCacheHardEntries = 8192 + // transcriptBodyRenderCacheHardCharacters caps retained render bytes. + transcriptBodyRenderCacheHardCharacters = 8 * 1024 * 1024 +) + +var defaultTranscriptBodyRenders = newTranscriptBodyRenderCache( + defaultTranscriptBodyRenderCacheMaxEntries, + defaultTranscriptBodyRenderCacheMaxCharacters, +) + +// clear drops every cached render. Like the static render cache, it must run +// when the active theme changes so stale entries painted in the old palette +// are never reused. +func (c *transcriptBodyRenderCache) clear() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.items = map[string]*list.Element{} + c.lru.Init() + c.retained = 0 +} + +// render returns the display string and a selectable copy shifted to +// startBodyY. When stable is false or key is empty (live rows whose render +// embeds per-frame state like the spinner), the cache is bypassed entirely. +func (c *transcriptBodyRenderCache) render(key string, stable bool, startBodyY int, build func() (string, []transcriptSelectableLine)) (string, []transcriptSelectableLine) { + if c == nil || !stable || key == "" { + rendered, selectable := build() + return rendered, shiftSelectableBodyY(selectable, startBodyY) + } + c.mu.Lock() + if element, ok := c.items[key]; ok { + c.hits++ + c.lru.MoveToFront(element) + entry := element.Value.(*transcriptBodyRenderedEntry) + selectable := shiftSelectableBodyY(copySelectableLines(entry.selectable), startBodyY) + c.mu.Unlock() + return entry.rendered, selectable + } + c.mu.Unlock() + rendered, selectable := build() + // The builder renders at startBodyY == 0; the returned slice is owned by + // the caller and shifted in place, while the cache stores the origin copy. + cached := copySelectableLines(selectable) + c.mu.Lock() + c.misses++ + chars := len(rendered) + if c.maxEntries > 0 && c.maxCharacters > 0 && chars <= c.maxCharacters { + c.items[key] = c.lru.PushFront(&transcriptBodyRenderedEntry{key: key, rendered: rendered, selectable: cached, chars: chars}) + c.retained += chars + c.growLocked() + for len(c.items) > c.maxEntries || c.retained > c.maxCharacters { + element := c.lru.Back() + if element == nil { + c.retained = 0 + break + } + entry := element.Value.(*transcriptBodyRenderedEntry) + delete(c.items, entry.key) + c.lru.Remove(element) + c.retained -= entry.chars + } + } + c.mu.Unlock() + return rendered, shiftSelectableBodyY(selectable, startBodyY) +} + +// growLocked raises the entry and character ceilings toward their hard caps +// as the working set grows, for the same reason and with the same shape as +// transcriptBodyHeightCache.growLocked: a long run's live tail previously +// evicted rows the very next tick still needed, turning every spinner frame +// into a full markdown re-wrap + highlight + selectable re-derivation. +func (c *transcriptBodyRenderCache) growLocked() { + if c.maxEntries < transcriptBodyRenderCacheHardEntries { + c.maxEntries = minInt(maxInt(c.maxEntries*2, c.maxEntries+256), transcriptBodyRenderCacheHardEntries) + } + if c.maxCharacters < transcriptBodyRenderCacheHardCharacters { + c.maxCharacters = minInt(maxInt(c.maxCharacters*2, c.maxCharacters+defaultTranscriptBodyRenderCacheMaxCharacters), transcriptBodyRenderCacheHardCharacters) + } +} + +// reset drops every cached render and shrinks the ceilings back to their +// defaults. Like clear(), it must run when the active theme changes so stale +// entries painted in the old palette are never reused; unlike clear() it also +// releases the growth a previous working set earned, since that set is gone. +func (c *transcriptBodyRenderCache) reset() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.items = map[string]*list.Element{} + c.lru.Init() + c.retained = 0 + c.maxEntries = defaultTranscriptBodyRenderCacheMaxEntries + c.maxCharacters = defaultTranscriptBodyRenderCacheMaxCharacters +} + +func copySelectableLines(lines []transcriptSelectableLine) []transcriptSelectableLine { + if lines == nil { + return nil + } + out := make([]transcriptSelectableLine, len(lines)) + copy(out, lines) + return out +} + +func shiftSelectableBodyY(lines []transcriptSelectableLine, delta int) []transcriptSelectableLine { + if delta == 0 { + return lines + } + for index := range lines { + lines[index].bodyY += delta + } + return lines +} + func (c *transcriptBodyHeightCache) set(key string, height int) { if c == nil || key == "" || c.maxEntries <= 0 { return @@ -53,6 +238,7 @@ func (c *transcriptBodyHeightCache) set(key string, height int) { return } c.items[key] = c.lru.PushFront(&transcriptBodyHeightCacheEntry{key: key, height: height}) + c.growLocked() for len(c.items) > c.maxEntries { element := c.lru.Back() if element == nil { @@ -63,3 +249,18 @@ func (c *transcriptBodyHeightCache) set(key string, height int) { c.lru.Remove(element) } } + +// growLocked raises the entry ceiling toward the hard cap as the working set +// grows. A long run whose live tail (81-row tool outputs, plan checklists, +// specialist cards) exceeds the static cap turned every frame into full +// re-renders: the LRU evicted rows the next tick still needed (confirmed by +// profile: ~99% height-cache miss rate on sessions with big tails). Heights +// are content-addressed and cheap to retain, so the cache grows to fit the +// observed working set instead of thrashing; eviction resumes at the cap. +func (c *transcriptBodyHeightCache) growLocked() { + if c.maxEntries >= transcriptBodyHeightCacheHardCap { + return + } + next := maxInt(c.maxEntries*2, c.maxEntries+512) + c.maxEntries = minInt(next, transcriptBodyHeightCacheHardCap) +} diff --git a/internal/tui/transcript_body_cache_test.go b/internal/tui/transcript_body_cache_test.go new file mode 100644 index 000000000..8e3b1f50d --- /dev/null +++ b/internal/tui/transcript_body_cache_test.go @@ -0,0 +1,183 @@ +package tui + +import ( + "fmt" + "strings" + "testing" +) + +func TestTranscriptBodyHeightCacheGrowsBeyondInitialCap(t *testing.T) { + cache := newTranscriptBodyHeightCache(4) + for i := 0; i < 40; i++ { + key := fmt.Sprintf("row-%d", i) + cache.set(key, i) + if _, ok := cache.get(key); !ok { + t.Fatalf("entry %d evicted despite auto-growth (cap now %d)", i, cache.maxEntries) + } + } + if cache.maxEntries <= 4 { + t.Fatalf("maxEntries = %d, want auto-growth past the initial cap", cache.maxEntries) + } + if cache.maxEntries > transcriptBodyHeightCacheHardCap { + t.Fatalf("maxEntries = %d, want at most hard cap %d", cache.maxEntries, transcriptBodyHeightCacheHardCap) + } +} + +func TestTranscriptBodyHeightCacheEvictsAtHardCap(t *testing.T) { + cache := newTranscriptBodyHeightCache(4) + for i := 0; i < transcriptBodyHeightCacheHardCap*2+8; i++ { + cache.set(fmt.Sprintf("row-%d", i), i) + } + if cache.maxEntries != transcriptBodyHeightCacheHardCap { + t.Fatalf("maxEntries = %d, want exactly the hard cap", cache.maxEntries) + } + if len(cache.items) > transcriptBodyHeightCacheHardCap { + t.Fatalf("items = %d, want at most the hard cap", len(cache.items)) + } + // The oldest entries must be evicted once the hard cap binds. + if _, ok := cache.get("row-0"); ok { + t.Fatal("row-0 still cached past the hard cap; LRU eviction never engaged") + } + // The newest entry stays. + if _, ok := cache.get(fmt.Sprintf("row-%d", transcriptBodyHeightCacheHardCap*2+7)); !ok { + t.Fatal("newest entry missing; eviction loop damaged fresh inserts") + } +} + +func TestTranscriptBodyHeightCacheResetShrinksCeiling(t *testing.T) { + cache := newTranscriptBodyHeightCache(4) + for i := 0; i < 40; i++ { + cache.set(fmt.Sprintf("row-%d", i), i) + } + cache.reset() + if cache.maxEntries != defaultTranscriptBodyHeightCacheMaxEntries { + t.Fatalf("maxEntries = %d after reset, want default %d", cache.maxEntries, defaultTranscriptBodyHeightCacheMaxEntries) + } + if len(cache.items) != 0 { + t.Fatalf("items = %d after reset, want empty", len(cache.items)) + } +} + +func TestTranscriptBodyRenderCacheGrowsBeyondInitialCap(t *testing.T) { + cache := newTranscriptBodyRenderCache(4, 512) + build := func(value string) func() (string, []transcriptSelectableLine) { + return func() (string, []transcriptSelectableLine) { + return value, []transcriptSelectableLine{{bodyY: 0, text: value}} + } + } + for i := 0; i < 40; i++ { + key := fmt.Sprintf("row-%d", i) + want := fmt.Sprintf("rendered %d", i) + got, _ := cache.render(key, true, 0, build(want)) + if got != want { + t.Fatalf("entry %d = %q, want %q", i, got, want) + } + // Re-render must reuse the stored value even after later insertions, + // which is the whole point of auto-growth. + second, _ := cache.render(key, true, 0, build("should not render")) + if second != want { + t.Fatalf("entry %d rebuilt on second frame (%q); LRU evicted it despite auto-growth", i, second) + } + } + if cache.maxEntries <= 4 { + t.Fatalf("maxEntries = %d, want auto-growth past the initial cap", cache.maxEntries) + } +} + +func TestTranscriptBodyRenderCacheResetRestoresDefaults(t *testing.T) { + cache := newTranscriptBodyRenderCache(4, 512) + for i := 0; i < 40; i++ { + key := fmt.Sprintf("row-%d", i) + cache.render(key, true, 0, func() (string, []transcriptSelectableLine) { + return strings.Repeat("x", 32), nil + }) + } + cache.reset() + if cache.maxEntries != defaultTranscriptBodyRenderCacheMaxEntries || cache.maxCharacters != defaultTranscriptBodyRenderCacheMaxCharacters { + t.Fatalf("ceilings after reset = %d/%d, want defaults %d/%d", + cache.maxEntries, cache.maxCharacters, + defaultTranscriptBodyRenderCacheMaxEntries, defaultTranscriptBodyRenderCacheMaxCharacters) + } + if len(cache.items) != 0 || cache.retained != 0 { + t.Fatalf("items=%d retained=%d after reset, want empty", len(cache.items), cache.retained) + } +} + +// TestStreamingTailFramesHitCachesPastStaticCaps is the regression test for the +// pathological streaming state captured in cpu4/mem4: a long run whose live +// tail (frontier blocked by an unresolved tool call) holds more rendered rows +// than the static height/render cache caps. Before auto-growth every spinner +// frame evicted rows the next frame still needed, so measureTranscriptBodyItems +// re-rendered the whole tail (~78% of session CPU). A steady-state frame must +// now pay zero build() calls. +func TestStreamingTailFramesHitCachesPastStaticCaps(t *testing.T) { + m := transcriptViewTestModel() + m.altScreen = true + m.height = 40 + for i := 0; i < 5; i++ { + m.transcript = append(m.transcript, + transcriptRow{kind: rowUser, text: fmt.Sprintf("question %d", i), runID: i}, + transcriptRow{kind: rowAssistant, text: strings.Repeat("answer ", 60), final: true, runID: i}, + ) + } + m, _ = m.settleTranscript() + + // Active run: the frontier blocks on the unresolved live call, leaving a + // settled tail far larger than the static height-cache cap behind it. + const runID = 99 + m.pending = true + m.activeRunID = runID + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolCall, id: "live_call", tool: "exec_command", arg: "long-running", detail: "running", status: "running", runID: runID}, + ) + for i := 0; i < 400; i++ { + m.transcript = append(m.transcript, + transcriptRow{kind: rowToolCall, id: fmt.Sprintf("call_%d", i), tool: "exec_command", arg: "ls", detail: "hint", status: "completed", runID: runID}, + transcriptRow{kind: rowToolResult, id: fmt.Sprintf("call_%d", i), tool: "exec_command", detail: strings.Repeat(fmt.Sprintf("output %d ", i), 200), status: "completed", runID: runID}, + ) + } + m.streamingText = []byte("partial streaming text") + m, _ = m.settleTranscript() + if m.flushed >= len(m.transcript) { + t.Fatalf("frontier flushed=%d/%d, want it blocked by the live call", m.flushed, len(m.transcript)) + } + + width := m.chatColumnWidth() + + // Count rebuilds across frames via a fresh render cache installed before + // priming, so the primed frame is its first. + renders := newTranscriptBodyRenderCache( + defaultTranscriptBodyRenderCacheMaxEntries, + defaultTranscriptBodyRenderCacheMaxCharacters, + ) + old := defaultTranscriptBodyRenders + defaultTranscriptBodyRenders = renders + t.Cleanup(func() { defaultTranscriptBodyRenders = old }) + + // Prime the caches with one full frame. + _ = m.scrollableTranscriptItemsView("", m.transcriptBodyItems(width, "", false), "", width, "") + missesAfterPrime := renders.misses + + // Height pass on a steady frame must fully hit the height cache: any miss + // renders the entire row just to count lines. + items := m.transcriptBodyItems(width, "", false) + misses := 0 + for _, item := range items { + if !item.heightCacheStable || item.heightCacheKey == "" { + continue + } + if _, ok := m.transcriptBodyHeights.get(item.heightCacheKey); !ok { + misses++ + } + } + if misses > 0 { + t.Fatalf("%d/%d stable items missed the height cache on a steady frame; caches thrash again", misses, len(items)) + } + + // And the render pass must reuse cached renders for all but live items. + _ = m.scrollableTranscriptItemsView("", m.transcriptBodyItems(width, "", false), "", width, "") + missDelta := renders.misses - missesAfterPrime + if missDelta > 8 { + t.Fatalf("second steady frame rebuilt %d items, want only the handful of live/unstable ones", missDelta) + } +} diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index c65581635..a50808906 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -175,6 +175,18 @@ func shiftSelectableX(lines []transcriptSelectableLine, gutter int) []transcript // shifts its selectable spans to match, and THEN paints the selection highlight — // so the highlight is computed in the same shifted coordinate the mouse maps to // and lands exactly where the user selected (instead of gutter cells off). +// cachedTranscriptBodyRender memoises a body item's display string and +// selectable geometry (rendered at bodyY origin 0) in the shared +// transcriptBodyRenderCache. A settled row on a throbber tick previously paid +// a full markdown re-wrap, an ansi.Strip per line, and toggle-state +// re-derivation; on a hit it pays a string copy and a selectable shift. +// Selection highlight and hover are painted fresh on every frame from the +// cached pre-highlight lines, so interaction state never leaks into the key. +func (m model) cachedTranscriptBodyRender(key string, stable bool, startBodyY int, build func() (string, []transcriptSelectableLine), gutter int) transcriptBodyRenderedItem { + rendered, selectable := defaultTranscriptBodyRenders.render(key, stable, startBodyY, build) + return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY) +} + func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcriptSelectableLine, gutter int, startBodyY int) transcriptBodyRenderedItem { if m.transcriptInteraction != nil { m.transcriptSelection, m.hover = m.transcriptInteraction.get() @@ -326,18 +338,20 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed heightCacheKey: transcriptBlockBodyHeightCacheKey(transcriptBodyItemRow, block), heightCacheStable: true, render: func(startBodyY int) transcriptBodyRenderedItem { - rendered := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc) - selectable := make([]transcriptSelectableLine, 0, len(groupIndicesCopy)+1) - for offset, line := range viewLines(rendered) { - rowIndex := firstRowIndex - if offset > 0 && offset-1 < len(groupIndicesCopy) { - rowIndex = groupIndicesCopy[offset-1] - } - if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+offset, line, false); ok { - selectable = append(selectable, meta) + return m.cachedTranscriptBodyRender(transcriptBlockBodyHeightCacheKey(transcriptBodyItemRow, block), true, startBodyY, func() (string, []transcriptSelectableLine) { + rendered := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc) + selectable := make([]transcriptSelectableLine, 0, len(groupIndicesCopy)+1) + for offset, line := range viewLines(rendered) { + rowIndex := firstRowIndex + if offset > 0 && offset-1 < len(groupIndicesCopy) { + rowIndex = groupIndicesCopy[offset-1] + } + if meta, ok := selectableLineFromRenderedLine(rowIndex, offset, line, false); ok { + selectable = append(selectable, meta) + } } - } - return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY) + return rendered, selectable + }, gutter) }, }) shownAny = true @@ -412,8 +426,9 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed heightCacheKey: heightCacheKey, heightCacheStable: heightCacheStable, render: func(startBodyY int) transcriptBodyRenderedItem { - rendered, selectable := renderRowFn(rowIndex, transcriptRow, contentWidth, rc, startBodyY) - return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY) + return m.cachedTranscriptBodyRender(heightCacheKey, heightCacheStable, startBodyY, func() (string, []transcriptSelectableLine) { + return renderRowFn(rowIndex, transcriptRow, contentWidth, rc, 0) + }, gutter) }, }) shownAny = true @@ -465,8 +480,10 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed rowIndex: -1, render: func(startBodyY int) transcriptBodyRenderedItem { // Streaming text shares the reading column so it doesn't snap - // width when the turn finalizes into a row. - return m.finalizeTranscriptBodyRow(m.interimBlock(contentWidth), m.renderSelectableStreamingReasoning(contentWidth, startBodyY), gutter, startBodyY) + // width when the turn finalizes into a row. One reasoning + // conversion serves both the block and its selectable lines. + reasoning := m.streamingReasoningString() + return m.finalizeTranscriptBodyRow(m.interimBlockWithReasoning(contentWidth, reasoning), m.renderSelectableStreamingReasoning(contentWidth, startBodyY, reasoning), gutter, startBodyY) }, }) } @@ -1019,8 +1036,8 @@ func (m model) renderSelectableReasoningRow(rowIndex int, row transcriptRow, wid return strings.Join(lines, "\n"), selectable } -func (m model) renderSelectableStreamingReasoning(width int, startBodyY int) []transcriptSelectableLine { - _, selectable := m.renderSelectableReasoningBlock(-1, m.streamingReasoningString(), m.streamingReasoningExpanded, true, 0, width, startBodyY) +func (m model) renderSelectableStreamingReasoning(width int, startBodyY int, reasoningRaw string) []transcriptSelectableLine { + _, selectable := m.renderSelectableReasoningBlock(-1, reasoningRaw, m.streamingReasoningExpanded, true, 0, width, startBodyY) for index := range selectable { selectable[index].live = true }