Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 80 additions & 3 deletions packages/coding-agent/src/modes/components/transcript-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -168,6 +175,8 @@ export class TranscriptContainer

override clear(): void {
this.#generation++;
this.#compactedChildStart = 0;
this.#committedRows = 0;
super.clear();
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -380,6 +393,7 @@ export class TranscriptContainer
rowCount: 0,
sep: 0,
finalized,
compactable,
version,
};
continue;
Expand Down Expand Up @@ -431,6 +445,7 @@ export class TranscriptContainer
rowCount,
sep,
finalized,
compactable,
version,
};
row += rowCount;
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve compacted transcript rows for full repaints

When this advances #compactedChildStart, subsequent render() calls start walking children from that index, so the compacted prefix is no longer available in the rendered frame. That only works while the terminal's native scrollback still contains those rows; on a non-multiplexer resize settle the TUI explicitly requests a full repaint that clears scrollback (packages/tui/src/tui.ts:3295), and reset-display paths do the same, but the transcript can now replay only the retained tail. After a long session has compacted history, any such resize/reset permanently drops the compacted transcript rows from the terminal history instead of re-emitting the full transcript.

Useful? React with 👍 / 👎.

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,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"]);
Expand Down Expand Up @@ -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"]));
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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<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
11 changes: 11 additions & 0 deletions packages/tui/test/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading