refactor(modes): removed scrollback compaction from TranscriptContainer

- Removed `#compactedChildStart` state, `compactable` segment field, and `#compactCommittedPrefix()` method, stopping the local-frame pruning of committed finalized rows.
- Post-finalize mutations now surface on every render via version tracking, instead of requiring a destructive replay to rehydrate the compacted block.
- Changed `print-mode.ts` to use `session.getLastAssistantMessage()` instead of array tail read, fixing access to terminal assistant messages after compaction removal.
This commit is contained in:
can1357
2026-07-18 17:51:45 +02:00
parent 3fdd85ab6c
commit 127cca511a
4 changed files with 37 additions and 199 deletions
@@ -3,7 +3,6 @@ import {
Container,
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type NativeScrollbackReplay,
type RenderStablePrefix,
type ViewportTailProvider,
} from "@oh-my-pi/pi-tui";
@@ -19,17 +18,11 @@ interface FinalizableBlock {
/**
* Monotonic content version for blocks that can still mutate *after*
* reporting finalized (e.g. `AssistantMessageComponent`: the inline error
* restored at the next turn's `agent_start`, late tool-result images). While
* the block's rows are still on screen (not yet fully committed), the
* committed-scrollback render bypass replays its previous rows only when the
* version is unchanged; a bump forces a real render so the TUI's
* committed-prefix audit can observe and re-anchor the change. Once the rows
* fully commit to native scrollback they are dropped from the local frame
* (compacted) — a later mutation no longer recommits on an ordinary frame
* (immutable history the terminal owns; recommitting would duplicate it) and
* instead rehydrates on the next destructive full replay
* ({@link "@oh-my-pi/pi-tui".NativeScrollbackReplay}). Blocks that never
* mutate post-finalize simply omit the method.
* restored at the next turn's `agent_start`, late tool-result images). The
* committed-scrollback render bypass only replays a block's previous rows
* when the version is unchanged; without this signal a post-finalize
* mutation would stay invisible until a global invalidation. Blocks that
* never mutate post-finalize simply omit the method.
*/
getTranscriptBlockVersion?(): number;
/**
@@ -130,8 +123,6 @@ interface BlockSegment {
sep: number;
/** Whether the block reported finalized when this segment was rendered. */
finalized: boolean;
/** Safe to drop from the local frame once its rows fully commit to native scrollback: produced while finalized. */
compactable: boolean;
/** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
version: number | undefined;
}
@@ -167,12 +158,7 @@ const EMPTY_TAIL: readonly string[] = [];
*/
export class TranscriptContainer
extends Container
implements
NativeScrollbackLiveRegion,
NativeScrollbackCommittedRows,
NativeScrollbackReplay,
RenderStablePrefix,
ViewportTailProvider
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
{
// Bumped to retire every block segment at once (theme change / clear); a
// segment is only reused when its stored generation matches.
@@ -190,14 +176,6 @@ 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
@@ -213,14 +191,12 @@ export class TranscriptContainer
override clear(): void {
this.#generation++;
super.clear();
this.#compactedChildStart = 0;
this.#committedRows = 0;
this.#replayPending = false;
}
override setNativeScrollbackCommittedRows(rows: number): void {
this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
for (let i = this.#compactedChildStart; i < this.children.length; i++) {
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i]!;
const segment = this.#segments[i];
if (segment === undefined || segment.component !== child) continue;
@@ -243,18 +219,6 @@ export class TranscriptContainer
}
}
override prepareNativeScrollbackReplay(): void {
// Replay retires the old terminal tape, so descendants may discard layout
// locks whose only purpose was keeping that immutable history byte-stable.
super.prepareNativeScrollbackReplay();
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;
@@ -275,14 +239,8 @@ 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 === undefined || segment.component !== component) continue;
if (segment.component !== component) continue;
return segment.rowCount === 0 || segment.startRow >= this.#committedRows;
}
return true;
@@ -336,7 +294,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 >= this.#compactedChildStart && total < maxRows; i--) {
for (let i = this.children.length - 1; i >= 0 && 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
@@ -360,7 +318,6 @@ 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
@@ -370,7 +327,7 @@ 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 = this.#compactedChildStart; i < count && i < this.#segments.length; i++) {
for (let i = 0; i < count && i < this.#segments.length; i++) {
const previous = this.#segments[i];
if (previous === undefined) continue;
if (previous.startRow >= this.#committedRows) break;
@@ -386,7 +343,7 @@ export class TranscriptContainer
// reaches.
let liveStartIndex = -1;
let hasLiveBlock = false;
for (let i = this.#compactedChildStart; i < count; i++) {
for (let i = 0; i < count; i++) {
if (!isBlockFinalized(this.children[i]!)) {
liveStartIndex = i;
hasLiveBlock = true;
@@ -418,7 +375,7 @@ export class TranscriptContainer
// Frame row cursor: rows emitted (reused or pushed) so far.
let row = 0;
let stableRows = 0;
for (let i = this.#compactedChildStart; i < count; i++) {
for (let i = 0; i < count; i++) {
const child = this.children[i]!;
// This child's contribution: its current render with plain-blank
@@ -459,7 +416,6 @@ export class TranscriptContainer
previous.width === width &&
previous.generation === this.#generation);
const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
const compactable = finalized && previous?.finalized !== false;
// Empty (or stripped-to-nothing) children contribute nothing and never
// affect spacing. An empty still-live child still gates the commit
@@ -484,7 +440,6 @@ export class TranscriptContainer
rowCount: 0,
sep: 0,
finalized,
compactable,
version,
};
continue;
@@ -536,7 +491,6 @@ export class TranscriptContainer
rowCount,
sep,
finalized,
compactable,
version,
};
row += rowCount;
@@ -546,72 +500,8 @@ 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);
}
}
}
/**