diff options
Diffstat (limited to 'packages/app/src/addons')
| -rw-r--r-- | packages/app/src/addons/serialize.test.ts | 272 | ||||
| -rw-r--r-- | packages/app/src/addons/serialize.ts | 595 |
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, + ) + } +} |
