09a81f4c59
- Updated live transcript state derivation to treat a trailing-line extension with unchanged rows as append-only. - Replaced the prior append-grew heuristic with preserved-row checks so non-rewritten history is not marked volatile. - Added a regression test covering in-place streaming line growth and scrollback commit safety in transcript containers.
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
|
|
const kSnapshot = Symbol("transcript.frozenRender");
|
|
|
|
interface FrozenRender {
|
|
width: number;
|
|
lines: string[];
|
|
generation: number;
|
|
appendOnly: boolean;
|
|
volatile: boolean;
|
|
}
|
|
|
|
interface SnapshotCarrier {
|
|
[kSnapshot]?: FrozenRender;
|
|
}
|
|
|
|
/**
|
|
* A transcript block that is still mutating (a foreground tool awaiting its
|
|
* result, an assistant message mid-stream) reports `false` so the container
|
|
* keeps it inside the live (repaintable) region instead of freezing it. Blocks
|
|
* without the method are treated as finalized — the default, stable behavior.
|
|
*/
|
|
interface FinalizableBlock {
|
|
isTranscriptBlockFinalized?(): boolean;
|
|
}
|
|
|
|
function isBlockFinalized(child: Component): boolean {
|
|
const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
|
|
return fn ? fn.call(child) : true;
|
|
}
|
|
|
|
// A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
|
|
// separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
|
|
// to a background-colored padding row, whose escape sequences contain `\S` and
|
|
// are therefore preserved as part of a block's visual design.
|
|
const NON_WHITESPACE = /\S/;
|
|
function isPlainBlank(line: string): boolean {
|
|
return !NON_WHITESPACE.test(line);
|
|
}
|
|
|
|
// Strip leading/trailing plain-blank rows so each block contributes only its
|
|
// visible body; the container owns the gaps between blocks. Returns the input
|
|
// array unchanged when there is nothing to trim (no allocation on the hot path).
|
|
function stripPlainBlankEdges(lines: string[]): string[] {
|
|
let start = 0;
|
|
let end = lines.length;
|
|
while (start < end && isPlainBlank(lines[start]!)) start++;
|
|
while (end > start && isPlainBlank(lines[end - 1]!)) end--;
|
|
return start === 0 && end === lines.length ? lines : lines.slice(start, end);
|
|
}
|
|
|
|
interface LiveCommitState {
|
|
appendOnly: boolean;
|
|
volatile: boolean;
|
|
safeLength: number;
|
|
}
|
|
|
|
function hasValidSnapshot(
|
|
snapshot: FrozenRender | undefined,
|
|
width: number,
|
|
generation: number,
|
|
): snapshot is FrozenRender {
|
|
return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
|
|
}
|
|
|
|
function commonPrefixLength(prev: string[], cur: string[]): number {
|
|
const limit = Math.min(prev.length, cur.length);
|
|
let i = 0;
|
|
while (i < limit && prev[i] === cur[i]) i++;
|
|
return i;
|
|
}
|
|
|
|
function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
|
|
const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
|
|
let i = 0;
|
|
while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
|
|
return i;
|
|
}
|
|
|
|
function deriveLiveCommitState(
|
|
previous: FrozenRender | undefined,
|
|
current: string[],
|
|
width: number,
|
|
generation: number,
|
|
): LiveCommitState {
|
|
let appendOnly = false;
|
|
let volatile = false;
|
|
if (hasValidSnapshot(previous, width, generation)) {
|
|
appendOnly = previous.appendOnly;
|
|
volatile = previous.volatile;
|
|
|
|
const prefixLength = commonPrefixLength(previous.lines, current);
|
|
const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
|
|
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. Three
|
|
// shapes qualify: a pure bottom append, an insertion above stable trailing
|
|
// chrome (a streaming tool's footer/border), and an in-place extension of
|
|
// the current line by one streamed token (line count unchanged). The first
|
|
// two preserve every previous row across a matching prefix + suffix; the
|
|
// last leaves a single divergent previous row that the current row merely
|
|
// lengthens. A divergent interior row that is genuinely rewritten means the
|
|
// block re-laid-out committed content — volatile, and never committed.
|
|
const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
|
|
const tailExtendedInPlace =
|
|
prefixLength + suffixLength === previous.lines.length - 1 &&
|
|
prefixLength < current.length &&
|
|
current[prefixLength]!.startsWith(previous.lines[prefixLength]!);
|
|
if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) {
|
|
appendOnly = true;
|
|
} else if (!preservedEveryRow && !tailExtendedInPlace) {
|
|
volatile = true;
|
|
appendOnly = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
appendOnly,
|
|
volatile,
|
|
safeLength: volatile ? 0 : appendOnly ? current.length : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transcript container that freezes the rendered output of every block except
|
|
* the bottom-most (live) one on terminals where committed native scrollback is
|
|
* immutable.
|
|
*
|
|
* On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
|
|
* the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
|
|
* whether the user has scrolled, so any block that re-lays-out *after* it has
|
|
* scrolled past the viewport leaves a stale duplicate above the live region
|
|
* (a finalized assistant message re-wrapping, a tool preview collapsing to its
|
|
* compact result, a late async tool completion). The renderer's only safe move
|
|
* for such an offscreen edit is to not repaint — which is correct only if the
|
|
* committed region never changes underneath it.
|
|
*
|
|
* This container provides that guarantee: a block's render is snapshotted while
|
|
* it is the live (bottom-most) block, and once a newer block is appended it
|
|
* replays the snapshot instead of recomputing. Mutations after a block leaves
|
|
* live are intentionally deferred until the next checkpoint {@link thaw} (prompt
|
|
* submit → native-scrollback rebuild), where the whole transcript is replayed
|
|
* and any drift reconciles safely. On terminals that can rebuild history this
|
|
* freezing is unnecessary, so it renders every block live for full fidelity.
|
|
*/
|
|
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
|
|
// Bumped to invalidate every block's snapshot at once; a snapshot is only
|
|
// honored when its stored generation still matches.
|
|
#generation = 0;
|
|
// Line index where the live (repaintable) region began on the previous
|
|
// render — the start of the earliest still-mutating block, or the bottom
|
|
// block when everything is finalized. A block leaves the live region only
|
|
// once it has finalized AND a finalized block sits below it; the frame it
|
|
// crosses out is recomputed so it freezes at its true final content, not the
|
|
// mid-stream snapshot it last rendered while live (TUI render coalescing can
|
|
// advance a block's content in the very frame it stops being live).
|
|
#prevLiveStartIndex = 0;
|
|
// Local line index where the current live region begins in the most recent
|
|
// render. TUI extends the native-scrollback pinned region from this point
|
|
// through the live blocks and the root chrome rendered below them.
|
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
// Local line index up to which the leading run of live blocks is safe to
|
|
// commit. Finalized blocks contribute their full frozen body; still-live
|
|
// blocks contribute only after their stripped render has been observed
|
|
// growing without changing a previously rendered interior row.
|
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
|
|
override invalidate(): void {
|
|
// A theme/global invalidation forces a full recompute on the rebuild that
|
|
// follows; retire every snapshot.
|
|
this.#generation++;
|
|
super.invalidate();
|
|
}
|
|
|
|
override clear(): void {
|
|
this.#generation++;
|
|
super.clear();
|
|
}
|
|
|
|
getNativeScrollbackLiveRegionStart(): number | undefined {
|
|
return this.#nativeScrollbackLiveRegionStart;
|
|
}
|
|
|
|
getNativeScrollbackCommitSafeEnd(): number | undefined {
|
|
return this.#nativeScrollbackCommitSafeEnd;
|
|
}
|
|
|
|
/**
|
|
* Retire all frozen snapshots so the next render reflects each block's current
|
|
* state. Call at reconciliation checkpoints (prompt submit) where the whole
|
|
* transcript is replayed into native scrollback and any drift a frozen block
|
|
* accumulated is reconciled.
|
|
*/
|
|
thaw(): void {
|
|
this.#generation++;
|
|
}
|
|
|
|
override render(width: number): string[] {
|
|
width = Math.max(1, width);
|
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
|
|
// Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
|
|
// block renders live. Inter-block spacing applies on BOTH paths so the gap
|
|
// between blocks is identical regardless of terminal.
|
|
const risk = TERMINAL.eagerEraseScrollbackRisk;
|
|
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 repaintable: out-of-band
|
|
// inserts (TTSR/todo cards) can append a finalized block *below* a tool that
|
|
// is still awaiting its result, and freezing the tool there would strand its
|
|
// committed rows on the mid-stream preview the late result never reaches.
|
|
let liveStartIndex = count - 1;
|
|
for (let i = 0; i < count; i++) {
|
|
if (!isBlockFinalized(this.children[i]!)) {
|
|
liveStartIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
// Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
|
|
// recompute them so they freeze at their final content. Everything below
|
|
// the lower of the two cutoffs was already frozen last frame and replays.
|
|
const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
|
|
if (risk) this.#prevLiveStartIndex = liveStartIndex;
|
|
|
|
const lines: string[] = [];
|
|
// 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 the
|
|
// cutoff; empty leading blocks (or a separator) must not claim it early.
|
|
let liveRecorded = false;
|
|
for (let i = 0; i < count; i++) {
|
|
const child = this.children[i]! as Component & SnapshotCarrier;
|
|
|
|
// Resolve this child's contribution — its visible body with plain-blank
|
|
// top/bottom edges stripped (the container owns inter-block gaps). On
|
|
// ED3-risk terminals a frozen, scrolled-off block replays its snapshot
|
|
// instead of recomputing; a stale generation (post-thaw) or width
|
|
// mismatch (resize) recomputes, as does a block still live last frame.
|
|
let contribution: string[] | undefined;
|
|
const previousSnapshot = risk ? child[kSnapshot] : undefined;
|
|
if (risk && i < liveStartIndex && i < replayCutoff) {
|
|
if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
|
|
contribution = previousSnapshot.lines;
|
|
}
|
|
}
|
|
let liveCommitState: LiveCommitState | undefined;
|
|
if (contribution === undefined) {
|
|
const rendered = child.render(width);
|
|
contribution = stripPlainBlankEdges(rendered);
|
|
if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
|
|
liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
|
|
}
|
|
// Cache every block's latest contribution. While a block is in the
|
|
// live region this keeps its snapshot current; on the frame it crosses
|
|
// out, the recompute above refreshes it before it freezes.
|
|
if (risk) {
|
|
child[kSnapshot] = {
|
|
width,
|
|
lines: contribution,
|
|
generation: this.#generation,
|
|
appendOnly: liveCommitState?.appendOnly ?? false,
|
|
volatile: liveCommitState?.volatile ?? false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Empty (or stripped-to-nothing) children contribute nothing and never
|
|
// affect spacing or the live-region offsets. An empty still-live child
|
|
// still closes the commit-safe run: if it later gains rows, it pushes
|
|
// everything below it.
|
|
if (contribution.length === 0) {
|
|
if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
|
|
continue;
|
|
}
|
|
|
|
// Every block is separated from preceding visible content by exactly one
|
|
// blank row — skipped when it opens the transcript or the prior row is
|
|
// already a plain blank (a fragment's own trailing pad), never doubling.
|
|
const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
|
|
|
|
// The separator before the first live block stays in the committed prefix
|
|
// (it is deterministic and never changes once the prior block is frozen),
|
|
// so the live region begins at the block's first content row.
|
|
if (risk && !liveRecorded && i >= liveStartIndex) {
|
|
this.#nativeScrollbackLiveRegionStart = lines.length + sep;
|
|
liveRecorded = true;
|
|
}
|
|
|
|
if (sep) lines.push("");
|
|
const blockStart = lines.length;
|
|
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
|
|
|
|
if (risk && i >= liveStartIndex && commitSafeOpen) {
|
|
const finalized = isBlockFinalized(child);
|
|
const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
|
|
if (safeLength > 0) {
|
|
this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
|
|
}
|
|
// 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;
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Groups a run of sibling rows (an IRC card's header + body, a file-mention
|
|
* list, a bordered command/version panel) into a single transcript child so the
|
|
* container spaces it as one block — one blank line above, none injected between
|
|
* its rows. Without this wrapper the rows would be top-level children and the
|
|
* container would put a blank line between each (and inside any border box).
|
|
* It is a plain {@link Container}; the named subclass documents intent and makes
|
|
* every manual block grouping greppable.
|
|
*/
|
|
export class TranscriptBlock extends Container {}
|