2
0
Эх сурвалжийг харах

fix: terminal serialization and isolation

Adam 2 сар өмнө
parent
commit
9111005165

+ 13 - 0
bun.lock

@@ -153,8 +153,10 @@
         "virtua": "catalog:",
       },
       "devDependencies": {
+        "@happy-dom/global-registrator": "20.0.11",
         "@tailwindcss/vite": "catalog:",
         "@tsconfig/bun": "1.0.9",
+        "@types/bun": "catalog:",
         "@types/luxon": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
@@ -444,6 +446,9 @@
     "web-tree-sitter",
     "tree-sitter-bash",
   ],
+  "patchedDependencies": {
+    "[email protected]": "patches/[email protected]",
+  },
   "overrides": {
     "@types/bun": "catalog:",
     "@types/node": "catalog:",
@@ -844,6 +849,8 @@
 
     "@fontsource/inter": ["@fontsource/[email protected]", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
 
+    "@happy-dom/global-registrator": ["@happy-dom/[email protected]", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
+
     "@hey-api/json-schema-ref-parser": ["@hey-api/[email protected]", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w=="],
 
     "@hey-api/openapi-ts": ["@hey-api/[email protected]", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "js-yaml": "4.1.0", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-PoJukNBkUfHOoMDpN33bBETX49TUhy7Hu8Sa0jslOvFndvZ5VjQr4Nl/Dzjb9LG1Lp5HjybyTJMA6a1zYk/q6A=="],
@@ -1748,6 +1755,8 @@
 
     "@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
 
+    "@types/whatwg-mimetype": ["@types/[email protected]", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
     "@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="],
 
     "@types/yargs": ["@types/[email protected]", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -2464,6 +2473,8 @@
 
     "handlebars": ["[email protected]", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
 
+    "happy-dom": ["[email protected]", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="],
+
     "has-bigints": ["[email protected]", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
 
     "has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -3758,6 +3769,8 @@
 
     "webidl-conversions": ["[email protected]", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
 
+    "whatwg-mimetype": ["[email protected]", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
     "whatwg-url": ["[email protected]", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
 
     "which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],

+ 3 - 0
package.json

@@ -86,5 +86,8 @@
   "overrides": {
     "@types/bun": "catalog:",
     "@types/node": "catalog:"
+  },
+  "patchedDependencies": {
+    "[email protected]": "patches/[email protected]"
   }
 }

+ 2 - 0
packages/desktop/bunfig.toml

@@ -0,0 +1,2 @@
+[test]
+preload = ["./happydom.ts"]

+ 75 - 0
packages/desktop/happydom.ts

@@ -0,0 +1,75 @@
+import { GlobalRegistrator } from "@happy-dom/global-registrator"
+
+GlobalRegistrator.register()
+
+const originalGetContext = HTMLCanvasElement.prototype.getContext
+// @ts-expect-error - we're overriding with a simplified mock
+HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) {
+  if (contextType === "2d") {
+    return {
+      canvas: this,
+      fillStyle: "#000000",
+      strokeStyle: "#000000",
+      font: "12px monospace",
+      textAlign: "start",
+      textBaseline: "alphabetic",
+      globalAlpha: 1,
+      globalCompositeOperation: "source-over",
+      imageSmoothingEnabled: true,
+      lineWidth: 1,
+      lineCap: "butt",
+      lineJoin: "miter",
+      miterLimit: 10,
+      shadowBlur: 0,
+      shadowColor: "rgba(0, 0, 0, 0)",
+      shadowOffsetX: 0,
+      shadowOffsetY: 0,
+      fillRect: () => {},
+      strokeRect: () => {},
+      clearRect: () => {},
+      fillText: () => {},
+      strokeText: () => {},
+      measureText: (text: string) => ({ width: text.length * 8 }),
+      drawImage: () => {},
+      save: () => {},
+      restore: () => {},
+      scale: () => {},
+      rotate: () => {},
+      translate: () => {},
+      transform: () => {},
+      setTransform: () => {},
+      resetTransform: () => {},
+      createLinearGradient: () => ({ addColorStop: () => {} }),
+      createRadialGradient: () => ({ addColorStop: () => {} }),
+      createPattern: () => null,
+      beginPath: () => {},
+      closePath: () => {},
+      moveTo: () => {},
+      lineTo: () => {},
+      bezierCurveTo: () => {},
+      quadraticCurveTo: () => {},
+      arc: () => {},
+      arcTo: () => {},
+      ellipse: () => {},
+      rect: () => {},
+      fill: () => {},
+      stroke: () => {},
+      clip: () => {},
+      isPointInPath: () => false,
+      isPointInStroke: () => false,
+      getTransform: () => ({}),
+      getImageData: () => ({
+        data: new Uint8ClampedArray(0),
+        width: 0,
+        height: 0,
+      }),
+      putImageData: () => {},
+      createImageData: () => ({
+        data: new Uint8ClampedArray(0),
+        width: 0,
+        height: 0,
+      }),
+    } as unknown as CanvasRenderingContext2D
+  }
+  return originalGetContext.call(this, contextType as "2d", _options)
+}

+ 2 - 0
packages/desktop/package.json

@@ -16,8 +16,10 @@
   },
   "license": "MIT",
   "devDependencies": {
+    "@happy-dom/global-registrator": "20.0.11",
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
+    "@types/bun": "catalog:",
     "@types/luxon": "catalog:",
     "@types/node": "catalog:",
     "@typescript/native-preview": "catalog:",

+ 272 - 0
packages/desktop/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("!")
+    })
+  })
+})

+ 77 - 124
packages/desktop/src/addons/serialize.ts

@@ -157,6 +157,23 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
 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()
 
@@ -171,7 +188,8 @@ abstract class BaseSerializeHandler {
       const line = this._buffer.getLine(row)
       if (line) {
         const startLineColumn = row === range.start.y ? startColumn : 0
-        const endLineColumn = row === range.end.y ? endColumn : line.length
+        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) {
@@ -209,17 +227,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
   private _currentRow: string = ""
   private _nullCellCount: number = 0
   private _cursorStyle: IBufferCell
-  private _cursorStyleRow: number = 0
-  private _cursorStyleCol: number = 0
-  private _backgroundCell: IBufferCell
   private _firstRow: number = 0
   private _lastCursorRow: number = 0
   private _lastCursorCol: number = 0
   private _lastContentCursorRow: number = 0
   private _lastContentCursorCol: number = 0
-  private _thisRowLastChar: IBufferCell
-  private _thisRowLastSecondChar: IBufferCell
-  private _nextRowFirstChar: IBufferCell
 
   constructor(
     buffer: IBuffer,
@@ -227,10 +239,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
   ) {
     super(buffer)
     this._cursorStyle = this._buffer.getNullCell()
-    this._backgroundCell = this._buffer.getNullCell()
-    this._thisRowLastChar = this._buffer.getNullCell()
-    this._thisRowLastSecondChar = this._buffer.getNullCell()
-    this._nextRowFirstChar = this._buffer.getNullCell()
   }
 
   protected _beforeSerialize(rows: number, start: number, _end: number): void {
@@ -241,82 +249,15 @@ class StringSerializeHandler extends BaseSerializeHandler {
   }
 
   protected _rowEnd(row: number, isLastRow: boolean): void {
-    // if there is colorful empty cell at line end, we must pad it back
-    if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
-      this._currentRow += `\u001b[${this._nullCellCount}X`
-    }
-
     let rowSeparator = ""
 
     if (!isLastRow) {
-      // Enable BCE
-      if (row - this._firstRow >= this._terminal.rows) {
-        const line = this._buffer.getLine(this._cursorStyleRow)
-        const cell = line?.getCell(this._cursorStyleCol)
-        if (cell) {
-          this._backgroundCell = cell
-        }
-      }
+      const nextLine = this._buffer.getLine(row + 1)
 
-      const currentLine = this._buffer.getLine(row)!
-      const nextLine = this._buffer.getLine(row + 1)!
-
-      if (!nextLine.isWrapped) {
+      if (!nextLine?.isWrapped) {
         rowSeparator = "\r\n"
         this._lastCursorRow = row + 1
         this._lastCursorCol = 0
-      } else {
-        rowSeparator = ""
-        const thisRowLastChar = currentLine.getCell(currentLine.length - 1)
-        const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2)
-        const nextRowFirstChar = nextLine.getCell(0)
-
-        if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar
-        if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar
-        if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar
-
-        const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1
-
-        let isValid = false
-
-        if (
-          this._nextRowFirstChar.getChars() &&
-          (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
-        ) {
-          if (
-            (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) &&
-            equalBg(this._thisRowLastChar, this._nextRowFirstChar)
-          ) {
-            isValid = true
-          }
-
-          if (
-            isNextRowFirstCharDoubleWidth &&
-            (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) &&
-            equalBg(this._thisRowLastChar, this._nextRowFirstChar) &&
-            equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar)
-          ) {
-            isValid = true
-          }
-        }
-
-        if (!isValid) {
-          rowSeparator = "-".repeat(this._nullCellCount + 1)
-          rowSeparator += "\u001b[1D\u001b[1X"
-
-          if (this._nullCellCount > 0) {
-            rowSeparator += "\u001b[A"
-            rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`
-            rowSeparator += `\u001b[${this._nullCellCount}X`
-            rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`
-            rowSeparator += "\u001b[B"
-          }
-
-          this._lastContentCursorRow = row + 1
-          this._lastContentCursorCol = 0
-          this._lastCursorRow = row + 1
-          this._lastCursorCol = 0
-        }
       }
     }
 
@@ -338,11 +279,36 @@ class StringSerializeHandler extends BaseSerializeHandler {
           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) {
-            // RGB
+          if (mode === 2 || mode === 3 || mode === -1) {
             sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
           } else if (mode === 1) {
             // Palette
@@ -358,8 +324,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
         if (bgChanged) {
           const color = cell.getBgColor()
           const mode = cell.getBgColorMode()
-          if (mode === 2) {
-            // RGB
+          if (mode === 2 || mode === 3 || mode === -1) {
             sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
           } else if (mode === 1) {
             // Palette
@@ -372,32 +337,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
             sgrSeq.push(49)
           }
         }
-        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)
-          }
-        }
       }
     }
 
@@ -405,9 +344,31 @@ class StringSerializeHandler extends BaseSerializeHandler {
   }
 
   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 (
-      cell.getFgColorMode() === 0 &&
-      cell.getBgColorMode() === 0 &&
+      fgColor === nullFg &&
+      bgColor === nullBg &&
       !cell.isBold() &&
       !cell.isItalic() &&
       !cell.isUnderline() &&
@@ -426,7 +387,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
       return
     }
 
-    const isEmptyCell = cell.getChars() === ""
+    const codepoint = cell.getCode()
+    const isGarbage = codepoint >= 0xf000
+    const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
 
     const sgrSeq = this._diffStyle(cell, this._cursorStyle)
 
@@ -434,9 +397,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
 
     if (styleChanged) {
       if (this._nullCellCount > 0) {
-        if (!equalBg(this._cursorStyle, this._backgroundCell)) {
-          this._currentRow += `\u001b[${this._nullCellCount}X`
-        }
         this._currentRow += `\u001b[${this._nullCellCount}C`
         this._nullCellCount = 0
       }
@@ -450,8 +410,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
       const cellFromLine = line?.getCell(col)
       if (cellFromLine) {
         this._cursorStyle = cellFromLine
-        this._cursorStyleRow = row
-        this._cursorStyleCol = col
       }
     }
 
@@ -459,12 +417,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
       this._nullCellCount += cell.getWidth()
     } else {
       if (this._nullCellCount > 0) {
-        if (equalBg(this._cursorStyle, this._backgroundCell)) {
-          this._currentRow += `\u001b[${this._nullCellCount}C`
-        } else {
-          this._currentRow += `\u001b[${this._nullCellCount}X`
-          this._currentRow += `\u001b[${this._nullCellCount}C`
-        }
+        this._currentRow += `\u001b[${this._nullCellCount}C`
         this._nullCellCount = 0
       }
 

+ 12 - 5
packages/desktop/src/components/terminal.tsx

@@ -1,11 +1,9 @@
-import { init, Terminal as Term, FitAddon } from "ghostty-web"
+import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
 import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
 import { useSDK } from "@/context/sdk"
 import { SerializeAddon } from "@/addons/serialize"
 import { LocalPTY } from "@/context/session"
 
-await init()
-
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
   onSubmit?: () => void
@@ -19,10 +17,14 @@ export const Terminal = (props: TerminalProps) => {
   const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
   let ws: WebSocket
   let term: Term
+  let ghostty: Ghostty
   let serializeAddon: SerializeAddon
   let fitAddon: FitAddon
+  let handleResize: () => void
 
   onMount(async () => {
+    ghostty = await Ghostty.load()
+
     ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
     term = new Term({
       cursorBlink: true,
@@ -34,6 +36,7 @@ export const Terminal = (props: TerminalProps) => {
         foreground: "#d4d4d4",
       },
       scrollback: 10_000,
+      ghostty,
     })
     term.attachCustomKeyEventHandler((event) => {
       // allow for ctrl-` to toggle terminal in parent
@@ -60,13 +63,14 @@ export const Terminal = (props: TerminalProps) => {
       if (local.pty.scrollY) {
         term.scrollToLine(local.pty.scrollY)
       }
+      fitAddon.fit()
     }
 
     container.focus()
 
-    fitAddon.fit()
     fitAddon.observeResize()
-    window.addEventListener("resize", () => fitAddon.fit())
+    handleResize = () => fitAddon.fit()
+    window.addEventListener("resize", handleResize)
     term.onResize(async (size) => {
       if (ws && ws.readyState === WebSocket.OPEN) {
         await sdk.client.pty.update({
@@ -118,6 +122,9 @@ export const Terminal = (props: TerminalProps) => {
   })
 
   onCleanup(() => {
+    if (handleResize) {
+      window.removeEventListener("resize", handleResize)
+    }
     if (serializeAddon && props.onCleanup) {
       const buffer = serializeAddon.serialize()
       props.onCleanup({

+ 40 - 0
patches/[email protected]

@@ -0,0 +1,40 @@
+diff --git a/dist/ghostty-web.js b/dist/ghostty-web.js
+index 7c9d64a617bbeb29d757a1acd54686e582868313..2d61098cdb77fa66cbb162897c5590f35cfcf791 100644
+--- a/dist/ghostty-web.js
++++ b/dist/ghostty-web.js
+@@ -1285,7 +1285,7 @@ const e = class H {
+         continue;
+       }
+       const C = g.getCodepoint();
+-      C === 0 || C < 32 ? B.push(" ") : B.push(String.fromCodePoint(C));
++      C === 0 || C < 32 || C > 1114111 || (C >= 55296 && C <= 57343) ? B.push(" ") : B.push(String.fromCodePoint(C));
+     }
+     return B.join("");
+   }
+@@ -1484,7 +1484,7 @@ class _ {
+       return;
+     let J = "";
+     A.flags & U.ITALIC && (J += "italic "), A.flags & U.BOLD && (J += "bold "), this.ctx.font = `${J}${this.fontSize}px ${this.fontFamily}`, this.ctx.fillStyle = this.rgbToCSS(w, o, i), A.flags & U.FAINT && (this.ctx.globalAlpha = 0.5);
+-    const s = g, F = C + this.metrics.baseline, a = String.fromCodePoint(A.codepoint || 32);
++    const s = g, F = C + this.metrics.baseline, a = (A.codepoint === 0 || A.codepoint == null || A.codepoint < 0 || A.codepoint > 1114111 || (A.codepoint >= 55296 && A.codepoint <= 57343)) ? " " : String.fromCodePoint(A.codepoint);
+     if (this.ctx.fillText(a, s, F), A.flags & U.FAINT && (this.ctx.globalAlpha = 1), A.flags & U.UNDERLINE) {
+       const N = C + this.metrics.baseline + 2;
+       this.ctx.strokeStyle = this.ctx.fillStyle, this.ctx.lineWidth = 1, this.ctx.beginPath(), this.ctx.moveTo(g, N), this.ctx.lineTo(g + I, N), this.ctx.stroke();
+@@ -1730,7 +1730,7 @@ const L = class R {
+       let G = "";
+       for (let J = M; J <= k; J++) {
+         const s = o[J];
+-        if (s && s.codepoint !== 0) {
++        if (s && s.codepoint !== 0 && s.codepoint <= 1114111 && !(s.codepoint >= 55296 && s.codepoint <= 57343)) {
+           const F = String.fromCodePoint(s.codepoint);
+           G += F, F.trim() && (i = G.length);
+         } else
+@@ -1995,7 +1995,7 @@ const L = class R {
+     if (!Q)
+       return null;
+     const g = (w) => {
+-      if (!w || w.codepoint === 0)
++      if (!w || w.codepoint === 0 || w.codepoint > 1114111 || (w.codepoint >= 55296 && w.codepoint <= 57343))
+         return !1;
+       const o = String.fromCodePoint(w.codepoint);
+       return /[\w-]/.test(o);