serialize.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. /**
  2. * SerializeAddon - Serialize terminal buffer contents
  3. *
  4. * Port of xterm.js addon-serialize for ghostty-web.
  5. * Enables serialization of terminal contents to a string that can
  6. * be written back to restore terminal state.
  7. *
  8. * Usage:
  9. * ```typescript
  10. * const serializeAddon = new SerializeAddon();
  11. * term.loadAddon(serializeAddon);
  12. * const content = serializeAddon.serialize();
  13. * ```
  14. */
  15. import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web"
  16. // ============================================================================
  17. // Buffer Types (matching ghostty-web internal interfaces)
  18. // ============================================================================
  19. interface IBuffer {
  20. readonly type: "normal" | "alternate"
  21. readonly cursorX: number
  22. readonly cursorY: number
  23. readonly viewportY: number
  24. readonly baseY: number
  25. readonly length: number
  26. getLine(y: number): IBufferLine | undefined
  27. getNullCell(): IBufferCell
  28. }
  29. interface IBufferLine {
  30. readonly length: number
  31. readonly isWrapped: boolean
  32. getCell(x: number): IBufferCell | undefined
  33. translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
  34. }
  35. interface IBufferCell {
  36. getChars(): string
  37. getCode(): number
  38. getWidth(): number
  39. getFgColorMode(): number
  40. getBgColorMode(): number
  41. getFgColor(): number
  42. getBgColor(): number
  43. isBold(): number
  44. isItalic(): number
  45. isUnderline(): number
  46. isStrikethrough(): number
  47. isBlink(): number
  48. isInverse(): number
  49. isInvisible(): number
  50. isFaint(): number
  51. isDim(): boolean
  52. }
  53. // ============================================================================
  54. // Types
  55. // ============================================================================
  56. export interface ISerializeOptions {
  57. /**
  58. * The row range to serialize. When an explicit range is specified, the cursor
  59. * will get its final repositioning.
  60. */
  61. range?: ISerializeRange
  62. /**
  63. * The number of rows in the scrollback buffer to serialize, starting from
  64. * the bottom of the scrollback buffer. When not specified, all available
  65. * rows in the scrollback buffer will be serialized.
  66. */
  67. scrollback?: number
  68. /**
  69. * Whether to exclude the terminal modes from the serialization.
  70. * Default: false
  71. */
  72. excludeModes?: boolean
  73. /**
  74. * Whether to exclude the alt buffer from the serialization.
  75. * Default: false
  76. */
  77. excludeAltBuffer?: boolean
  78. }
  79. export interface ISerializeRange {
  80. /**
  81. * The line to start serializing (inclusive).
  82. */
  83. start: number
  84. /**
  85. * The line to end serializing (inclusive).
  86. */
  87. end: number
  88. }
  89. export interface IHTMLSerializeOptions {
  90. /**
  91. * The number of rows in the scrollback buffer to serialize, starting from
  92. * the bottom of the scrollback buffer.
  93. */
  94. scrollback?: number
  95. /**
  96. * Whether to only serialize the selection.
  97. * Default: false
  98. */
  99. onlySelection?: boolean
  100. /**
  101. * Whether to include the global background of the terminal.
  102. * Default: false
  103. */
  104. includeGlobalBackground?: boolean
  105. /**
  106. * The range to serialize. This is prioritized over onlySelection.
  107. */
  108. range?: {
  109. startLine: number
  110. endLine: number
  111. startCol: number
  112. }
  113. }
  114. // ============================================================================
  115. // Helper Functions
  116. // ============================================================================
  117. function constrain(value: number, low: number, high: number): number {
  118. return Math.max(low, Math.min(value, high))
  119. }
  120. function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
  121. return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor()
  122. }
  123. function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
  124. return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor()
  125. }
  126. function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
  127. return (
  128. !!cell1.isInverse() === !!cell2.isInverse() &&
  129. !!cell1.isBold() === !!cell2.isBold() &&
  130. !!cell1.isUnderline() === !!cell2.isUnderline() &&
  131. !!cell1.isBlink() === !!cell2.isBlink() &&
  132. !!cell1.isInvisible() === !!cell2.isInvisible() &&
  133. !!cell1.isItalic() === !!cell2.isItalic() &&
  134. !!cell1.isDim() === !!cell2.isDim() &&
  135. !!cell1.isStrikethrough() === !!cell2.isStrikethrough()
  136. )
  137. }
  138. // ============================================================================
  139. // Base Serialize Handler
  140. // ============================================================================
  141. abstract class BaseSerializeHandler {
  142. constructor(protected readonly _buffer: IBuffer) {}
  143. public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
  144. let oldCell = this._buffer.getNullCell()
  145. const startRow = range.start.y
  146. const endRow = range.end.y
  147. const startColumn = range.start.x
  148. const endColumn = range.end.x
  149. this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
  150. for (let row = startRow; row <= endRow; row++) {
  151. const line = this._buffer.getLine(row)
  152. if (line) {
  153. const startLineColumn = row === range.start.y ? startColumn : 0
  154. const endLineColumn = Math.min(endColumn, line.length)
  155. for (let col = startLineColumn; col < endLineColumn; col++) {
  156. const c = line.getCell(col)
  157. if (!c) {
  158. continue
  159. }
  160. this._nextCell(c, oldCell, row, col)
  161. oldCell = c
  162. }
  163. }
  164. this._rowEnd(row, row === endRow)
  165. }
  166. this._afterSerialize()
  167. return this._serializeString(excludeFinalCursorPosition)
  168. }
  169. protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
  170. protected _rowEnd(_row: number, _isLastRow: boolean): void {}
  171. protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
  172. protected _afterSerialize(): void {}
  173. protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
  174. return ""
  175. }
  176. }
  177. // ============================================================================
  178. // String Serialize Handler
  179. // ============================================================================
  180. class StringSerializeHandler extends BaseSerializeHandler {
  181. private _rowIndex: number = 0
  182. private _allRows: string[] = []
  183. private _allRowSeparators: string[] = []
  184. private _currentRow: string = ""
  185. private _nullCellCount: number = 0
  186. private _cursorStyle: IBufferCell
  187. private _firstRow: number = 0
  188. private _lastCursorRow: number = 0
  189. private _lastCursorCol: number = 0
  190. private _lastContentCursorRow: number = 0
  191. private _lastContentCursorCol: number = 0
  192. constructor(
  193. buffer: IBuffer,
  194. private readonly _terminal: ITerminalCore,
  195. ) {
  196. super(buffer)
  197. this._cursorStyle = this._buffer.getNullCell()
  198. }
  199. protected _beforeSerialize(rows: number, start: number, _end: number): void {
  200. this._allRows = new Array<string>(rows)
  201. this._allRowSeparators = new Array<string>(rows)
  202. this._rowIndex = 0
  203. this._currentRow = ""
  204. this._nullCellCount = 0
  205. this._cursorStyle = this._buffer.getNullCell()
  206. this._lastContentCursorRow = start
  207. this._lastCursorRow = start
  208. this._firstRow = start
  209. }
  210. protected _rowEnd(row: number, isLastRow: boolean): void {
  211. let rowSeparator = ""
  212. if (this._nullCellCount > 0) {
  213. this._currentRow += " ".repeat(this._nullCellCount)
  214. this._nullCellCount = 0
  215. }
  216. if (!isLastRow) {
  217. const nextLine = this._buffer.getLine(row + 1)
  218. if (!nextLine?.isWrapped) {
  219. rowSeparator = "\r\n"
  220. this._lastCursorRow = row + 1
  221. this._lastCursorCol = 0
  222. }
  223. }
  224. this._allRows[this._rowIndex] = this._currentRow
  225. this._allRowSeparators[this._rowIndex++] = rowSeparator
  226. this._currentRow = ""
  227. this._nullCellCount = 0
  228. }
  229. private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
  230. const sgrSeq: number[] = []
  231. const fgChanged = !equalFg(cell, oldCell)
  232. const bgChanged = !equalBg(cell, oldCell)
  233. const flagsChanged = !equalFlags(cell, oldCell)
  234. if (fgChanged || bgChanged || flagsChanged) {
  235. if (this._isAttributeDefault(cell)) {
  236. if (!this._isAttributeDefault(oldCell)) {
  237. sgrSeq.push(0)
  238. }
  239. } else {
  240. if (flagsChanged) {
  241. if (!!cell.isInverse() !== !!oldCell.isInverse()) {
  242. sgrSeq.push(cell.isInverse() ? 7 : 27)
  243. }
  244. if (!!cell.isBold() !== !!oldCell.isBold()) {
  245. sgrSeq.push(cell.isBold() ? 1 : 22)
  246. }
  247. if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
  248. sgrSeq.push(cell.isUnderline() ? 4 : 24)
  249. }
  250. if (!!cell.isBlink() !== !!oldCell.isBlink()) {
  251. sgrSeq.push(cell.isBlink() ? 5 : 25)
  252. }
  253. if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
  254. sgrSeq.push(cell.isInvisible() ? 8 : 28)
  255. }
  256. if (!!cell.isItalic() !== !!oldCell.isItalic()) {
  257. sgrSeq.push(cell.isItalic() ? 3 : 23)
  258. }
  259. if (!!cell.isDim() !== !!oldCell.isDim()) {
  260. sgrSeq.push(cell.isDim() ? 2 : 22)
  261. }
  262. if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
  263. sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
  264. }
  265. }
  266. if (fgChanged) {
  267. const color = cell.getFgColor()
  268. const mode = cell.getFgColorMode()
  269. if (mode === 2 || mode === 3 || mode === -1) {
  270. sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
  271. } else if (mode === 1) {
  272. // Palette
  273. if (color >= 16) {
  274. sgrSeq.push(38, 5, color)
  275. } else {
  276. sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
  277. }
  278. } else {
  279. sgrSeq.push(39)
  280. }
  281. }
  282. if (bgChanged) {
  283. const color = cell.getBgColor()
  284. const mode = cell.getBgColorMode()
  285. if (mode === 2 || mode === 3 || mode === -1) {
  286. sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
  287. } else if (mode === 1) {
  288. // Palette
  289. if (color >= 16) {
  290. sgrSeq.push(48, 5, color)
  291. } else {
  292. sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
  293. }
  294. } else {
  295. sgrSeq.push(49)
  296. }
  297. }
  298. }
  299. }
  300. return sgrSeq
  301. }
  302. private _isAttributeDefault(cell: IBufferCell): boolean {
  303. const mode = cell.getFgColorMode()
  304. const bgMode = cell.getBgColorMode()
  305. if (mode === 0 && bgMode === 0) {
  306. return (
  307. !cell.isBold() &&
  308. !cell.isItalic() &&
  309. !cell.isUnderline() &&
  310. !cell.isBlink() &&
  311. !cell.isInverse() &&
  312. !cell.isInvisible() &&
  313. !cell.isDim() &&
  314. !cell.isStrikethrough()
  315. )
  316. }
  317. const fgColor = cell.getFgColor()
  318. const bgColor = cell.getBgColor()
  319. const nullCell = this._buffer.getNullCell()
  320. const nullFg = nullCell.getFgColor()
  321. const nullBg = nullCell.getBgColor()
  322. return (
  323. fgColor === nullFg &&
  324. bgColor === nullBg &&
  325. !cell.isBold() &&
  326. !cell.isItalic() &&
  327. !cell.isUnderline() &&
  328. !cell.isBlink() &&
  329. !cell.isInverse() &&
  330. !cell.isInvisible() &&
  331. !cell.isDim() &&
  332. !cell.isStrikethrough()
  333. )
  334. }
  335. protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
  336. const isPlaceHolderCell = cell.getWidth() === 0
  337. if (isPlaceHolderCell) {
  338. return
  339. }
  340. const codepoint = cell.getCode()
  341. const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
  342. const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
  343. const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
  344. const sgrSeq = this._diffStyle(cell, this._cursorStyle)
  345. const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
  346. if (styleChanged) {
  347. if (this._nullCellCount > 0) {
  348. this._currentRow += " ".repeat(this._nullCellCount)
  349. this._nullCellCount = 0
  350. }
  351. this._lastContentCursorRow = this._lastCursorRow = row
  352. this._lastContentCursorCol = this._lastCursorCol = col
  353. this._currentRow += `\u001b[${sgrSeq.join(";")}m`
  354. const line = this._buffer.getLine(row)
  355. const cellFromLine = line?.getCell(col)
  356. if (cellFromLine) {
  357. this._cursorStyle = cellFromLine
  358. }
  359. }
  360. if (isEmptyCell) {
  361. this._nullCellCount += cell.getWidth()
  362. } else {
  363. if (this._nullCellCount > 0) {
  364. this._currentRow += " ".repeat(this._nullCellCount)
  365. this._nullCellCount = 0
  366. }
  367. this._currentRow += cell.getChars()
  368. this._lastContentCursorRow = this._lastCursorRow = row
  369. this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
  370. }
  371. }
  372. protected _serializeString(excludeFinalCursorPosition?: boolean): string {
  373. let rowEnd = this._allRows.length
  374. if (this._buffer.length - this._firstRow <= this._terminal.rows) {
  375. rowEnd = this._lastContentCursorRow + 1 - this._firstRow
  376. this._lastCursorCol = this._lastContentCursorCol
  377. this._lastCursorRow = this._lastContentCursorRow
  378. }
  379. let content = ""
  380. for (let i = 0; i < rowEnd; i++) {
  381. content += this._allRows[i]
  382. if (i + 1 < rowEnd) {
  383. content += this._allRowSeparators[i]
  384. }
  385. }
  386. if (!excludeFinalCursorPosition) {
  387. const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
  388. const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
  389. const cursorCol = this._buffer.cursorX + 1
  390. content += `\u001b[${cursorRow};${cursorCol}H`
  391. }
  392. return content
  393. }
  394. }
  395. // ============================================================================
  396. // SerializeAddon Class
  397. // ============================================================================
  398. export class SerializeAddon implements ITerminalAddon {
  399. private _terminal?: ITerminalCore
  400. /**
  401. * Activate the addon (called by Terminal.loadAddon)
  402. */
  403. public activate(terminal: ITerminalCore): void {
  404. this._terminal = terminal
  405. }
  406. /**
  407. * Dispose the addon and clean up resources
  408. */
  409. public dispose(): void {
  410. this._terminal = undefined
  411. }
  412. /**
  413. * Serializes terminal rows into a string that can be written back to the
  414. * terminal to restore the state. The cursor will also be positioned to the
  415. * correct cell.
  416. *
  417. * @param options Custom options to allow control over what gets serialized.
  418. */
  419. public serialize(options?: ISerializeOptions): string {
  420. if (!this._terminal) {
  421. throw new Error("Cannot use addon until it has been loaded")
  422. }
  423. const terminal = this._terminal as any
  424. const buffer = terminal.buffer
  425. if (!buffer) {
  426. return ""
  427. }
  428. const normalBuffer = buffer.normal || buffer.active
  429. const altBuffer = buffer.alternate
  430. if (!normalBuffer) {
  431. return ""
  432. }
  433. let content = options?.range
  434. ? this._serializeBufferByRange(normalBuffer, options.range, true)
  435. : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
  436. if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
  437. const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
  438. content += `\u001b[?1049h\u001b[H${alternateContent}`
  439. }
  440. return content
  441. }
  442. /**
  443. * Serializes terminal content as plain text (no escape sequences)
  444. * @param options Custom options to allow control over what gets serialized.
  445. */
  446. public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
  447. if (!this._terminal) {
  448. throw new Error("Cannot use addon until it has been loaded")
  449. }
  450. const terminal = this._terminal as any
  451. const buffer = terminal.buffer
  452. if (!buffer) {
  453. return ""
  454. }
  455. const activeBuffer = buffer.active || buffer.normal
  456. if (!activeBuffer) {
  457. return ""
  458. }
  459. const maxRows = activeBuffer.length
  460. const scrollback = options?.scrollback
  461. const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
  462. const startRow = maxRows - correctRows
  463. const endRow = maxRows - 1
  464. const lines: string[] = []
  465. for (let row = startRow; row <= endRow; row++) {
  466. const line = activeBuffer.getLine(row)
  467. if (line) {
  468. const text = line.translateToString(options?.trimWhitespace ?? true)
  469. lines.push(text)
  470. }
  471. }
  472. // Trim trailing empty lines if requested
  473. if (options?.trimWhitespace) {
  474. while (lines.length > 0 && lines[lines.length - 1] === "") {
  475. lines.pop()
  476. }
  477. }
  478. return lines.join("\n")
  479. }
  480. private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
  481. const maxRows = buffer.length
  482. const rows = this._terminal?.rows ?? 24
  483. const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
  484. return this._serializeBufferByRange(
  485. buffer,
  486. {
  487. start: maxRows - correctRows,
  488. end: maxRows - 1,
  489. },
  490. false,
  491. )
  492. }
  493. private _serializeBufferByRange(
  494. buffer: IBuffer,
  495. range: ISerializeRange,
  496. excludeFinalCursorPosition: boolean,
  497. ): string {
  498. const handler = new StringSerializeHandler(buffer, this._terminal!)
  499. const cols = this._terminal?.cols ?? 80
  500. return handler.serialize(
  501. {
  502. start: { x: 0, y: range.start },
  503. end: { x: cols, y: range.end },
  504. },
  505. excludeFinalCursorPosition,
  506. )
  507. }
  508. }