summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/addons
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 19:38:50 -0600
committerAdam <[email protected]>2025-12-22 19:39:00 -0600
commit794fe8f381c846f5241800363023d892c12cf495 (patch)
treebff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/app/src/addons
parenta4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff)
downloadopencode-794fe8f381c846f5241800363023d892c12cf495.tar.gz
opencode-794fe8f381c846f5241800363023d892c12cf495.zip
chore: rename packages/desktop -> packages/app
Diffstat (limited to 'packages/app/src/addons')
-rw-r--r--packages/app/src/addons/serialize.test.ts272
-rw-r--r--packages/app/src/addons/serialize.ts595
2 files changed, 867 insertions, 0 deletions
diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts
new file mode 100644
index 000000000..ad165f43f
--- /dev/null
+++ b/packages/app/src/addons/serialize.test.ts
@@ -0,0 +1,272 @@
+import { describe, test, expect, beforeAll, afterEach } from "bun:test"
+import { Terminal, Ghostty } from "ghostty-web"
+import { SerializeAddon } from "./serialize"
+
+let ghostty: Ghostty
+beforeAll(async () => {
+ ghostty = await Ghostty.load()
+})
+
+const terminals: Terminal[] = []
+
+afterEach(() => {
+ for (const term of terminals) {
+ term.dispose()
+ }
+ terminals.length = 0
+ document.body.innerHTML = ""
+})
+
+function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } {
+ const container = document.createElement("div")
+ document.body.appendChild(container)
+
+ const term = new Terminal({ cols, rows, ghostty })
+ const addon = new SerializeAddon()
+ term.loadAddon(addon)
+ term.open(container)
+ terminals.push(term)
+
+ return { term, addon, container }
+}
+
+function writeAndWait(term: Terminal, data: string): Promise<void> {
+ return new Promise((resolve) => {
+ term.write(data, resolve)
+ })
+}
+
+describe("SerializeAddon", () => {
+ describe("ANSI color preservation", () => {
+ test("should preserve text attributes (bold, italic, underline)", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ expect(origLine!.getCell(0)!.isBold()).toBe(1)
+ expect(origLine!.getCell(5)!.isItalic()).toBe(1)
+ expect(origLine!.getCell(12)!.isUnderline()).toBe(1)
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+
+ const boldCell = line!.getCell(0)
+ expect(boldCell!.getChars()).toBe("B")
+ expect(boldCell!.isBold()).toBe(1)
+
+ const italicCell = line!.getCell(5)
+ expect(italicCell!.getChars()).toBe("I")
+ expect(italicCell!.isItalic()).toBe(1)
+
+ const underCell = line!.getCell(12)
+ expect(underCell!.getChars()).toBe("U")
+ expect(underCell!.isUnderline()).toBe(1)
+ })
+
+ test("should preserve basic 16-color foreground colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedFg = origLine!.getCell(0)!.getFgColor()
+ const origGreenFg = origLine!.getCell(3)!.getFgColor()
+ const origBlueFg = origLine!.getCell(8)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ expect(line).toBeDefined()
+
+ const redCell = line!.getCell(0)
+ expect(redCell!.getChars()).toBe("R")
+ expect(redCell!.getFgColor()).toBe(origRedFg)
+
+ const greenCell = line!.getCell(3)
+ expect(greenCell!.getChars()).toBe("G")
+ expect(greenCell!.getFgColor()).toBe(origGreenFg)
+
+ const blueCell = line!.getCell(8)
+ expect(blueCell!.getChars()).toBe("B")
+ expect(blueCell!.getFgColor()).toBe(origBlueFg)
+ })
+
+ test("should preserve 256-color palette colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedFg = origLine!.getCell(0)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const redCell = line!.getCell(0)
+ expect(redCell!.getChars()).toBe("R")
+ expect(redCell!.getFgColor()).toBe(origRedFg)
+ })
+
+ test("should preserve RGB/truecolor colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRgbFg = origLine!.getCell(0)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const rgbCell = line!.getCell(0)
+ expect(rgbCell!.getChars()).toBe("R")
+ expect(rgbCell!.getFgColor()).toBe(origRgbFg)
+ })
+
+ test("should preserve background colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedBg = origLine!.getCell(0)!.getBgColor()
+ const origGreenBg = origLine!.getCell(6)!.getBgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+
+ const redBgCell = line!.getCell(0)
+ expect(redBgCell!.getChars()).toBe("R")
+ expect(redBgCell!.getBgColor()).toBe(origRedBg)
+
+ const greenBgCell = line!.getCell(6)
+ expect(greenBgCell!.getChars()).toBe("G")
+ expect(greenBgCell!.getBgColor()).toBe(origGreenBg)
+ })
+
+ test("should handle combined colors and attributes", async () => {
+ const { term, addon } = createTerminal()
+
+ const input =
+ "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL "
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origFg = origLine!.getCell(0)!.getFgColor()
+ const origBg = origLine!.getCell(0)!.getBgColor()
+ expect(origLine!.getCell(0)!.isBold()).toBe(1)
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+ const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "")
+
+ expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true)
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, cleanSerialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const comboCell = line!.getCell(0)
+
+ expect(comboCell!.getChars()).toBe("C")
+ expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m")
+ })
+ })
+
+ describe("round-trip serialization", () => {
+ test("should not produce ECH sequences", async () => {
+ const { term, addon } = createTerminal()
+
+ await writeAndWait(term, "\x1b[31mHello\x1b[0m World")
+
+ const serialized = addon.serialize()
+
+ const hasECH = /\x1b\[\d+X/.test(serialized)
+ expect(hasECH).toBe(false)
+ })
+
+ test("multi-line content should not have garbage characters", async () => {
+ const { term, addon } = createTerminal()
+
+ const content = [
+ "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
+ "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
+ "total 42",
+ ].join("\r\n")
+
+ await writeAndWait(term, content)
+
+ const serialized = addon.serialize()
+
+ expect(/\x1b\[\d+X/.test(serialized)).toBe(false)
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ for (let row = 0; row < 3; row++) {
+ const line = term2.buffer.active.getLine(row)?.translateToString(true)
+ expect(line?.includes("𑼝")).toBe(false)
+ }
+
+ expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
+ expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
+ expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
+ })
+
+ test("serialized output written to new terminal should match original colors", async () => {
+ const { term, addon } = createTerminal(40, 5)
+
+ const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! "
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origHelloFg = origLine!.getCell(0)!.getFgColor()
+ const origWorldFg = origLine!.getCell(6)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal(40, 5)
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const newLine = term2.buffer.active.getLine(0)
+
+ expect(newLine!.getCell(0)!.getChars()).toBe("H")
+ expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg)
+
+ expect(newLine!.getCell(6)!.getChars()).toBe("W")
+ expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg)
+
+ expect(newLine!.getCell(11)!.getChars()).toBe("!")
+ })
+ })
+})
diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts
new file mode 100644
index 000000000..cb1ff8442
--- /dev/null
+++ b/packages/app/src/addons/serialize.ts
@@ -0,0 +1,595 @@
+/**
+ * SerializeAddon - Serialize terminal buffer contents
+ *
+ * Port of xterm.js addon-serialize for ghostty-web.
+ * Enables serialization of terminal contents to a string that can
+ * be written back to restore terminal state.
+ *
+ * Usage:
+ * ```typescript
+ * const serializeAddon = new SerializeAddon();
+ * term.loadAddon(serializeAddon);
+ * const content = serializeAddon.serialize();
+ * ```
+ */
+
+import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web"
+
+// ============================================================================
+// Buffer Types (matching ghostty-web internal interfaces)
+// ============================================================================
+
+interface IBuffer {
+ readonly type: "normal" | "alternate"
+ readonly cursorX: number
+ readonly cursorY: number
+ readonly viewportY: number
+ readonly baseY: number
+ readonly length: number
+ getLine(y: number): IBufferLine | undefined
+ getNullCell(): IBufferCell
+}
+
+interface IBufferLine {
+ readonly length: number
+ readonly isWrapped: boolean
+ getCell(x: number): IBufferCell | undefined
+ translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
+}
+
+interface IBufferCell {
+ getChars(): string
+ getCode(): number
+ getWidth(): number
+ getFgColorMode(): number
+ getBgColorMode(): number
+ getFgColor(): number
+ getBgColor(): number
+ isBold(): number
+ isItalic(): number
+ isUnderline(): number
+ isStrikethrough(): number
+ isBlink(): number
+ isInverse(): number
+ isInvisible(): number
+ isFaint(): number
+ isDim(): boolean
+}
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ISerializeOptions {
+ /**
+ * The row range to serialize. When an explicit range is specified, the cursor
+ * will get its final repositioning.
+ */
+ range?: ISerializeRange
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer. When not specified, all available
+ * rows in the scrollback buffer will be serialized.
+ */
+ scrollback?: number
+ /**
+ * Whether to exclude the terminal modes from the serialization.
+ * Default: false
+ */
+ excludeModes?: boolean
+ /**
+ * Whether to exclude the alt buffer from the serialization.
+ * Default: false
+ */
+ excludeAltBuffer?: boolean
+}
+
+export interface ISerializeRange {
+ /**
+ * The line to start serializing (inclusive).
+ */
+ start: number
+ /**
+ * The line to end serializing (inclusive).
+ */
+ end: number
+}
+
+export interface IHTMLSerializeOptions {
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer.
+ */
+ scrollback?: number
+ /**
+ * Whether to only serialize the selection.
+ * Default: false
+ */
+ onlySelection?: boolean
+ /**
+ * Whether to include the global background of the terminal.
+ * Default: false
+ */
+ includeGlobalBackground?: boolean
+ /**
+ * The range to serialize. This is prioritized over onlySelection.
+ */
+ range?: {
+ startLine: number
+ endLine: number
+ startCol: number
+ }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function constrain(value: number, low: number, high: number): number {
+ return Math.max(low, Math.min(value, high))
+}
+
+function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor()
+}
+
+function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor()
+}
+
+function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return (
+ !!cell1.isInverse() === !!cell2.isInverse() &&
+ !!cell1.isBold() === !!cell2.isBold() &&
+ !!cell1.isUnderline() === !!cell2.isUnderline() &&
+ !!cell1.isBlink() === !!cell2.isBlink() &&
+ !!cell1.isInvisible() === !!cell2.isInvisible() &&
+ !!cell1.isItalic() === !!cell2.isItalic() &&
+ !!cell1.isDim() === !!cell2.isDim() &&
+ !!cell1.isStrikethrough() === !!cell2.isStrikethrough()
+ )
+}
+
+// ============================================================================
+// Base Serialize Handler
+// ============================================================================
+
+abstract class BaseSerializeHandler {
+ constructor(protected readonly _buffer: IBuffer) {}
+
+ private _isRealContent(codepoint: number): boolean {
+ if (codepoint === 0) return false
+ if (codepoint >= 0xf000) return false
+ return true
+ }
+
+ private _findLastContentColumn(line: IBufferLine): number {
+ let lastContent = -1
+ for (let col = 0; col < line.length; col++) {
+ const cell = line.getCell(col)
+ if (cell && this._isRealContent(cell.getCode())) {
+ lastContent = col
+ }
+ }
+ return lastContent + 1
+ }
+
+ public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
+ let oldCell = this._buffer.getNullCell()
+
+ const startRow = range.start.y
+ const endRow = range.end.y
+ const startColumn = range.start.x
+ const endColumn = range.end.x
+
+ this._beforeSerialize(endRow - startRow, startRow, endRow)
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = this._buffer.getLine(row)
+ if (line) {
+ const startLineColumn = row === range.start.y ? startColumn : 0
+ const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
+ const endLineColumn = Math.min(maxColumn, line.length)
+ for (let col = startLineColumn; col < endLineColumn; col++) {
+ const c = line.getCell(col)
+ if (!c) {
+ continue
+ }
+ this._nextCell(c, oldCell, row, col)
+ oldCell = c
+ }
+ }
+ this._rowEnd(row, row === endRow)
+ }
+
+ this._afterSerialize()
+
+ return this._serializeString(excludeFinalCursorPosition)
+ }
+
+ protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
+ protected _rowEnd(_row: number, _isLastRow: boolean): void {}
+ protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
+ protected _afterSerialize(): void {}
+ protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
+ return ""
+ }
+}
+
+// ============================================================================
+// String Serialize Handler
+// ============================================================================
+
+class StringSerializeHandler extends BaseSerializeHandler {
+ private _rowIndex: number = 0
+ private _allRows: string[] = []
+ private _allRowSeparators: string[] = []
+ private _currentRow: string = ""
+ private _nullCellCount: number = 0
+ private _cursorStyle: IBufferCell
+ private _firstRow: number = 0
+ private _lastCursorRow: number = 0
+ private _lastCursorCol: number = 0
+ private _lastContentCursorRow: number = 0
+ private _lastContentCursorCol: number = 0
+
+ constructor(
+ buffer: IBuffer,
+ private readonly _terminal: ITerminalCore,
+ ) {
+ super(buffer)
+ this._cursorStyle = this._buffer.getNullCell()
+ }
+
+ protected _beforeSerialize(rows: number, start: number, _end: number): void {
+ this._allRows = new Array<string>(rows)
+ this._lastContentCursorRow = start
+ this._lastCursorRow = start
+ this._firstRow = start
+ }
+
+ protected _rowEnd(row: number, isLastRow: boolean): void {
+ let rowSeparator = ""
+
+ if (!isLastRow) {
+ const nextLine = this._buffer.getLine(row + 1)
+
+ if (!nextLine?.isWrapped) {
+ rowSeparator = "\r\n"
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
+ }
+ }
+
+ this._allRows[this._rowIndex] = this._currentRow
+ this._allRowSeparators[this._rowIndex++] = rowSeparator
+ this._currentRow = ""
+ this._nullCellCount = 0
+ }
+
+ private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
+ const sgrSeq: number[] = []
+ const fgChanged = !equalFg(cell, oldCell)
+ const bgChanged = !equalBg(cell, oldCell)
+ const flagsChanged = !equalFlags(cell, oldCell)
+
+ if (fgChanged || bgChanged || flagsChanged) {
+ if (this._isAttributeDefault(cell)) {
+ if (!this._isAttributeDefault(oldCell)) {
+ sgrSeq.push(0)
+ }
+ } else {
+ if (flagsChanged) {
+ if (!!cell.isInverse() !== !!oldCell.isInverse()) {
+ sgrSeq.push(cell.isInverse() ? 7 : 27)
+ }
+ if (!!cell.isBold() !== !!oldCell.isBold()) {
+ sgrSeq.push(cell.isBold() ? 1 : 22)
+ }
+ if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
+ sgrSeq.push(cell.isUnderline() ? 4 : 24)
+ }
+ if (!!cell.isBlink() !== !!oldCell.isBlink()) {
+ sgrSeq.push(cell.isBlink() ? 5 : 25)
+ }
+ if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
+ sgrSeq.push(cell.isInvisible() ? 8 : 28)
+ }
+ if (!!cell.isItalic() !== !!oldCell.isItalic()) {
+ sgrSeq.push(cell.isItalic() ? 3 : 23)
+ }
+ if (!!cell.isDim() !== !!oldCell.isDim()) {
+ sgrSeq.push(cell.isDim() ? 2 : 22)
+ }
+ if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
+ sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
+ }
+ }
+ if (fgChanged) {
+ const color = cell.getFgColor()
+ const mode = cell.getFgColorMode()
+ if (mode === 2 || mode === 3 || mode === -1) {
+ sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(38, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(39)
+ }
+ }
+ if (bgChanged) {
+ const color = cell.getBgColor()
+ const mode = cell.getBgColorMode()
+ if (mode === 2 || mode === 3 || mode === -1) {
+ sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(48, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(49)
+ }
+ }
+ }
+ }
+
+ return sgrSeq
+ }
+
+ private _isAttributeDefault(cell: IBufferCell): boolean {
+ const mode = cell.getFgColorMode()
+ const bgMode = cell.getBgColorMode()
+
+ if (mode === 0 && bgMode === 0) {
+ return (
+ !cell.isBold() &&
+ !cell.isItalic() &&
+ !cell.isUnderline() &&
+ !cell.isBlink() &&
+ !cell.isInverse() &&
+ !cell.isInvisible() &&
+ !cell.isDim() &&
+ !cell.isStrikethrough()
+ )
+ }
+
+ const fgColor = cell.getFgColor()
+ const bgColor = cell.getBgColor()
+ const nullCell = this._buffer.getNullCell()
+ const nullFg = nullCell.getFgColor()
+ const nullBg = nullCell.getBgColor()
+
+ return (
+ fgColor === nullFg &&
+ bgColor === nullBg &&
+ !cell.isBold() &&
+ !cell.isItalic() &&
+ !cell.isUnderline() &&
+ !cell.isBlink() &&
+ !cell.isInverse() &&
+ !cell.isInvisible() &&
+ !cell.isDim() &&
+ !cell.isStrikethrough()
+ )
+ }
+
+ protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
+ const isPlaceHolderCell = cell.getWidth() === 0
+
+ if (isPlaceHolderCell) {
+ return
+ }
+
+ const codepoint = cell.getCode()
+ const isGarbage = codepoint >= 0xf000
+ const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
+
+ const sgrSeq = this._diffStyle(cell, this._cursorStyle)
+
+ const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+
+ if (styleChanged) {
+ if (this._nullCellCount > 0) {
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ this._nullCellCount = 0
+ }
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col
+
+ this._currentRow += `\u001b[${sgrSeq.join(";")}m`
+
+ const line = this._buffer.getLine(row)
+ const cellFromLine = line?.getCell(col)
+ if (cellFromLine) {
+ this._cursorStyle = cellFromLine
+ }
+ }
+
+ if (isEmptyCell) {
+ this._nullCellCount += cell.getWidth()
+ } else {
+ if (this._nullCellCount > 0) {
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ this._nullCellCount = 0
+ }
+
+ this._currentRow += cell.getChars()
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
+ }
+ }
+
+ protected _serializeString(excludeFinalCursorPosition?: boolean): string {
+ let rowEnd = this._allRows.length
+
+ if (this._buffer.length - this._firstRow <= this._terminal.rows) {
+ rowEnd = this._lastContentCursorRow + 1 - this._firstRow
+ this._lastCursorCol = this._lastContentCursorCol
+ this._lastCursorRow = this._lastContentCursorRow
+ }
+
+ let content = ""
+
+ for (let i = 0; i < rowEnd; i++) {
+ content += this._allRows[i]
+ if (i + 1 < rowEnd) {
+ content += this._allRowSeparators[i]
+ }
+ }
+
+ if (!excludeFinalCursorPosition) {
+ 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`
+ }
+
+ return content
+ }
+}
+
+// ============================================================================
+// SerializeAddon Class
+// ============================================================================
+
+export class SerializeAddon implements ITerminalAddon {
+ private _terminal?: ITerminalCore
+
+ /**
+ * Activate the addon (called by Terminal.loadAddon)
+ */
+ public activate(terminal: ITerminalCore): void {
+ this._terminal = terminal
+ }
+
+ /**
+ * Dispose the addon and clean up resources
+ */
+ public dispose(): void {
+ this._terminal = undefined
+ }
+
+ /**
+ * Serializes terminal rows into a string that can be written back to the
+ * terminal to restore the state. The cursor will also be positioned to the
+ * correct cell.
+ *
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serialize(options?: ISerializeOptions): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const normalBuffer = buffer.normal || buffer.active
+ const altBuffer = buffer.alternate
+
+ if (!normalBuffer) {
+ return ""
+ }
+
+ let content = options?.range
+ ? this._serializeBufferByRange(normalBuffer, options.range, true)
+ : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+
+ if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
+ const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
+ content += `\u001b[?1049h\u001b[H${alternateContent}`
+ }
+
+ return content
+ }
+
+ /**
+ * Serializes terminal content as plain text (no escape sequences)
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const activeBuffer = buffer.active || buffer.normal
+ if (!activeBuffer) {
+ return ""
+ }
+
+ const maxRows = activeBuffer.length
+ const scrollback = options?.scrollback
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
+
+ const startRow = maxRows - correctRows
+ const endRow = maxRows - 1
+ const lines: string[] = []
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = activeBuffer.getLine(row)
+ if (line) {
+ const text = line.translateToString(options?.trimWhitespace ?? true)
+ lines.push(text)
+ }
+ }
+
+ // Trim trailing empty lines if requested
+ if (options?.trimWhitespace) {
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
+ lines.pop()
+ }
+ }
+
+ return lines.join("\n")
+ }
+
+ private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
+ const maxRows = buffer.length
+ const rows = this._terminal?.rows ?? 24
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
+ return this._serializeBufferByRange(
+ buffer,
+ {
+ start: maxRows - correctRows,
+ end: maxRows - 1,
+ },
+ false,
+ )
+ }
+
+ private _serializeBufferByRange(
+ buffer: IBuffer,
+ range: ISerializeRange,
+ excludeFinalCursorPosition: boolean,
+ ): string {
+ const handler = new StringSerializeHandler(buffer, this._terminal!)
+ const cols = this._terminal?.cols ?? 80
+ return handler.serialize(
+ {
+ start: { x: 0, y: range.start },
+ end: { x: cols, y: range.end },
+ },
+ excludeFinalCursorPosition,
+ )
+ }
+}