Files
oh-my-pi/packages/coding-agent/src/modes/components/transcript-container.ts
T
can1357 09a81f4c59 fix(coding-agent): fixed append-only detection for in-place streaming line growth
- 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.
2026-06-08 18:28:06 +02:00

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 {}