import { type Component, Container, type NativeScrollbackCommittedRows, type NativeScrollbackLiveRegion, type NativeScrollbackReplay, type RenderStablePrefix, type ViewportTailProvider, } from "@oh-my-pi/pi-tui"; /** * A transcript block that is still mutating (a foreground tool awaiting its * result, an assistant message mid-stream) reports `false` so the container * keeps it inside the live (repaintable) region instead of freezing it. Blocks * without the method are treated as finalized — the default, stable behavior. */ interface FinalizableBlock { isTranscriptBlockFinalized?(): boolean; /** * 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). 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; /** * Leading rows of the block's current render() output that are declared * FINAL while the block is still live: byte-stable at the current width * until the block finalizes, monotone non-decreasing under streaming * growth, re-derived per render (the container reads it right after * calling render()). The container extends the native-scrollback commit * boundary through these rows so a long streaming reply's scrolled-off * head reaches terminal history mid-stream. Declaring a row that later * changes strands a stale copy in immutable history (the engine audit * repairs by recommitting below — duplication, never loss), so * implementers report only rows whose bytes provably cannot change (e.g. * rendered output of markdown's frozen token prefix). Absent = 0: nothing * commits until the block finalizes. */ getTranscriptBlockSettledRows?(): number; /** * Whether the block is a displaceable snapshot (todo/poll card) kept * unfinalized only so a follow-up matching call can retract it. Paired * with {@link seal}: once any of its rows enters native scrollback the * container seals it — rows on the tape are immutable, so retraction is * no longer possible, and an unfinalized block would otherwise pin the * live-region seam open for the rest of the turn (every row committed * below it audit-exempt, mass-recommitted when it finally finalizes). */ isDisplaceableBlock?(): boolean; /** Finalize a displaceable snapshot in place (settle animation, freeze bytes). */ seal?(): void; } function isBlockFinalized(child: Component): boolean { const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized; return fn ? fn.call(child) : true; } function getBlockVersion(child: Component): number | undefined { const fn = (child as Component & FinalizableBlock).getTranscriptBlockVersion; return fn ? fn.call(child) : undefined; } /** Clamped read of a block's declared settled rows (see {@link FinalizableBlock}). */ function getBlockSettledRows(child: Component): number { const fn = (child as Component & FinalizableBlock).getTranscriptBlockSettledRows; if (!fn) return 0; const value = fn.call(child); return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } /** Seal a displaceable snapshot whose rows entered native scrollback (see {@link FinalizableBlock.isDisplaceableBlock}). */ function sealCommittedSnapshot(child: Component): void { const block = child as Component & FinalizableBlock; if (block.isDisplaceableBlock?.()) block.seal?.(); } function setBlockCommittedRows(child: Component, rows: number): void { (child as Component & Partial).setNativeScrollbackCommittedRows?.(rows); } // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed // to a background-colored padding row, whose escape sequences contain `\S` and // are therefore preserved as part of a block's visual design. const NON_WHITESPACE = /\S/; function isPlainBlank(line: string): boolean { return !NON_WHITESPACE.test(line); } // Strip leading/trailing plain-blank rows so each block contributes only its // visible body; the container owns the gaps between blocks. Returns the input // array unchanged when there is nothing to trim (no allocation on the hot path). function stripPlainBlankEdges(lines: readonly string[]): readonly string[] { let start = 0; let end = lines.length; while (start < end && isPlainBlank(lines[start]!)) start++; while (end > start && isPlainBlank(lines[end - 1]!)) end--; return start === 0 && end === lines.length ? lines : lines.slice(start, end); } /** * One block's recorded contribution to the assembled transcript: the raw array * reference its render() returned, the stripped contribution derived from it, * and where those rows landed. Reference-compared on the next render — per the * Component render contract, an identical raw reference proves the block's * rows are byte-identical, so the stripped contribution and the assembled rows * can be reused without re-deriving anything. */ interface BlockSegment { component: Component; rawRef: readonly string[]; contribution: readonly string[]; width: number; generation: number; /** Frame row of this block's first emitted row (the separator when present). */ startRow: number; /** Rows emitted: separator + contribution (0 for empty contributions). */ rowCount: number; 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; } const EMPTY_SEGMENTS: BlockSegment[] = []; /** Shared empty result for an empty viewport-tail render (no allocation). */ const EMPTY_TAIL: readonly string[] = []; /** * Transcript container that renders every block's current content each frame * and reports the native-scrollback exactness boundary * (`NativeScrollbackLiveRegion`): the frame row below which every rendered * row is final. The boundary covers the leading run of finalized blocks plus * the first still-live block's declared settled rows * ({@link FinalizableBlock.getTranscriptBlockSettledRows}). Rows below it * commit to native scrollback as exact, audited content; rows above it that * scroll off the window commit as frozen visual snapshots the engine never * re-anchors or recommits (the tape records what was on screen). * * The engine never rewrites committed history: rows that have entered the * tape keep whatever bytes they were committed with ("let the history be"), * while the visible window always repaints from each block's latest render — * a late tool result, a post-finalize error pin, or an expand toggle is * always reflected on screen while it remains in the window. * * Assembly is incremental: the returned array is persistent and mutated in * place. Each block's render is still called every frame, but a block whose * render returned the same array reference at an unchanged offset reuses its * previously assembled rows; the array is truncated and re-pushed only from * the first divergent block. The leading byte-identical row count is reported * through {@link RenderStablePrefix} so the engine can skip marker scanning, * line preparation, and the committed-prefix audit for those rows. */ export class TranscriptContainer extends Container 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. #generation = 0; // Local line index below which every row of the most recent render is // final: the leading finalized blocks plus the first live block's declared // settled rows. TUI commits rows to native scrollback only above it. #nativeScrollbackLiveRegionStart: number | undefined; // Persistent assembled transcript rows. Rows before the stable floor are // byte-identical to the previous render; rows at/after it were re-pushed. #lines: string[] = []; #segments: BlockSegment[] = EMPTY_SEGMENTS; #renderWidth = -1; // Local rows already committed to native scrollback by the previous frame. // 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 // between engine frames lower it; they can never inflate it. #stableRowsFloor = 0; override invalidate(): void { // Theme/global invalidation: retire every diff snapshot so stale styling // is not diffed against the recolored render. this.#generation++; super.invalidate(); } 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++) { const child = this.children[i]!; const segment = this.#segments[i]; if (segment === undefined || segment.component !== child) continue; const committedContribution = Math.min( segment.contribution.length, Math.max(0, this.#committedRows - segment.startRow - segment.sep), ); if (committedContribution === 0) { setBlockCommittedRows(child, 0); continue; } // Transcript assembly strips plain blank edges from each block. Map the // committed contribution back into the child's raw render coordinates so // nested containers can split the prefix against their exact child rows. let leadingTrimmedRows = 0; while (leadingTrimmedRows < segment.rawRef.length && isPlainBlank(segment.rawRef[leadingTrimmedRows]!)) { leadingTrimmedRows++; } setBlockCommittedRows(child, Math.min(segment.rawRef.length, leadingTrimmedRows + committedContribution)); } } 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; return value; } getNativeScrollbackLiveRegionStart(): number | undefined { return this.#nativeScrollbackLiveRegionStart; } /** * Whether none of `component`'s rows (per the most recent render) have * entered native scrollback. Callers that retract ephemeral blocks (IRC * cards, displaceable todo/job snapshots) must check this: removing a * block whose rows are already on the tape is an interior deletion of * committed history the engine cannot express — the block must be sealed * in place as history instead. A component that has never rendered has no * 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; return segment.rowCount === 0 || segment.startRow >= this.#committedRows; } return true; } /** * Whether `component` is inside the live (repaintable) region exactly as * {@link render} computes it: at/after the first still-mutating block, or * the transcript tail when every block has finalized. Self-animating * finalized blocks (a detached task's shimmering progress rows) poll this * to stop animating — and settle on static bytes — the moment they sit * above the seam, where their rows become commit-eligible native-scrollback * history. */ isBlockInLiveRegion(component: Component): boolean { const children = this.children; const index = children.indexOf(component); if (index < 0) return false; for (let i = 0; i <= index; i++) { if (!isBlockFinalized(children[i]!)) return true; } // Every block at/before `index` finalized: the live region starts at the // first unfinalized block below it, or at the last child when none exists. for (let i = index + 1; i < children.length; i++) { if (!isBlockFinalized(children[i]!)) return false; } return index === children.length - 1; } /** * Render only the bottom `maxRows` rows of the transcript at `width`, walking * blocks from the last toward the first and stopping the instant enough rows * are collected — blocks above the fold are never rendered. The engine's * resize viewport fast path uses this so a drag (a SIGWINCH burst, each event * a fresh width that misses every per-width cache) re-lays-out only the * handful of visible blocks instead of the whole history every event. * * State-isolated by contract: touches none of the persistent full-compose * fields (#lines, #segments, the per-block diff snapshots, the commit/stable * bookkeeping), so the authoritative full render on settle reconciles exactly * as if this never ran. Calling each block's render() still warms its own * per-width cache, which that settle render then reuses for free. * * Consecutive visible blocks are joined by exactly one blank separator, the * same rule render() applies, so the result equals the bottom of a full * render except for an at-most-one-row separator on the topmost included * block — a transient discrepancy the settle paint overwrites. */ renderViewportTail(width: number, maxRows: number): readonly string[] { width = Math.max(1, width); 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--) { const contribution = stripPlainBlankEdges(this.children[i]!.render(width)); if (contribution.length === 0) continue; // One blank separator sits between this block and the (already // collected) visible block below it. if (collected.length > 0) total += 1; collected.push(contribution); total += contribution.length; } if (collected.length === 0) return EMPTY_TAIL; const rows: string[] = []; for (let k = collected.length - 1; k >= 0; k--) { if (rows.length > 0) rows.push(""); const body = collected[k]!; for (let j = 0; j < body.length; j++) rows.push(body[j]!); } return rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows; } override render(width: number): readonly string[] { width = Math.max(1, width); 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 // computed against): immutable history can no longer be retracted, and // left unfinalized such a block would pin the live-region seam open below // it. Runs before the live-block scan so the seam unpins in this same // 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++) { 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); } // The commit boundary stops at the earliest still-mutating block. A // block that has not finalized must gate it: out-of-band inserts // (TTSR/todo cards) can append a finalized block *below* a tool that is // still awaiting its result, and committing rows there would strand the // tool's history rows on a mid-stream preview the late result never // reaches. let liveStartIndex = -1; let hasLiveBlock = false; for (let i = this.#compactedChildStart; i < count; i++) { if (!isBlockFinalized(this.children[i]!)) { liveStartIndex = i; hasLiveBlock = true; break; } } const lines = this.#lines; const previousSegments = this.#segments; const segments: BlockSegment[] = new Array(count); // Poisoned until the walk completes: a block render throwing mid-walk // leaves the persistent array half-rebuilt, and the next render must // not trust stale segments against it. Restored at the end. this.#segments = EMPTY_SEGMENTS; const stableFloorBefore = this.#stableRowsFloor; this.#stableRowsFloor = 0; // Stability requires the same width and, per segment, the same block at // the same offset returning the same array reference. The first // divergence truncates the persistent array there; everything after // re-pushes. let chainStable = this.#renderWidth === width; this.#renderWidth = width; // Entry-unstable (width change): the divergence truncation inside the // loop only fires on a stable→unstable transition, so reset the // persistent array here to keep the `!chainStable ⇒ lines.length === row` // invariant — otherwise re-pushed rows land after the stale frame. if (!chainStable) lines.length = 0; // Frame row cursor: rows emitted (reused or pushed) so far. let row = 0; let stableRows = 0; for (let i = this.#compactedChildStart; i < count; i++) { const child = this.children[i]!; // This child's contribution: its current render with plain-blank // top/bottom edges stripped (the container owns inter-block gaps). // Finalized blocks wholly inside committed native scrollback can reuse // their previous contribution without calling render(): those rows are // immutable terminal history for the current width/generation. Blocks // outside committed history still render normally so late results, // post-finalize re-layouts, and expand toggles remain visible. const previous = previousSegments[i]; const finalized = isBlockFinalized(child); const version = getBlockVersion(child); const committedReusable = previous !== undefined && previous.component === child && previous.width === width && previous.generation === this.#generation && previous.startRow === row && previous.startRow + previous.rowCount <= this.#committedRows && finalized && // Only replay bytes that were themselves produced by a finalized // render: a block finalizing between frames may have changed content // while its rows were already committed via the append-only live // path, so the first post-transition frame must render. Defense in // depth on the transcript side — the TUI commit policy should keep // that window closed, but the safety must not live there alone. previous.finalized && // Post-finalize mutations (inline error restore, late tool images) // bump the block version; a mismatch forces a real render so the // committed-prefix audit can observe and re-anchor the change. previous.version === version; const raw = committedReusable ? previous.rawRef : child.render(width); const reusable = committedReusable || (previous !== undefined && previous.component === child && previous.rawRef === raw && 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 // boundary at its position: if it later gains rows, it pushes // everything below it. if (contribution.length === 0) { if (hasLiveBlock && i === liveStartIndex) { this.#nativeScrollbackLiveRegionStart = row; } if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) { chainStable = false; lines.length = row; } if (chainStable) stableRows = row; segments[i] = { component: child, rawRef: raw, contribution, width, generation: this.#generation, startRow: row, rowCount: 0, sep: 0, finalized, compactable, version, }; continue; } // Every block is separated from preceding visible content by exactly one // blank row — skipped when it opens the transcript or the prior row is // already a plain blank (a fragment's own trailing pad), never doubling. // `lines[row - 1]` is valid in both modes: reused rows are still present // in the persistent array, re-pushed rows were just written. const sep = row > 0 && !isPlainBlank(lines[row - 1]!) ? 1 : 0; // The separator before the first live block stays in the committed // prefix (it is deterministic once the prior block's body is // settled); the boundary then extends through the live block's // declared settled rows, mapped from its raw render into the // stripped contribution. if (hasLiveBlock && i === liveStartIndex) { let settled = 0; const settledRaw = getBlockSettledRows(child); if (settledRaw > 0) { let lead = 0; while (lead < raw.length && isPlainBlank(raw[lead]!)) lead++; settled = Math.max(0, Math.min(contribution.length, settledRaw - lead)); } this.#nativeScrollbackLiveRegionStart = row + sep + settled; } const rowCount = sep + contribution.length; const stable = chainStable && reusable && previous.startRow === row && previous.sep === sep; if (stable) { stableRows = row + rowCount; } else { if (chainStable) { chainStable = false; lines.length = row; } if (sep) lines.push(""); for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!); } segments[i] = { component: child, rawRef: raw, contribution, width, generation: this.#generation, startRow: row, rowCount, sep, finalized, compactable, version, }; row += rowCount; } // Trailing shrink: blocks removed from the tail leave stale rows behind // when every surviving segment was reused. 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); } } } /** * Groups a run of sibling rows (an IRC card's header + body, a file-mention * list, a bordered command/version panel) into a single transcript child so the * container spaces it as one block — one blank line above, none injected between * its rows. Without this wrapper the rows would be top-level children and the * container would put a blank line between each (and inside any border box). * It is a plain {@link Container}; the named subclass documents intent and makes * every manual block grouping greppable. */ export class TranscriptBlock extends Container {}