import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; 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 { theme as activeTheme, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { AgentProgress, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types"; import { evalToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/eval-render"; import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils"; import { type Component, TUI } from "@oh-my-pi/pi-tui"; import { VirtualTerminal } from "../../tui/test/virtual-terminal"; // Long, path-like output that wraps at the box's inner width — the case that // made a fixed 10-line preview overflow the viewport once committed. function longLines(count: number): string { return Array.from( { length: count }, (_, i) => `out-line-${i} ${"=".repeat(60)} https://example.com/very/long/path/segment/${i}`, ).join("\n"); } type DrainableScheduler = { now(): number; scheduleImmediate(cb: () => void): void; scheduleRender(cb: () => void, delayMs: number): { cancel(): void }; flush(): void; }; function makeDrainableScheduler(): DrainableScheduler { let clock = 0; const queue: Array<{ run: () => void; cancelled: boolean }> = []; const enqueue = (cb: () => void) => { const item = { run: cb, cancelled: false }; queue.push(item); return item; }; return { now: () => clock, scheduleImmediate(cb) { enqueue(cb); }, scheduleRender(cb) { const item = enqueue(cb); return { cancel() { item.cancelled = true; }, }; }, flush() { let guard = 0; while (queue.length > 0) { if (++guard > 100_000) throw new Error("scheduler did not settle"); const item = queue.shift()!; clock += 1; if (!item.cancelled) item.run(); } }, }; } // Plain Component → finalized by default: a settled block above the live region. class StaticBlock implements Component { #lines: string[]; constructor(lines: string[]) { this.#lines = lines; } invalidate(): void {} render(_width: number): string[] { return this.#lines; } } // A still-live predecessor (e.g. a parallel tool that is still running) pins the // transcript commit boundary, so rows below it stay repaintable until the // predecessor finalizes. class LiveBarrier extends StaticBlock { isTranscriptBlockFinalized(): boolean { return false; } } // Stand-in for the input editor + status drawn below the transcript. class Footer implements Component { #rows: number; constructor(rows: number) { this.#rows = rows; } invalidate(): void {} render(_width: number): string[] { return Array.from({ length: this.#rows }, (_, i) => `editor-${i}`); } } const ORIGINAL_ROWS = Object.getOwnPropertyDescriptor(process.stdout, "rows"); function stubStdoutRows(rows: number): void { Object.defineProperty(process.stdout, "rows", { configurable: true, value: rows }); } function makeAssistantMessage(content: AssistantMessage["content"], output = 0): AssistantMessage { return { role: "assistant", content, api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", usage: { input: 0, output, cacheRead: 0, cacheWrite: 0, totalTokens: output, reasoningTokens: output, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; } function streamingPrefixes(text: string, step: number): string[] { const prefixes: string[] = []; for (let end = Math.min(step, text.length); end < text.length; end += step) { prefixes.push(text.slice(0, end)); } prefixes.push(text); return prefixes; } async function settleFrame(term: VirtualTerminal, scheduler: DrainableScheduler): Promise { // Keep the real Ghostty-backed terminal and differential renderer, but drive // frame scheduling explicitly: these regressions assert painted scrollback, // not the production scheduler's ~33 ms cadence. scheduler.flush(); await term.flush(); } function plainScrollBuffer(term: VirtualTerminal): string[] { return term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd()); } function makeEvalProbeResult(output: string, status: "running" | "complete") { return { content: [{ type: "text" as const, text: output }], details: { language: "js" as const, languages: ["js" as const], cells: [ { index: 0, code: "for (const line of probeLines) console.log(line);", language: "js" as const, output, status, }, ], }, isError: false, }; } /** * Six running designer agents at `tick`, shaped as a detached (async-running) * task snapshot. Rows carry the time-derived fields the real executor sets — * a current-tool start (elapsed suffix) and one retry countdown — so the * regression also covers clock-derived byte drift. */ function detachedTaskDetails(tick: number): TaskToolDetails { const now = Date.now(); const progress: AgentProgress[] = Array.from({ length: 6 }, (_, index) => ({ index, id: `Task${index + 1}`, agent: "designer", agentSource: "bundled", status: "running", task: `Design concept ${index + 1}`, assignment: `Design concept ${index + 1}`, description: `Concept ${index + 1}, update ${tick}`, currentTool: tick % 2 === 0 ? "read" : "hub", currentToolStartMs: now - 20_000, lastIntent: `progress ${tick}`, recentTools: [], recentOutput: [], toolCount: tick, requests: tick, tokens: tick * 100, cost: tick / 100, durationMs: tick * 100, retryState: index === 5 ? { attempt: 1, maxAttempts: 5, delayMs: 120_000, errorMessage: "429 rate limited", startedAtMs: now, } : undefined, })); return { projectAgentsDir: null, results: [], totalDurationMs: tick * 100, progress, async: { state: "running", jobId: "Task1", type: "task" }, }; } describe("streaming tool output never sprays duplicate scrollback banners", () => { beforeAll(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, cwd: process.cwd() }); await initTheme(); }); afterAll(() => { resetSettingsForTest(); }); afterEach(() => { if (ORIGINAL_ROWS) Object.defineProperty(process.stdout, "rows", ORIGINAL_ROWS); else Reflect.deleteProperty(process.stdout, "rows"); vi.restoreAllMocks(); }); test("bash: growing partial output under a live predecessor does not duplicate banners", async () => { if (process.platform === "win32") return; const rows = 14; stubStdoutRows(rows); const term = new VirtualTerminal(80, rows); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); transcript.addChild(new StaticBlock(["user: run the build"])); transcript.addChild(new LiveBarrier(["assistant: still working in a parallel tool…"])); const bash = new ToolExecutionComponent("bash", { command: "build.sh" }, {}, undefined, tui, process.cwd()); transcript.addChild(bash); tui.addChild(transcript); tui.addChild(new Footer(6)); try { tui.start(); scheduler.flush(); await term.flush(); for (let n = 1; n <= 40; n++) { bash.updateResult({ content: [{ type: "text", text: longLines(n) }], isError: false }, true); term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); } const buffer = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd()); const banners = buffer.filter(row => row.includes("ctrl+o")).length; // Pre-fix this re-committed a fresh snapshot per streamed frame (~30+). expect(banners).toBeLessThanOrEqual(1); } finally { bash.stopAnimation(); tui.stop(); await term.flush(); } }, 30_000); // The detached-task smear (user report): a background (`async.state === // "running"`) task block reports finalized so later turns can commit, but // as the transcript tail its head rows enter native scrollback the moment // the frame outgrows the viewport. Progress snapshots kept mutating those // committed rows; with scrollback rebuild off (default) every audit repair // appended a fresh copy of the agent one-liners below the stale one. test("task: detached progress never duplicates agent rows in scrollback", async () => { const rows = 24; stubStdoutRows(rows); const term = new VirtualTerminal(100, rows); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // Default engine mode: divergence repairs append below the stale copy, // which is exactly the duplication this test guards against. tui.setScrollbackRebuild(false); const transcript = new TranscriptContainer(); const taskArgs = { context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"), tasks: Array.from({ length: 6 }, (_, index) => ({ name: `Task${index + 1}`, agent: "designer", task: `Design concept ${index + 1}`, })), }; const task = new ToolExecutionComponent( "task", taskArgs, { liveRegion: transcript }, undefined, tui, process.cwd(), ); transcript.addChild(task); tui.addChild(transcript); tui.addChild(new Footer(6)); try { tui.start(); scheduler.flush(); await term.flush(); for (let tick = 1; tick <= 12; tick++) { task.updateResult( { content: [{ type: "text", text: "running" }], details: detachedTaskDetails(tick) }, true, ); term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); } // The turn's reply streams in below the card (the screenshot shape): // the whole card — progress rows included — is pushed above the // window top and committed to the tape as frozen history. transcript.addChild(new StaticBlock(Array.from({ length: 24 }, (_, i) => `reply-line-${i}`))); term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); // A rebuild without a new snapshot (settings toggle, theme epoch) // re-derives the frame; time-derived rows (current-tool elapsed, // retry countdown) must not drift under committed history, so the // render clock freezes with the latch: the rebuild output must be // byte-identical even with the wall clock 90s ahead. const beforeRebuild = task.render(100).join("\n"); vi.spyOn(Date, "now").mockReturnValue(Date.now() + 90_000); task.setShowImages(false); expect(task.render(100).join("\n")).toBe(beforeRebuild); term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); // The terminal snapshot settles an on-screen card in place, but a // card with committed rows is immutable history: replacing it would // re-commit the whole slab below the stale copy, so it is dropped // (the job result surfaces through its own delivery message). const settledDetails = detachedTaskDetails(13); settledDetails.async = { state: "completed", jobId: "Task1", type: "task" }; settledDetails.progress = []; settledDetails.results = Array.from({ length: 6 }, (_, index) => ({ index, id: `Task${index + 1}`, agent: "designer", agentSource: "bundled" as const, task: `Design concept ${index + 1}`, assignment: `Design concept ${index + 1}`, description: `Concept ${index + 1}`, exitCode: 0, output: `concept ${index + 1} shipped`, stderr: "", truncated: false, durationMs: 1000 + index, tokens: 1200, requests: 12, })); task.updateResult({ content: [{ type: "text", text: "done" }], details: settledDetails }, false); // A committed card is immutable: the dropped settlement leaves its // render byte-identical (the frozen progress rows, not result rows). expect(task.render(100).join("\n")).toBe(beforeRebuild); for (let i = 0; i < 2; i++) { term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); } const buffer = plainScrollBuffer(term); // The launch brief is append-only content: it belongs in history, // exactly once. expect(buffer.filter(line => line.includes("context-line-0"))).toHaveLength(1); // Collapsed view shows the last 4 agents; each one-liner must appear // exactly once across history + grid — no stale generations stacked. for (let index = 3; index <= 6; index++) { expect(buffer.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); } expect(buffer.filter(line => line.includes("more agents"))).toHaveLength(1); expect(buffer.filter(line => line.includes("retrying 1/5")).length).toBeLessThanOrEqual(1); // The dropped settlement never re-rendered the card as results. expect(buffer.some(line => line.includes("shipped"))).toBe(false); } finally { task.stopAnimation(); tui.stop(); await term.flush(); } }, 30_000); // A blocking (foreground) task call is a self-replacing dashboard: its // progress rows rewrite in place, so none of them may be recorded as // frozen scrollback snapshots mid-run. Only the finalized result commits. test("task: blocking progress taller than the viewport records only the final frame", async () => { const rows = 24; stubStdoutRows(rows); const term = new VirtualTerminal(100, rows); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); tui.setScrollbackRebuild(false); const transcript = new TranscriptContainer(); const taskArgs = { context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"), tasks: Array.from({ length: 6 }, (_, index) => ({ name: `Task${index + 1}`, agent: "designer", task: `Design concept ${index + 1}`, })), }; const task = new ToolExecutionComponent( "task", taskArgs, { liveRegion: transcript }, undefined, tui, process.cwd(), ); transcript.addChild(task); tui.addChild(transcript); tui.addChild(new Footer(6)); try { tui.start(); scheduler.flush(); await term.flush(); for (let tick = 1; tick <= 12; tick++) { const details = detachedTaskDetails(tick); delete details.async; // blocking call: no async job attached task.updateResult({ content: [{ type: "text", text: "running" }], details }, true); term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); } const midRun = plainScrollBuffer(term); for (let index = 1; index <= 6; index++) { expect(midRun.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); } // Finalize: results replace the progress rows and commit exactly once. const finalDetails = detachedTaskDetails(12); delete finalDetails.async; finalDetails.progress = []; finalDetails.results = Array.from({ length: 6 }, (_, index) => ({ index, id: `Task${index + 1}`, agent: "designer", agentSource: "bundled" as const, task: `Design concept ${index + 1}`, assignment: `Design concept ${index + 1}`, description: `Concept ${index + 1}`, exitCode: 0, output: `concept ${index + 1} shipped`, stderr: "", truncated: false, durationMs: 1000 + index, tokens: 1200, requests: 12, })); task.updateResult({ content: [{ type: "text", text: "done" }], details: finalDetails }, false); for (let i = 0; i < 2; i++) { term.scrollLines(1000); tui.requestRender(); scheduler.flush(); await term.flush(); } const settled = plainScrollBuffer(term); expect(settled.filter(line => line.includes("context-line-0"))).toHaveLength(1); for (let index = 1; index <= 6; index++) { expect(settled.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); } } finally { task.stopAnimation(); tui.stop(); await term.flush(); } }, 30_000); test("eval: collapsed cell output stays within the viewport budget", () => { const rows = 18; stubStdoutRows(rows); const result = { content: [{ type: "text", text: "" }], details: { cells: [ { index: 0, code: "run()", language: "js" as const, output: longLines(60), status: "running" as const }, ], }, isError: false, }; const component = evalToolRenderer.renderResult(result, { expanded: false, isPartial: true }, activeTheme); const lines = component.render(80); // The collapsed cell box fits the viewport budget: code + output tails are // each capped at previewWindowRows() VISUAL rows. Pre-fix the long output // wrapped into ~2x its line count and blew past this. expect(lines.length).toBeLessThanOrEqual(previewWindowRows() + 10); expect(lines.map(line => Bun.stripANSI(line)).join("\n")).toContain("ctrl+o"); }); test("hidden todo snapshot does not clip settled rows from a later streaming response", async () => { const rows = 8; stubStdoutRows(rows); const term = new VirtualTerminal(60, rows); Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); tui.setScrollbackRebuild(false); const transcript = new TranscriptContainer(); transcript.setToolActivityVisible(false); transcript.addChild(new StaticBlock(["user: run the plan"])); const todo = new ToolExecutionComponent("todo", { op: "init" }, {}, undefined, tui, process.cwd()); todo.updateResult( { content: [{ type: "text", text: "" }], details: { phases: [ { name: "Workflow", tasks: [{ content: "Stream the response", status: "in_progress" }], }, ], storage: "session", }, }, false, ); transcript.addChild(todo); const assistant = new AssistantMessageComponent(undefined, true); transcript.addChild(assistant); tui.addChild(transcript); tui.addChild(new Footer(4)); const text = Array.from( { length: 30 }, (_, index) => `stream-row-${index} with stable content that must remain in terminal history.`, ).join("\n\n"); try { tui.start(); scheduler.flush(); await term.flush(); for (const partialText of streamingPrefixes(text, 300)) { assistant.updateContent(makeAssistantMessage([{ type: "text", text: partialText }]), { transient: true, }); tui.requestRender(); scheduler.flush(); await term.flush(); } const midStreamRows = plainScrollBuffer(term); expect(midStreamRows.some(row => row.includes("stream-row-0 "))).toBe(true); } finally { todo.seal(); assistant.dispose(); tui.stop(); await term.flush(); } }); test("streams live assistant thinking and answer rows into native scrollback before finalize", async () => { const rows = 8; stubStdoutRows(rows); const term = new VirtualTerminal(60, rows); Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); const assistant = new AssistantMessageComponent(undefined, false); transcript.addChild(assistant); tui.addChild(transcript); const thinking = Array.from( { length: 6 }, (_, i) => `Thinking paragraph ${i} streaming with plenty of words to wrap here.`, ).join("\n\n"); const text = Array.from( { length: 10 }, (_, i) => `Answer paragraph ${i} with enough content to occupy a full row or two.`, ).join("\n\n"); const fullContent: AssistantMessage["content"] = [ { type: "thinking", thinking }, { type: "text", text }, ]; try { tui.start(); await settleFrame(term, scheduler); for (const partialThinking of streamingPrefixes(thinking, 300)) { assistant.updateContent(makeAssistantMessage([{ type: "thinking", thinking: partialThinking }]), { transient: true, }); tui.requestRender(); await settleFrame(term, scheduler); } for (const partialText of streamingPrefixes(text, 300)) { assistant.updateContent( makeAssistantMessage([ { type: "thinking", thinking }, { type: "text", text: partialText }, ]), { transient: true }, ); tui.requestRender(); await settleFrame(term, scheduler); } const midStreamRows = plainScrollBuffer(term); expect(midStreamRows.some(row => row.includes("Thinking paragraph 0"))).toBe(true); expect(midStreamRows.some(row => row.includes("Answer paragraph 0 "))).toBe(true); assistant.updateContent(makeAssistantMessage(fullContent), { transient: false }); assistant.markTranscriptBlockFinalized(); for (let i = 0; i < 2; i++) { tui.requestRender(); await settleFrame(term, scheduler); } const finalRows = plainScrollBuffer(term); expect(finalRows.filter(row => row.includes("Thinking paragraph 0"))).toHaveLength(1); expect(finalRows.filter(row => row.includes("Answer paragraph 0 "))).toHaveLength(1); } finally { assistant.dispose(); tui.stop(); await term.flush(); } }, 30_000); test("finalizes a width-growing streamed table exactly once", async () => { const rows = 8; stubStdoutRows(rows); const term = new VirtualTerminal(80, rows); Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); // Exercise the default append-only path directly; erase-and-replay would // hide the duplicate-history regression this test is meant to catch. tui.setScrollbackRebuild(false); const transcript = new TranscriptContainer(); const assistant = new AssistantMessageComponent(undefined, false); transcript.addChild(assistant); tui.addChild(transcript); const entries = [ "alpha", "beta-entry", "gamma-component", "delta-module", "epsilon-adapter", "zeta-runner", "eta-service", "theta-provider", "iota-component-with-later-width-growth", "kappa-component-with-later-width-growth", ]; const markers = entries.map((_, index) => `R${index.toString().padStart(3, "0")}`); const table = (count: number): string => [ "| Entry | Col A | Col B | Col C | Col D |", "| --- | --- | --- | --- | --- |", ...entries.slice(0, count).map((entry, index) => `| ${entry} | - | ${markers[index]} | - | - |`), ].join("\n"); try { tui.start(); scheduler.flush(); await term.flush(); for (let count = 1; count <= 8; count++) { assistant.updateContent(makeAssistantMessage([{ type: "text", text: table(count) }]), { transient: true, }); tui.requestRender(); scheduler.flush(); await term.flush(); } const midRows = plainScrollBuffer(term); const headerIndex = midRows.findIndex(row => row.includes("Entry")); expect(headerIndex).toBeGreaterThanOrEqual(0); expect(headerIndex).toBeLessThan(term.getBufferPosition().baseY); expect(midRows.filter(row => row.includes("Entry"))).toHaveLength(1); for (let count = 9; count <= entries.length; count++) { assistant.updateContent(makeAssistantMessage([{ type: "text", text: table(count) }]), { transient: true, }); tui.requestRender(); scheduler.flush(); await term.flush(); } assistant.updateContent(makeAssistantMessage([{ type: "text", text: table(entries.length) }]), { transient: false, }); assistant.markTranscriptBlockFinalized(); for (let i = 0; i < 2; i++) { tui.requestRender(); scheduler.flush(); await term.flush(); } const finalRows = plainScrollBuffer(term); expect(finalRows.filter(row => row.includes("Entry"))).toHaveLength(1); expect(markers.map(marker => finalRows.filter(row => row.includes(marker)).length)).toEqual( markers.map(() => 1), ); } finally { assistant.dispose(); tui.stop(); await term.flush(); } }, 30_000); test("expanded live eval output records painted rows without spraying after settle", async () => { const rows = 8; stubStdoutRows(rows); const term = new VirtualTerminal(60, rows); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); const component = new ToolExecutionComponent( "eval", { code: "probeLines.forEach(console.log)", language: "js" }, {}, undefined, tui, ); transcript.addChild(component); tui.addChild(transcript); const probeLines = Array.from({ length: 30 }, (_, i) => `probe-line-${i}`); const output = probeLines.join("\n"); try { tui.start(); await settleFrame(term, scheduler); component.updateResult(makeEvalProbeResult(output, "running"), true); component.setExpanded(true); for (let i = 0; i < 3; i++) { tui.requestRender(); await settleFrame(term, scheduler); } const midRunRows = plainScrollBuffer(term); expect(midRunRows.some(row => row.includes("probe-line-0"))).toBe(true); component.updateResult(makeEvalProbeResult(output, "complete"), false); for (let i = 0; i < 2; i++) { tui.requestRender(); await settleFrame(term, scheduler); } const settledRows = plainScrollBuffer(term); for (const line of probeLines) { expect(settledRows.some(row => row.includes(line))).toBe(true); } const settledTape = settledRows.join("\n"); const settledLength = settledRows.length; for (let i = 0; i < 2; i++) { tui.requestRender(); await settleFrame(term, scheduler); } const repeatedRows = plainScrollBuffer(term); expect(repeatedRows).toHaveLength(settledLength); expect(repeatedRows.join("\n")).toBe(settledTape); } finally { component.stopAnimation(); tui.stop(); await term.flush(); } }, 30_000); test("tmux height growth preserves finalized history above a live response", async () => { const previousTmux = Bun.env.TMUX; Bun.env.TMUX = "issue-6011"; const term = new VirtualTerminal(40, 10, 1_000); const scheduler = makeDrainableScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); transcript.addChild(new StaticBlock(Array.from({ length: 30 }, (_, index) => `history-${index}`))); transcript.addChild(new LiveBarrier(Array.from({ length: 20 }, (_, index) => `live-${index}`))); tui.addChild(transcript); tui.addChild(new Footer(2)); try { tui.start(); scheduler.flush(); await term.flush(); expect(term.getViewport().some(row => Bun.stripANSI(row).includes("history-"))).toBe(false); const writes: string[] = []; const write = term.write.bind(term); vi.spyOn(term, "write").mockImplementation(data => { writes.push(data); write(data); }); term.resize(80, 60); scheduler.flush(); await term.flush(); let viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd()); expect(viewport.some(row => row === "history-0")).toBe(true); expect(viewport.some(row => row === "live-19")).toBe(true); tui.requestRender(); scheduler.flush(); await term.flush(); viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd()); expect(viewport.some(row => row === "history-0")).toBe(true); expect(writes.join("")).not.toContain("\x1b[3J"); } finally { tui.stop(); await term.flush(); if (previousTmux === undefined) delete Bun.env.TMUX; else Bun.env.TMUX = previousTmux; } }); });