diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3a03a197746..b8f0bbe0009 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn. +### Fixed + +- Bounded transcript render retention after finalized rows enter native scrollback, so long resumed TUI sessions no longer keep the full committed transcript row arrays live in JavaScript ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)). + ## [16.3.11] - 2026-07-06 ### Changed diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index c66b081c1ee..ed0c8c55af5 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -101,6 +101,8 @@ interface BlockSegment { sep: number; /** Whether the block reported finalized when this segment was rendered. */ finalized: boolean; + /** Safe to drop after commit: produced while finalized, without post-finalize version tracking. */ + compactable: boolean; /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */ version: number | undefined; } @@ -154,6 +156,11 @@ export class TranscriptContainer // Finalized blocks wholly before this boundary are immutable on-screen history; // their previous contribution can be replayed without calling render(). #committedRows = 0; + // Leading children whose rows were already handed to native scrollback and + // dropped from the local render frame. They remain in `children` so session + // ownership and external references stay intact, but this container no + // longer renders or retains their row arrays. + #compactedChildStart = 0; // Stable-prefix floor accumulated across renders since the last // getRenderStablePrefixRows() read (see RenderStablePrefix: reading // consumes the report and re-bases the baseline). Out-of-band renders @@ -168,6 +175,8 @@ export class TranscriptContainer override clear(): void { this.#generation++; + this.#compactedChildStart = 0; + this.#committedRows = 0; super.clear(); } @@ -195,6 +204,8 @@ export class TranscriptContainer * committed rows and is safely removable. */ isBlockUncommitted(component: Component): boolean { + const index = this.children.indexOf(component); + if (index >= 0 && index < this.#compactedChildStart) return false; for (const segment of this.#segments) { if (segment.component !== component) continue; return segment.rowCount === 0 || segment.startRow >= this.#committedRows; @@ -250,7 +261,7 @@ export class TranscriptContainer if (maxRows <= 0) return EMPTY_TAIL; const collected: (readonly string[])[] = []; let total = 0; - for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) { + for (let i = this.children.length - 1; i >= this.#compactedChildStart && total < maxRows; i--) { const contribution = stripPlainBlankEdges(this.children[i]!.render(width)); if (contribution.length === 0) continue; // One blank separator sits between this block and the (already @@ -274,6 +285,7 @@ export class TranscriptContainer this.#nativeScrollbackLiveRegionStart = undefined; const count = this.children.length; + if (this.#compactedChildStart > count) this.#compactedChildStart = count; // The commit boundary stops at the earliest still-mutating block. A // block that has not finalized must gate it: out-of-band inserts @@ -283,7 +295,7 @@ export class TranscriptContainer // reaches. let liveStartIndex = -1; let hasLiveBlock = false; - for (let i = 0; i < count; i++) { + for (let i = this.#compactedChildStart; i < count; i++) { if (!isBlockFinalized(this.children[i]!)) { liveStartIndex = i; hasLiveBlock = true; @@ -315,7 +327,7 @@ export class TranscriptContainer // Frame row cursor: rows emitted (reused or pushed) so far. let row = 0; let stableRows = 0; - for (let i = 0; i < count; i++) { + for (let i = this.#compactedChildStart; i < count; i++) { const child = this.children[i]!; // This child's contribution: its current render with plain-blank @@ -356,6 +368,7 @@ export class TranscriptContainer previous.width === width && previous.generation === this.#generation); const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw); + const compactable = finalized && version === undefined && previous?.finalized !== false; // Empty (or stripped-to-nothing) children contribute nothing and never // affect spacing. An empty still-live child still gates the commit @@ -380,6 +393,7 @@ export class TranscriptContainer rowCount: 0, sep: 0, finalized, + compactable, version, }; continue; @@ -431,6 +445,7 @@ export class TranscriptContainer rowCount, sep, finalized, + compactable, version, }; row += rowCount; @@ -440,8 +455,70 @@ export class TranscriptContainer if (lines.length !== row) lines.length = row; this.#segments = segments; this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row); + this.#compactCommittedPrefix(); return lines; } + + #compactCommittedPrefix(): void { + if (this.#committedRows <= 0 || this.#compactedChildStart >= this.children.length) return; + const lines = this.#lines; + const segments = this.#segments; + let dropRows = 0; + let dropUntil = this.#compactedChildStart; + for (let i = this.#compactedChildStart; i < segments.length; i++) { + const segment = segments[i]; + if (segment === undefined) break; + if (!segment.compactable) break; + const segmentEnd = segment.startRow + segment.rowCount; + if (segmentEnd > this.#committedRows) break; + dropRows = segmentEnd; + dropUntil = i + 1; + } + const retained = segments[dropUntil]; + if (retained !== undefined && retained.sep > 0) { + const committedSeparatorRows = this.#committedRows - retained.startRow; + if (committedSeparatorRows <= 0) { + dropRows = 0; + dropUntil = this.#compactedChildStart; + } else { + const trim = Math.min(retained.sep, committedSeparatorRows); + dropRows += trim; + retained.sep -= trim; + retained.rowCount -= trim; + } + } + if (dropRows === 0) return; + + lines.splice(0, dropRows); + for (let i = this.#compactedChildStart; i < dropUntil; i++) { + const segment = segments[i]; + if (segment === undefined) continue; + segments[i] = { + component: segment.component, + rawRef: EMPTY_TAIL, + contribution: EMPTY_TAIL, + width: segment.width, + generation: segment.generation, + startRow: 0, + rowCount: 0, + sep: 0, + finalized: true, + compactable: false, + version: segment.version, + }; + } + for (let i = dropUntil; i < segments.length; i++) { + const segment = segments[i]; + if (segment === undefined) continue; + segment.startRow -= dropRows; + } + this.#compactedChildStart = dropUntil; + this.#committedRows = Math.max(0, this.#committedRows - dropRows); + this.#stableRowsFloor = 0; + if (this.#nativeScrollbackLiveRegionStart !== undefined) { + this.#nativeScrollbackLiveRegionStart = Math.max(0, this.#nativeScrollbackLiveRegionStart - dropRows); + } + } } /** diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 73f6bbaa894..97186ae17b6 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -348,6 +348,18 @@ describe("TranscriptContainer", () => { expect(container.render(40)).toEqual(["history", "", "live", "", "finalized-below-0", "finalized-below-1"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); }); + it("omits finalized head rows already committed to native scrollback from subsequent renders", () => { + const container = new TranscriptContainer(); + container.addChild(new StreamingBlock(["committed-history"], true)); + container.addChild(new StreamingBlock(["retained-tail"], true)); + + expect(container.render(40)).toEqual(["committed-history", "", "retained-tail"]); + + container.setNativeScrollbackCommittedRows(2); + + expect(container.render(40)).toEqual(["retained-tail"]); + }); + it("does not re-render finalized rows already committed to native scrollback", () => { const container = new TranscriptContainer(); const committed = new CountingFinalizedBlock(["committed"]); @@ -655,6 +667,19 @@ describe("TranscriptContainer isBlockUncommitted", () => { expect(container.isBlockUncommitted(block)).toBe(false); }); + it("keeps compacted committed blocks marked committed", () => { + const container = new TranscriptContainer(); + const committed = new StreamingBlock(["committed"], true); + container.addChild(committed); + container.addChild(new StreamingBlock(["tail"], true)); + + expect(container.render(40)).toEqual(["committed", "", "tail"]); + container.setNativeScrollbackCommittedRows(2); + expect(container.render(40)).toEqual(["tail"]); + + expect(container.isBlockUncommitted(committed)).toBe(false); + }); + it("keeps empty-render blocks uncommitted after committed rows advance", () => { const container = new TranscriptContainer(); container.addChild(new MutableBlock(["history"])); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b6d4864c998..5ea58499e8c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Changed the Markdown render cache to use weighted size limits, preventing very large rendered documents from occupying normal cache slots indefinitely ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)). + ## [16.3.10] - 2026-07-06 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index b6eee348cb4..78386b52164 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -453,8 +453,21 @@ markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnv // (Rust FFI) work for content/layout combinations already seen this session. const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos +const RENDER_CACHE_MAX_SIZE = 512 * 1024; +const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024; const EMPTY_RENDER_LINES: readonly string[] = []; -const renderCache = new LRUCache({ max: RENDER_CACHE_MAX }); +const renderCache = new LRUCache({ + max: RENDER_CACHE_MAX, + maxSize: RENDER_CACHE_MAX_SIZE, + maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE, + sizeCalculation: renderedLinesCacheSize, +}); + +function renderedLinesCacheSize(lines: readonly string[]): number { + let size = lines.length; + for (let i = 0; i < lines.length; i++) size += lines[i]!.length; + return Math.max(1, size); +} // A reference-link definition (`[label]: dest`) resolves across the whole // document, so a split lex cannot reproduce it — disable the streaming fast path diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index dc33d442057..3fc6136eb0f 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1517,6 +1517,17 @@ describe("Markdown.render reference stability", () => { const b = new Markdown("Shared markdown body", 1, 0, defaultMarkdownTheme); expect(b.render(40)).toBe(a.render(40)); }); + it("does not share very large renders across instances through the L2 cache", () => { + const width = 80; + const paragraph = `cache-budget sentinel ${"x".repeat(120)}`; + const largeText = Array.from({ length: 160 }, (_, index) => `Paragraph ${index}: ${paragraph}`).join("\n\n"); + + const first = new Markdown(largeText, 0, 0, defaultMarkdownTheme).render(width); + const second = new Markdown(largeText, 0, 0, defaultMarkdownTheme).render(width); + + expect(second).toEqual(first); + expect(second).not.toBe(first); + }); it("returns a new reference with updated content after setText", () => { const md = new Markdown("Before edit", 1, 0, defaultMarkdownTheme);