serialize.ts 19 KB

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