import { type Component, Container, type NativeScrollbackCommittedRows, type NativeScrollbackLiveRegion, type RenderStablePrefix, type ViewportTailProvider, } from "@oh-my-pi/pi-tui"; const kSnapshot = Symbol("transcript.liveDiffSnapshot"); /** * Per-block render cache: the block's previous stripped contribution plus the * derived append-only state. Still-live blocks use it as input to * {@link deriveLiveCommitState}; finalized blocks wholly inside already * committed native scrollback can replay it without calling render(). */ interface LiveDiffSnapshot { width: number; lines: readonly string[]; generation: number; appendOnly: boolean; /** * Frames remaining until a block that rewrote an interior row may re-earn * append-only status. `0` means the block is not under rewrite suspicion. */ volatileCooldown: number; /** * Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows * promoted as commit-safe because they stayed visibly identical for * {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight * candidate run and its age. */ stablePrefixLength: number; candidatePrefixLength: number; candidatePrefixAge: number; /** * Topmost row index ever observed rewritten in place (see * {@link deriveLiveCommitState}): the stable-prefix ratchet never promotes * rows at/after it. `Infinity` until the first rewrite. */ rewriteFloor: number; } interface SnapshotCarrier { [kSnapshot]?: LiveDiffSnapshot; } /** * 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; /** * Whether a still-live block's visually settled leading rows are durable — * guaranteed to survive the block's remaining transitions (finalize, * displacement) byte-stable — and may therefore be promoted as commit-safe * by {@link deriveLiveCommitState}. Blocks whose pending render is * provisional (a tool call's tail-window streaming preview, replaced * wholesale by the result render) return `false`: committing such rows * strands a stale copy in immutable terminal history the moment the real * content re-lays-out the block (the engine audit recommits below it — * "duplication, never loss"). Absent = `true`, the default for blocks * whose live rows persist (a streaming assistant message). */ isTranscriptBlockCommitStable?(): boolean; } 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; } function isBlockCommitStable(child: Component): boolean { const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable; return fn ? fn.call(child) : true; } // 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; /** 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[] = []; interface LiveCommitState { appendOnly: boolean; volatileCooldown: number; stablePrefixLength: number; candidatePrefixLength: number; candidatePrefixAge: number; rewriteFloor: number; safeLength: number; } /** * Render frames a block must stay clean (static or append-shaped) after an * interior rewrite before its rows become committable again. A one-off * re-layout (a codespan finalizing across a wrap boundary, a paragraph * re-parsed as a heading) only suspends commits briefly — the pinned emitter * appends from the stalled high-water mark, so the gap backfills contiguously * once the block re-earns append-only. Periodic animations (a spinner rewrites * its row every few frames) keep resetting the countdown and never re-earn it, * so genuinely volatile blocks stay deferred. Frames arrive at most at the * TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming. */ const VOLATILE_REARM_FRAMES = 30; /** * Consecutive frames a leading row run must stay visibly identical before it * is promoted as commit-safe even though the block's tail keeps rewriting. * Append-only detection alone is all-or-nothing per block: one perpetually * ticking row (a task tool's progress tree, per-agent cost/tool counters, a * log line spinner) suspends commits for the WHOLE block forever, so once the * block outgrows the viewport its static head — e.g. a task's prompt/context * markdown — is neither committed to native scrollback nor on screen: the * transcript reads as cut off for the entire (possibly minutes-long) run. * The ratchet commits the settled head while only the genuinely volatile tail * stays deferred. If a promoted row is later rewritten (a collapsing * preview), the engine's committed-prefix audit re-anchors and recommits — * duplication, never loss — and the ratchet retreats to the divergence. */ const STABLE_PREFIX_COMMIT_FRAMES = 30; /** * Rows at a live block's tail treated as the volatile streaming edge. Real * streaming is not strictly append-only at the bottom: the in-flight markdown * paragraph re-wraps as words arrive (rewriting its last 1-2 visual rows), an * unclosed token (`**bold`, a half-streamed link) re-renders when its closer * arrives, and a wrap-shrink moves the last word onto a new row. Divergence * confined to this zone is clean growth, and the zone itself is held back * from the offered commit boundary — so a tolerated rewrite can never touch a * row the engine may have committed. Width 4 covers the observed shapes (≤2 * rows) with margin for wide glyphs and multi-row token spans; the cost is * only that the last 4 rows of a live block commit at finalization instead of * mid-stream, which is invisible (they are on screen — the viewport is always * taller than the holdback). */ const TAIL_VOLATILITY_ROWS = 4; /** * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are * write framing, not content. A styled line's closing escape moves when the * line stops being the last of its span (a wrapped thinking paragraph growing * by one row), and width-padded rows shift their trailing spaces as text * grows; both leave the on-screen cells identical and must not count as a * rewrite of a committed-candidate row. Committed scrollback rows are written * with a full SGR/OSC reset terminator, so escape-placement drift between * visually identical renders cannot bleed styles across rows. */ function normalizeRow(line: string): string { return Bun.stripANSI(line).trimEnd(); } function rowsVisiblyEqual(prev: string, cur: string): boolean { return prev === cur || normalizeRow(prev) === normalizeRow(cur); } /** * Whether `cur` is `prev` grown in place: the visible content of `prev` is a * strict-or-equal prefix of `cur`'s (token streaming appending to the cursor * row). Escape placement and pad drift are ignored, same as rowsVisiblyEqual. */ function rowVisiblyGrew(prev: string, cur: string): boolean { return normalizeRow(cur).startsWith(normalizeRow(prev)); } function hasValidSnapshot( snapshot: LiveDiffSnapshot | undefined, width: number, generation: number, ): snapshot is LiveDiffSnapshot { return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width; } function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number { const limit = Math.min(prev.length, cur.length); let i = 0; while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++; return i; } function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number { const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength); let i = 0; while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++; return i; } function deriveLiveCommitState( previous: LiveDiffSnapshot | undefined, current: readonly string[], width: number, generation: number, ): LiveCommitState { let appendOnly = false; let volatileCooldown = 0; let stablePrefixLength = 0; let candidatePrefixLength = 0; let candidatePrefixAge = 0; let rewriteFloor = Number.POSITIVE_INFINITY; let trailingRowGrowth = false; if (hasValidSnapshot(previous, width, generation)) { appendOnly = previous.appendOnly; volatileCooldown = previous.volatileCooldown; stablePrefixLength = previous.stablePrefixLength; candidatePrefixLength = previous.candidatePrefixLength; candidatePrefixAge = previous.candidatePrefixAge; rewriteFloor = previous.rewriteFloor; const prefixLength = commonPrefixLength(previous.lines, current); const staticRender = prefixLength === previous.lines.length && prefixLength === current.length; let cleanFrame = true; if (!staticRender) { const suffixLength = commonSuffixLength(previous.lines, current, prefixLength); // Append-only growth never rewrites a row that may already have scrolled // into native scrollback; it only grows the block at/near its tail. Two // shapes qualify: // - a pure insertion that preserves every previous row across a // matching prefix + suffix (a bottom append, or an insertion above // stable trailing chrome like a streaming tool's footer/border); // - a rewrite whose divergence BEGINS inside the trailing // TAIL_VOLATILITY_ROWS of the previous render — the streaming edge: // the in-flight paragraph re-wrapping as words arrive (its last 1-2 // visual rows), an unclosed markdown token (`**bold`) re-rendering // when its closer streams in, a wrap-shrink pushing the last word // onto an appended row. That zone is held back from `safeLength` // below, so a tolerated rewrite can never touch a row that was // offered for commit. // The anchor matters: the gap must START in the tail zone, not merely // be small — a one-row ticker mid-block with stable rows beneath it // would otherwise classify clean, get offered past, and rewrite // committed rows on every tick. Any deeper divergent row means the // block re-laid-out committed-candidate content — a rewrite, which // suspends commits until the block re-earns append-only. const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length; const tailConfined = preservedEveryRow || prefixLength >= previous.lines.length - TAIL_VOLATILITY_ROWS; if (tailConfined && current.length >= previous.lines.length) { // Strict trailing-row growth: every previous row except the last // is visibly unchanged and the last grew in place as a visible // prefix, with no rows appended — a line accumulating tokens. // The sole divergent row is the block's physical last row, which // the engine's window floor never commits while it stays last // (chunkTo ≤ windowTop ≤ last row index), so the volatile-tail // holdback below is unnecessary: the whole body is offerable and // the block's scrolled-off head reaches native scrollback. trailingRowGrowth = current.length === previous.lines.length && prefixLength === previous.lines.length - 1 && rowVisiblyGrew(previous.lines[prefixLength]!, current[prefixLength]!); if (volatileCooldown === 0) appendOnly = true; // Clean growth inserts/rewrites rows at the divergence; a floor // inside the preserved suffix travels down with it, a floor at or // above the divergent zone stays put (conservative: a stale floor // index can only point at an earlier row, never a later one). const delta = current.length - previous.lines.length; if (delta > 0 && Number.isFinite(rewriteFloor)) { const suffixStart = Math.max(prefixLength, previous.lines.length - suffixLength); if (rewriteFloor >= suffixStart) rewriteFloor += delta; } } else { cleanFrame = false; appendOnly = false; volatileCooldown = VOLATILE_REARM_FRAMES; } } if (cleanFrame && volatileCooldown > 0) volatileCooldown--; // Stable-prefix ratchet, independent of append-only. `prefixLength` is // this frame's visibly-unchanged leading run; the candidate accumulates // the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so // promotion means every promoted row stayed identical for the whole // window (row r is inside frame i's common prefix iff r < p_i, so // r < min(p) holds for every frame of the window). A row settling // mid-window promotes at most two windows later. The engine audit owns // any promoted rows that already committed (recommit, never loss). if (prefixLength < stablePrefixLength) { // A divergence inside the promoted run is the ratchet's proof of // over-promotion: this row was visibly stable for a full window, // got promoted (and likely committed), and then mutated anyway — a // slow ticker (an agent row's tool/cost counter, a growing progress // tree), not settling content. It will mutate again, and every // promote→mutate cycle makes the engine audit recommit, spraying a // stale snapshot of the block into native scrollback. Floor the // ratchet at the divergence permanently: rows above it may still // promote, rows at/below it never re-promote while the block lives. // One-off re-layouts before any promotion (a call→result frame // transition, a codespan finalizing) never hit this branch, and the // append-only re-arm path commits the full block regardless of the // floor. rewriteFloor = Math.min(rewriteFloor, prefixLength); stablePrefixLength = prefixLength; candidatePrefixLength = prefixLength; candidatePrefixAge = 0; } else { candidatePrefixLength = candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength); candidatePrefixAge++; if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) { // Cap at the volatile-tail holdback: a long static stretch would // otherwise promote the streaming edge itself (min prefix == full // length), and the next chunk's tail re-wrap would then rewrite // offered rows. stablePrefixLength = Math.min( candidatePrefixLength, rewriteFloor, Math.max(0, current.length - TAIL_VOLATILITY_ROWS), ); candidatePrefixLength = prefixLength; candidatePrefixAge = 0; } } } return { appendOnly, volatileCooldown, stablePrefixLength, candidatePrefixLength, candidatePrefixAge, rewriteFloor, // A clean-streaming block's body is committable up to the volatile-tail // holdback (the streaming edge is never offered, so its tolerated // rewrites can never touch committed rows); otherwise the settled head // still is — only the volatile tail stays deferred. Strict in-place // growth of the trailing row skips the holdback: its only mutable row // is the block's last, which cannot commit while it remains last. safeLength: appendOnly ? trailingRowGrowth ? current.length : Math.max(stablePrefixLength, current.length - TAIL_VOLATILITY_ROWS, 0) : stablePrefixLength, }; } /** * Transcript container that renders every block's current content each frame * and reports the live-region seam (`NativeScrollbackLiveRegion`) that gates * the engine's append-only scrollback commits. * * The engine never rewrites committed history: rows above the seam 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. Blocks that are still mutating (an * unfinalized tool, a streaming assistant message) stay below the seam so * their rows do not enter history while they can still change; a streaming * block whose render grows append-only deepens the seam through its settled * head so a long reply's scrolled-off rows still reach scrollback mid-stream. * * 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, RenderStablePrefix, ViewportTailProvider { // Bumped to retire every block's diff snapshot at once (theme change / // clear); a snapshot is only honored when its stored generation matches. #generation = 0; // Local line index where the current live region begins in the most recent // render. TUI commits rows to native scrollback only above this seam (or // the deeper commit-safe end below). #nativeScrollbackLiveRegionStart: number | undefined; // Local line index up to which the leading run of live blocks is safe to // commit. Finalized blocks contribute their full body; still-live blocks // contribute only while their render has been observed growing without // visibly rewriting a previously rendered interior row (escape placement // and pad drift are ignored). A rewrite suspends the block's contribution // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames; // the engine then backfills the stalled gap. #nativeScrollbackCommitSafeEnd: number | undefined; // Local line index up to which the leading run of live blocks is DURABLE: a // commit-stable block's full body is permanent content even while its interior // rows re-lay-out (a streaming markdown table re-aligning columns), so the // engine must append their scroll-off snapshot rather than drop it. Reported // separately from the byte-stable commit-safe end because these rows may still // drift after commit; the engine commits them audit-exempt. Provisional // (commit-unstable) blocks never extend it. #nativeScrollbackSnapshotSafeEnd: 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; // 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(); } setNativeScrollbackCommittedRows(rows: number): void { this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 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; } getNativeScrollbackCommitSafeEnd(): number | undefined { return this.#nativeScrollbackCommitSafeEnd; } getNativeScrollbackSnapshotSafeEnd(): number | undefined { return this.#nativeScrollbackSnapshotSafeEnd; } /** * Whether `component` sits below a still-mutating block — i.e. inside the * live region, where its rows cannot have been committed to native * scrollback yet (commits are prefix-only and stop at the first * still-live block). Callers that retract ephemeral blocks (IRC cards) * must check this: removing a block whose rows may already be in history * is an interior deletion of the committed prefix, which the engine can * only repair by recommitting everything below it — duplication. */ isWithinLiveRegion(component: Component): boolean { const index = this.children.indexOf(component); if (index < 0) return false; for (let i = 0; i < index; i++) { if (!isBlockFinalized(this.children[i]!)) return true; } return false; } /** * 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. Unlike * {@link isWithinLiveRegion} (strictly below a still-mutating block, i.e. * guaranteed-uncommitted), this also counts the trailing block that anchors * the live region. 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 >= 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 // 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; this.#nativeScrollbackCommitSafeEnd = undefined; this.#nativeScrollbackSnapshotSafeEnd = undefined; const count = this.children.length; // The live region spans from the earliest still-mutating block through the // bottom. A block that has not finalized must stay below the seam: out-of- // band inserts (TTSR/todo cards) can append a finalized block *below* a // tool that is still awaiting its result, and committing the tool there // would strand its history rows on the mid-stream preview the late result // never reaches. let liveStartIndex = count - 1; for (let i = 0; i < count; i++) { if (!isBlockFinalized(this.children[i]!)) { liveStartIndex = i; 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; // Tracks whether we are still inside the leading run of commit-safe live // blocks. The first still-live volatile block closes it, but rendering // continues so lower blocks remain visible. let commitSafeOpen = true; // The live-region start is recorded at the first visible row at/after // liveStartIndex; empty leading blocks (or a separator) must not claim it // early. let liveRecorded = false; // Frame row cursor: rows emitted (reused or pushed) so far. let row = 0; let stableRows = 0; for (let i = 0; i < count; i++) { const child = this.children[i]! as Component & SnapshotCarrier; // 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 previousSnapshot = child[kSnapshot]; 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); let liveCommitState: LiveCommitState | undefined; // Provisional live renders (commit-unstable blocks) never feed the // promotion machinery: their settled-looking rows are replaced // wholesale on finalize, so offering them would commit a stale // preview the result render can only duplicate, never erase. if (i >= liveStartIndex && !finalized && isBlockCommitStable(child)) { liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation); } // Cache the latest contribution as the next frame's diff input. child[kSnapshot] = { width, lines: contribution, generation: this.#generation, appendOnly: liveCommitState?.appendOnly ?? false, volatileCooldown: liveCommitState?.volatileCooldown ?? 0, stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0, candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0, candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0, rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY, }; // Empty (or stripped-to-nothing) children contribute nothing and never // affect spacing or the live-region offsets. An empty still-live child // still closes the commit-safe run: if it later gains rows, it pushes // everything below it. if (contribution.length === 0) { if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false; 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, 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), // so the live region begins at the block's first content row. if (!liveRecorded && i >= liveStartIndex) { this.#nativeScrollbackLiveRegionStart = row + sep; liveRecorded = true; } 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]!); } const blockStart = row + sep; if (i >= liveStartIndex && commitSafeOpen) { const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0); if (safeLength > 0) { this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength; } // Durable snapshot end: a commit-stable block's whole body is durable // content — its scrolled-off rows are permanent even while interior // rows re-lay-out (a streaming table re-aligning columns), so the // engine must commit their snapshot on scroll-off rather than drop it. // Finalized blocks are wholly durable; provisional (commit-unstable) // blocks offer nothing beyond their byte-stable safe length. const snapshotLength = finalized || isBlockCommitStable(child) ? contribution.length : safeLength; if (snapshotLength > 0) { this.#nativeScrollbackSnapshotSafeEnd = blockStart + snapshotLength; } // A finalized, fully safe block may let the contiguous safe run extend // into blocks rendered below it. A still-live block keeps pushing lower // rows around as it grows, so the run closes there. if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false; } segments[i] = { component: child, rawRef: raw, contribution, width, generation: this.#generation, startRow: row, rowCount, sep, finalized, 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); return lines; } } /** * 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 {}