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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+96
-304
@@ -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<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
|
||||
}
|
||||
|
||||
function getNativeScrollbackCommitSafeEnd(component: Component): number | undefined {
|
||||
return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackCommitSafeEnd?.();
|
||||
}
|
||||
|
||||
function getNativeScrollbackSnapshotSafeEnd(component: Component): number | undefined {
|
||||
return (component as Component & Partial<NativeScrollbackLiveRegion>).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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user