refactor(packages/coding-agent): simplified terminal transcript rendering

- Removed terminal risk mode toggles from config, terminal state collection, and render controllers.
- Dropped snapshot freezing, thaw tracking, and finalized-block replay in transcript rendering.
- Removed clear-on-shrink settings and initialization hooks from selector and interactive mode flows.
- Simplified render scheduling by using requestRender() without mutation flags or stream checkpoints.
This commit is contained in:
can1357
2026-06-09 20:45:01 +02:00
parent aee1e1824e
commit 153c3d6565
13 changed files with 527 additions and 839 deletions
@@ -686,16 +686,6 @@ export const SETTINGS_SCHEMA = {
ui: { tab: "appearance", label: "Show Hardware Cursor", description: "Show terminal cursor for IME support" },
},
clearOnShrink: {
type: "boolean",
default: false,
ui: {
tab: "appearance",
label: "Clear on Shrink",
description: "Clear empty rows when content shrinks (may cause flicker)",
},
},
// ────────────────────────────────────────────────────────────────────────
// Model
// ────────────────────────────────────────────────────────────────────────
@@ -36,7 +36,6 @@ export interface TerminalStateInfo {
hyperlinks: boolean;
deccara: boolean;
screenToScrollback: boolean;
eagerEraseScrollbackRisk: boolean;
synchronizedOutput: boolean;
multiplexer: string | null;
env: { TERM?: string; TERM_PROGRAM?: string; TERM_PROGRAM_VERSION?: string; COLORTERM?: string };
@@ -82,7 +81,6 @@ export function collectTerminalState(runtime: TerminalRuntimeState): TerminalSta
hyperlinks: TERMINAL.hyperlinks,
deccara: TERMINAL.deccara,
screenToScrollback: TERMINAL.supportsScreenToScrollback,
eagerEraseScrollbackRisk: TERMINAL.eagerEraseScrollbackRisk,
synchronizedOutput: runtime.synchronizedOutput,
multiplexer: detectMultiplexer(env),
env: {
@@ -115,7 +113,6 @@ export function formatTerminalState(info: TerminalStateInfo): string {
"",
"Scrollback",
` Screen->history clear: ${info.screenToScrollback ? "CSI 22 J" : "CSI 2 J (redraw)"}`,
` Eager-erase risk: ${yesNo(info.eagerEraseScrollbackRisk)} (ED3 may yank scrolled readers)`,
"",
"Detection signals",
` TERM: ${info.env.TERM ?? "(unset)"}`,
@@ -1,8 +1,14 @@
import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
import { type Component, Container, type NativeScrollbackLiveRegion } from "@oh-my-pi/pi-tui";
const kSnapshot = Symbol("transcript.frozenRender");
const kSnapshot = Symbol("transcript.liveDiffSnapshot");
interface FrozenRender {
/**
* Per-block diff cache: the block's previous stripped contribution plus the
* derived append-only state. Purely an input to {@link deriveLiveCommitState}
* for still-live blocks — it is never replayed as render output. Every block
* renders its current content on every frame.
*/
interface LiveDiffSnapshot {
width: number;
lines: string[];
generation: number;
@@ -15,7 +21,7 @@ interface FrozenRender {
}
interface SnapshotCarrier {
[kSnapshot]?: FrozenRender;
[kSnapshot]?: LiveDiffSnapshot;
}
/**
@@ -91,10 +97,10 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
}
function hasValidSnapshot(
snapshot: FrozenRender | undefined,
snapshot: LiveDiffSnapshot | undefined,
width: number,
generation: number,
): snapshot is FrozenRender {
): snapshot is LiveDiffSnapshot {
return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
}
@@ -113,7 +119,7 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
}
function deriveLiveCommitState(
previous: FrozenRender | undefined,
previous: LiveDiffSnapshot | undefined,
current: string[],
width: number,
generation: number,
@@ -173,55 +179,40 @@ function deriveLiveCommitState(
}
/**
* Transcript container that freezes the rendered output of every block except
* the bottom-most (live) one on terminals where committed native scrollback is
* immutable.
* Transcript container that always renders every block's current content and
* reports the live-region seam (`NativeScrollbackLiveRegion`) that gates the
* engine's append-only scrollback commits.
*
* 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.
* 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.
*/
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.
// Bumped to retire every block's diff snapshot at once (theme change /
// clear); a snapshot is only honored when its stored generation matches.
#generation = 0;
// 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.
// render. TUI commits rows to native scrollback only above this seam (or
// the deeper commit-safe end below).
#nativeScrollbackLiveRegionStart: number | undefined;
// Local line index up to which the leading run of live blocks is safe to
// commit. Finalized blocks contribute their full frozen 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 pinned emitter then backfills the stalled gap.
// 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;
override invalidate(): void {
// A theme/global invalidation forces a full recompute on the rebuild that
// follows; retire every snapshot.
// Theme/global invalidation: retire every diff snapshot so stale styling
// is not diffed against the recolored render.
this.#generation++;
super.invalidate();
}
@@ -239,32 +230,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
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.
// bottom. A block that has not finalized must stay below the seam: out-of-
// band inserts (TTSR/todo cards) can append a finalized block *below* a
// tool that is still awaiting its result, and committing the tool there
// would strand its history rows on the mid-stream preview the late result
// never reaches.
let liveStartIndex = count - 1;
for (let i = 0; i < count; i++) {
if (!isBlockFinalized(this.children[i]!)) {
@@ -272,62 +250,45 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
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.
// 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;
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;
}
}
// This child's contribution: its current render with plain-blank
// top/bottom edges stripped (the container owns inter-block gaps).
// Always the latest content — committed history keeps whatever bytes
// it was written with, but the window must reflect the present state
// (late tool results, post-finalize re-layouts, expand toggles).
const previousSnapshot = child[kSnapshot];
const contribution = stripPlainBlankEdges(child.render(width));
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,
volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
};
}
if (i >= liveStartIndex && !isBlockFinalized(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,
};
// 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;
if (i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
continue;
}
@@ -336,10 +297,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
// 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),
// 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 (risk && !liveRecorded && i >= liveStartIndex) {
if (!liveRecorded && i >= liveStartIndex) {
this.#nativeScrollbackLiveRegionStart = lines.length + sep;
liveRecorded = true;
}
@@ -348,7 +309,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
const blockStart = lines.length;
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
if (risk && i >= liveStartIndex && commitSafeOpen) {
if (i >= liveStartIndex && commitSafeOpen) {
const finalized = isBlockFinalized(child);
const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
if (safeLength > 0) {
@@ -36,19 +36,6 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
*/
export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
// Events that change foreground streaming state, or that reset a turn. The TUI
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
// IRC/notices/status refreshes do not toggle scrollback replay policy.
const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
agent_start: true,
agent_end: true,
message_start: true,
message_end: true,
tool_execution_start: true,
tool_execution_update: true,
tool_execution_end: true,
};
type AgentSessionEventHandlers = {
[E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
};
@@ -65,7 +52,6 @@ export class EventController {
#renderedCustomMessages = new Set<string>();
#lastIntent: string | undefined = undefined;
#backgroundToolCallIds = new Set<string>();
#assistantMessageStreaming = false;
#agentTurnActive = false;
#interrupting = false;
#readToolCallArgs = new Map<string, Record<string, unknown>>();
@@ -217,30 +203,6 @@ export class EventController {
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
await run(event);
// While an assistant turn is active, visible status chrome and foreground
// transcript blocks can re-render after rows have entered native scrollback
// (idle Working loader, Markdown fences, wrapping, tool previews). Let the
// TUI use its foreground live-region path instead of idle deferral, which
// can otherwise leave the loader/status frame frozen until the next input.
// Background-running tools after the turn ends are excluded so late async
// updates keep the no-yank deferral; agent_start/agent_end bracket the
// foreground turn.
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
this.#refreshToolRenderMode();
}
}
#refreshToolRenderMode(): void {
let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
if (!foregroundToolActive) {
for (const toolCallId of this.ctx.pendingTools.keys()) {
if (!this.#backgroundToolCallIds.has(toolCallId)) {
foregroundToolActive = true;
break;
}
}
}
this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
}
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
@@ -250,7 +212,6 @@ export class EventController {
this.#readToolCallArgs.clear();
this.#readToolCallAssistantComponents.clear();
this.#resetReadGroup();
this.#assistantMessageStreaming = false;
this.#lastAssistantComponent = undefined;
// Restore the previous turn's inline error in the transcript before dropping
// the banner, so the error stays in history once the banner is gone.
@@ -267,7 +228,6 @@ export class EventController {
this.ctx.statusContainer.clear();
}
this.#cancelIdleCompaction();
this.#refreshToolRenderMode();
this.ctx.ensureLoadingAnimation();
this.ctx.ui.requestRender();
}
@@ -340,7 +300,6 @@ export class EventController {
this.ctx.addMessageToChat(event.message);
this.ctx.ui.requestRender();
} else if (event.message.role === "assistant") {
this.#assistantMessageStreaming = true;
this.#lastVisibleBlockCount = 0;
this.ctx.streamingComponent = new AssistantMessageComponent(
undefined,
@@ -491,9 +450,6 @@ export class EventController {
async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
if (event.message.role === "user") return;
if (event.message.role === "assistant") {
this.#assistantMessageStreaming = false;
}
if (this.ctx.streamingComponent && event.message.role === "assistant") {
this.ctx.streamingMessage = event.message;
this.#streamingReveal.stop();
@@ -701,7 +657,6 @@ export class EventController {
}
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
this.#agentTurnActive = false;
this.#assistantMessageStreaming = false;
this.#streamingReveal.stop();
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
@@ -267,7 +267,7 @@ export class InputController {
const focused = this.ctx.ui.getFocused();
const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
target.pasteText(text);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.ui.requestRender();
},
pasteImage: async image => {
// Images can only land in the main editor — when a modal Input is
@@ -755,7 +755,7 @@ export class InputController {
const dims = await this.#imageDimensions(imageData);
const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
this.ctx.editor.insertText(`${label} `);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.ui.requestRender();
}
/** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the
@@ -801,7 +801,7 @@ export class InputController {
});
if (!image) {
this.ctx.editor.pasteText(path);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.ui.requestRender();
this.ctx.showStatus("Pasted path is not a supported image");
return;
}
@@ -811,7 +811,7 @@ export class InputController {
);
} catch (error) {
this.ctx.editor.pasteText(path);
this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ctx.ui.requestRender();
this.ctx.showStatus(
error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
);
@@ -266,10 +266,6 @@ export class SelectorController {
this.ctx.updateEditorBorderColor();
break;
case "clearOnShrink":
this.ctx.ui.setClearOnShrink(value as boolean);
break;
case "autocompleteMaxVisible":
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
break;
@@ -409,7 +409,6 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
// OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
// capability (`TERMINAL.textSizing` defaults on for Kitty) so it stays off
@@ -429,7 +428,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.requestRender(true);
};
this.editor.onAutocompleteUpdate = () => {
this.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ui.requestRender();
};
this.#syncEditorMaxHeight();
this.#resizeHandler = () => {
@@ -959,13 +958,6 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.editor.setText("");
this.editor.imageLinks = undefined;
// Reconciliation checkpoint: only retire frozen block snapshots after TUI
// proves the native viewport is at the tail and replays scrollback safely.
// Unknown host viewports stay frozen; thawing them would expose live rows
// over stale native history and can yank or duplicate when ED3 is unsafe.
if (this.ui.refreshNativeScrollbackIfDirty()) {
this.chatContainer.thaw();
}
this.ensureLoadingAnimation();
this.ui.requestRender();
return submission;
@@ -2587,7 +2579,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.requestRender(true);
};
nextEditor.onAutocompleteUpdate = () => {
this.ui.requestRender(false, { allowUnknownViewportMutation: true });
this.ui.requestRender();
};
nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
if (this.historyStorage) {
@@ -19,7 +19,6 @@ const sample: TerminalStateInfo = {
hyperlinks: false,
deccara: true,
screenToScrollback: true,
eagerEraseScrollbackRisk: true,
synchronizedOutput: false,
multiplexer: null,
env: { TERM: "xterm-kitty", TERM_PROGRAM: undefined, TERM_PROGRAM_VERSION: undefined, COLORTERM: "truecolor" },
@@ -42,7 +41,6 @@ describe("formatTerminalState", () => {
expect(out).toContain("120x40 cells · cell 9x18px");
// supportsScreenToScrollback -> the non-destructive CSI 22 J clear.
expect(out).toContain("Screen->history clear: CSI 22 J");
expect(out).toContain("Eager-erase risk: yes");
});
it("renders the redraw fallback when screen-to-scrollback is unsupported", () => {
@@ -6,11 +6,11 @@ import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/compo
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
import { type Component, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { type Component, Text } from "@oh-my-pi/pi-tui";
// Models a transcript block that re-lays-out (tool preview collapsing, assistant
// message finalizing, late async result) after it has scrolled past the live
// region — the mutation that leaves a stale duplicate on ED3-risk terminals.
// message finalizing, late async result) after newer blocks were appended below
// it — the window must always reflect its current content.
class MutableBlock implements Component {
#lines: string[];
constructor(lines: string[]) {
@@ -51,9 +51,6 @@ class StreamingBlock implements Component {
}
}
const riskFlag = TERMINAL as unknown as { eagerEraseScrollbackRisk: boolean };
const original = riskFlag.eagerEraseScrollbackRisk;
beforeAll(() => {
initTheme();
});
@@ -64,7 +61,6 @@ beforeEach(async () => {
});
afterEach(() => {
riskFlag.eagerEraseScrollbackRisk = original;
resetSettingsForTest();
});
@@ -94,34 +90,34 @@ function plain(lines: string[]): string {
}
describe("TranscriptContainer", () => {
it("freezes a block at its last live render once a newer block is appended (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
it("always renders a block's current content, even after newer blocks append below it", () => {
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
container.addChild(a);
expect(container.render(40)).toEqual(["a1"]);
// While `a` is still the live (bottom-most) block its render tracks updates.
a.set(["a2"]);
expect(container.render(40)).toEqual(["a2"]);
// A newer block makes `a` non-live; it now replays its last live render.
const b = new MutableBlock(["b1"]);
container.addChild(b);
expect(container.render(40)).toEqual(["a2", "", "b1"]);
// A post-freeze mutation of `a` (its collapse/re-layout) is NOT reflected —
// the committed rows stay stable so no stale duplicate enters scrollback.
// A late re-layout of `a` (collapse, late async result, expand toggle) is
// reflected immediately: committed history keeps its old bytes, but the
// visible window always shows the present state.
a.set(["a3-collapsed"]);
expect(container.render(40)).toEqual(["a2", "", "b1"]);
expect(container.render(40)).toEqual(["a3-collapsed", "", "b1"]);
// The live block still updates freely.
b.set(["b2"]);
expect(container.render(40)).toEqual(["a2", "", "b2"]);
expect(container.render(40)).toEqual(["a3-collapsed", "", "b2"]);
// Width changes recompute like any other frame.
a.set(["a-reflowed"]);
expect(container.render(80)).toEqual(["a-reflowed", "", "b2"]);
});
it("reports the live block start for native scrollback pinning (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
it("reports the live block start that gates native scrollback commits", () => {
const container = new TranscriptContainer();
const a = new MutableBlock(["a1", "a2"]);
const b = new MutableBlock(["b1"]);
@@ -136,93 +132,7 @@ describe("TranscriptContainer", () => {
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
});
it("seals the prior block at its final content when finalize+append coalesce (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["Nat"]);
container.addChild(a);
// `a` streamed a partial chunk and rendered while live.
expect(container.render(40)).toEqual(["Nat"]);
// TUI render coalescing: `a` finalizes AND a newer block is appended within
// one throttled frame, so no render happens between the two mutations.
a.set(["Natives built, now..."]);
const b = new MutableBlock(["b1"]);
container.addChild(b);
// The transition frame must seal `a` at its final content, not the stale
// mid-stream snapshot ("Nat") it last rendered while live.
expect(container.render(40)).toEqual(["Natives built, now...", "", "b1"]);
// Once sealed, a later re-layout of `a` stays frozen until the next thaw.
a.set(["a-collapsed"]);
expect(container.render(40)).toEqual(["Natives built, now...", "", "b1"]);
});
it("thaw() reconciles frozen blocks to their current state", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
a.set(["a-final"]);
expect(container.render(40)).toEqual(["a1", "", "b1"]); // frozen
container.thaw();
expect(container.render(40)).toEqual(["a-final", "", "b1"]); // reconciled
});
it("invalidate() retires frozen snapshots so resetDisplay reflects current state", () => {
// resetDisplay() (Ctrl+L, and the Ctrl+O expand path) reflows by calling
// TUI.invalidate(), which propagates to this container. That must retire the
// frozen snapshots the same way thaw() does, or a forced full replay would
// still emit the pre-mutation (e.g. collapsed) render.
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a-collapsed"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
a.set(["a-expanded-1", "a-expanded-2"]);
expect(container.render(40)).toEqual(["a-collapsed", "", "b1"]); // frozen
container.invalidate();
expect(container.render(40)).toEqual(["a-expanded-1", "a-expanded-2", "", "b1"]);
});
it("recomputes a frozen block on a width change", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
a.set(["a-reflowed"]);
expect(container.render(40)).toEqual(["a1", "", "b1"]); // frozen at width 40
// A resize is an explicit rebuild that reconciles history, so recompute.
expect(container.render(80)).toEqual(["a-reflowed", "", "b1"]);
});
it("renders every block live on terminals that can rebuild history", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
const a = new MutableBlock(["a1"]);
const b = new MutableBlock(["b1"]);
container.addChild(a);
container.addChild(b);
container.render(40);
// No freezing: a non-live block's mutation is reflected (the renderer can
// rebuild committed history on these terminals).
a.set(["a-updated"]);
expect(container.render(40)).toEqual(["a-updated", "", "b1"]);
});
it("keeps an unfinalized block live when a finalized block is appended below it (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
it("keeps an unfinalized block below the seam when a finalized block is appended below it", () => {
const container = new TranscriptContainer();
// A foreground tool whose args are still streaming (no result yet).
const tool = new StreamingBlock(["write (streaming)"]);
@@ -230,26 +140,25 @@ describe("TranscriptContainer", () => {
expect(container.render(40)).toEqual(["write (streaming)"]);
// An out-of-band card (TTSR/todo reminder) is appended below the in-flight
// tool while it is still streaming. The tool must NOT freeze here.
// tool while it is still streaming. The tool's rows must not commit here.
const card = new MutableBlock(["rule card"]);
container.addChild(card);
expect(container.render(40)).toEqual(["write (streaming)", "", "rule card"]);
// The live region begins at the unfinalized tool, not the bottom card.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(0);
// The tool's result lands after the card is already below it. Because the
// tool was kept live, its final content is reflected — the bug was it
// freezing on the streaming preview and never showing the result.
// The tool's result lands after the card is already below it.
tool.finalize(["✔ write: 4 lines"]);
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
// The seam moves past the now-finalized tool.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// Now finalized, it freezes: a later re-layout stays put until the next thaw.
// Even after finalizing, a late re-layout still repaints in the window.
tool.set(["collapsed"]);
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
expect(container.render(40)).toEqual(["collapsed", "", "rule card"]);
});
it("keeps a streaming assistant live so final interrupted content can land after status rows below it (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
it("keeps a streaming assistant live so final interrupted content can land after status rows below it", () => {
const container = new TranscriptContainer();
const assistant = new AssistantMessageComponent();
assistant.updateContent(
@@ -283,8 +192,7 @@ describe("TranscriptContainer", () => {
expect(container.getNativeScrollbackLiveRegionStart()).not.toBe(0);
});
it("seals the live region at the earliest of several unfinalized blocks (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
it("starts the live region at the earliest of several unfinalized blocks", () => {
const container = new TranscriptContainer();
const sealed = new StreamingBlock(["done"], true);
const pending = new StreamingBlock(["pending"]);
@@ -297,20 +205,21 @@ describe("TranscriptContainer", () => {
// leading block can commit while pending + card stay repaintable.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// The leading sealed block freezes; its re-layout is not reflected.
// The sealed block's late re-layout still renders current content; the
// seam is unaffected (it keys off finalization, not row diffs).
sealed.set(["done-collapsed"]);
expect(container.render(40)).toEqual(["done", "", "pending", "", "card"]);
expect(container.render(40)).toEqual(["done-collapsed", "", "pending", "", "card"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// The pending block updates freely while live.
pending.finalize(["pending-final"]);
expect(container.render(40)).toEqual(["done", "", "pending-final", "", "card"]);
expect(container.render(40)).toEqual(["done-collapsed", "", "pending-final", "", "card"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(4);
});
});
describe("TranscriptContainer spacing", () => {
it("inserts exactly one blank line between consecutive blocks", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
container.addChild(new MutableBlock(["b"]));
@@ -320,7 +229,6 @@ describe("TranscriptContainer spacing", () => {
});
it("strips a block's plain-blank top/bottom padding", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
// Leading Spacer rows + a trailing paddingY row collapse to just the body.
@@ -329,7 +237,6 @@ describe("TranscriptContainer spacing", () => {
});
it("preserves background-colored padding rows (block-internal design)", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const bgPad = "\x1b[48;2;0;0;0m \x1b[0m";
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
@@ -339,7 +246,6 @@ describe("TranscriptContainer spacing", () => {
});
it("does not double the gap when a block carries its own trailing blank", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
// The trailing blank is stripped, so only the container's separator remains.
container.addChild(new MutableBlock(["note", ""]));
@@ -348,7 +254,6 @@ describe("TranscriptContainer spacing", () => {
});
it("does not inject separators within a single block's rows", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
// An IRC card / file-mention list wrapped as one block stays tight inside.
container.addChild(new MutableBlock(["header", " body1", " body2"]));
@@ -356,7 +261,6 @@ describe("TranscriptContainer spacing", () => {
});
it("drops a blank-only block without leaving a stray gap", () => {
riskFlag.eagerEraseScrollbackRisk = false;
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["a"]));
container.addChild(new MutableBlock(["", " "]));
@@ -365,7 +269,6 @@ describe("TranscriptContainer spacing", () => {
});
it("counts the separator into the committed prefix below the live region (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
// A finalized block, then a still-live block below it.
container.addChild(new MutableBlock(["a1", "a2"]));
@@ -3,7 +3,7 @@ import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-ag
import { ReadToolGroupComponent } from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
import type { Component } from "@oh-my-pi/pi-tui";
/** Minimal transcript block whose finalized state is fixed at construction. */
class StubBlock implements Component {
@@ -21,8 +21,6 @@ function successResult() {
}
describe("ReadToolGroupComponent transcript freezing", () => {
let prevRisk: boolean;
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
@@ -31,7 +29,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
afterEach(() => {
settings.clearOverride("tui.hyperlinks");
TERMINAL.eagerEraseScrollbackRisk = prevRisk;
vi.restoreAllMocks();
});
@@ -42,9 +39,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
// ED3-risk terminals the container froze the group at its pending preview, so
// the late success result never repainted — the read stuck on "⏳ Read <path>".
it("repaints a late read result instead of freezing the pending preview", () => {
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
TERMINAL.eagerEraseScrollbackRisk = true;
const tc = new TranscriptContainer();
const group = new ReadToolGroupComponent();
group.updateArgs({ path: "/tmp/example.ts", sel: "280-345" }, "id1");
@@ -67,7 +61,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
// The finalization seam the TranscriptContainer keys off of.
it("stays live until pending entries settle, then reports finalized", () => {
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
const group = new ReadToolGroupComponent();
group.updateArgs({ path: "/tmp/a.ts" }, "id1");
@@ -87,7 +80,6 @@ describe("ReadToolGroupComponent transcript freezing", () => {
// Turn-end safety: a read that never delivers a result (aborted turn) must not
// pin the live region forever. seal() forces it terminal.
it("seals a never-resolved pending read so it can freeze", () => {
prevRisk = TERMINAL.eagerEraseScrollbackRisk;
const group = new ReadToolGroupComponent();
group.updateArgs({ path: "/tmp/a.ts" }, "id1");
group.finalize();
@@ -242,18 +242,12 @@ describe("streaming edit preview height (stable, full tail window)", () => {
expect(sawPreviewSentinel).toBe(true);
expect(maxStreamingHeight).toBeGreaterThan(term.rows);
const preCheckpointBufferText = normalizedBufferRows(term).join("\n");
const stalePreviewRowsExistedBeforeCheckpoint = preCheckpointBufferText.includes(previewPrefix);
term.scrollLines(1_000);
const checkpointRefreshed = tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
await settleTerminal(term);
const finalBufferText = normalizedBufferRows(term).join("\n");
expect(finalBufferText).toContain(finalSentinel);
expect(finalBufferText).not.toContain(previewPrefix);
if (stalePreviewRowsExistedBeforeCheckpoint) {
expect(checkpointRefreshed).toBe(true);
}
term.scrollLines(-1_000);
await term.flush();
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,6 @@
import { describe, expect, it } from "bun:test";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
type MutableTerminalInfo = { eagerEraseScrollbackRisk: boolean };
const mutableTerminalInfo = TERMINAL as unknown as MutableTerminalInfo;
async function withTerminalRisk<T>(risk: boolean, run: () => T | Promise<T>): Promise<T> {
const saved = TERMINAL.eagerEraseScrollbackRisk;
mutableTerminalInfo.eagerEraseScrollbackRisk = risk;
try {
return await run();
} finally {
mutableTerminalInfo.eagerEraseScrollbackRisk = saved;
}
}
import type { Component } from "@oh-my-pi/pi-tui";
class MutableLiveBlock implements Component {
#lines: string[];
@@ -32,21 +19,19 @@ class MutableLiveBlock implements Component {
}
describe("transcript streaming commit (assistant text)", () => {
it("treats in-place growth of the trailing line as append-only", async () => {
await withTerminalRisk(true, () => {
const chat = new TranscriptContainer();
// Models a streaming assistant reply: stable head rows plus a current
// line that grows token-by-token without adding a new row.
const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]);
chat.addChild(block);
it("treats in-place growth of the trailing line as append-only", () => {
const chat = new TranscriptContainer();
// Models a streaming assistant reply: stable head rows plus a current
// line that grows token-by-token without adding a new row.
const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]);
chat.addChild(block);
chat.render(80);
chat.render(80);
block.setLines(["para one", "para two", "the quick brown fox"]);
chat.render(80);
// The head rows never changed; only the trailing line grew. Its scrolled-
// off head must be committable to native scrollback (tmux pane history).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
});
block.setLines(["para one", "para two", "the quick brown fox"]);
chat.render(80);
// The head rows never changed; only the trailing line grew. Its scrolled-
// off head must be committable to native scrollback (tmux pane history).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
});
});