serialize.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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. private _isRealContent(codepoint: number): boolean {
  144. if (codepoint === 0) return false
  145. if (codepoint >= 0xf000) return false
  146. return true
  147. }
  148. private _findLastContentColumn(line: IBufferLine): number {
  149. let lastContent = -1
  150. for (let col = 0; col < line.length; col++) {
  151. const cell = line.getCell(col)
  152. if (cell && this._isRealContent(cell.getCode())) {
  153. lastContent = col
  154. }
  155. }
  156. return lastContent + 1
  157. }
  158. public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
  159. let oldCell = this._buffer.getNullCell()
  160. const startRow = range.start.y
  161. const endRow = range.end.y
  162. const startColumn = range.start.x
  163. const endColumn = range.end.x
  164. this._beforeSerialize(endRow - startRow, startRow, endRow)
  165. for (let row = startRow; row <= endRow; row++) {
  166. const line = this._buffer.getLine(row)
  167. if (line) {
  168. const startLineColumn = row === range.start.y ? startColumn : 0
  169. const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
  170. const endLineColumn = Math.min(maxColumn, line.length)
  171. for (let col = startLineColumn; col < endLineColumn; col++) {
  172. const c = line.getCell(col)
  173. if (!c) {
  174. continue
  175. }
  176. this._nextCell(c, oldCell, row, col)
  177. oldCell = c
  178. }
  179. }
  180. this._rowEnd(row, row === endRow)
  181. }
  182. this._afterSerialize()
  183. return this._serializeString(excludeFinalCursorPosition)
  184. }
  185. protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
  186. protected _rowEnd(_row: number, _isLastRow: boolean): void {}
  187. protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
  188. protected _afterSerialize(): void {}
  189. protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
  190. return ""
  191. }
  192. }
  193. // ============================================================================
  194. // String Serialize Handler
  195. // ============================================================================
  196. class StringSerializeHandler extends BaseSerializeHandler {
  197. private _rowIndex: number = 0
  198. private _allRows: string[] = []
  199. private _allRowSeparators: string[] = []
  200. private _currentRow: string = ""
  201. private _nullCellCount: number = 0
  202. private _cursorStyle: IBufferCell
  203. private _firstRow: number = 0
  204. private _lastCursorRow: number = 0
  205. private _lastCursorCol: number = 0
  206. private _lastContentCursorRow: number = 0
  207. private _lastContentCursorCol: number = 0
  208. constructor(
  209. buffer: IBuffer,
  210. private readonly _terminal: ITerminalCore,
  211. ) {
  212. super(buffer)
  213. this._cursorStyle = this._buffer.getNullCell()
  214. }
  215. protected _beforeSerialize(rows: number, start: number, _end: number): void {
  216. this._allRows = new Array<string>(rows)
  217. this._lastContentCursorRow = start
  218. this._lastCursorRow = start
  219. this._firstRow = start
  220. }
  221. protected _rowEnd(row: number, isLastRow: boolean): void {
  222. let rowSeparator = ""
  223. if (!isLastRow) {
  224. const nextLine = this._buffer.getLine(row + 1)
  225. if (!nextLine?.isWrapped) {
  226. rowSeparator = "\r\n"
  227. this._lastCursorRow = row + 1
  228. this._lastCursorCol = 0
  229. }
  230. }
  231. this._allRows[this._rowIndex] = this._currentRow
  232. this._allRowSeparators[this._rowIndex++] = rowSeparator
  233. this._currentRow = ""
  234. this._nullCellCount = 0
  235. }
  236. private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
  237. const sgrSeq: number[] = []
  238. const fgChanged = !equalFg(cell, oldCell)
  239. const bgChanged = !equalBg(cell, oldCell)
  240. const flagsChanged = !equalFlags(cell, oldCell)
  241. if (fgChanged || bgChanged || flagsChanged) {
  242. if (this._isAttributeDefault(cell)) {
  243. if (!this._isAttributeDefault(oldCell)) {
  244. sgrSeq.push(0)
  245. }
  246. } else {
  247. if (flagsChanged) {
  248. if (!!cell.isInverse() !== !!oldCell.isInverse()) {
  249. sgrSeq.push(cell.isInverse() ? 7 : 27)
  250. }
  251. if (!!cell.isBold() !== !!oldCell.isBold()) {
  252. sgrSeq.push(cell.isBold() ? 1 : 22)
  253. }
  254. if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
  255. sgrSeq.push(cell.isUnderline() ? 4 : 24)
  256. }
  257. if (!!cell.isBlink() !== !!oldCell.isBlink()) {
  258. sgrSeq.push(cell.isBlink() ? 5 : 25)
  259. }
  260. if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
  261. sgrSeq.push(cell.isInvisible() ? 8 : 28)
  262. }
  263. if (!!cell.isItalic() !== !!oldCell.isItalic()) {
  264. sgrSeq.push(cell.isItalic() ? 3 : 23)
  265. }
  266. if (!!cell.isDim() !== !!oldCell.isDim()) {
  267. sgrSeq.push(cell.isDim() ? 2 : 22)
  268. }
  269. if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
  270. sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
  271. }
  272. }
  273. if (fgChanged) {
  274. const color = cell.getFgColor()
  275. const mode = cell.getFgColorMode()
  276. if (mode === 2 || mode === 3 || mode === -1) {
  277. sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
  278. } else if (mode === 1) {
  279. // Palette
  280. if (color >= 16) {
  281. sgrSeq.push(38, 5, color)
  282. } else {
  283. sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
  284. }
  285. } else {
  286. sgrSeq.push(39)
  287. }
  288. }
  289. if (bgChanged) {
  290. const color = cell.getBgColor()
  291. const mode = cell.getBgColorMode()
  292. if (mode === 2 || mode === 3 || mode === -1) {
  293. sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
  294. } else if (mode === 1) {
  295. // Palette
  296. if (color >= 16) {
  297. sgrSeq.push(48, 5, color)
  298. } else {
  299. sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
  300. }
  301. } else {
  302. sgrSeq.push(49)
  303. }
  304. }
  305. }
  306. }
  307. return sgrSeq
  308. }
  309. private _isAttributeDefault(cell: IBufferCell): boolean {
  310. const mode = cell.getFgColorMode()
  311. const bgMode = cell.getBgColorMode()
  312. if (mode === 0 && bgMode === 0) {
  313. return (
  314. !cell.isBold() &&
  315. !cell.isItalic() &&
  316. !cell.isUnderline() &&
  317. !cell.isBlink() &&
  318. !cell.isInverse() &&
  319. !cell.isInvisible() &&
  320. !cell.isDim() &&
  321. !cell.isStrikethrough()
  322. )
  323. }
  324. const fgColor = cell.getFgColor()
  325. const bgColor = cell.getBgColor()
  326. const nullCell = this._buffer.getNullCell()
  327. const nullFg = nullCell.getFgColor()
  328. const nullBg = nullCell.getBgColor()
  329. return (
  330. fgColor === nullFg &&
  331. bgColor === nullBg &&
  332. !cell.isBold() &&
  333. !cell.isItalic() &&
  334. !cell.isUnderline() &&
  335. !cell.isBlink() &&
  336. !cell.isInverse() &&
  337. !cell.isInvisible() &&
  338. !cell.isDim() &&
  339. !cell.isStrikethrough()
  340. )
  341. }
  342. protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
  343. const isPlaceHolderCell = cell.getWidth() === 0
  344. if (isPlaceHolderCell) {
  345. return
  346. }
  347. const codepoint = cell.getCode()
  348. const isGarbage = codepoint >= 0xf000
  349. const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
  350. const sgrSeq = this._diffStyle(cell, this._cursorStyle)
  351. const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
  352. if (styleChanged) {
  353. if (this._nullCellCount > 0) {
  354. this._currentRow += `\u001b[${this._nullCellCount}C`
  355. this._nullCellCount = 0
  356. }
  357. this._lastContentCursorRow = this._lastCursorRow = row
  358. this._lastContentCursorCol = this._lastCursorCol = col
  359. this._currentRow += `\u001b[${sgrSeq.join(";")}m`
  360. const line = this._buffer.getLine(row)
  361. const cellFromLine = line?.getCell(col)
  362. if (cellFromLine) {
  363. this._cursorStyle = cellFromLine
  364. }
  365. }
  366. if (isEmptyCell) {
  367. this._nullCellCount += cell.getWidth()
  368. } else {
  369. if (this._nullCellCount > 0) {
  370. this._currentRow += `\u001b[${this._nullCellCount}C`
  371. this._nullCellCount = 0
  372. }
  373. this._currentRow += cell.getChars()
  374. this._lastContentCursorRow = this._lastCursorRow = row
  375. this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
  376. }
  377. }
  378. protected _serializeString(excludeFinalCursorPosition?: boolean): string {
  379. let rowEnd = this._allRows.length
  380. if (this._buffer.length - this._firstRow <= this._terminal.rows) {
  381. rowEnd = this._lastContentCursorRow + 1 - this._firstRow
  382. this._lastCursorCol = this._lastContentCursorCol
  383. this._lastCursorRow = this._lastContentCursorRow
  384. }
  385. let content = ""
  386. for (let i = 0; i < rowEnd; i++) {
  387. content += this._allRows[i]
  388. if (i + 1 < rowEnd) {
  389. content += this._allRowSeparators[i]
  390. }
  391. }
  392. if (!excludeFinalCursorPosition) {
  393. const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
  394. const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
  395. const cursorCol = this._buffer.cursorX + 1
  396. content += `\u001b[${cursorRow};${cursorCol}H`
  397. }
  398. return content
  399. }
  400. }
  401. // ============================================================================
  402. // SerializeAddon Class
  403. // ============================================================================
  404. export class SerializeAddon implements ITerminalAddon {
  405. private _terminal?: ITerminalCore
  406. /**
  407. * Activate the addon (called by Terminal.loadAddon)
  408. */
  409. public activate(terminal: ITerminalCore): void {
  410. this._terminal = terminal
  411. }
  412. /**
  413. * Dispose the addon and clean up resources
  414. */
  415. public dispose(): void {
  416. this._terminal = undefined
  417. }
  418. /**
  419. * Serializes terminal rows into a string that can be written back to the
  420. * terminal to restore the state. The cursor will also be positioned to the
  421. * correct cell.
  422. *
  423. * @param options Custom options to allow control over what gets serialized.
  424. */
  425. public serialize(options?: ISerializeOptions): string {
  426. if (!this._terminal) {
  427. throw new Error("Cannot use addon until it has been loaded")
  428. }
  429. const terminal = this._terminal as any
  430. const buffer = terminal.buffer
  431. if (!buffer) {
  432. return ""
  433. }
  434. const normalBuffer = buffer.normal || buffer.active
  435. const altBuffer = buffer.alternate
  436. if (!normalBuffer) {
  437. return ""
  438. }
  439. let content = options?.range
  440. ? this._serializeBufferByRange(normalBuffer, options.range, true)
  441. : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
  442. if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
  443. const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
  444. content += `\u001b[?1049h\u001b[H${alternateContent}`
  445. }
  446. return content
  447. }
  448. /**
  449. * Serializes terminal content as plain text (no escape sequences)
  450. * @param options Custom options to allow control over what gets serialized.
  451. */
  452. public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
  453. if (!this._terminal) {
  454. throw new Error("Cannot use addon until it has been loaded")
  455. }
  456. const terminal = this._terminal as any
  457. const buffer = terminal.buffer
  458. if (!buffer) {
  459. return ""
  460. }
  461. const activeBuffer = buffer.active || buffer.normal
  462. if (!activeBuffer) {
  463. return ""
  464. }
  465. const maxRows = activeBuffer.length
  466. const scrollback = options?.scrollback
  467. const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
  468. const startRow = maxRows - correctRows
  469. const endRow = maxRows - 1
  470. const lines: string[] = []
  471. for (let row = startRow; row <= endRow; row++) {
  472. const line = activeBuffer.getLine(row)
  473. if (line) {
  474. const text = line.translateToString(options?.trimWhitespace ?? true)
  475. lines.push(text)
  476. }
  477. }
  478. // Trim trailing empty lines if requested
  479. if (options?.trimWhitespace) {
  480. while (lines.length > 0 && lines[lines.length - 1] === "") {
  481. lines.pop()
  482. }
  483. }
  484. return lines.join("\n")
  485. }
  486. private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
  487. const maxRows = buffer.length
  488. const rows = this._terminal?.rows ?? 24
  489. const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
  490. return this._serializeBufferByRange(
  491. buffer,
  492. {
  493. start: maxRows - correctRows,
  494. end: maxRows - 1,
  495. },
  496. false,
  497. )
  498. }
  499. private _serializeBufferByRange(
  500. buffer: IBuffer,
  501. range: ISerializeRange,
  502. excludeFinalCursorPosition: boolean,
  503. ): string {
  504. const handler = new StringSerializeHandler(buffer, this._terminal!)
  505. const cols = this._terminal?.cols ?? 80
  506. return handler.serialize(
  507. {
  508. start: { x: 0, y: range.start },
  509. end: { x: cols, y: range.end },
  510. },
  511. excludeFinalCursorPosition,
  512. )
  513. }
  514. }