From 499e22bf5261856716142376a372f187396fe1b2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:04:21 +1000 Subject: [PATCH] fix(app): reuse terminal cells during serialization (#46763) --- .../app/e2e/performance/terminals/README.md | 44 +++ .../terminals/playwright.config.ts | 20 + .../app/e2e/performance/terminals/probe.ts | 74 ++++ .../app/e2e/performance/terminals/shell.ps1 | 13 + .../terminals/terminal-benchmark.spec.ts | 352 ++++++++++++++++++ .../e2e/performance/terminals/vite.config.ts | 17 + .../src/session/terminal/serialize.test.ts | 28 +- .../app/src/session/terminal/serialize.ts | 7 +- .../app/src/session/terminal/writer.test.ts | 28 ++ 9 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 packages/app/e2e/performance/terminals/README.md create mode 100644 packages/app/e2e/performance/terminals/playwright.config.ts create mode 100644 packages/app/e2e/performance/terminals/probe.ts create mode 100644 packages/app/e2e/performance/terminals/shell.ps1 create mode 100644 packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/terminals/vite.config.ts diff --git a/packages/app/e2e/performance/terminals/README.md b/packages/app/e2e/performance/terminals/README.md new file mode 100644 index 00000000000..4b9fd6a05f9 --- /dev/null +++ b/packages/app/e2e/performance/terminals/README.md @@ -0,0 +1,44 @@ +# Native Terminal Benchmark + +Manual Windows benchmark. Run only in an isolated development worktree. It does +not connect to an OpenCode service, user profile, or database. + +Build from `packages/app` with +`bun x vite build --config e2e/performance/terminals/vite.config.ts`, then freeze +`dist` outside the repository. Set `PLAYWRIGHT_BUILD=1`, `PLAYWRIGHT_BASE_URL` to +an unused loopback URL, `TERMINAL_BUILD` to the frozen build, +`TERMINAL_ARTIFACTS` to an existing external directory, and `TERMINAL_RESULTS` +to an external result directory. Run: + +```sh +bun x playwright test --config e2e/performance/terminals/playwright.config.ts --repeat-each=20 +``` + +The runner owns its preview server and each test owns a PowerShell ConPTY process. +Session metadata is deterministic. Native output is forwarded through Playwright's +WebSocket fixture into the real production `Terminal`, writer, Ghostty WASM/canvas, +and serializer. No output is dropped, paused, or delayed. This is native terminal +plus production renderer evidence, not the production PTY backend or Electron IPC. + +The workload is 12,000 colored build/test log lines with file paths, durations, and +result descriptions. Cases separate visible output, the same output while hidden, +and closing the session tab after filling the configured scrollback. Ghostty +converts the app's 10,000-line setting to bytes at its initial 80-column width; +resizing reduces the effective row capacity. The report records actual retained +rows and the first retained fixture record rather than assuming 10,000 rows. Completion +requires the final marker in Ghostty and completion of its write callbacks, not +just WebSocket delivery. Teardown requires Home readiness and the final serialized +snapshot. Input, focus, resizing, and native process survival are checked. + +`probe.ts` is included only by this benchmark build. It observes actual writes, +renderer calls, and serialization. Chrome `TaskDuration` measures renderer task +time, not total process CPU or RAM. For attribution, set +`OPENCODE_PERFORMANCE_TRACE_DIR`; keep traced runs separate from clean timing. +`TERMINAL_DRAW_PROBE=1` separately counts actual canvas draws to verify hidden +rendering; do not mix these instrumented samples with clean timing. +Use `TERMINAL_REVISION` and `TERMINAL_BUNDLE` to identify frozen artifacts. +`TERMINAL_SCREENSHOTS` captures the visible result after timing. + +The benchmark has no machine-dependent performance thresholds. Keep raw logs, +snapshots, traces, and screenshots outside Git. Run heavy work through the +coordinator's exclusive gate when participating in a shared performance wave. diff --git a/packages/app/e2e/performance/terminals/playwright.config.ts b/packages/app/e2e/performance/terminals/playwright.config.ts new file mode 100644 index 00000000000..46de831b0a7 --- /dev/null +++ b/packages/app/e2e/performance/terminals/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test" +import config from "../../../playwright.config" + +export default defineConfig({ + ...config, + testDir: ".", + testIgnore: [], + testMatch: "terminal-benchmark.spec.ts", + workers: 1, + retries: 0, + timeout: 120_000, + outputDir: process.env.TERMINAL_RESULTS, + reporter: [["line"]], + webServer: { + command: `bun x vite preview --host 127.0.0.1 --port ${new URL(process.env.PLAYWRIGHT_BASE_URL!).port} --strictPort --outDir ${process.env.TERMINAL_BUILD}`, + url: process.env.PLAYWRIGHT_BASE_URL, + reuseExistingServer: false, + }, + use: { ...config.use, viewport: { width: 1440, height: 900 }, trace: "off", video: "off", serviceWorkers: "block" }, +}) diff --git a/packages/app/e2e/performance/terminals/probe.ts b/packages/app/e2e/performance/terminals/probe.ts new file mode 100644 index 00000000000..d2c1c98c71c --- /dev/null +++ b/packages/app/e2e/performance/terminals/probe.ts @@ -0,0 +1,74 @@ +import { Terminal } from "ghostty-web" +import { SerializeAddon } from "../../../src/session/terminal/serialize" + +export type TerminalProbe = { + term?: Terminal + writes: number + pending: number + bytes: number + renders: number + hiddenRenders: number + draws: number + hiddenDraws: number + serialized: { ms: number; bytes: number; value: string }[] +} + +declare global { + interface Window { + terminalProbe: TerminalProbe + } +} + +const probe: TerminalProbe = { + writes: 0, + pending: 0, + bytes: 0, + renders: 0, + hiddenRenders: 0, + draws: 0, + hiddenDraws: 0, + serialized: [], +} +window.terminalProbe = probe +const open = Terminal.prototype.open +Terminal.prototype.open = function (element) { + probe.term = this + open.call(this, element) + // Ghostty does not expose render events. This benchmark-only wrapper observes its + // actual renderer; it does not alter scheduling, parsing, or drawing. + const renderer = (this as unknown as { renderer: { render: (...args: unknown[]) => void } }).renderer + const render = renderer.render + let hidden = false + renderer.render = function (...args) { + probe.renders++ + hidden = !element.checkVisibility() + if (hidden) probe.hiddenRenders++ + return render.apply(this, args) + } + if (new URL(location.href).searchParams.has("terminalDrawProbe")) { + const context = element.querySelector("canvas")!.getContext("2d")! + const draw = context.drawImage + context.drawImage = function (...args: unknown[]) { + probe.draws++ + if (hidden) probe.hiddenDraws++ + Reflect.apply(draw, this, args) + } + } +} +const write = Terminal.prototype.write +Terminal.prototype.write = function (data, done) { + probe.writes++ + probe.pending++ + probe.bytes += typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength + return write.call(this, data, () => { + probe.pending-- + done?.() + }) +} +const serialize = SerializeAddon.prototype.serialize +SerializeAddon.prototype.serialize = function (options) { + const start = performance.now() + const value = serialize.call(this, options) + probe.serialized.push({ ms: performance.now() - start, bytes: new TextEncoder().encode(value).byteLength, value }) + return value +} diff --git a/packages/app/e2e/performance/terminals/shell.ps1 b/packages/app/e2e/performance/terminals/shell.ps1 new file mode 100644 index 00000000000..b8457d0c654 --- /dev/null +++ b/packages/app/e2e/performance/terminals/shell.ps1 @@ -0,0 +1,13 @@ +param([Parameter(Mandatory = $true)][string]$Fixture) +$ErrorActionPreference = 'Stop' +[Console]::WriteLine('TERMINAL_FIXTURE_READY') +while ($null -ne ($command = [Console]::ReadLine())) { + if ($command -eq 'exit') { exit 0 } + if ($command -eq 'run') { + foreach ($line in [System.IO.File]::ReadLines($Fixture)) { + [Console]::WriteLine($line) + } + [Console]::WriteLine('TERMINAL_WORKLOAD_DONE') + } + if ($command -eq 'ping') { [Console]::WriteLine('TERMINAL_PROCESS_ALIVE') } +} diff --git a/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts b/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts new file mode 100644 index 00000000000..d26e661148a --- /dev/null +++ b/packages/app/e2e/performance/terminals/terminal-benchmark.spec.ts @@ -0,0 +1,352 @@ +import { createRequire } from "node:module" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" +import type { Page } from "@playwright/test" +import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { expectSessionTitle } from "../../utils/waits" +import type {} from "./probe" + +// Use the same installed native PTY package as Core, with a fixture-owned process. +const native = createRequire(new URL("../../../../core/package.json", import.meta.url))("@lydell/node-pty") as { + spawn: ( + file: string, + args: string[], + options: { cols: number; rows: number; cwd: string }, + ) => { + pid: number + write: (data: string) => void + resize: (cols: number, rows: number) => void + kill: () => void + onData: (handler: (data: string) => void) => { dispose: () => void } + onExit: (handler: () => void) => { dispose: () => void } + } +} + +const sessionID = "ses_terminal_benchmark" +const ptyID = "pty_terminal_benchmark" +const title = "Terminal build output" +const server = process.env.PLAYWRIGHT_BASE_URL! +const href = `/server/${Buffer.from(server).toString("base64url")}/session/${sessionID}` +const lines = Array.from({ length: 12_000 }, (_, i) => { + const unit = ["session/history", "session/runner", "project/discovery", "tool/shell", "provider/stream"][i % 5] + return `\x1b[32mPASS\x1b[0m packages/core/test/${unit}-${String(i).padStart(5, "0")}.test.ts \x1b[2m[${10 + (i % 237)}ms]\x1b[0m validates ordered output and durable recovery` +}).join("\r\n") + +benchmark.use({ traceScope: "interaction", viewport: { width: 1440, height: 900 } }) + +for (const scenario of ["visible-output", "hidden-output", "full-scrollback-teardown"] as const) { + benchmark(scenario, async ({ page, report }, info) => { + const dir = await mkdtemp(path.join(process.env.TERMINAL_ARTIFACTS ?? tmpdir(), "terminal-fixture-")) + await writeFile(path.join(dir, "build.log"), lines) + const pty = native.spawn( + "pwsh.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + fileURLToPath(new URL("./shell.ps1", import.meta.url)), + "-Fixture", + path.join(dir, "build.log"), + ], + { cols: 120, rows: 24, cwd: dir }, + ) + const exited = new Promise((resolve) => pty.onExit(resolve)) + let output = "" + let connected = 0 + let closed = 0 + let send: ((data: string) => void) | undefined + const listener = pty.onData((data) => { + output += data + send?.(data) + }) + const sizes: { cols: number; rows: number }[] = [] + const removals: string[] = [] + try { + if (process.env.TERMINAL_DRAW_PROBE) { + await page.addInitScript(() => { + const fill = CanvasRenderingContext2D.prototype.fillText + CanvasRenderingContext2D.prototype.fillText = function (...args: Parameters) { + if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-component="terminal"]')) { + window.terminalProbe.draws++ + if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++ + } + Reflect.apply(fill, this, args) + } + }) + } + const location = { directory: dir, project: { id: "proj_terminal_benchmark", directory: dir } } + const data = { + id: ptyID, + title: "Terminal 1", + command: "pwsh.exe", + args: [], + cwd: dir, + status: "running", + pid: pty.pid, + } + await mockOpenCodeServer(page, { + directory: dir, + project: { + id: location.project.id, + worktree: dir, + vcs: "git", + name: "terminal-benchmark", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID: location.project.id, + directory: dir, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty**", async (route) => { + if (route.request().method() === "DELETE") removals.push(route.request().url()) + const body = route.request().postDataJSON() + if (body?.size) { + sizes.push(body.size) + pty.resize(body.size.cols, body.size.rows) + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location, + data: route.request().url().includes("connect-token") ? { ticket: "fixture", expires_in: 60 } : data, + }), + }) + }) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (socket) => { + connected++ + send = (data) => socket.send(data) + socket.send(output.slice(Number(new URL(socket.url()).searchParams.get("cursor") ?? 0))) + socket.onMessage((data) => pty.write(String(data))) + socket.onClose(() => { + closed++ + send = undefined + }) + }) + await page.addInitScript( + ({ server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { server, sessionID }, + ) + await page.goto( + `${href}${process.env.TERMINAL_DRAW_PROBE && scenario !== "full-scrollback-teardown" ? "?terminalDrawProbe" : ""}`, + ) + await expectSessionTitle(page, title) + await page.keyboard.press("Control+Backquote") + await waitForText(page, "TERMINAL_FIXTURE_READY") + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + await expect + .poll(async () => { + const size = await page.evaluate(() => ({ + cols: window.terminalProbe.term!.cols, + rows: window.terminalProbe.term!.rows, + })) + return sizes.at(-1)?.cols === size.cols && sizes.at(-1)?.rows === size.rows + }) + .toBe(true) + if (scenario === "hidden-output") { + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeHidden() + } + const cdp = await page.context().newCDPSession(page) + await cdp.send("Performance.enable") + const before = await cdp.send("Performance.getMetrics") + const start = await page.evaluate(() => { + window.terminalProbe.renders = 0 + window.terminalProbe.hiddenRenders = 0 + window.terminalProbe.draws = 0 + window.terminalProbe.hiddenDraws = 0 + return performance.now() + }) + await benchmarkDiagnostics(page).startTrace() + // The producer is not throttled. The visible and hidden cases receive the same bytes. + pty.write("run\r") + await waitForText(page, "TERMINAL_WORKLOAD_DONE") + const produced = await page.evaluate( + (start) => ({ + ms: performance.now() - start, + renders: window.terminalProbe.renders, + hiddenRenders: window.terminalProbe.hiddenRenders, + draws: window.terminalProbe.draws, + hiddenDraws: window.terminalProbe.hiddenDraws, + bytes: window.terminalProbe.bytes, + scrollback: window.terminalProbe.term!.getScrollbackLength(), + cols: window.terminalProbe.term!.cols, + rows: window.terminalProbe.term!.rows, + firstRecord: Number( + window.terminalProbe + .term!.buffer.normal.getLine(0) + ?.translateToString(true) + .match(/-(\d{5})\.test\.ts/)?.[1], + ), + }), + start, + ) + const after = await cdp.send("Performance.getMetrics") + const cpuMs = + (after.metrics.find((x) => x.name === "TaskDuration")!.value - + before.metrics.find((x) => x.name === "TaskDuration")!.value) * + 1000 + let interaction: Record = {} + if (scenario === "hidden-output") { + const start = await page.evaluate(() => performance.now()) + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await waitForText(page, "TERMINAL_WORKLOAD_DONE") + interaction = { returnMs: await page.evaluate((start) => performance.now() - start, start) } + } + if (scenario === "full-scrollback-teardown") { + // Ghostty converts the configured line limit to bytes at the initial + // 80-column size. Resizing changes the effective retained row count. + expect(produced.scrollback).toBeGreaterThan(0) + expect(produced.firstRecord).toBeGreaterThan(0) + expect(produced.firstRecord).toBeLessThan(11_999) + const close = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"]) [data-component="icon-button-v2"]`) + await expect(close).toBeVisible() + const cpuBefore = await cdp.send("Performance.getMetrics") + const start = await page.evaluate(() => performance.now()) + await close.click() + await expect(page).toHaveURL("/") + await expect(page.locator('[data-component="home-session-search"]')).toBeVisible() + await expect(page.locator('[data-component="home-session-search"] input')).toBeEditable() + await expect.poll(() => page.evaluate(() => window.terminalProbe.serialized.length)).toBe(1) + interaction = await page.evaluate( + (start) => ({ + homeReadyMs: performance.now() - start, + serializeMs: window.terminalProbe.serialized[0].ms, + serializedBytes: window.terminalProbe.serialized[0].bytes, + }), + start, + ) + const cpuAfter = await cdp.send("Performance.getMetrics") + interaction.teardownCpuMs = + (cpuAfter.metrics.find((x) => x.name === "TaskDuration")!.value - + cpuBefore.metrics.find((x) => x.name === "TaskDuration")!.value) * + 1000 + const snapshot = await page.evaluate(() => window.terminalProbe.serialized[0].value) + expect(Array.from(snapshot.matchAll(/-(\d{5})\.test\.ts/g), (match) => Number(match[1]))).toEqual( + Array.from({ length: 12_000 - produced.firstRecord }, (_, index) => produced.firstRecord + index), + ) + expect(snapshot).toContain("TERMINAL_WORKLOAD_DONE") + await writeFile( + path.join( + process.env.TERMINAL_ARTIFACTS ?? tmpdir(), + `${process.env.TERMINAL_BUNDLE}-${info.repeatEachIndex}.ansi`, + ), + snapshot, + ) + await expect(terminal).toHaveCount(0) + expect(closed).toBe(1) + // UI teardown must not terminate the native process. + pty.write("ping\r") + await expect.poll(() => output.includes("TERMINAL_PROCESS_ALIVE")).toBe(true) + } + await benchmarkDiagnostics(page).stop() + expect(connected).toBe(1) + expect(removals).toEqual([]) + expect(sizes.length).toBeGreaterThan(0) + report( + { ...produced, cpuMs, ...interaction }, + { + revision: process.env.TERMINAL_REVISION, + bundle: process.env.TERMINAL_BUNDLE, + fixtureBytes: Buffer.byteLength(lines), + fixtureLines: 12_000, + transport: "Windows ConPTY -> Playwright WebSocket fixture -> production Terminal/writer/Ghostty", + scope: "Chromium renderer; not Electron total RAM or production backend IPC", + }, + ) + if (scenario !== "full-scrollback-teardown") { + // Validate input, focus, and resize after both visible and hidden output. + await terminal.click() + await expect(terminal.locator("textarea")).toBeFocused() + await page.keyboard.type("ping") + await page.keyboard.press("Enter") + await waitForText(page, "TERMINAL_PROCESS_ALIVE") + const columns = await page.evaluate(() => window.terminalProbe.term!.cols) + await page.setViewportSize({ width: 1100, height: 800 }) + await expect.poll(() => page.evaluate(() => window.terminalProbe.term!.cols)).not.toBe(columns) + await expect + .poll(async () => sizes.at(-1)?.cols === (await page.evaluate(() => window.terminalProbe.term!.cols))) + .toBe(true) + expect(closed).toBe(0) + } + if (process.env.TERMINAL_SCREENSHOTS && scenario !== "full-scrollback-teardown") { + await page.screenshot({ + path: path.join(process.env.TERMINAL_SCREENSHOTS, `${scenario}-${info.repeatEachIndex}.png`), + }) + } + } finally { + listener.dispose() + try { + await benchmarkDiagnostics(page).stop() + // Stop fixture request handlers before killing their native resource. The + // app debounces PTY resize requests independently of the canvas resize. + await page.unrouteAll({ behavior: "wait" }) + await page.close() + } finally { + pty.kill() + await exited + await writeFile( + path.join( + process.env.TERMINAL_ARTIFACTS ?? tmpdir(), + `${process.env.TERMINAL_BUNDLE}-${scenario}-${info.repeatEachIndex}.native.log`, + ), + output, + ) + await rm(dir, { recursive: true, force: true }) + } + } + }) +} + +async function waitForText(page: Page, text: string) { + await expect + .poll(() => + page.evaluate((text) => { + const probe = window.terminalProbe + const term = probe?.term + if (!term || probe.pending !== 0) return false + const buffer = term.buffer.active + return Array.from( + { length: term.rows }, + (_, i) => buffer.getLine(buffer.length - term.rows + i)?.translateToString(true) ?? "", + ) + .join("\n") + .includes(text) + }, text), + ) + .toBe(true) +} diff --git a/packages/app/e2e/performance/terminals/vite.config.ts b/packages/app/e2e/performance/terminals/vite.config.ts new file mode 100644 index 00000000000..02f17d0ce54 --- /dev/null +++ b/packages/app/e2e/performance/terminals/vite.config.ts @@ -0,0 +1,17 @@ +import { mergeConfig } from "vite" +import config from "../../../vite.config" + +// The probe is included only in this manual benchmark build, never in the app build. +export default mergeConfig(config, { + plugins: [ + { + name: "terminal-benchmark-probe", + transformIndexHtml: { + order: "pre", + handler: () => [ + { tag: "script", attrs: { type: "module", src: "/e2e/performance/terminals/probe.ts" }, injectTo: "head" }, + ], + }, + }, + ], +}) diff --git a/packages/app/src/session/terminal/serialize.test.ts b/packages/app/src/session/terminal/serialize.test.ts index d45af34c43c..1927a581083 100644 --- a/packages/app/src/session/terminal/serialize.test.ts +++ b/packages/app/src/session/terminal/serialize.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, afterEach } from "bun:test" +import { describe, test, expect, beforeAll, afterEach, spyOn } from "bun:test" import { Terminal, Ghostty } from "ghostty-web" import { SerializeAddon } from "./serialize" @@ -37,6 +37,32 @@ function writeAndWait(term: Terminal, data: string): Promise { } describe("SerializeAddon", () => { + test("reuses cells across style changes without rereading their rows", async () => { + const { term, addon } = createTerminal(20, 5) + await writeAndWait(term, "\x1b[31mred\x1b[32mgreen\x1b[0m\r\n\x1b[1;44mbold\x1b[0m") + const reads = spyOn(term.buffer.normal, "getLine") + const serialized = addon.serialize({ range: { start: 0, end: 1 } }) + expect(reads.mock.calls.map((args) => args[0])).toEqual([0, 1, 1]) + reads.mockRestore() + + const restored = createTerminal(20, 5) + await writeAndWait(restored.term, serialized) + for (let row = 0; row < 2; row++) { + const original = term.buffer.normal.getLine(row)! + const replayed = restored.term.buffer.normal.getLine(row)! + expect(replayed.translateToString()).toBe(original.translateToString()) + for (let col = 0; col < 20; col++) { + const before = original.getCell(col)! + const after = replayed.getCell(col)! + expect([after.getFgColor(), after.getBgColor(), after.isBold()]).toEqual([ + before.getFgColor(), + before.getBgColor(), + before.isBold(), + ]) + } + } + }) + test("preserves color scheme reporting mode", async () => { const { term, addon } = createTerminal() await writeAndWait(term, "\x1b[?2031h") diff --git a/packages/app/src/session/terminal/serialize.ts b/packages/app/src/session/terminal/serialize.ts index 515153488c1..2b37f546207 100644 --- a/packages/app/src/session/terminal/serialize.ts +++ b/packages/app/src/session/terminal/serialize.ts @@ -442,11 +442,8 @@ class StringSerializeHandler extends BaseSerializeHandler { this._currentRow += `\u001b[${sgrSeq.join(";")}m` - const line = this._buffer.getLine(row) - const cellFromLine = line?.getCell(col) - if (cellFromLine) { - this._cursorStyle = cellFromLine - } + // Ghostty cells are snapshots; rereading this cell copies the whole row again. + this._cursorStyle = cell } if (isEmptyCell) { diff --git a/packages/app/src/session/terminal/writer.test.ts b/packages/app/src/session/terminal/writer.test.ts index 10ac33dc78b..777f332b8d6 100644 --- a/packages/app/src/session/terminal/writer.test.ts +++ b/packages/app/src/session/terminal/writer.test.ts @@ -61,4 +61,32 @@ describe("terminalWriter", () => { done?.() expect(settled).toBe(true) }) + + test("final flush preserves output queued behind an in-flight write", () => { + const scheduled: VoidFunction[] = [] + const completions: VoidFunction[] = [] + const events: string[] = [] + const writer = terminalWriter( + (data, done) => { + events.push(data) + if (done) completions.push(done) + }, + (flush) => scheduled.push(flush), + ) + writer.push("build started\r\n") + scheduled.shift()?.() + writer.push("\x1b[32mPASS\x1b[0m ") + writer.push("session/history.test.ts\r\n") + writer.flush(() => events.push("persist and dispose")) + expect(events).toEqual(["build started\r\n"]) + completions.shift()?.() + scheduled.shift()?.() + expect(events).toEqual(["build started\r\n", "\x1b[32mPASS\x1b[0m session/history.test.ts\r\n"]) + completions.shift()?.() + expect(events).toEqual([ + "build started\r\n", + "\x1b[32mPASS\x1b[0m session/history.test.ts\r\n", + "persist and dispose", + ]) + }) })