diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 10f7883ed34..f3f3d1c10b2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Bounded transcript render retention after finalized rows enter native scrollback, while rehydrating the complete transcript before destructive resize/reset replays, so long resumed TUI sessions no longer retain every committed row or lose history on repaint ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)). ## [16.4.8] - 2026-07-12 ### Fixed diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index c7f082556d7..576dec06460 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -3,6 +3,7 @@ import { Container, type NativeScrollbackCommittedRows, type NativeScrollbackLiveRegion, + type NativeScrollbackReplay, type RenderStablePrefix, type ViewportTailProvider, } from "@oh-my-pi/pi-tui"; @@ -119,6 +120,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,7 +157,12 @@ const EMPTY_TAIL: readonly string[] = []; */ export class TranscriptContainer extends Container - implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider + implements + NativeScrollbackLiveRegion, + NativeScrollbackCommittedRows, + NativeScrollbackReplay, + RenderStablePrefix, + ViewportTailProvider { // Bumped to retire every block segment at once (theme change / clear); a // segment is only reused when its stored generation matches. @@ -172,6 +180,14 @@ 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 handed to native scrollback and dropped + // from the local frame. Children remain owned by the session and can be + // re-rendered when the TUI prepares a destructive full replay. + #compactedChildStart = 0; + // Suppresses re-compaction for the rehydrating render. The TUI feeds its old + // committed-row count immediately before render, so resetting that count + // alone cannot distinguish a replay from an ordinary update. + #replayPending = false; // 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 @@ -187,12 +203,24 @@ export class TranscriptContainer override clear(): void { this.#generation++; super.clear(); + this.#compactedChildStart = 0; + this.#committedRows = 0; + this.#replayPending = false; } setNativeScrollbackCommittedRows(rows: number): void { this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0; } + prepareNativeScrollbackReplay(): void { + if (this.#compactedChildStart === 0) return; + this.#compactedChildStart = 0; + this.#replayPending = true; + this.#generation++; + this.#lines.length = 0; + this.#stableRowsFloor = 0; + } + getRenderStablePrefixRows(): number { const value = Math.min(this.#stableRowsFloor, this.#lines.length); this.#stableRowsFloor = this.#lines.length; @@ -213,8 +241,14 @@ export class TranscriptContainer * committed rows and is safely removable. */ isBlockUncommitted(component: Component): boolean { + const index = this.children.indexOf(component); + // Compacted prefix is already committed native history and must not be + // retracted. Compacted slots may be sparse holes after a later re-render + // (render only fills from #compactedChildStart), so the loop below must + // skip undefined entries. + if (index >= 0 && index < this.#compactedChildStart) return false; for (const segment of this.#segments) { - if (segment.component !== component) continue; + if (segment === undefined || segment.component !== component) continue; return segment.rowCount === 0 || segment.startRow >= this.#committedRows; } return true; @@ -268,7 +302,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 @@ -292,6 +326,7 @@ export class TranscriptContainer this.#nativeScrollbackLiveRegionStart = undefined; const count = this.children.length; + if (this.#compactedChildStart > count) this.#compactedChildStart = count; // Seal displaceable snapshots whose rows are already on the tape (per the // previous frame's segments — the geometry the committed count was @@ -301,8 +336,9 @@ export class TranscriptContainer // frame, and every frame so a block that BECAME displaceable after its // pending-preview rows committed (late result on a scrolled-off call) is // caught too. - for (let i = 0; i < count && i < this.#segments.length; i++) { - const previous = this.#segments[i]!; + for (let i = this.#compactedChildStart; i < count && i < this.#segments.length; i++) { + const previous = this.#segments[i]; + if (previous === undefined) continue; if (previous.startRow >= this.#committedRows) break; if (previous.rowCount === 0 || previous.component !== this.children[i]) continue; sealCommittedSnapshot(previous.component); @@ -316,7 +352,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; @@ -348,7 +384,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 @@ -389,6 +425,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 @@ -413,6 +450,7 @@ export class TranscriptContainer rowCount: 0, sep: 0, finalized, + compactable, version, }; continue; @@ -464,6 +502,7 @@ export class TranscriptContainer rowCount, sep, finalized, + compactable, version, }; row += rowCount; @@ -473,8 +512,72 @@ export class TranscriptContainer if (lines.length !== row) lines.length = row; this.#segments = segments; this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row); + if (this.#replayPending) { + this.#replayPending = false; + } else { + 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 || !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) 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 ae08beade46..6a812f0ba6b 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,26 @@ describe("TranscriptContainer", () => { expect(container.render(40)).toEqual(["history", "", "live", "", "finalized-below-0", "finalized-below-1"]); expect(container.getNativeScrollbackLiveRegionStart()).toBe(2); }); + it("drops committed finalized head rows and rehydrates them for a full replay", () => { + const container = new TranscriptContainer(); + const history = new CountingFinalizedBlock(["committed-history"]); + const tail = new CountingFinalizedBlock(["retained-tail"]); + container.addChild(history); + container.addChild(tail); + + expect(container.render(40)).toEqual(["committed-history", "", "retained-tail"]); + container.setNativeScrollbackCommittedRows(2); + expect(container.render(40)).toEqual(["retained-tail"]); + expect(history.renderCount).toBe(1); + + container.prepareNativeScrollbackReplay(); + // The TUI supplies its previous committed count immediately before the + // replay render; the complete frame must still survive this one compose. + container.setNativeScrollbackCommittedRows(2); + expect(container.render(40)).toEqual(["committed-history", "", "retained-tail"]); + expect(history.renderCount).toBe(2); + }); + it("does not re-render finalized rows already committed to native scrollback", () => { const container = new TranscriptContainer(); const committed = new CountingFinalizedBlock(["committed"]); @@ -655,6 +675,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"])); @@ -667,6 +700,26 @@ describe("TranscriptContainer isBlockUncommitted", () => { container.setNativeScrollbackCommittedRows(100); expect(container.isBlockUncommitted(empty)).toBe(true); }); + + it("survives sparse compacted segment holes when checking uncommitted status", () => { + const container = new TranscriptContainer(); + const committed = new StreamingBlock(["committed"], true); + const live = new StreamingBlock(["live"], true); + container.addChild(committed); + container.addChild(live); + + expect(container.render(40)).toEqual(["committed", "", "live"]); + container.setNativeScrollbackCommittedRows(2); + // Compaction first rewrites the compacted prefix into zero-row placeholders. + expect(container.render(40)).toEqual(["live"]); + // The next render only repopulates from #compactedChildStart, leaving a + // sparse hole at the compacted index. Retiring IRC/ephemeral cards walks + // every segment and must not crash on those undefined entries. + expect(container.render(40)).toEqual(["live"]); + expect(() => container.isBlockUncommitted(live)).not.toThrow(); + expect(() => container.isBlockUncommitted(committed)).not.toThrow(); + expect(container.isBlockUncommitted(committed)).toBe(false); + }); }); describe("TranscriptContainer renderViewportTail", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3143afbfc57..0aa32078adc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Bounded the Markdown L2 render cache by weighted output size and excluded oversized renders, preventing a few large documents from occupying ordinary cache slots indefinitely ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)). ## [16.4.7] - 2026-07-12 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index e81cd52aa04..d0ed1094662 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -602,8 +602,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/src/tui.ts b/packages/tui/src/tui.ts index 885f07af2b4..32e9e6c3bbb 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -207,6 +207,18 @@ export interface NativeScrollbackCommittedRows { setNativeScrollbackCommittedRows(rows: number): void; } +/** + * A component that discards rows after they enter native scrollback implements + * this hook so a destructive full replay can rehydrate its complete frame. + */ +export interface NativeScrollbackReplay { + prepareNativeScrollbackReplay(): void; +} + +function prepareNativeScrollbackReplay(component: Component): void { + (component as Component & Partial).prepareNativeScrollbackReplay?.(); +} + function setNativeScrollbackCommittedRows(component: Component, rows: number): void { (component as Component & Partial).setNativeScrollbackCommittedRows?.(rows); } @@ -2713,6 +2725,21 @@ export class TUI extends Container { return; } + // A destructive replay erases native history and must receive the complete + // component frame. Give virtualized roots one compose to rehydrate rows + // they dropped after commit. Height-only and net-unchanged resize events + // count too: both enter the geometry rebuild path below. + const replayFullHistory = + this.#hasEverRendered && + !resizeRepaintsInPlace() && + (this.#clearScrollbackOnNextRender || + this.#resizeEventPending || + (this.#previousWidth > 0 && this.#previousWidth !== width) || + (this.#previousHeight > 0 && this.#previousHeight !== height)); + if (replayFullHistory) { + for (const child of this.children) prepareNativeScrollbackReplay(child); + } + // 1. Compose the frame. Bracket the render so the image budget observes // every inline image in display order (overlays carry none). A // component-scoped frame skips the budget pass instead — it is gated on diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index 9518b9f466d..598be48cfbf 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { type Component, Container, type NativeScrollbackLiveRegion, TUI } from "@oh-my-pi/pi-tui"; +import { + type Component, + Container, + type NativeScrollbackCommittedRows, + type NativeScrollbackLiveRegion, + type NativeScrollbackReplay, + TUI, +} from "@oh-my-pi/pi-tui"; import { StressRenderScheduler } from "./render-stress-scheduler"; import { VirtualTerminal } from "./virtual-terminal"; @@ -69,6 +76,73 @@ class RenderCountingTUI extends TUI { } } +class ReplayVirtualizedLines implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay { + readonly lines: readonly string[]; + replayPreparations = 0; + #compacted = false; + #replayPending = false; + + constructor(lines: readonly string[]) { + this.lines = lines; + } + + invalidate(): void {} + + setNativeScrollbackCommittedRows(rows: number): void { + if (rows >= 4) this.#compacted = true; + } + + prepareNativeScrollbackReplay(): void { + this.replayPreparations++; + this.#replayPending = true; + } + + render(_width: number): readonly string[] { + if (this.#replayPending) { + this.#replayPending = false; + return this.lines; + } + return this.#compacted ? this.lines.slice(4) : this.lines; + } +} + +describe("TUI native scrollback replay", () => { + it("rehydrates virtualized roots before a destructive full paint", async () => { + const term = new VirtualTerminal(40, 4, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const transcript = new ReplayVirtualizedLines([ + "history-0", + "history-1", + "history-2", + "history-3", + "tail-0", + "tail-1", + "tail-2", + "tail-3", + ]); + tui.addChild(transcript); + + try { + tui.start(); + await scheduler.drain(term); + tui.requestRender(); + await scheduler.drain(term); + + tui.requestRender(true, { clearScrollback: true }); + await scheduler.drain(term); + + expect(transcript.replayPreparations).toBe(1); + const buffer = strip(term.getScrollBuffer()); + expect(buffer).toContain("history-0"); + expect(buffer).toContain("tail-3"); + } finally { + tui.stop(); + await term.flush(); + } + }); +}); + describe("TUI.requestComponentRender", () => { it("re-renders only the requesting subtree on a quiet frame", async () => { const term = new VirtualTerminal(40, 8, 1_000); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 233c15b6e10..3623f20d615 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1518,6 +1518,18 @@ describe("Markdown.render reference stability", () => { expect(b.render(40)).toBe(a.render(40)); }); + it("does not share oversized renders 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); const before = md.render(40);