diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 96e316c5fa..f27678b2e7 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -18,6 +18,7 @@ jobs: uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.head_ref || github.ref }} - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 08fcc388d0..d974e2a76d 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -46,9 +46,10 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }' + PR_TITLE: ${{ steps.pr-details.outputs.title }} run: | PR_BODY=$(jq -r .body pr_data.json) - opencode run -m anthropic/claude-opus-4-5 "A new pull request has been created: '${{ steps.pr-details.outputs.title }}' + opencode run -m anthropic/claude-opus-4-5 "A new pull request has been created: '${PR_TITLE}' ${{ steps.pr-number.outputs.number }} 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/flake.lock b/flake.lock index bc74b63623..6aaa22e94e 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1764915887, - "narHash": "sha256-CeBCJ9BMsuzVgn8GVfuSRZ6xeau7szzG0Xn6O/OxP9M=", + "lastModified": 1764947035, + "narHash": "sha256-EYHSjVM4Ox4lvCXUMiKKs2vETUSL5mx+J2FfutM7T9w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "42e29df35be6ef54091d3a3b4e97056ce0a98ce8", + "rev": "a672be65651c80d3f592a89b3945466584a22069", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index c22d9f8b85..b8cea04400 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-Nem37Y/1kAA2vNOxDsMwE2qfkgneIKPg85JOv8Z0oug=" + "nodeModules": "sha256-7eJpUUtS50/uHa6sU0pl/MbwJykkVlYwEeAoKKNk4+g=" } 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/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 7d27f94167..38e4b921a7 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -17,7 +17,7 @@ process.on("unhandledRejection", (reason, promise) => { export const AcpCommand = cmd({ command: "acp", - describe: "Start ACP (Agent Client Protocol) server", + describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { return yargs .option("cwd", { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 3fb20f1679..e7fa4a6de4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -11,7 +11,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" -import { DialogModel } from "@tui/component/dialog-model" +import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" @@ -169,6 +169,26 @@ function App() { console.log(JSON.stringify(route.data)) }) + // Update terminal window title based on current route and session + createEffect(() => { + if (route.data.type === "home") { + renderer.setTerminalTitle("opencode") + return + } + + if (route.data.type === "session") { + const session = sync.session.get(route.data.sessionID) + if (!session || SessionApi.isDefaultTitle(session.title)) { + renderer.setTerminalTitle("opencode") + return + } + + // Truncate title to 40 chars max + const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title + renderer.setTerminalTitle(`oc | ${title}`) + } + }) + const args = useArgs() onMount(() => { batch(() => { @@ -213,18 +233,21 @@ function App() { ), ) + const connected = useConnected() command.register(() => [ { title: "Switch session", value: "session.list", keybind: "session_list", category: "Session", + suggested: sync.data.session.length > 0, onSelect: () => { dialog.replace(() => ) }, }, { title: "New session", + suggested: route.data.type === "session", value: "session.new", keybind: "session_new", category: "Session", @@ -243,6 +266,7 @@ function App() { title: "Switch model", value: "model.list", keybind: "model_list", + suggested: true, category: "Agent", onSelect: () => { dialog.replace(() => ) @@ -250,6 +274,7 @@ function App() { }, { title: "Model cycle", + disabled: true, value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", @@ -259,6 +284,7 @@ function App() { }, { title: "Model cycle reverse", + disabled: true, value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", @@ -295,6 +321,15 @@ function App() { local.agent.move(-1) }, }, + { + title: "Connect provider", + value: "provider.connect", + suggested: !connected(), + onSelect: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, { title: "View status", keybind: "status_view", @@ -312,14 +347,6 @@ function App() { }, category: "System", }, - { - title: "Connect provider", - value: "provider.connect", - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, { title: "Toggle appearance", value: "theme.switch_mode", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index b9ba4a9bab..06b52863d1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -1,5 +1,5 @@ import { useDialog } from "@tui/ui/dialog" -import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" import { createContext, createMemo, @@ -18,6 +18,7 @@ const ctx = createContext() export type CommandOption = DialogSelectOption & { keybind?: keyof KeybindsConfig + suggested?: boolean } function init() { @@ -26,7 +27,19 @@ function init() { const dialog = useDialog() const keybind = useKeybind() const options = createMemo(() => { - return registrations().flatMap((x) => x()) + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested) + return [ + ...suggested.map((x) => ({ + ...x, + category: "Suggested", + value: "suggested." + x.value, + })), + ...all, + ].map((x) => ({ + ...x, + footer: x.keybind ? keybind.print(x.keybind) : undefined, + })) }) const suspended = () => suspendCount() > 0 @@ -99,14 +112,12 @@ export function CommandProvider(props: ParentProps) { } function DialogCommand(props: { options: CommandOption[] }) { - const keybind = useKeybind() + let ref: DialogSelectRef return ( (ref = r)} title="Commands" - options={props.options.map((x) => ({ - ...x, - footer: x.keybind ? keybind.print(x.keybind) : undefined, - }))} + options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 4aaac6123c..0ea4cbd68a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -6,28 +6,41 @@ import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { Keybind } from "@/util/keybind" -import { iife } from "@/util/iife" -export function DialogModel() { +export function useConnected() { + const sync = useSync() + return createMemo(() => + sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), + ) +} + +export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() const [ref, setRef] = createSignal>() - const connected = createMemo(() => - sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), - ) - + const connected = useConnected() const providers = createDialogProviderOptions() + const showExtra = createMemo(() => { + if (!connected()) return false + if (props.providerID) return false + return true + }) + const options = createMemo(() => { const query = ref()?.filter - const favorites = connected() ? local.model.favorite() : [] + const favorites = showExtra() ? local.model.favorite() : [] const recents = local.model.recent() - const recentList = recents - .filter((item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID)) - .slice(0, 5) + const recentList = showExtra() + ? recents + .filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ) + .slice(0, 5) + : [] const favoriteOptions = !query ? favorites.flatMap((item) => { @@ -108,6 +121,8 @@ export function DialogModel() { pipe( provider.models, entries(), + filter(([_, info]) => info.status !== "deprecated"), + filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), map(([model, info]) => { const value = { providerID: provider.id, @@ -149,7 +164,10 @@ export function DialogModel() { if (inRecents) return false return true }), - sortBy((x) => x.title), + sortBy( + (x) => x.footer !== "Free", + (x) => x.title, + ), ), ), ), @@ -168,6 +186,15 @@ export function DialogModel() { ] }) + const provider = createMemo(() => + props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, + ) + + const title = createMemo(() => { + if (provider()) return provider()!.name + return "Select model" + }) + return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 8ba7845f2d..b96fc2d093 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -124,7 +124,7 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }) return ( @@ -172,7 +172,7 @@ function CodeMethod(props: CodeMethodProps) { if (!error) { await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) return } setError(true) @@ -229,7 +229,7 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 59db5fe7d1..4f51215965 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -21,9 +21,6 @@ export function Logo() { )} - - {Installation.VERSION} - ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 84d0030198..b7a02b850d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -44,6 +44,8 @@ export type PromptRef = { focus(): void } +const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -104,6 +106,79 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ + { + title: "Clear prompt", + value: "prompt.clear", + category: "Prompt", + disabled: true, + onSelect: (dialog) => { + input.extmarks.clear() + input.clear() + dialog.clear() + }, + }, + { + title: "Submit prompt", + value: "prompt.submit", + disabled: true, + keybind: "input_submit", + category: "Prompt", + onSelect: (dialog) => { + if (!input.focused) return + submit() + dialog.clear() + }, + }, + { + title: "Paste", + value: "prompt.paste", + disabled: true, + keybind: "input_paste", + category: "Prompt", + onSelect: async () => { + const content = await Clipboard.read() + if (content?.mime.startsWith("image/")) { + await pasteImage({ + filename: "clipboard", + mime: content.mime, + content: content.data, + }) + } + }, + }, + { + title: "Interrupt session", + value: "session.interrupt", + keybind: "session_interrupt", + disabled: status().type === "idle", + category: "Session", + onSelect: (dialog) => { + if (autocomplete.visible) return + if (!input.focused) return + // TODO: this should be its own command + if (store.mode === "shell") { + setStore("mode", "normal") + return + } + if (!props.sessionID) return + + setStore("interrupt", store.interrupt + 1) + + setTimeout(() => { + setStore("interrupt", 0) + }, 5000) + + if (store.interrupt >= 2) { + sdk.client.session.abort({ + path: { + id: props.sessionID, + }, + }) + setStore("interrupt", 0) + } + dialog.clear() + }, + }, { title: "Open editor", category: "Session", @@ -188,79 +263,6 @@ export function Prompt(props: PromptProps) { input.cursorOffset = Bun.stringWidth(content) }, }, - { - title: "Clear prompt", - value: "prompt.clear", - category: "Prompt", - disabled: true, - onSelect: (dialog) => { - input.extmarks.clear() - input.clear() - dialog.clear() - }, - }, - { - title: "Submit prompt", - value: "prompt.submit", - disabled: true, - keybind: "input_submit", - category: "Prompt", - onSelect: (dialog) => { - if (!input.focused) return - submit() - dialog.clear() - }, - }, - { - title: "Paste", - value: "prompt.paste", - disabled: true, - keybind: "input_paste", - category: "Prompt", - onSelect: async () => { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - await pasteImage({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - } - }, - }, - { - title: "Interrupt session", - value: "session.interrupt", - keybind: "session_interrupt", - disabled: status().type === "idle", - category: "Session", - onSelect: (dialog) => { - if (autocomplete.visible) return - if (!input.focused) return - // TODO: this should be its own command - if (store.mode === "shell") { - setStore("mode", "normal") - return - } - if (!props.sessionID) return - - setStore("interrupt", store.interrupt + 1) - - setTimeout(() => { - setStore("interrupt", 0) - }, 5000) - - if (store.interrupt >= 2) { - sdk.client.session.abort({ - path: { - id: props.sessionID, - }, - }) - setStore("interrupt", 0) - } - dialog.clear() - }, - }, ] }) @@ -278,7 +280,9 @@ export function Prompt(props: PromptProps) { mode: "normal" | "shell" extmarkToPartIndex: Map interrupt: number + placeholder: number }>({ + placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), prompt: { input: "", parts: [], @@ -666,7 +670,7 @@ export function Prompt(props: PromptProps) { flexGrow={1} >