diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3b55518ce..d5ca24482 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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 // ──────────────────────────────────────────────────────────────────────── diff --git a/packages/coding-agent/src/debug/terminal-info.ts b/packages/coding-agent/src/debug/terminal-info.ts index 7d33a70b0..252b86b28 100644 --- a/packages/coding-agent/src/debug/terminal-info.ts +++ b/packages/coding-agent/src/debug/terminal-info.ts @@ -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)"}`, diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 15ba42219..c77bdb70f 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index d61d30378..8ec843bc0 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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 = { - 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) => Promise; }; @@ -65,7 +52,6 @@ export class EventController { #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; #backgroundToolCallIds = new Set(); - #assistantMessageStreaming = false; #agentTurnActive = false; #interrupting = false; #readToolCallArgs = new Map>(); @@ -217,30 +203,6 @@ export class EventController { const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise; 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): Promise { @@ -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): Promise { 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): Promise { this.#agentTurnActive = false; - this.#assistantMessageStreaming = false; this.#streamingReveal.stop(); if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5ae666fb8..711b24c22 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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", ); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 5d644aa4a..3ab9d7a45 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index fec6c32c1..9bfda6c1d 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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) { diff --git a/packages/coding-agent/test/debug/terminal-info.test.ts b/packages/coding-agent/test/debug/terminal-info.test.ts index b937efbc5..cad500705 100644 --- a/packages/coding-agent/test/debug/terminal-info.test.ts +++ b/packages/coding-agent/test/debug/terminal-info.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 9b55a714e..8ac9b507c 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -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"])); diff --git a/packages/coding-agent/test/read-tool-group-freeze.test.ts b/packages/coding-agent/test/read-tool-group-freeze.test.ts index 3ffd85113..160dd2a2e 100644 --- a/packages/coding-agent/test/read-tool-group-freeze.test.ts +++ b/packages/coding-agent/test/read-tool-group-freeze.test.ts @@ -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 ". 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(); diff --git a/packages/coding-agent/test/streaming-preview-height.test.ts b/packages/coding-agent/test/streaming-preview-height.test.ts index 59f01cea2..009b08a37 100644 --- a/packages/coding-agent/test/streaming-preview-height.test.ts +++ b/packages/coding-agent/test/streaming-preview-height.test.ts @@ -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(); diff --git a/packages/coding-agent/test/tool-live-region-scrollback.test.ts b/packages/coding-agent/test/tool-live-region-scrollback.test.ts index 8caf5deee..838e5b3a7 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -5,25 +5,9 @@ import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/compo import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; 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 { type Component, TERMINAL, Text, TUI } from "@oh-my-pi/pi-tui"; +import { type Component, Text, TUI } from "@oh-my-pi/pi-tui"; import { VirtualTerminal } from "../../tui/test/virtual-terminal"; -type MutableTerminalInfo = { - eagerEraseScrollbackRisk: boolean; -}; - -const mutableTerminalInfo = TERMINAL as unknown as MutableTerminalInfo; - -async function withTerminalRisk(risk: boolean, run: () => T | Promise): Promise { - const saved = TERMINAL.eagerEraseScrollbackRisk; - mutableTerminalInfo.eagerEraseScrollbackRisk = risk; - try { - return await run(); - } finally { - mutableTerminalInfo.eagerEraseScrollbackRisk = saved; - } -} - class MutableLiveBlock implements Component { #lines: string[]; #finalized: boolean; @@ -56,131 +40,117 @@ function stripRows(rows: string[]): string { describe("transcript reactive commit boundary", () => { it("treats growth before stable trailing chrome as append-only", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - const block = new MutableLiveBlock(["top", "stable", "bottom"]); - chat.addChild(block); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(["top", "stable", "bottom"]); + chat.addChild(block); - expect(chat.render(80)).toEqual(["top", "stable", "bottom"]); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); + expect(chat.render(80)).toEqual(["top", "stable", "bottom"]); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); - block.setLines(["top", "stable", "inserted", "bottom"]); - expect(chat.render(80)).toEqual(["top", "stable", "inserted", "bottom"]); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4); - }); + block.setLines(["top", "stable", "inserted", "bottom"]); + expect(chat.render(80)).toEqual(["top", "stable", "inserted", "bottom"]); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4); }); 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 — the dominant - // streaming shape, and the one a strict line-count-growth check missed, - // stranding the scrolled-off head outside tmux pane history. - const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]); - chat.addChild(block); + 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 — the dominant + // streaming shape, and the one a strict line-count-growth check missed, + // stranding the scrolled-off head outside tmux pane history. + const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]); + chat.addChild(block); - chat.render(80); - block.setLines(["para one", "para two", "the quick brown fox"]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); - }); + chat.render(80); + block.setLines(["para one", "para two", "the quick brown fox"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); }); it("marks interior live re-layout volatile and defers commit", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - const block = new MutableLiveBlock(["top", "old", "bottom"]); - chat.addChild(block); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(["top", "old", "bottom"]); + chat.addChild(block); - chat.render(80); - block.setLines(["top", "new", "extra", "bottom"]); - expect(chat.render(80)).toEqual(["top", "new", "extra", "bottom"]); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); + chat.render(80); + block.setLines(["top", "new", "extra", "bottom"]); + expect(chat.render(80)).toEqual(["top", "new", "extra", "bottom"]); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); - block.setLines(["top", "new", "extra", "more", "bottom"]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); - }); + block.setLines(["top", "new", "extra", "more", "bottom"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); }); it("treats escape placement and pad drift on visually unchanged rows as append-only", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - // Field failure shape (streaming styled thinking): the previous last row - // carried the span-closing SGR before its width padding; when the - // paragraph wrapped onto a new row, the close moved to the new last row - // while the first row's visible cells stayed identical. - const sty = "\x1b[38;2;156;163;176m"; - const block = new MutableLiveBlock([`${sty}alpha beta\x1b[39m `]); - chat.addChild(block); + const chat = new TranscriptContainer(); + // Field failure shape (streaming styled thinking): the previous last row + // carried the span-closing SGR before its width padding; when the + // paragraph wrapped onto a new row, the close moved to the new last row + // while the first row's visible cells stayed identical. + const sty = "\x1b[38;2;156;163;176m"; + const block = new MutableLiveBlock([`${sty}alpha beta\x1b[39m `]); + chat.addChild(block); - chat.render(80); - block.setLines([`${sty}alpha beta `, `${sty}gamma\x1b[39m `]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(2); - }); + chat.render(80); + block.setLines([`${sty}alpha beta `, `${sty}gamma\x1b[39m `]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(2); }); it("treats a wrap-shrink of the trailing line as append-only", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - // A streamed token extends the last word past the wrap column, so the - // word moves down onto an appended row and the previous bottom line - // shrinks. The bottom line is on screen by definition, so this is not a - // rewrite of committed-candidate rows. - const block = new MutableLiveBlock(["para one", "foo bar baz"]); - chat.addChild(block); + const chat = new TranscriptContainer(); + // A streamed token extends the last word past the wrap column, so the + // word moves down onto an appended row and the previous bottom line + // shrinks. The bottom line is on screen by definition, so this is not a + // rewrite of committed-candidate rows. + const block = new MutableLiveBlock(["para one", "foo bar baz"]); + chat.addChild(block); - chat.render(80); - block.setLines(["para one", "foo bar", "bazqux and more"]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); - }); + chat.render(80); + block.setLines(["para one", "foo bar", "bazqux and more"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); }); it("re-earns append-only after a one-off interior rewrite heals", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - const block = new MutableLiveBlock(["top", "old", "bottom"]); - chat.addChild(block); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(["top", "old", "bottom"]); + chat.addChild(block); - chat.render(80); - // Interior rewrite (a codespan finalizing across a wrap) suspends commits. - block.setLines(["top", "new", "bottom"]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); + chat.render(80); + // Interior rewrite (a codespan finalizing across a wrap) suspends commits. + block.setLines(["top", "new", "bottom"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); - // Clean static frames re-arm the block... - for (let i = 0; i < 30; i++) chat.render(80); - // ...and the next append-shaped frame resumes committing the full block, - // so the pinned emitter can backfill the stalled gap contiguously. - block.setLines(["top", "new", "bottom", "appended"]); - chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4); - }); + // Clean static frames re-arm the block... + for (let i = 0; i < 30; i++) chat.render(80); + // ...and the next append-shaped frame resumes committing the full block, + // so the pinned emitter can backfill the stalled gap contiguously. + block.setLines(["top", "new", "bottom", "appended"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(4); }); it("keeps a periodically rewriting block (spinner) deferred", async () => { - await withTerminalRisk(true, () => { - const chat = new TranscriptContainer(); - const block = new MutableLiveBlock(["⠋ running", "body"]); - chat.addChild(block); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(["⠋ running", "body"]); + chat.addChild(block); + chat.render(80); + const glyphs = ["⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋"]; + for (const glyph of glyphs) { + // Spinner advances every third frame; the static frames in between + // must never accumulate into a re-arm. + block.setLines([`${glyph} running`, "body"]); chat.render(80); - const glyphs = ["⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋"]; - for (const glyph of glyphs) { - // Spinner advances every third frame; the static frames in between - // must never accumulate into a re-arm. - block.setLines([`${glyph} running`, "body"]); - chat.render(80); - chat.render(80); - chat.render(80); - } - block.setLines(["⠋ running", "body", "appended"]); chat.render(80); - expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); - }); + chat.render(80); + } + block.setLines(["⠋ running", "body", "appended"]); + chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined(); }); }); @@ -192,350 +162,315 @@ describe("tool live-region scrollback", () => { it("does not splice stale pending eval preview above the running eval viewport", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const code = Array.from({ length: 20 }, (_unused, i) => `const line${i} = ${i};`).join("\n"); - const title = "call model with new prompt + check box heights"; - const args = { cells: [{ language: "js", title, code }] }; - const component = new ToolExecutionComponent("eval", args, {}, undefined, tui, process.cwd()); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const code = Array.from({ length: 20 }, (_unused, i) => `const line${i} = ${i};`).join("\n"); + const title = "call model with new prompt + check box heights"; + const args = { cells: [{ language: "js", title, code }] }; + const component = new ToolExecutionComponent("eval", args, {}, undefined, tui, process.cwd()); - try { - chat.addChild( - new Text("Now let me verify by calling the model and checking the box heights it produces:", 0, 0), - ); - chat.addChild(new Text("prior filler\n".repeat(8).trimEnd(), 0, 0)); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); + try { + chat.addChild( + new Text("Now let me verify by calling the model and checking the box heights it produces:", 0, 0), + ); + chat.addChild(new Text("prior filler\n".repeat(8).trimEnd(), 0, 0)); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); - chat.addChild(component); - tui.requestRender(); - await term.waitForRender(); + chat.addChild(component); + tui.requestRender(); + await term.waitForRender(); - component.updateResult( - { - content: [{ type: "text", text: "" }], - details: { cells: [{ index: 0, title, code, language: "js", output: "", status: "running" }] }, - }, - true, - ); - tui.requestRender(); - await term.waitForRender(); + component.updateResult( + { + content: [{ type: "text", text: "" }], + details: { cells: [{ index: 0, title, code, language: "js", output: "", status: "running" }] }, + }, + true, + ); + tui.requestRender(); + await term.waitForRender(); - const bufferText = term - .getScrollBuffer() - .map(row => Bun.stripANSI(row).trimEnd()) - .join("\n"); - expect(bufferText).not.toContain("pending [1/1]"); - expect(bufferText).toContain("const line9 = 9;"); - expect(bufferText).toContain("const line19 = 19;"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); - } - }); + const bufferText = term + .getScrollBuffer() + .map(row => Bun.stripANSI(row).trimEnd()) + .join("\n"); + expect(bufferText).not.toContain("pending [1/1]"); + expect(bufferText).toContain("const line9 = 9;"); + expect(bufferText).toContain("const line19 = 19;"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); it("repaints a finalized write whose result lands after a card was appended below it", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 20); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const content = Array.from({ length: 5 }, (_unused, i) => `const line${i} = ${i};`).join("\n"); - const args = { file_path: "packages/coding-agent/test/probe.ts", content }; - const component = new ToolExecutionComponent("write", args, {}, undefined, tui, process.cwd()); + const term = new VirtualTerminal(120, 20); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const content = Array.from({ length: 5 }, (_unused, i) => `const line${i} = ${i};`).join("\n"); + const args = { file_path: "packages/coding-agent/test/probe.ts", content }; + const component = new ToolExecutionComponent("write", args, {}, undefined, tui, process.cwd()); - try { - chat.addChild(new Text("prior filler", 0, 0)); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); + try { + chat.addChild(new Text("prior filler", 0, 0)); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); - // The write streams its preview while it is the live block. - chat.addChild(component); - tui.requestRender(); - await term.waitForRender(); + // The write streams its preview while it is the live block. + chat.addChild(component); + tui.requestRender(); + await term.waitForRender(); - // An out-of-band card (e.g. a TTSR rule notification) is appended below - // the still-in-flight write. Previously this froze the write on its - // streaming preview, so the eventual result never repainted. - chat.addChild(new Text("⚠ Injecting rule: ts-set-map", 0, 0)); - tui.requestRender(); - await term.waitForRender(); + // An out-of-band card (e.g. a TTSR rule notification) is appended below + // the still-in-flight write. Previously this froze the write on its + // streaming preview, so the eventual result never repainted. + chat.addChild(new Text("⚠ Injecting rule: ts-set-map", 0, 0)); + tui.requestRender(); + await term.waitForRender(); - const beforeResult = term - .getScrollBuffer() - .map(row => Bun.stripANSI(row).trimEnd()) - .join("\n"); - expect(beforeResult).toContain("(streaming)"); + const beforeResult = term + .getScrollBuffer() + .map(row => Bun.stripANSI(row).trimEnd()) + .join("\n"); + expect(beforeResult).toContain("(streaming)"); - // The write finishes after the card is already below it. - component.updateResult({ content: [{ type: "text", text: "" }], details: { path: args.file_path } }, false); - tui.requestRender(); - await term.waitForRender(); + // The write finishes after the card is already below it. + component.updateResult({ content: [{ type: "text", text: "" }], details: { path: args.file_path } }, false); + tui.requestRender(); + await term.waitForRender(); - const afterResult = term - .getScrollBuffer() - .map(row => Bun.stripANSI(row).trimEnd()) - .join("\n"); - // The streaming preview is gone and the finalized header repainted in place. - expect(afterResult).not.toContain("(streaming)"); - expect(afterResult).toContain("· 5 lines"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); - } - }); + const afterResult = term + .getScrollBuffer() + .map(row => Bun.stripANSI(row).trimEnd()) + .join("\n"); + // The streaming preview is gone and the finalized header repainted in place. + expect(afterResult).not.toContain("(streaming)"); + expect(afterResult).toContain("· 5 lines"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); it("commits the scrolled-off head of an over-tall expanded streaming write to scrollback", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 20); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const body = (n: number) => Array.from({ length: n }, (_unused, i) => `MARK-${i}`).join("\n"); - const filePath = "packages/coding-agent/test/probe.txt"; - // Expanded (Ctrl+O) lifts the tail-window cap, so the preview renders the - // whole content top-anchored — append-only growth as chunks stream in. - const component = new ToolExecutionComponent( - "write", - { file_path: filePath, content: body(12) }, - {}, - undefined, - tui, - process.cwd(), - ); - component.setExpanded(true); + const term = new VirtualTerminal(120, 20); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const body = (n: number) => Array.from({ length: n }, (_unused, i) => `MARK-${i}`).join("\n"); + const filePath = "packages/coding-agent/test/probe.txt"; + // Expanded (Ctrl+O) lifts the tail-window cap, so the preview renders the + // whole content top-anchored — append-only growth as chunks stream in. + const component = new ToolExecutionComponent( + "write", + { file_path: filePath, content: body(12) }, + {}, + undefined, + tui, + process.cwd(), + ); + component.setExpanded(true); - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + for (const lineCount of [24, 40]) { + component.updateArgs({ file_path: filePath, content: body(lineCount) }); + tui.requestRender(); await term.waitForRender(); - - for (const lineCount of [24, 40]) { - component.updateArgs({ file_path: filePath, content: body(lineCount) }); - tui.requestRender(); - await term.waitForRender(); - } - - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); - - // MARK-0 scrolled above the viewport: it must live in native scrollback - // (committed), not nowhere. Before the fix the tool block was not - // append-only, so its scrolled-off head was dropped — a yanked stream. - expect(viewportText).not.toContain("MARK-0"); - expect(scrollText).toContain("MARK-0"); - // The streaming tail stays on screen, and nothing went missing between. - expect(viewportText).toContain("MARK-39"); - expect(viewportText).toContain("(streaming)"); - expect(scrollText).toContain("MARK-20"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); } - }); + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + // MARK-0 scrolled above the viewport: it must live in native scrollback + // (committed), not nowhere. Before the fix the tool block was not + // append-only, so its scrolled-off head was dropped — a yanked stream. + expect(viewportText).not.toContain("MARK-0"); + expect(scrollText).toContain("MARK-0"); + // The streaming tail stays on screen, and nothing went missing between. + expect(viewportText).toContain("MARK-39"); + expect(viewportText).toContain("(streaming)"); + expect(scrollText).toContain("MARK-20"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); it("commits the scrolled-off head of an over-tall pending task context to scrollback", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const context = (n: number) => Array.from({ length: n }, (_unused, i) => `- CTX-${i}`).join("\n"); - const args = (n: number) => ({ - agent: "task", - context: context(n), - tasks: [{ id: "alpha", description: "probe", assignment: "Inspect the task context." }], - }); - const component = new ToolExecutionComponent("task", args(4), {}, undefined, tui, process.cwd()); - - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); - - for (const lineCount of [12, 24, 40]) { - component.updateArgs(args(lineCount)); - tui.requestRender(); - await term.waitForRender(); - } - - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); - - expect(viewportText).not.toContain("CTX-0"); - expect(scrollText).toContain("CTX-0"); - expect(scrollText).toContain("CTX-20"); - expect(viewportText).toContain("CTX-39"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); - } + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const context = (n: number) => Array.from({ length: n }, (_unused, i) => `- CTX-${i}`).join("\n"); + const args = (n: number) => ({ + agent: "task", + context: context(n), + tasks: [{ id: "alpha", description: "probe", assignment: "Inspect the task context." }], }); + const component = new ToolExecutionComponent("task", args(4), {}, undefined, tui, process.cwd()); + + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + for (const lineCount of [12, 24, 40]) { + component.updateArgs(args(lineCount)); + tui.requestRender(); + await term.waitForRender(); + } + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + expect(viewportText).not.toContain("CTX-0"); + expect(scrollText).toContain("CTX-0"); + expect(scrollText).toContain("CTX-20"); + expect(viewportText).toContain("CTX-39"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); it("commits the scrolled-off head of a tall finalized bottom tool result", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const content = markerLines("FINAL-", 40).join("\n"); - const args = { path: "packages/coding-agent/test/finalized.txt" }; - const component = new ToolExecutionComponent("read", args, {}, undefined, tui, process.cwd()); - component.setExpanded(true); - component.updateResult( - { - content: [{ type: "text", text: content }], - details: { displayContent: { text: content, startLine: 1 } }, - }, - false, - ); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const content = markerLines("FINAL-", 40).join("\n"); + const args = { path: "packages/coding-agent/test/finalized.txt" }; + const component = new ToolExecutionComponent("read", args, {}, undefined, tui, process.cwd()); + component.setExpanded(true); + component.updateResult( + { + content: [{ type: "text", text: content }], + details: { displayContent: { text: content, startLine: 1 } }, + }, + false, + ); - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); - expect(viewportText).not.toContain("FINAL-0"); - expect(scrollText).toContain("FINAL-0"); - expect(scrollText).toContain("FINAL-20"); - expect(viewportText).toContain("FINAL-39"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); - } - }); + expect(viewportText).not.toContain("FINAL-0"); + expect(scrollText).toContain("FINAL-0"); + expect(scrollText).toContain("FINAL-20"); + expect(viewportText).toContain("FINAL-39"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); it("keeps a re-layouting live block's changed head out of scrollback", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const block = new MutableLiveBlock(markerLines("OLD-", 8)); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const block = new MutableLiveBlock(markerLines("OLD-", 8)); - try { - chat.addChild(block); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); + try { + chat.addChild(block); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); - block.setLines(markerLines("NEW-", 40)); - tui.requestRender(); - await term.waitForRender(); + block.setLines(markerLines("NEW-", 40)); + tui.requestRender(); + await term.waitForRender(); - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); - expect(viewportText).not.toContain("NEW-0"); - expect(scrollText).not.toContain("NEW-0"); - expect(scrollText).not.toContain("NEW-20"); - expect(viewportText).toContain("NEW-39"); - } finally { - tui.stop(); - await term.flush(); - } - }); + expect(viewportText).not.toContain("NEW-0"); + expect(scrollText).not.toContain("NEW-0"); + expect(scrollText).not.toContain("NEW-20"); + expect(viewportText).toContain("NEW-39"); + } finally { + tui.stop(); + await term.flush(); + } }); it("commits the scrolled-off head of an expanded eval whose output streams past the viewport", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const title = "stream lots of output"; - const code = "for (let i = 0; i < 40; i++) console.log('MARK-' + i);"; - const args = { cells: [{ language: "js", title, code }] }; - const component = new ToolExecutionComponent("eval", args, {}, undefined, tui, process.cwd()); - component.setExpanded(true); - const out = (n: number) => Array.from({ length: n }, (_unused, i) => `MARK-${i}`).join("\n"); - const partial = (output: string) => - component.updateResult( - { - content: [{ type: "text", text: "" }], - details: { cells: [{ index: 0, title, code, language: "js", output, status: "running" }] }, - }, - true, - ); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const title = "stream lots of output"; + const code = "for (let i = 0; i < 40; i++) console.log('MARK-' + i);"; + const args = { cells: [{ language: "js", title, code }] }; + const component = new ToolExecutionComponent("eval", args, {}, undefined, tui, process.cwd()); + component.setExpanded(true); + const out = (n: number) => Array.from({ length: n }, (_unused, i) => `MARK-${i}`).join("\n"); + const partial = (output: string) => + component.updateResult( + { + content: [{ type: "text", text: "" }], + details: { cells: [{ index: 0, title, code, language: "js", output, status: "running" }] }, + }, + true, + ); - partial(out(4)); + partial(out(4)); - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + for (const lineCount of [12, 24, 40]) { + partial(out(lineCount)); + tui.requestRender(); await term.waitForRender(); - - for (const lineCount of [12, 24, 40]) { - partial(out(lineCount)); - tui.requestRender(); - await term.waitForRender(); - } - - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); - - // The streamed output head scrolled above the viewport: it must live in - // native scrollback (committed), not nowhere. The fixed code cell rides - // along as the stable prefix above it. - expect(viewportText).not.toContain("MARK-0"); - expect(scrollText).toContain("MARK-0"); - expect(scrollText).toContain("MARK-20"); - // The streaming tail stays on screen, and nothing went missing between. - expect(viewportText).toContain("MARK-39"); - } finally { - component.stopAnimation(); - tui.stop(); - await term.flush(); } - }); + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + // The streamed output head scrolled above the viewport: it must live in + // native scrollback (committed), not nowhere. The fixed code cell rides + // along as the stable prefix above it. + expect(viewportText).not.toContain("MARK-0"); + expect(scrollText).toContain("MARK-0"); + expect(scrollText).toContain("MARK-20"); + // The streaming tail stays on screen, and nothing went missing between. + expect(viewportText).toContain("MARK-39"); + } finally { + component.stopAnimation(); + tui.stop(); + await term.flush(); + } }); }); @@ -574,104 +509,94 @@ describe("assistant live-region scrollback", () => { it("commits a streamed reply's scrolled-off head to scrollback instead of dropping it", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - // A streaming assistant reply, mid-stream (no message in the ctor → live). - // A markdown list yields one stable row per item, so growth is append-only. - const component = new AssistantMessageComponent(undefined, false); - const markers = Array.from({ length: 40 }, (_unused, i) => `- MARK-${i}`); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + // A streaming assistant reply, mid-stream (no message in the ctor → live). + // A markdown list yields one stable row per item, so growth is append-only. + const component = new AssistantMessageComponent(undefined, false); + const markers = Array.from({ length: 40 }, (_unused, i) => `- MARK-${i}`); - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); - await term.waitForRender(); + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); - component.updateContent(makeAssistantMessage(markers.slice(0, 4).join("\n"))); + component.updateContent(makeAssistantMessage(markers.slice(0, 4).join("\n"))); + tui.requestRender(); + await term.waitForRender(); + + for (const lineCount of [12, 24, 40]) { + component.updateContent(makeAssistantMessage(markers.slice(0, lineCount).join("\n"))); tui.requestRender(); await term.waitForRender(); - - for (const lineCount of [12, 24, 40]) { - component.updateContent(makeAssistantMessage(markers.slice(0, lineCount).join("\n"))); - tui.requestRender(); - await term.waitForRender(); - } - - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); - - // MARK-0 scrolled above the viewport: with the fix it lives in native - // scrollback (committed), not nowhere. The regression dropped it. - expect(viewportText).not.toContain("MARK-0"); - expect(scrollText).toContain("MARK-0"); - // The tail is still on screen, and nothing went missing in between. - expect(viewportText).toContain("MARK-39"); - expect(scrollText).toContain("MARK-20"); - } finally { - tui.stop(); - await term.flush(); } - }); + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + // MARK-0 scrolled above the viewport: with the fix it lives in native + // scrollback (committed), not nowhere. The regression dropped it. + expect(viewportText).not.toContain("MARK-0"); + expect(scrollText).toContain("MARK-0"); + // The tail is still on screen, and nothing went missing in between. + expect(viewportText).toContain("MARK-39"); + expect(scrollText).toContain("MARK-20"); + } finally { + tui.stop(); + await term.flush(); + } }); it("commits scrolled-off styled thinking paragraphs to scrollback while streaming", async () => { if (process.platform === "win32") return; - await withTerminalRisk(true, async () => { - const term = new VirtualTerminal(120, 12); - (term as unknown as { isNativeViewportAtBottom: () => boolean | undefined }).isNativeViewportAtBottom = () => - undefined; - const tui = new TUI(term); - const chat = new TranscriptContainer(); - const component = new AssistantMessageComponent(undefined, false); - // Word-wrapped italic/colored paragraphs — the styled streaming shape the - // raw-byte append detector mis-classified as volatile (the span-closing - // SGR moves rows as the paragraph wraps), which froze the commit boundary - // and dropped every later paragraph that scrolled past the viewport top. - const paragraphs = Array.from( - { length: 8 }, - (_unused, i) => - `PARA-${i} considering the resolver path and the descriptor defaults, the policy layer must keep the ` + - `reasoning flag intact while discovery maps an unknown model entry onto the bundled reference shape ` + - `so the runtime request stays correct across upstream metadata shifts.`, - ); - const fullText = paragraphs.join("\n\n"); - const words = fullText.split(" "); + const term = new VirtualTerminal(120, 12); + const tui = new TUI(term); + const chat = new TranscriptContainer(); + const component = new AssistantMessageComponent(undefined, false); + // Word-wrapped italic/colored paragraphs — the styled streaming shape the + // raw-byte append detector mis-classified as volatile (the span-closing + // SGR moves rows as the paragraph wraps), which froze the commit boundary + // and dropped every later paragraph that scrolled past the viewport top. + const paragraphs = Array.from( + { length: 8 }, + (_unused, i) => + `PARA-${i} considering the resolver path and the descriptor defaults, the policy layer must keep the ` + + `reasoning flag intact while discovery maps an unknown model entry onto the bundled reference shape ` + + `so the runtime request stays correct across upstream metadata shifts.`, + ); + const fullText = paragraphs.join("\n\n"); + const words = fullText.split(" "); - try { - chat.addChild(component); - tui.addChild(chat); - tui.start(); - tui.setEagerNativeScrollbackRebuild(true); + try { + chat.addChild(component); + tui.addChild(chat); + tui.start(); + await term.waitForRender(); + + // Stream a few words per frame so the in-flight bottom line extends, + // wraps, and sheds words onto new rows across many coalesced frames. + for (let i = 5; i <= words.length; i += 5) { + component.updateContent(makeThinkingMessage(words.slice(0, i).join(" "))); + tui.requestRender(); await term.waitForRender(); - - // Stream a few words per frame so the in-flight bottom line extends, - // wraps, and sheds words onto new rows across many coalesced frames. - for (let i = 5; i <= words.length; i += 5) { - component.updateContent(makeThinkingMessage(words.slice(0, i).join(" "))); - tui.requestRender(); - await term.waitForRender(); - } - - const scrollText = stripRows(term.getScrollBuffer()); - const viewportText = stripRows(term.getViewport()); - - // Early paragraphs scrolled above the viewport: they must live in - // native scrollback, not vanish into the dropped gap. - expect(viewportText).not.toContain("PARA-0"); - expect(scrollText).toContain("PARA-0"); - expect(scrollText).toContain("PARA-4"); - // The tail is still on screen. - expect(viewportText).toContain("PARA-7"); - } finally { - tui.stop(); - await term.flush(); } - }); + + const scrollText = stripRows(term.getScrollBuffer()); + const viewportText = stripRows(term.getViewport()); + + // Early paragraphs scrolled above the viewport: they must live in + // native scrollback, not vanish into the dropped gap. + expect(viewportText).not.toContain("PARA-0"); + expect(scrollText).toContain("PARA-0"); + expect(scrollText).toContain("PARA-4"); + // The tail is still on screen. + expect(viewportText).toContain("PARA-7"); + } finally { + tui.stop(); + await term.flush(); + } }); }); diff --git a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts index 767e93bcc..25532f3f2 100644 --- a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts +++ b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts @@ -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(risk: boolean, run: () => T | Promise): Promise { - 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); }); });