Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 110 additions & 7 deletions packages/coding-agent/src/modes/components/transcript-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Container,
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type NativeScrollbackReplay,
type RenderStablePrefix,
type ViewportTailProvider,
} from "@oh-my-pi/pi-tui";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
Comment on lines +183 to +184

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Keeping every compacted child reachable leaves the per-component render arrays alive. For example, each retained Markdown descendant owns #cachedLines (packages/tui/src/components/markdown.ts:964-970); once this container starts its render walk at #compactedChildStart, those historical descendants are skipped but their caches remain referenced indefinitely. This trims only the aggregate #lines/segment references, not the one rendered array per historical block, so the claimed live-memory bound is not achieved. Please retire/dispose compacted child trees while rebasing the TUI's aligned ledgers, rather than retaining them solely for replay, or otherwise release every descendant render cache.

// 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
Expand All @@ -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;
Expand All @@ -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
Comment on lines 243 to +245

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: A later post-compaction render allocates new Array(count) and fills only indices at/after #compactedChildStart, leaving sparse undefined slots in #segments. The for (const segment of this.#segments) immediately below then dereferences segment.component and crashes. I reproduced this by rendering two finalized blocks, committing the first two rows, rendering twice, then calling isBlockUncommitted(tail): TypeError: undefined is not an object (evaluating 'segment.component'). This is reachable from ephemeral-card retirement. Please skip undefined slots and add a regression that performs the second render before querying either block.

// 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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -413,6 +450,7 @@ export class TranscriptContainer
rowCount: 0,
sep: 0,
finalized,
compactable,
version,
};
continue;
Expand Down Expand Up @@ -464,6 +502,7 @@ export class TranscriptContainer
rowCount,
sep,
finalized,
compactable,
version,
};
row += rowCount;
Expand All @@ -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);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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"]));
Expand All @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion packages/tui/src/components/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly string[]>({ max: RENDER_CACHE_MAX });
const renderCache = new LRUCache<string, readonly string[]>({
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
Expand Down
27 changes: 27 additions & 0 deletions packages/tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeScrollbackReplay>).prepareNativeScrollbackReplay?.();
}

function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
(component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading