summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-06 06:16:21 -0600
committerAdam <[email protected]>2025-12-06 06:43:53 -0600
commit91110051658f42341ac713327991605461d4bb0b (patch)
treee415be079c7b38647c6797b433b91fc4e7e8d6aa
parent3ceac25fb54d1607c77de4cc861c394e3abd14f0 (diff)
downloadopencode-91110051658f42341ac713327991605461d4bb0b.tar.gz
opencode-91110051658f42341ac713327991605461d4bb0b.zip
fix: terminal serialization and isolation
-rw-r--r--bun.lock13
-rw-r--r--package.json3
-rw-r--r--packages/desktop/bunfig.toml2
-rw-r--r--packages/desktop/happydom.ts75
-rw-r--r--packages/desktop/package.json2
-rw-r--r--packages/desktop/src/addons/serialize.test.ts272
-rw-r--r--packages/desktop/src/addons/serialize.ts201
-rw-r--r--packages/desktop/src/components/terminal.tsx17
-rw-r--r--patches/[email protected]40
9 files changed, 496 insertions, 129 deletions
diff --git a/bun.lock b/bun.lock
index 5141ec417..fe395e1f5 100644
--- a/bun.lock
+++ b/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": {
+ },
"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=="],
diff --git a/package.json b/package.json
index a962be926..fd559eed0 100644
--- a/package.json
+++ b/package.json
@@ -86,5 +86,8 @@
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
+ },
+ "patchedDependencies": {
}
}
diff --git a/packages/desktop/bunfig.toml b/packages/desktop/bunfig.toml
new file mode 100644
index 000000000..363990451
--- /dev/null
+++ b/packages/desktop/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./happydom.ts"]
diff --git a/packages/desktop/happydom.ts b/packages/desktop/happydom.ts
new file mode 100644
index 000000000..de726718f
--- /dev/null
+++ b/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)
+}
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 78e51bdbc..a71a33fe8 100644
--- a/packages/desktop/package.json
+++ b/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:",
diff --git a/packages/desktop/src/addons/serialize.test.ts b/packages/desktop/src/addons/serialize.test.ts
new file mode 100644
index 000000000..ad165f43f
--- /dev/null
+++ b/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("!")
+ })
+ })
+})
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts
index c9f848180..cb1ff8442 100644
--- a/packages/desktop/src/addons/serialize.ts
+++ b/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
}
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
index aaf5da880..b312c3589 100644
--- a/packages/desktop/src/components/terminal.tsx
+++ b/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({
diff --git a/patches/[email protected] b/patches/[email protected]
new file mode 100644
index 000000000..d63a693b8
--- /dev/null
+++ b/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);