From d5e9084e6587ac510d87e1bf2e92b9c31ffa64ab Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Jul 2026 09:45:57 +0200 Subject: [PATCH] refactor: restructured audit logic and render boundary tracking - Removed complex snapshot caching and volatile/stable state tracking logic. - Replaced multi-zone audit logic with streamlined tail-sample checks. - Simplified render boundaries by deriving a single final boundary from the live region. - Eliminated redundant audit state management and auxiliary safe-end interfaces. --- packages/coding-agent/CHANGELOG.md | 4 + .../modes/components/transcript-container.ts | 479 +++--------------- packages/tui/CHANGELOG.md | 5 + packages/tui/src/components/markdown.ts | 63 ++- packages/tui/src/tui.ts | 400 ++++----------- 5 files changed, 230 insertions(+), 721 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8de3614a0..8904c09c3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Updated transcript streaming logic to support custom settled row declarations for scrollback + ## [16.3.5] - 2026-07-04 ### Fixed diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index a4abc2379..8a49a5e39 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -7,45 +7,6 @@ import { 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 @@ -65,18 +26,20 @@ interface FinalizableBlock { */ 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). + * 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. */ - isTranscriptBlockCommitStable?(): boolean; + getTranscriptBlockSettledRows?(): number; } function isBlockFinalized(child: Component): boolean { @@ -89,9 +52,12 @@ function getBlockVersion(child: Component): number | undefined { return fn ? fn.call(child) : undefined; } -function isBlockCommitStable(child: Component): boolean { - const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable; - return fn ? fn.call(child) : true; +/** 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; } // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks @@ -143,270 +109,24 @@ 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. + * and reports the native-scrollback commit 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}); the engine + * commits rows to native scrollback only above it. * - * 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. + * The engine never rewrites committed history: rows above the boundary 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 at/below + * the boundary so their rows do not enter history while they can still + * change; when the live region outgrows the viewport its undeclared rows + * scroll into a deferred gap and reach history, in order, once they settle. * * 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 @@ -420,29 +140,13 @@ 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. + // 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 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). + // 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; - // 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[] = []; @@ -483,14 +187,6 @@ export class TranscriptContainer 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 @@ -581,21 +277,21 @@ export class TranscriptContainer 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; + // 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 = 0; i < count; i++) { if (!isBlockFinalized(this.children[i]!)) { liveStartIndex = i; + hasLiveBlock = true; break; } } @@ -621,19 +317,11 @@ export class TranscriptContainer // 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; + 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). @@ -642,7 +330,6 @@ export class TranscriptContainer // 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); @@ -674,33 +361,15 @@ export class TranscriptContainer 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 + // 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 (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false; + if (hasLiveBlock && i === liveStartIndex) { + this.#nativeScrollbackLiveRegionStart = row; + } if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) { chainStable = false; lines.length = row; @@ -729,11 +398,19 @@ export class TranscriptContainer 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; + // 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; @@ -749,28 +426,6 @@ export class TranscriptContainer 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, diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 0d7ee4464..694779b88 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Changed + +- Simplified native scrollback logic to use a single finality boundary +- Optimized committed prefix auditing by removing exempt-zone tracking + ## [16.3.5] - 2026-07-04 ### Fixed diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index c9624a9b9..b6eee348c 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -817,6 +817,21 @@ export class Markdown implements Component { #streamPrefixText?: string; #streamPrefixTokens?: Token[]; #streamPrefixLineCache?: StreamPrefixLineCache; + // Rows of the most recent render() that are settled — top padding plus the + // rendered frozen token prefix — exposed via getLastRenderSettledRows() + // for native-scrollback commit gating. + #lastRenderSettledRows = 0; + // Frozen-prefix text backing the last non-zero settled exposure. Settled + // rows are declared final downstream, so a render whose frozen text no + // longer extends this prefix (a rewind / wholesale rewrite) resets the + // exposure to 0 and re-earns it — the exposure is hard-monotone within a + // text lineage. + #settledExposedText?: string; + // True while #renderStreamingContentLines renders the frozen token range: + // frozen code blocks highlight even in transient mode so their bytes match + // the finalized render (they render once into the prefix line cache, so + // the FFI cost is amortized); the volatile tail stays unhighlighted. + #renderingFrozenPrefix = false; #ignoreTight = false; @@ -857,6 +872,7 @@ export class Markdown implements Component { this.#streamPrefixText = undefined; this.#streamPrefixTokens = undefined; this.#streamPrefixLineCache = undefined; + this.#settledExposedText = undefined; } this.invalidate(); return true; @@ -878,6 +894,19 @@ export class Markdown implements Component { this.invalidate(); } + /** + * Rows at the top of the most recent render() (top padding + rendered + * frozen-token prefix) whose bytes are settled: byte-stable at this + * width/theme for as long as the text keeps growing append-only. Hosts + * feed this to transcript commit gating (see the coding agent's + * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming + * (`transientRenderCache`) mode, after a text rewind (re-earned on the new + * lineage), and on cache-served non-streaming renders. + */ + getLastRenderSettledRows(): number { + return this.#lastRenderSettledRows; + } + // Lex `text` into block tokens, reusing the frozen stable prefix when the text // only grew (the streaming path). Falls back to a full lex whenever the prefix // is no longer a prefix (non-append edit), the text carries reference-link @@ -965,6 +994,10 @@ export class Markdown implements Component { return this.#cachedLines; } + // Recomputed below by the streaming path; every other path (cache-served, + // empty text, non-streaming full render) exposes no settled rows. + this.#lastRenderSettledRows = 0; + // Calculate available width for content (subtract horizontal padding) const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX); const contentWidth = Math.max(1, width - paddingX * 2); @@ -1076,9 +1109,16 @@ export class Markdown implements Component { } if (renderedUntil < frozenTokenCount) { - contentLines.push( - ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature), - ); + // Frozen tokens render with full fidelity (syntax highlighting on) + // so these cached rows byte-match the finalized render. + this.#renderingFrozenPrefix = true; + try { + contentLines.push( + ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature), + ); + } finally { + this.#renderingFrozenPrefix = false; + } renderedUntil = frozenTokenCount; } @@ -1089,6 +1129,19 @@ export class Markdown implements Component { lines: contentLines.slice(), }; + // Settled exposure (hard-monotone): these rows are declared final to + // the host, so expose them only while the frozen text still extends + // the previously exposed prefix; a rewind resets to 0 and re-earns on + // the rewritten lineage. + if (contentLines.length > 0) { + if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) { + this.#settledExposedText = frozenText; + this.#lastRenderSettledRows = signature.paddingY + contentLines.length; + } else { + this.#settledExposedText = undefined; + } + } + if (renderedUntil < tokens.length) { contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature)); } @@ -1361,7 +1414,7 @@ export class Markdown implements Component { const codeIndent = padding(this.#codeBlockIndent); lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.#theme.highlightCode && !this.transientRenderCache) { + if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) { const highlightedLines = this.#theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { lines.push(`${codeIndent}${hlLine}`); @@ -1751,7 +1804,7 @@ export class Markdown implements Component { // Code block in list item const codeIndent = padding(this.#codeBlockIndent); lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false }); - if (this.#theme.highlightCode && !this.transientRenderCache) { + if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) { const highlightedLines = this.#theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { lines.push({ text: `${codeIndent}${hlLine}`, nested: false }); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d59015ab2..19b449702 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -183,45 +183,23 @@ export interface OverlayFocusOwner { } /** - * Component seam for append-only native-scrollback commits. A component that - * renders a finalized prefix followed by a live/mutating suffix reports the - * local line index where that suffix begins after each render. The engine - * commits rows to native scrollback only up to that boundary; everything - * below repaints in place inside the visible window and never enters history - * until it finalizes. + * Component seam for append-only native-scrollback commits. A component whose + * rendered rows can still change reports, after each render, the local line + * index where that mutable suffix begins. Rows above the boundary are declared + * FINAL — byte-stable at the current width for the component's lifetime — and + * are the only rows the engine commits to native scrollback. Rows at/after the + * boundary repaint in place inside the visible window; if they scroll above + * the window top before finalizing they are neither painted nor committed (a + * deferred gap, invisible until the boundary passes it) and enter history + * later, in order. A root that reports no seam commits everything that + * scrolls (shell semantics). * - * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary - * inside the live suffix: the line index up to which the live region is - * append-only (earlier rows never re-layout — a streaming assistant message). - * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are - * technically live, because they will never change. Without it, a single live - * block that alone overflows the window would hold its scrolled-off head out - * of history until it finalizes. Volatile live blocks (tool previews that - * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that - * reports no seam at all commits everything that scrolls (shell semantics). - * `getNativeScrollbackSnapshotSafeEnd` optionally reports a still deeper - * boundary: the line index up to which the live region is *durable* — its rows - * may still change bytes later (a streaming markdown table re-aligning its - * columns every row), but their CURRENT snapshot is permanent content, so - * dropping them when they scroll above the window is forbidden. Unlike - * `commitSafeEnd` (byte-stable: offered rows are asserted never to re-layout and - * stay under the committed-prefix audit), rows committed under the snapshot end - * are audit-EXEMPT once they pass the window top — the engine appends their - * scroll-off snapshot and never recommits them, so later layout drift becomes a - * frozen stale row in history (duplication never loss) instead of either a - * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing - * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to - * `commitSafeEnd ?? liveRegionStart` when absent. - * - * When several root children report a seam in the same frame, the topmost - * one (and its commit-safe / snapshot-safe extension) defines the boundary: - * commits are prefix-only, so everything below the first seam is already - * excluded. + * When several root children report a seam in the same frame, the topmost one + * defines the boundary: commits are prefix-only, so everything below the + * first seam is already excluded. */ export interface NativeScrollbackLiveRegion { getNativeScrollbackLiveRegionStart(): number | undefined; - getNativeScrollbackCommitSafeEnd?(): number | undefined; - getNativeScrollbackSnapshotSafeEnd?(): number | undefined; } export interface NativeScrollbackCommittedRows { @@ -243,14 +221,6 @@ function getNativeScrollbackLiveRegionStart(component: Component): number | unde return (component as Component & Partial).getNativeScrollbackLiveRegionStart?.(); } -function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined { - return (component as Component & Partial).getNativeScrollbackCommitSafeEnd?.(); -} - -function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined { - return (component as Component & Partial).getNativeScrollbackSnapshotSafeEnd?.(); -} - /** * Opt-in stability report for components that mutate their returned render * array in place across frames (instead of returning a fresh array per @@ -617,7 +587,7 @@ interface CursorControlResult extends HardwareCursorUpdate { * One root child's contribution to the composed frame: the array reference its * render() returned, the frame row it starts at, the row count recorded at * compose time (in-place mutators keep the reference but may change length), - * and the child-local seam reports captured at render time — replayed verbatim + * and the child-local seam report captured at render time — replayed verbatim * when a component-scoped frame reuses this segment without re-rendering. */ interface FrameSegment { @@ -626,8 +596,6 @@ interface FrameSegment { start: number; rowCount: number; liveLocalStart?: number; - commitLocalEnd?: number; - snapshotLocalEnd?: number; } /** Depth-first identity search through `Container`-shaped children. */ @@ -803,33 +771,25 @@ const RESYNC_TAIL_SAMPLES = 8; * re-anchor the commit index when it does not. Returns the resync row index, * or -1 when no resync is needed. * - * Audits the committed prefix [0, auditTo) EXCEPT the exempt window - * [exemptFrom, exemptTo): rows in the window are durable snapshots (a streaming - * table re-aligning its columns) that may drift legitimately, so their drift - * never triggers a re-anchor. Rows below the window — including forced-overflow - * rows committed only because they scrolled above the viewport under a - * commit-unstable barrier — ARE audited. + * Committed rows are declared-final by the component seam, so divergence is a + * contract violation (a budget-demoted image collapsing to its text fallback, + * a TTSR rewind truncating a sealed block) — rare by construction. The check + * exploits the asymmetry between the two mutation classes: an in-place + * edit/restyle of a committed row disturbs only the touched rows (alignment + * below stays intact; the stale copy in history is the long-accepted + * artifact), while an insertion/deletion shifts EVERY row below it. Up to 8 + * non-blank rows within the last 24 committed rows are compared SGR-stripped + * (theme changes stay quiet), tolerating a SINGLE mismatch: aligned ⇒ no + * resync; misaligned ⇒ resync at the first non-equivalent committed row — the + * stale copy stays in history and rows recommit from there (duplication, + * never loss) instead of silently dropping the rows beneath a stale prefix. * - * Two detectors run over the audited rows: - * - * 1. Hard scan of the now-permanent forced suffix [exemptTo, permanentEnd): - * forced-overflow rows that THIS frame asserts are durable/permanent (index < - * permanentEnd — the barrier above them finalized or cleared, so durableBoundary - * rose past them). A content change there is real finalized content, so ANY - * mismatch re-anchors. Scanned in FULL, not sampled, so a single edit far above - * the commit boundary with an unchanged tail still re-anchors (duplication, - * never loss) instead of being committed nowhere and painted nowhere. - * 2. Tail sample (only when the hard scan is clean): exploits the asymmetry - * between the two mutation classes — an in-place edit/restyle of a committed - * row disturbs only the touched rows (alignment below intact; the stale copy - * in history is the long-accepted artifact), while an insertion/deletion - * shifts EVERY row below it. So up to 8 non-blank rows within the last 24 - * audited rows are compared SGR-stripped (theme changes stay quiet), - * tolerating a SINGLE non-hard mismatch (a legitimate one-row edit): aligned ⇒ - * no resync; misaligned ⇒ resync at the first non-equivalent audited row. The - * tolerance keeps both an offscreen still-live barrier (a ticking spinner) and - * a no-seam in-place row edit from spraying duplicate snapshots every frame; - * the hard scan above is what forbids it from swallowing a finalized row. + * The single-mismatch tolerance is load-bearing for roots that report NO seam + * (shell semantics): an animated row that already scrolled into history would + * otherwise re-anchor on every glyph tick and spray a duplicate snapshot per + * frame. Seam-reporting components never commit legitimately-mutating rows, + * so for them the tolerance can only leave a single stale row in history — + * the bounded, long-accepted artifact. * * Highly repetitive tails (identical filler rows) can mask a shift in the tail * sample, in which case the skipped rows are content-identical to the committed @@ -840,57 +800,33 @@ export function findCommittedPrefixResync( frame: readonly string[], prefix: readonly string[], auditTo: number = prefix.length, - exemptFrom: number = auditTo, - exemptTo: number = exemptFrom, - permanentEnd = 0, ): number { const committed = Math.min(prefix.length, Math.max(0, Math.trunc(auditTo))); if (committed === 0) return -1; - // Exempt window [exFrom, exTo) clamped into the committed prefix. Rows there - // are durable-snapshot drift and skipped by both detectors and the scan. - const exFrom = Math.max(0, Math.min(committed, Math.trunc(exemptFrom))); - const exTo = Math.max(exFrom, Math.min(committed, Math.trunc(exemptTo))); - const audited = (i: number): boolean => i < exFrom || i >= exTo; if (frame.length >= committed) { - // 1. Hard scan: forced-overflow rows now asserted permanent. Full scan, no - // tolerance — a finalized row that changed must re-anchor. - const hardEnd = Math.min(committed, Math.max(0, Math.trunc(permanentEnd))); - let hardMismatch = false; - for (let i = exTo; i < hardEnd; i++) { - if (!rowsEquivalent(frame[i]!, prefix[i]!)) { - hardMismatch = true; - break; + // Tail sample: walk up from the commit boundary until LOOKBACK rows or + // SAMPLES non-blank comparisons. + let samples = 0; + let mismatches = 0; + for (let j = 1; j <= committed && j <= RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) { + const idx = committed - j; + const row = frame[idx]!; + const old = prefix[idx]!; + if (row === old) { + if (!isBlankRow(row)) samples++; + continue; } + if (isBlankRow(row) && isBlankRow(old)) continue; + samples++; + if (!rowsEquivalent(row, old)) mismatches++; } - if (!hardMismatch) { - // 2. Tail sample. Walk up from the commit boundary, skipping exempt - // rows, until LOOKBACK audited rows or SAMPLES non-blank comparisons. - let samples = 0; - let mismatches = 0; - let scanned = 0; - for (let j = 1; j <= committed && scanned < RESYNC_TAIL_LOOKBACK && samples < RESYNC_TAIL_SAMPLES; j++) { - const idx = committed - j; - if (!audited(idx)) continue; - scanned++; - const row = frame[idx]!; - const old = prefix[idx]!; - if (row === old) { - if (!isBlankRow(row)) samples++; - continue; - } - if (isBlankRow(row) && isBlankRow(old)) continue; - samples++; - if (!rowsEquivalent(row, old)) mismatches++; - } - // No signal (all-blank/all-exempt tail) or at most one edited row: aligned. - if (samples === 0 || mismatches <= 1) return -1; - } + // No signal (all-blank tail) or at most one edited row: aligned. + if (samples === 0 || mismatches <= 1) return -1; } - // Misaligned (hard mismatch, tail-sample shift, or the frame no longer covers - // the prefix): re-anchor at the first audited row whose content changed. + // Misaligned (tail-sample shift, or the frame no longer covers the + // prefix): re-anchor at the first row whose content changed. const limit = Math.min(committed, frame.length); for (let i = 0; i < limit; i++) { - if (!audited(i)) continue; if (!rowsEquivalent(frame[i]!, prefix[i]!)) return i; } return limit < committed ? limit : -1; @@ -1009,23 +945,6 @@ export class TUI extends Container { // #auditCommittedPrefix). Holds references to component-cached strings, so // the audit is a pointer walk in the common case. #committedPrefix: string[] = []; - // The committed prefix [0, committedRows) splits into three audit zones by - // two monotone marks auditRows ≤ durableRows ≤ committedRows: - // [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift). - // [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in - // place (a streaming table widening) without re-anchoring, so their - // expected drift never sprays duplicate snapshots. - // [durableRows, committedRows) FORCED-overflow — audited: rows committed - // only because they scrolled above the window under a commit-unstable - // barrier; auditing them re-anchors (duplication, never loss) when the - // barrier later shifts/finalizes/removes, instead of stranding a stale - // prefix that silently drops the rows beneath it. - // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry) - // and otherwise advance per the persistence rules in #updateCommittedAuditRows. - // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window - // [auditRows, durableRows). - #committedPrefixAuditRows = 0; - #committedPrefixDurableRows = 0; // Frame row currently mapped to screen row 0. Monotonic between full // paints: a shrink never re-exposes scrolled-off rows (they cannot be // un-scrolled without rewriting history); live rows repaint at fixed @@ -1034,8 +953,6 @@ export class TUI extends Container { // Exactly what is painted on the screen rows (post-composite, prepared). #previousWindow: string[] = []; #nativeScrollbackLiveRegionStart: number | undefined; - #nativeScrollbackCommitSafeEnd: number | undefined; - #nativeScrollbackSnapshotSafeEnd: number | undefined; #fullRedrawCount = 0; // Caps how many inline images render as live graphics; older ones fall back // to text via a purge + full redraw. Cap is configured by the host app. @@ -1153,8 +1070,6 @@ export class TUI extends Container { override render(width: number): readonly string[] { width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; - this.#nativeScrollbackCommitSafeEnd = undefined; - this.#nativeScrollbackSnapshotSafeEnd = undefined; const children = this.children; const previousSegments = this.#frameSegments; const segments: FrameSegment[] = new Array(children.length); @@ -1175,14 +1090,10 @@ export class TUI extends Container { partialRoots !== null && previous !== undefined && previous.component === child && !partialRoots.has(child); let childLines: readonly string[]; let liveLocalStart: number | undefined; - let commitLocalEnd: number | undefined; - let snapshotLocalEnd: number | undefined; let reported: number | undefined; if (reuse) { childLines = previous.lines; liveLocalStart = previous.liveLocalStart; - commitLocalEnd = previous.commitLocalEnd; - snapshotLocalEnd = previous.snapshotLocalEnd; } else { // Feed the engine's committed-row claim (from the previous frame's // emit) before rendering so the child can skip re-deriving blocks @@ -1195,22 +1106,6 @@ export class TUI extends Container { liveLocalStart = Number.isFinite(liveRegionStart) ? Math.max(0, Math.min(childLines.length, Math.trunc(liveRegionStart))) : childLines.length; - const commitSafeEnd = getNativeScrollbackCommitSafeEnd(child); - if (commitSafeEnd !== undefined) { - commitLocalEnd = Number.isFinite(commitSafeEnd) - ? Math.max(liveLocalStart, Math.min(childLines.length, Math.trunc(commitSafeEnd))) - : childLines.length; - } - // Durable snapshot end: clamped at/above the byte-stable end (or - // the live-region start when none) so a child can never report a - // shallower durable boundary than its byte-stable one. - const snapshotSafeEnd = getNativeScrollbackSnapshotSafeEnd(child); - if (snapshotSafeEnd !== undefined) { - const snapshotFloor = commitLocalEnd ?? liveLocalStart; - snapshotLocalEnd = Number.isFinite(snapshotSafeEnd) - ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd))) - : childLines.length; - } } // Consume the stability report unconditionally for implementers: // reading re-bases the component's baseline to the state this @@ -1221,19 +1116,13 @@ export class TUI extends Container { reported = getRenderStablePrefixRows(child); } // Topmost seam wins. Commits are prefix-only: the first child that - // reports a live region (plus its own commit-safe extension) already - // bounds everything below it, so a lower sibling's seam (e.g. a - // status loader under a streaming transcript) must never overwrite - // it — moving the boundary down would commit the earlier child's - // still-mutable rows as stale history. + // reports a live region already bounds everything below it, so a + // lower sibling's seam (e.g. a status loader under a streaming + // transcript) must never overwrite it — moving the boundary down + // would commit the earlier child's still-mutable rows as stale + // history. if (liveLocalStart !== undefined && this.#nativeScrollbackLiveRegionStart === undefined) { this.#nativeScrollbackLiveRegionStart = offset + liveLocalStart; - if (commitLocalEnd !== undefined) { - this.#nativeScrollbackCommitSafeEnd = offset + commitLocalEnd; - } - if (snapshotLocalEnd !== undefined) { - this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd; - } } if (chainStable) { if (previous !== undefined && previous.component === child && previous.start === offset) { @@ -1262,8 +1151,6 @@ export class TUI extends Container { start: offset, rowCount: childLines.length, liveLocalStart, - commitLocalEnd, - snapshotLocalEnd, }; offset += childLines.length; } @@ -2662,31 +2549,16 @@ export class TUI extends Container { // known. Ascending by frame row. const cursorMarkers = this.#frameCursorMarkers; const liveRegionStart = this.#nativeScrollbackLiveRegionStart; - const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd; - const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd; - // Commit boundaries (also used by the window/commit math in section 3), - // hoisted above the audit gate because the resync needs byteStableBoundary - // to tell a now-permanent forced row (must re-anchor) from a still-live one. - // The commit floor is windowTop in every non-frozen path (see chunkTo), so - // whatever scrolls above the window is committed — never committed nowhere - // AND painted nowhere (the loss bug). The boundaries no longer gate the - // commit; they define the audit-exempt span. byteStableBoundary: rows below - // it are byte-stable (never re-layout), audited. durableBoundary: rows in - // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off - // but may drift in place (a streaming table re-aligning), committed - // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they - // scrolled above the window (a commit-unstable barrier over a long tail) are - // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors - // (duplication, never loss) instead of stranding a stale prefix. Built on the - // finalized prefix (live-region start); the whole frame when the root reports - // no seam (shell semantics: whatever scrolls is final). + // Commit boundary (used by the window/commit math in section 3), hoisted + // above the audit gate. Rows below it are declared FINAL by the + // component seam — the only rows eligible to enter native scrollback; + // the whole frame is eligible when the root reports no seam (shell + // semantics: whatever scrolls is final). Live rows that scroll above + // the window are NOT committed: they wait, unpainted, and enter history + // in order once the boundary passes them. const frameLength = rawFrame.length; - const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); - const durableBoundary = Math.max( - byteStableBoundary, - Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary), - ); + const finalBoundary = Math.max(0, Math.min(frameLength, liveRegionStart ?? frameLength)); // 2. Transition state captured before any emitter runs. const prevWindowTop = this.#windowTopRow; @@ -2717,35 +2589,19 @@ export class TUI extends Container { // that provably did not change since the last (aligned) frame cannot // have diverged. let committedRowsResynced = false; - // Audit covers [0, auditRows) and the forced suffix [durableRows, - // committedRows); the durable middle [auditRows, durableRows) is exempt - // (in-place drift). Two reasons to run the audit this frame: - // - the stable prefix does not cover every audited row (auditUpper); or - // - a forced-overflow row this frame became durable/permanent - // (committedPrefixDurableRows < hardAuditEnd): the barrier above it - // finalized, so its committed bytes must be re-checked even though the - // stable prefix says nothing moved — a stale committed copy there would - // silently drop the row. The hard scan in findCommittedPrefixResync - // covers [durableRows, hardAuditEnd) in full (no tail-sample miss). - const auditUpper = - this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows; - const hardAuditEnd = Math.min(this.#committedRows, durableBoundary); - const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd; + // Run the audit only when the composed frame's stable prefix does not + // already cover every committed row — bytes that provably did not + // change since the last (aligned) frame cannot have diverged. const auditRan = this.#hasEverRendered && !geometryChanged && !this.#clearScrollbackOnNextRender && - (this.#renderStablePrefixRows < auditUpper || needHardAudit); + this.#renderStablePrefixRows < this.#committedRows; if (auditRan) { const committedRowsBeforeAudit = this.#committedRows; - this.#auditCommittedPrefix(rawFrame, durableBoundary); + this.#auditCommittedPrefix(rawFrame); committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit; } - // Committed-prefix state this frame's commit math extends from (post-audit). - // Drives the audit-rows / durable-rows caps recomputed after the emit. - const preCommitRows = this.#committedRows; - const preCommitAuditRows = this.#committedPrefixAuditRows; - const preCommitDurableRows = this.#committedPrefixDurableRows; // 3. Window and commit math (lengths only; content prepared below). let hasVisibleOverlay = false; @@ -2768,11 +2624,9 @@ export class TUI extends Container { const fullPaint = firstPaint || replaceRequested || geometryRebuild; let windowTop: number; let chunkTo: number; - let committedPrefixResliced = false; if (fullPaint) { - committedPrefixResliced = true; windowTop = Math.max(0, frameLength - height); - chunkTo = windowTop; + chunkTo = Math.min(windowTop, finalBoundary); } else if ( frameLength <= this.#committedRows || (committedRowsResynced && @@ -2790,8 +2644,7 @@ export class TUI extends Container { // is preferable to a live editor gap and matches the existing // "duplication, never loss" resync contract. windowTop = Math.max(0, frameLength - height); - chunkTo = windowTop; - committedPrefixResliced = true; + chunkTo = Math.min(windowTop, finalBoundary); this.#committedRows = chunkTo; this.#committedPrefix = rawFrame.slice(0, chunkTo); } else { @@ -2808,9 +2661,11 @@ export class TUI extends Container { // pane keeps its own (old-wrap) history — and re-bases the audit // prefix at the new width so the accepted wrap drift does not read // as a violation on the next ordinary frame. - chunkTo = hasVisibleOverlay || geometryChanged ? this.#committedRows : windowTop; + chunkTo = + hasVisibleOverlay || geometryChanged + ? this.#committedRows + : Math.max(this.#committedRows, Math.min(windowTop, finalBoundary)); if (geometryChanged) { - committedPrefixResliced = true; this.#committedPrefix = rawFrame.slice(0, this.#committedRows); } } @@ -2870,15 +2725,6 @@ export class TUI extends Container { cursorTrackingLineCount, }); this.#committedPrefix = rawFrame.slice(0, chunkTo); - this.#updateCommittedAuditRows( - true, - preCommitRows, - preCommitAuditRows, - preCommitDurableRows, - byteStableBoundary, - durableBoundary, - false, - ); this.#clearScrollbackOnNextRender = false; this.#hasEverRendered = true; if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle(); @@ -2899,15 +2745,6 @@ export class TUI extends Container { for (let i = this.#committedPrefix.length; i < chunkTo; i++) { this.#committedPrefix.push(rawFrame[i] ?? ""); } - this.#updateCommittedAuditRows( - committedPrefixResliced, - preCommitRows, - preCommitAuditRows, - preCommitDurableRows, - byteStableBoundary, - durableBoundary, - auditRan, - ); } /** @@ -2917,21 +2754,12 @@ export class TUI extends Container { * restyles keep their alignment and are left alone (stale styling in * history was always the accepted artifact). */ - #auditCommittedPrefix(rawFrame: readonly string[], permanentEnd: number): void { + #auditCommittedPrefix(rawFrame: readonly string[]): void { const prefix = this.#committedPrefix; if (prefix.length === 0) return; - const resyncTo = findCommittedPrefixResync( - rawFrame, - prefix, - prefix.length, - this.#committedPrefixAuditRows, - this.#committedPrefixDurableRows, - permanentEnd, - ); + const resyncTo = findCommittedPrefixResync(rawFrame, prefix); if (resyncTo < 0) return; this.#committedRows = resyncTo; - this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo); - this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo); prefix.length = resyncTo; if ($flag("PI_DEBUG_REDRAW")) { const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`; @@ -2939,46 +2767,6 @@ export class TUI extends Container { } } - /** - * Recompute the audit-rows / durable-rows marks after a commit (see the - * #committedPrefixAuditRows field doc for the three audit zones). - * - * auditRows tracks the byte-stable boundary; durableRows the durable snapshot - * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases - * each mark from the current frame (min(committed, boundary)). An incremental - * extend keeps a mark once a row past it has committed (mark < committed): a - * later RISE in a boundary (a table finalizing) must neither pull - * already-committed stale snapshots back under the byte-stable cap nor - * retroactively exempt forced-overflow rows already audited. durableRows is - * floored at auditRows so the exempt window can never invert. - */ - #updateCommittedAuditRows( - resliced: boolean, - preCommittedRows: number, - preAuditRows: number, - preDurableRows: number, - byteStableBoundary: number, - durableBoundary: number, - hardAudited: boolean, - ): void { - const committed = this.#committedRows; - const auditRows = - resliced || preAuditRows >= preCommittedRows - ? Math.min(committed, byteStableBoundary) - : Math.min(preAuditRows, committed); - // durableRows also advances when a hard audit ran this frame: the resync's - // full hard scan verified the forced suffix [durableRows, min(committed, - // durableBoundary)) (re-anchoring on any divergence), so those rows are now - // proven durable and may leave the audited set — otherwise the durable-rise - // gate would re-fire the full scan every frame (and spray on later drift). - const durableRows = - resliced || preDurableRows >= preCommittedRows || hardAudited - ? Math.min(committed, durableBoundary) - : Math.min(preDurableRows, committed); - this.#committedPrefixAuditRows = auditRows; - this.#committedPrefixDurableRows = Math.max(auditRows, durableRows); - } - /** * Prepare the composed frame for emission, in place. Rows below * `#preparedValidRows` are already prepared against the current frame (the @@ -3729,17 +3517,20 @@ export class TUI extends Container { } } - // In-window diff: nothing commits. While an overlay is visible, repaint - // the full viewport in place from a top-clamped cursor origin. Overlay - // cursor-only frames can leave the tracked row behind the physical cursor; - // a relative partial rewrite from that stale origin can CRLF on the bottom - // row and scroll native history without appending to the commit tape. - const overlayInPlaceRewrite = repaintVirtualScrollInPlace; - if (chunkLength === 0 && (scroll === 0 || overlayInPlaceRewrite)) { - if (forceWindowRewrite || overlayInPlaceRewrite) this.#fullRedrawCount += 1; - let firstChanged = forceWindowRewrite || overlayInPlaceRewrite ? 0 : -1; - let lastChanged = forceWindowRewrite || overlayInPlaceRewrite ? height - 1 : -1; - if (!forceWindowRewrite && !overlayInPlaceRewrite) { + // In-window diff: nothing commits. Rewrite in place when the window slid + // without a commit — an overlay visible (composited rows must never enter + // history), or the window sliding over a deferred live gap / pulling back + // down after a shrink. Overlay cursor-only frames can also leave the + // tracked row behind the physical cursor; a relative partial rewrite from + // that stale origin can CRLF on the bottom row and scroll native history + // without appending to the commit tape, so overlays always take the + // top-clamped full rewrite. + const inPlaceRewrite = repaintVirtualScrollInPlace || scroll !== 0; + if (chunkLength === 0) { + if (forceWindowRewrite || inPlaceRewrite) this.#fullRedrawCount += 1; + let firstChanged = forceWindowRewrite || inPlaceRewrite ? 0 : -1; + let lastChanged = forceWindowRewrite || inPlaceRewrite ? height - 1 : -1; + if (!forceWindowRewrite && !inPlaceRewrite) { const comparable = previousWindow.length === height; for (let r = 0; r < height; r++) { if (comparable && (window[r] ?? "") === (previousWindow[r] ?? "")) continue; @@ -3755,10 +3546,11 @@ export class TUI extends Container { return; } let buffer = this.#paintBeginSequence + purgeSequence; - if (overlayInPlaceRewrite) { - // The cursor tracker can be stale after overlay-only frames. A large - // CUU clamps at the viewport top without using absolute cursor home, - // so the following full-window rewrite cannot overflow the bottom. + if (inPlaceRewrite) { + // The cursor tracker can be stale after overlay-only frames, and + // meaningless after an uncommitted slide. A large CUU clamps at the + // viewport top without using absolute cursor home, so the following + // full-window rewrite cannot overflow the bottom. if (height > 1) buffer += `\x1b[${height - 1}A`; } else { const rowDelta = firstChanged - currentScreenRow; @@ -3838,7 +3630,7 @@ export class TUI extends Container { : `fullPaint(clearScrollback=${intent.clearScrollback})`; const state = `committed=${this.#committedRows}, windowTop=${this.#windowTopRow}, ` + - `lrStart=${this.#nativeScrollbackLiveRegionStart}, commitSafeEnd=${this.#nativeScrollbackCommitSafeEnd}`; + `lrStart=${this.#nativeScrollbackLiveRegionStart}`; const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousFrameLength}, new=${newLength}, height=${height}, ${state})\n`; fs.appendFileSync(getDebugLogPath(), msg); }