diff --git a/bun.lock b/bun.lock index 5141ec4173..fe395e1f57 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": { + "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch", + }, "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -844,6 +849,8 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "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/json-schema-ref-parser@1.0.6", "", { "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/openapi-ts@0.81.0", "", { "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/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], @@ -2464,6 +2473,8 @@ "handlebars": ["handlebars@4.7.8", "", { "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": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -3758,6 +3769,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], diff --git a/package.json b/package.json index a962be9260..fd559eed0f 100644 --- a/package.json +++ b/package.json @@ -86,5 +86,8 @@ "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:" + }, + "patchedDependencies": { + "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" } } diff --git a/packages/desktop/bunfig.toml b/packages/desktop/bunfig.toml new file mode 100644 index 0000000000..3639904511 --- /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 0000000000..de726718f6 --- /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 78e51bdbcb..a71a33fe8d 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 0000000000..ad165f43f7 --- /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 { + 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 c9f8481800..cb1ff84423 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,40 +279,6 @@ class StringSerializeHandler extends BaseSerializeHandler { sgrSeq.push(0) } } else { - if (fgChanged) { - const color = cell.getFgColor() - const mode = cell.getFgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(38, 5, color) - } else { - sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) - } - } else { - sgrSeq.push(39) - } - } - if (bgChanged) { - const color = cell.getBgColor() - const mode = cell.getBgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(48, 5, color) - } else { - sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) - } - } else { - sgrSeq.push(49) - } - } if (flagsChanged) { if (!!cell.isInverse() !== !!oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27) @@ -398,6 +305,38 @@ class StringSerializeHandler extends BaseSerializeHandler { sgrSeq.push(cell.isStrikethrough() ? 9 : 29) } } + if (fgChanged) { + const color = cell.getFgColor() + const mode = cell.getFgColorMode() + if (mode === 2 || mode === 3 || mode === -1) { + sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(38, 5, color) + } else { + sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) + } + } else { + sgrSeq.push(39) + } + } + if (bgChanged) { + const color = cell.getBgColor() + const mode = cell.getBgColorMode() + if (mode === 2 || mode === 3 || mode === -1) { + sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(48, 5, color) + } else { + sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) + } + } else { + sgrSeq.push(49) + } + } } } @@ -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 aaf5da8806..b312c35894 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/ghostty-web@0.3.0.patch b/patches/ghostty-web@0.3.0.patch new file mode 100644 index 0000000000..d63a693b88 --- /dev/null +++ b/patches/ghostty-web@0.3.0.patch @@ -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);