diff --git a/packages/app/src/session/terminal/serialize.test.ts b/packages/app/src/session/terminal/serialize.test.ts index 1927a581083..69ccf24791b 100644 --- a/packages/app/src/session/terminal/serialize.test.ts +++ b/packages/app/src/session/terminal/serialize.test.ts @@ -63,6 +63,36 @@ describe("SerializeAddon", () => { } }) + describe("scrollback option", () => { + test("reads only the requested tail and restores the cursor on its screen row", async () => { + const { term, addon } = createTerminal(20, 5) + await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n")) + await writeAndWait(term, "\x1b[2A\x1b[3G") + expect(term.buffer.normal.length).toBe(30) + expect([term.buffer.normal.cursorX, term.buffer.normal.cursorY]).toEqual([2, 2]) + + const reads = spyOn(term.buffer.normal, "getLine") + const serialized = addon.serialize({ scrollback: 3 }) + expect(new Set(reads.mock.calls.map((args) => args[0]))).toEqual(new Set([22, 23, 24, 25, 26, 27, 28, 29])) + reads.mockRestore() + + const restored = createTerminal(20, 5) + await writeAndWait(restored.term, serialized) + expect(restored.term.getScrollbackLength()).toBe(3) + for (let row = 0; row < 8; row++) { + expect(restored.term.buffer.normal.getLine(row)?.translateToString(true)).toBe(`line ${22 + row}`) + } + expect([restored.term.buffer.normal.cursorX, restored.term.buffer.normal.cursorY]).toEqual([2, 2]) + }) + + test("serializes the whole buffer when it has fewer rows than requested", async () => { + const { term, addon } = createTerminal(20, 5) + await writeAndWait(term, Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\r\n")) + + expect(addon.serialize({ scrollback: 100 })).toBe(addon.serialize()) + }) + }) + 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 2b37f546207..f29b8950df0 100644 --- a/packages/app/src/session/terminal/serialize.ts +++ b/packages/app/src/session/terminal/serialize.ts @@ -481,12 +481,12 @@ class StringSerializeHandler extends BaseSerializeHandler { if (excludeFinalCursorPosition) return content - const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY - const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) - const cursorCol = this._buffer.cursorX + 1 - content += `\u001b[${cursorRow};${cursorCol}H` + // CUP addresses the screen and ghostty-web reports cursorY relative to the screen, so the + // serialized range start must not shift the row. The cursor line sits in the screen region + // at the bottom of the buffer, after any scrollback rows. + content += `\u001b[${this._buffer.cursorY + 1};${this._buffer.cursorX + 1}H` - const line = this._buffer.getLine(absoluteCursorRow) + const line = this._buffer.getLine(this._buffer.length - this._terminal.rows + this._buffer.cursorY) const cell = line?.getCell(this._buffer.cursorX) const style = (() => { if (!cell) return this._buffer.getNullCell() diff --git a/packages/app/src/session/terminal/terminal.tsx b/packages/app/src/session/terminal/terminal.tsx index 1e6c95c9126..36eebc3173d 100644 --- a/packages/app/src/session/terminal/terminal.tsx +++ b/packages/app/src/session/terminal/terminal.tsx @@ -20,6 +20,12 @@ import { terminalWriter } from "@/session/terminal/writer" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" +// Serialization on unmount is a synchronous O(rows x cols) walk on the main thread and the +// result is written to localStorage or desktop state for every terminal in the workspace. +// Persisting the most recent 2k scrollback rows keeps restore fidelity for the history users +// actually scroll back through while capping teardown cost and snapshot size; the live +// terminal keeps its full 10k scrollback while mounted. +const persistedScrollbackRows = 2_000 export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY autoFocus?: boolean @@ -152,7 +158,7 @@ const persistTerminal = (input: { if (!input.addon || !input.onCleanup || !input.term) return const buffer = (() => { try { - return input.addon.serialize() + return input.addon.serialize({ scrollback: persistedScrollbackRows }) } catch { debugTerminal("failed to serialize terminal buffer") return ""