diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6b93a92..fdeeeea6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,54 @@ jobs: path: kit_*.tar.gz if-no-files-found: error + browser-tui: + name: Browser TUI / ${{ matrix.os }} / ${{ matrix.browser }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + browser: chromium + - os: ubuntu-latest + browser: firefox + - os: macos-15 + browser: webkit + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + working-directory: app + run: bun install --frozen-lockfile + + - name: Install browser + working-directory: app + run: bunx playwright install --with-deps ${{ matrix.browser }} + + - name: Build binary + working-directory: app + run: bun run build + + - name: Test browser TUI + working-directory: app + run: bunx playwright test --project=${{ matrix.browser }} + + - name: Upload browser artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-web-tui-${{ matrix.os }}-${{ matrix.browser }}-results + path: app/test-results/web-tui + if-no-files-found: ignore + release: name: Create GitHub release - needs: build + needs: [build, browser-tui] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/web-tui-browser.yml b/.github/workflows/web-tui-browser.yml new file mode 100644 index 00000000..4fc621af --- /dev/null +++ b/.github/workflows/web-tui-browser.yml @@ -0,0 +1,66 @@ +name: Browser TUI smoke + +on: + pull_request: + paths: + - "app/**" + - ".github/workflows/web-tui-browser.yml" + push: + branches: [main] + paths: + - "app/**" + - ".github/workflows/web-tui-browser.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: web-tui-browser-${{ github.ref }} + cancel-in-progress: true + +jobs: + browser: + name: ${{ matrix.os }} / ${{ matrix.browser }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + browser: chromium + - os: ubuntu-latest + browser: firefox + - os: macos-15 + browser: webkit + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + working-directory: app + run: bun install --frozen-lockfile + + - name: Install browser + working-directory: app + run: bunx playwright install --with-deps ${{ matrix.browser }} + + - name: Build binary + working-directory: app + run: bun run build + + - name: Test browser TUI + working-directory: app + run: bunx playwright test --project=${{ matrix.browser }} + + - name: Upload browser artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: web-tui-${{ matrix.os }}-${{ matrix.browser }}-results + path: app/test-results/web-tui + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 517a61e9..27368aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ .dist/ dist/ +test-results/ +playwright-report/ .DS_Store keymap-keybindings-progress.local.md diff --git a/README.md b/README.md index 09938884..ee20895b 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,10 @@ kit -p --model openai/gpt-5.5 "review this" # selects an exact provider/model kit -p --session abc123 "continue this" # continues and persists a specific session kit --rpc # runs as a headless JSONL subprocess kit --rpc --no-session # runs the RPC conversation in memory -kit --web # serves the current directory's latest session in the browser +kit --web # serves the current directory's latest session in the semantic browser UI kit --web --no-session # serves an in-memory browser session +kit --web-tui # serves the real interactive TUI in a desktop browser (experimental) +kit --web-tui --model openai/gpt-5.5 # starts the browser TUI with an exact provider/model kit -s abc123 # opens a specific session by ID (long or short id) kit threads # launches a session picker ``` diff --git a/app/biome.json b/app/biome.json index cbc2ea0a..f1c43d8d 100644 --- a/app/biome.json +++ b/app/biome.json @@ -1,7 +1,13 @@ { "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "files": { - "includes": ["**", "!!**/dist", "!!**/vendor"] + "includes": [ + "**", + "!!**/dist", + "!!**/vendor", + "!!**/test-results", + "!!**/playwright-report" + ] }, "linter": { "enabled": true, diff --git a/app/bun.lock b/app/bun.lock index 872fd91e..84747aa6 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -19,6 +19,7 @@ "@resvg/resvg-wasm": "2.6.2", "diff": "^9.0.0", "dompurify": "^3.4.13", + "ghostty-web": "0.4.0", "glob": "^13.0.6", "ignore": "^7.0.5", "marked": "17.0.1", @@ -32,6 +33,7 @@ "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@biomejs/biome": "2.3.14", + "@playwright/test": "1.62.1", "@types/babel__core": "7.20.5", "@types/bun": "latest", "@types/diff": "^8.0.0", @@ -230,6 +232,8 @@ "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -454,6 +458,8 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], @@ -468,6 +474,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], @@ -624,6 +632,10 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], diff --git a/app/e2e/web-tui.e2e.ts b/app/e2e/web-tui.e2e.ts new file mode 100644 index 00000000..ac68b36a --- /dev/null +++ b/app/e2e/web-tui.e2e.ts @@ -0,0 +1,874 @@ +import type { Browser, Page } from "@playwright/test"; +import { expect, test } from "./web-tui.fixture"; + +type TrackedFrame = + | { kind: "binary"; bytes: number[] } + | { kind: "text"; text: string }; + +type SocketTrackingSnapshot = { + receivedBytes: number; + sent: TrackedFrame[]; + socketCount: number; +}; + +type TerminalSizeControl = { + type: "init" | "resize"; + cols: number; + rows: number; +}; + +async function waitForTerminal(page: Page): Promise { + await expect(page.locator("#status")).toBeHidden({ timeout: 30_000 }); + await expect(page.locator("#terminal canvas")).toHaveCount(1); + await page.waitForFunction(() => { + const canvas = + document.querySelector("#terminal canvas"); + return Boolean(canvas && canvas.width > 100 && canvas.height > 100); + }); + await expect + .poll(() => + page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue("--kit-terminal-bg") + .trim(), + ), + ) + .toBe("#fdf6e3"); +} + +async function installSocketTracking(page: Page): Promise { + await page.addInitScript(() => { + type BrowserTrackedFrame = + | { kind: "binary"; bytes: number[] } + | { kind: "text"; text: string }; + type BrowserSocketTracking = { + receivedBytes: number; + sent: BrowserTrackedFrame[]; + sockets: WebSocket[]; + }; + const tracking: BrowserSocketTracking = { + receivedBytes: 0, + sent: [], + sockets: [], + }; + const OriginalWebSocket = window.WebSocket; + window.WebSocket = new Proxy(OriginalWebSocket, { + construct(target, args, newTarget) { + const socket = Reflect.construct(target, args, newTarget) as WebSocket; + tracking.sockets.push(socket); + const originalSend = socket.send.bind(socket); + socket.send = (( + data: string | ArrayBufferLike | Blob | ArrayBufferView, + ) => { + if (typeof data === "string") { + tracking.sent.push({ kind: "text", text: data }); + } else if (data instanceof ArrayBuffer) { + tracking.sent.push({ + kind: "binary", + bytes: Array.from(new Uint8Array(data)), + }); + } else if (ArrayBuffer.isView(data)) { + tracking.sent.push({ + kind: "binary", + bytes: Array.from( + new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + ), + }); + } + originalSend(data); + }) as typeof socket.send; + socket.addEventListener("message", (event) => { + if (event.data instanceof ArrayBuffer) { + tracking.receivedBytes += event.data.byteLength; + } else if (event.data instanceof Blob) { + tracking.receivedBytes += event.data.size; + } + }); + return socket; + }, + }); + Object.defineProperty(window, "__kitSocketTracking", { + value: tracking, + }); + }); +} + +async function socketTracking(page: Page): Promise { + return page.evaluate(() => { + const tracking = ( + window as typeof window & { + __kitSocketTracking: { + receivedBytes: number; + sent: TrackedFrame[]; + sockets: WebSocket[]; + }; + } + ).__kitSocketTracking; + return { + receivedBytes: tracking.receivedBytes, + sent: tracking.sent, + socketCount: tracking.sockets.length, + }; + }); +} + +function binaryText(frames: TrackedFrame[], offset = 0): string { + return Buffer.concat( + frames + .slice(offset) + .filter( + (frame): frame is Extract => + frame.kind === "binary", + ) + .map((frame) => Buffer.from(frame.bytes)), + ).toString("utf8"); +} + +function clipboardResult( + frames: TrackedFrame[], + id: number, +): { ok: boolean; error?: string } | undefined { + for (const frame of frames) { + if (frame.kind !== "text") continue; + try { + const value = JSON.parse(frame.text) as Record; + if ( + value.type === "clipboard-result" && + value.id === id && + typeof value.ok === "boolean" + ) { + return { + ok: value.ok, + ...(typeof value.error === "string" ? { error: value.error } : {}), + }; + } + } catch {} + } + return undefined; +} + +function terminalSizeControls( + frames: TrackedFrame[], + offset = 0, +): TerminalSizeControl[] { + return frames + .slice(offset) + .filter( + (frame): frame is Extract => + frame.kind === "text", + ) + .flatMap((frame) => { + try { + const value = JSON.parse(frame.text) as Record; + if ( + (value.type === "init" || value.type === "resize") && + typeof value.cols === "number" && + typeof value.rows === "number" + ) { + return [value as TerminalSizeControl]; + } + } catch {} + return []; + }); +} + +async function clickCellAtDeviceScale( + browser: Browser, + url: string, + deviceScaleFactor: number, +): Promise<{ + column: number; + row: number; + expectedColumn: number; + expectedRow: number; + backingScale: number; +}> { + const context = await browser.newContext({ + deviceScaleFactor, + viewport: { width: 1_000, height: 700 }, + }); + const page = await context.newPage(); + try { + await installSocketTracking(page); + await page.goto(url); + await waitForTerminal(page); + const canvas = page.locator("#terminal canvas"); + const bounds = await canvas.boundingBox(); + if (!bounds) throw new Error("Terminal Canvas has no bounds"); + const tracking = await socketTracking(page); + const size = terminalSizeControls(tracking.sent).at(-1); + if (!size) throw new Error("Terminal did not send its initial size"); + const offset = tracking.sent.length; + await page.mouse.click( + bounds.x + bounds.width * 0.37, + bounds.y + bounds.height * 0.41, + ); + const sgrPrefix = `${String.fromCharCode(27)}\\[<`; + let match: RegExpMatchArray | null = null; + await expect + .poll(async () => { + match = binaryText((await socketTracking(page)).sent, offset).match( + new RegExp(`${sgrPrefix}0;(\\d+);(\\d+)M`), + ); + return match !== null; + }) + .toBe(true); + if (!match) throw new Error("Terminal click did not produce an SGR cell"); + const backingWidth = await canvas.evaluate( + (element) => (element as HTMLCanvasElement).width, + ); + return { + column: Number(match[1]), + row: Number(match[2]), + expectedColumn: Math.floor(size.cols * 0.37) + 1, + expectedRow: Math.floor(size.rows * 0.41) + 1, + backingScale: backingWidth / bounds.width, + }; + } finally { + await context.close(); + } +} + +test("boots the compiled ghostty client and renders the custom theme", async ({ + browserName, + webTuiPage, +}) => { + const { diagnostics, page, server, url } = webTuiPage; + const responses = new Map(); + page.on("response", (response) => { + responses.set(new URL(response.url()).pathname, response.status()); + }); + + const documentResponse = await page.goto(url); + expect(documentResponse?.status()).toBe(200); + await waitForTerminal(page); + + expect(responses.get("/assets/tui-client.js")).toBe(200); + expect(responses.get("/assets/ghostty-vt.wasm")).toBe(200); + const csp = documentResponse?.headers()["content-security-policy"] ?? ""; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("connect-src 'self'"); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(await page.locator("#terminal textarea").count()).toBe(1); + expect( + await page.evaluate( + () => getComputedStyle(document.documentElement).colorScheme, + ), + ).toBe("light"); + + // CSS theme control can arrive just before ghostty paints OpenTUI's first + // themed frame; wait for the Canvas rather than racing its render loop. + await expect + .poll(() => + page.locator("#terminal canvas").evaluate((canvas) => { + const element = canvas as HTMLCanvasElement; + const context = element.getContext("2d"); + if (!context) return 0; + const data = context.getImageData( + 0, + 0, + element.width, + element.height, + ).data; + let background = 0; + let samples = 0; + for (let index = 0; index < data.length; index += 256) { + if ( + data[index] === 253 && + data[index + 1] === 246 && + data[index + 2] === 227 + ) { + background += 1; + } + samples += 1; + } + return background / samples; + }), + ) + .toBeGreaterThan(0.5); + + const pixels = await page.locator("#terminal canvas").evaluate((canvas) => { + const element = canvas as HTMLCanvasElement; + const context = element.getContext("2d"); + if (!context) throw new Error("Canvas 2D context is unavailable"); + const data = context.getImageData(0, 0, element.width, element.height).data; + const colors = new Set(); + let background = 0; + let hardcodedDark = 0; + let samples = 0; + for (let y = 0; y < element.height; y += 8) { + for (let x = 0; x < element.width; x += 8) { + const index = (y * element.width + x) * 4; + const red = data[index] ?? 0; + const green = data[index + 1] ?? 0; + const blue = data[index + 2] ?? 0; + colors.add(`${red},${green},${blue}`); + if (red === 253 && green === 246 && blue === 227) background += 1; + if (red === 10 && green === 10 && blue === 10) hardcodedDark += 1; + samples += 1; + } + } + return { + width: element.width, + height: element.height, + uniqueColors: colors.size, + background, + hardcodedDark, + samples, + }; + }); + expect(pixels.width).toBeGreaterThan(100); + expect(pixels.height).toBeGreaterThan(100); + expect(pixels.uniqueColors).toBeGreaterThan(3); + expect(pixels.background / pixels.samples).toBeGreaterThan(0.5); + expect(pixels.hardcodedDark).toBe(0); + expect(diagnostics.consoleErrors).toEqual([]); + expect(diagnostics.pageErrors).toEqual([]); + expect(diagnostics.failedRequests).toEqual([]); + if (browserName === "chromium") { + const crossOriginProbe = `http://localhost:${server.port}/api/health`; + const externalConnectionBlocked = await page.evaluate(async (probe) => { + try { + await fetch(probe); + return false; + } catch { + return true; + } + }, crossOriginProbe); + expect(externalConnectionBlocked).toBe(true); + await expect + .poll(() => + diagnostics.consoleErrors.some( + (message) => + message.includes("Content Security Policy") && + message.includes(crossOriginProbe), + ), + ) + .toBe(true); + } +}); + +test("encodes keyboard, mouse, wheel, and resize through the real browser", async ({ + browserName, + webTuiPage, +}) => { + const { diagnostics, page, server, url } = webTuiPage; + await installSocketTracking(page); + await page.addInitScript(() => { + window.addEventListener( + "keydown", + (event) => { + if (event.altKey) { + Object.defineProperty(window, "__kitLastAltKey", { + configurable: true, + value: event.key, + }); + } + }, + true, + ); + }); + await page.goto(url); + await waitForTerminal(page); + + const browserUsesMacKeys = await page.evaluate(() => { + const navigatorWithData = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return /mac|iphone|ipad|ipod/i.test( + navigatorWithData.userAgentData?.platform ?? navigator.platform, + ); + }); + const altOffset = (await socketTracking(page)).sent.length; + await page.keyboard.press("Alt+a"); + await page.waitForTimeout(100); + const altText = binaryText((await socketTracking(page)).sent, altOffset); + if (browserUsesMacKeys) { + const producedKey = await page.evaluate( + () => + (window as typeof window & { __kitLastAltKey?: string }) + .__kitLastAltKey, + ); + expect(producedKey).toBeTruthy(); + expect(altText).toBe(producedKey === "Dead" ? "" : producedKey); + } else { + expect(altText).toContain("\x1ba"); + } + + // Prevent Escape from taking the empty-composer quit path while its bytes + // are inspected across engines. + await page.keyboard.type("keep alive"); + await page.waitForTimeout(100); + const keyboardOffset = (await socketTracking(page)).sent.length; + await page.keyboard.press("Escape"); + await page.keyboard.press("ArrowUp"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, keyboardOffset), + ) + .toContain("\x1b"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, keyboardOffset), + ) + .toContain("\x1b[A"); + + const canvas = page.locator("#terminal canvas"); + const bounds = await canvas.boundingBox(); + if (!bounds) throw new Error("Terminal Canvas has no bounds"); + const mouseOffset = (await socketTracking(page)).sent.length; + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + await page.mouse.move(x - 10, y - 5); + await page.mouse.down(); + await page.mouse.move(x + 10, y + 5); + await page.mouse.up(); + await canvas.dispatchEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + deltaY: 100, + }); + const sgrPrefix = `${String.fromCharCode(27)}\\[<`; + for (const pattern of [ + new RegExp(`${sgrPrefix}0;\\d+;\\d+M`), + new RegExp(`${sgrPrefix}0;\\d+;\\d+m`), + new RegExp(`${sgrPrefix}(?:32|35);\\d+;\\d+M`), + new RegExp(`${sgrPrefix}65;\\d+;\\d+M`), + ]) { + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, mouseOffset), + ) + .toMatch(pattern); + } + + const rightClickOffset = (await socketTracking(page)).sent.length; + await page.mouse.click(x, y, { button: "right" }); + for (const pattern of [ + new RegExp(`${sgrPrefix}2;\\d+;\\d+M`), + new RegExp(`${sgrPrefix}2;\\d+;\\d+m`), + ]) { + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, rightClickOffset), + ) + .toMatch(pattern); + } + + const selectionOffset = (await socketTracking(page)).sent.length; + await page.keyboard.down("Shift"); + await page.mouse.move(bounds.x + 10, bounds.y + 10); + await page.mouse.down(); + await page.mouse.move( + bounds.x + bounds.width * 0.75, + bounds.y + bounds.height * 0.5, + ); + await page.mouse.up(); + await page.keyboard.up("Shift"); + await page.waitForTimeout(100); + expect((await socketTracking(page)).sent).toHaveLength(selectionOffset); + if (browserName === "chromium") { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { origin: url }); + await page.keyboard.press( + process.platform === "darwin" ? "Meta+C" : "Control+Shift+C", + ); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .not.toBe(""); + expect((await socketTracking(page)).sent).toHaveLength(selectionOffset); + + await page.evaluate(async () => { + await navigator.clipboard.writeText(""); + Object.defineProperty(navigator.clipboard, "writeText", { + configurable: true, + value: () => Promise.reject(new Error("forced clipboard fallback")), + }); + }); + const fallbackOffset = (await socketTracking(page)).sent.length; + await page.keyboard.press( + process.platform === "darwin" ? "Meta+C" : "Control+Shift+C", + ); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .not.toBe(""); + await page.keyboard.type("z"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, fallbackOffset), + ) + .toContain("z"); + } + + await expect + .poll( + async () => + terminalSizeControls((await socketTracking(page)).sent).length, + ) + .toBeGreaterThan(0); + const initialTracking = await socketTracking(page); + const initialSize = terminalSizeControls(initialTracking.sent).at(-1); + if (!initialSize) throw new Error("Terminal did not send its initial size"); + const initialCanvasSize = await canvas.evaluate((element) => ({ + height: (element as HTMLCanvasElement).height, + width: (element as HTMLCanvasElement).width, + })); + const resizeOffset = initialTracking.sent.length; + const resizeOutputOffset = initialTracking.receivedBytes; + await page.setViewportSize({ width: 840, height: 560 }); + await expect + .poll(async () => + terminalSizeControls( + (await socketTracking(page)).sent, + resizeOffset, + ).some( + (control) => + control.cols < initialSize.cols && control.rows < initialSize.rows, + ), + ) + .toBe(true); + await expect + .poll( + async () => + (await socketTracking(page)).receivedBytes - resizeOutputOffset, + ) + .toBeGreaterThan(100); + await expect + .poll(async () => { + const resized = await canvas.evaluate((element) => ({ + height: (element as HTMLCanvasElement).height, + width: (element as HTMLCanvasElement).width, + })); + return ( + resized.height < initialCanvasSize.height && + resized.width < initialCanvasSize.width + ); + }) + .toBe(true); + await expect(canvas).toHaveCount(1); + expect(diagnostics.consoleErrors).toEqual([]); + expect(diagnostics.pageErrors).toEqual([]); + expect(diagnostics.failedRequests).toEqual([]); + + server.allowExitCode(0); + const interruptOffset = (await socketTracking(page)).sent.length; + await page.keyboard.press("Control+C"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, interruptOffset), + ) + .toContain("\x03"); +}); + +test("maps pointer coordinates accurately at standard and high DPI", async ({ + browser, + webTuiServer, +}) => { + const standard = await clickCellAtDeviceScale(browser, webTuiServer.url, 1); + const highDpi = await clickCellAtDeviceScale(browser, webTuiServer.url, 2); + for (const result of [standard, highDpi]) { + expect({ column: result.column, row: result.row }).toEqual({ + column: result.expectedColumn, + row: result.expectedRow, + }); + } + expect(standard.backingScale).toBeCloseTo(1, 1); + expect(highDpi.backingScale).toBeCloseTo(2, 1); +}); + +test("preserves large Unicode input and browser-owned clipboard shortcuts", async ({ + browserName, + webTuiPage, +}) => { + const { diagnostics, page, url } = webTuiPage; + await installSocketTracking(page); + await page.goto(url); + await waitForTerminal(page); + + const value = "Ξ»πŸ™‚".repeat(6_000); + const expectedPaste = `\x1b[200~${value}\x1b[201~`; + const inputOffset = (await socketTracking(page)).sent.length; + await page.locator("#terminal textarea").evaluate((element, text) => { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: (type: string) => (type === "text/plain" ? text : "") }, + }); + element.dispatchEvent(event); + }, value); + await expect + .poll( + async () => + binaryText((await socketTracking(page)).sent, inputOffset).length, + ) + .toBe(expectedPaste.length); + const tracking = await socketTracking(page); + const inputFrames = tracking.sent + .slice(inputOffset) + .filter( + (frame): frame is Extract => + frame.kind === "binary", + ); + expect(inputFrames.length).toBeGreaterThan(1); + expect( + Math.max(...inputFrames.map((frame) => frame.bytes.length)), + ).toBeLessThanOrEqual(16 * 1024); + expect(binaryText(tracking.sent, inputOffset)).toBe(expectedPaste); + + const compositionOffset = tracking.sent.length; + await page.locator("#terminal textarea").evaluate((element) => { + element.dispatchEvent( + new CompositionEvent("compositionend", { + bubbles: true, + data: "ζΌ’", + }), + ); + }); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, compositionOffset), + ) + .toBe("ζΌ’"); + + const actionId = 77; + const actionText = "**whole message**"; + if (browserName === "chromium") { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { origin: url }); + await page.evaluate(() => navigator.clipboard.writeText("")); + } + const actionOffset = (await socketTracking(page)).sent.length; + await page.evaluate( + ({ id, text }) => { + const tracking = ( + window as typeof window & { + __kitSocketTracking: { sockets: WebSocket[] }; + } + ).__kitSocketTracking; + const socket = tracking.sockets.at(-1); + if (!socket) throw new Error("No browser-TUI WebSocket for copy action"); + socket.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ type: "clipboard-write", id, text }), + }), + ); + }, + { id: actionId, text: actionText }, + ); + await expect + .poll(async () => + clipboardResult( + (await socketTracking(page)).sent.slice(actionOffset), + actionId, + ), + ) + .toEqual({ ok: true }); + if (browserName === "chromium") { + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(actionText); + } + + const clipboardOffset = (await socketTracking(page)).sent.length; + await page.keyboard.press("Meta+C"); + await page.keyboard.press("Control+Shift+C"); + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + isComposing: true, + key: "Process", + }), + ); + }); + await page.waitForTimeout(100); + expect((await socketTracking(page)).sent).toHaveLength(clipboardOffset); + expect(diagnostics.consoleErrors).toEqual([]); + expect(diagnostics.pageErrors).toEqual([]); + expect(diagnostics.failedRequests).toEqual([]); +}); + +test("handles browser-owned notifications without prompting automatically", async ({ + webTuiPage, +}) => { + const { diagnostics, page, url } = webTuiPage; + await page.addInitScript(() => { + const state = { created: [] as string[], requested: 0 }; + class TestNotification { + static permission: NotificationPermission = "default"; + static requestPermission(): Promise { + state.requested += 1; + TestNotification.permission = "granted"; + return Promise.resolve("granted"); + } + constructor(title: string, options?: NotificationOptions) { + state.created.push(`${title}: ${options?.body ?? ""}`); + } + } + Object.defineProperty(window, "Notification", { + configurable: true, + value: TestNotification, + }); + Object.defineProperty(window, "__kitNotificationTest", { value: state }); + }); + await installSocketTracking(page); + await page.goto(url); + await waitForTerminal(page); + + const dispatchControl = (value: Record) => + page.evaluate((control) => { + const tracking = ( + window as typeof window & { + __kitSocketTracking: { sockets: WebSocket[] }; + } + ).__kitSocketTracking; + const socket = tracking.sockets.at(-1); + if (!socket) throw new Error("No browser-TUI WebSocket for notification"); + socket.dispatchEvent( + new MessageEvent("message", { data: JSON.stringify(control) }), + ); + }, value); + + await dispatchControl({ + type: "notification", + title: "Kit", + message: "Agent turn complete", + }); + await expect(page.locator("#notification")).toContainText( + "Kit: Agent turn complete", + ); + await expect(page.locator("#notification")).toBeVisible(); + expect( + await page.evaluate( + () => + ( + window as typeof window & { + __kitNotificationTest: { created: string[]; requested: number }; + } + ).__kitNotificationTest, + ), + ).toEqual({ created: [], requested: 0 }); + + await expect(page.locator("#enable-notifications")).toBeVisible(); + await page.locator("#enable-notifications").click(); + await expect(page.locator("#enable-notifications")).toBeHidden(); + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + Object.defineProperty(document, "hasFocus", { + configurable: true, + value: () => false, + }); + }); + await dispatchControl({ + type: "notification", + title: "Kit", + message: "Input needed", + }); + await dispatchControl({ type: "bell", kind: "attention" }); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __kitNotificationTest: { + created: string[]; + requested: number; + }; + } + ).__kitNotificationTest, + ), + ) + .toEqual({ created: ["Kit: Input needed"], requested: 1 }); + + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); + Object.defineProperty(document, "hasFocus", { + configurable: true, + value: () => true, + }); + Object.defineProperty(Notification, "permission", { + configurable: true, + value: "default", + writable: true, + }); + Object.defineProperty(Notification, "requestPermission", { + configurable: true, + value: () => Promise.reject(new Error("permission policy denied")), + }); + const button = document.getElementById("enable-notifications"); + if (button) button.hidden = false; + }); + const deniedPermissionOffset = (await socketTracking(page)).sent.length; + await page.locator("#enable-notifications").click(); + await expect(page.locator("#enable-notifications")).toBeHidden(); + await expect(page.locator("#notification")).toContainText( + "Browser notification permission unavailable", + ); + await page.keyboard.type("z"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, deniedPermissionOffset), + ) + .toContain("z"); + expect(diagnostics.consoleErrors).toEqual([]); + expect(diagnostics.pageErrors).toEqual([]); + expect(diagnostics.failedRequests).toEqual([]); +}); + +test("reconnects after network loss and reloads without duplicate browser state", async ({ + webTuiPage, +}) => { + const { diagnostics, page, url } = webTuiPage; + await installSocketTracking(page); + await page.goto(url); + await waitForTerminal(page); + + const reconnectOutputOffset = (await socketTracking(page)).receivedBytes; + await page.evaluate(() => { + const tracking = ( + window as typeof window & { + __kitSocketTracking: { sockets: WebSocket[] }; + } + ).__kitSocketTracking; + const socket = tracking.sockets.at(-1); + if (!socket) throw new Error("No browser-TUI WebSocket to disconnect"); + socket.close(4000, "browser test disconnect"); + }); + await expect(page.locator("#status")).toBeVisible(); + await expect(page.locator("#status")).toContainText("disconnected"); + await expect + .poll(async () => (await socketTracking(page)).socketCount) + .toBeGreaterThan(1); + await expect + .poll( + async () => + (await socketTracking(page)).receivedBytes - reconnectOutputOffset, + ) + .toBeGreaterThan(2_000); + await waitForTerminal(page); + await expect(page.locator("#terminal canvas")).toHaveCount(1); + await expect(page.locator("#terminal textarea")).toHaveCount(1); + + await page.reload(); + await expect + .poll(async () => (await socketTracking(page)).receivedBytes) + .toBeGreaterThan(2_000); + await waitForTerminal(page); + await expect(page.locator("#terminal canvas")).toHaveCount(1); + await expect(page.locator("#terminal textarea")).toHaveCount(1); + expect(diagnostics.consoleErrors).toEqual([]); + expect(diagnostics.pageErrors).toEqual([]); + expect(diagnostics.failedRequests).toEqual([]); +}); diff --git a/app/e2e/web-tui.fixture.ts b/app/e2e/web-tui.fixture.ts new file mode 100644 index 00000000..398a960e --- /dev/null +++ b/app/e2e/web-tui.fixture.ts @@ -0,0 +1,248 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test as base, type Page } from "@playwright/test"; + +const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const binary = path.join(appDir, "dist", "kit"); + +const fidelityTheme = { + tokens: { + bg: "#fdf6e3", + bgSurface: "#eee8d5", + bgMuted: "#ddd6c1", + bgAccent: "#c8bea4", + borderDefault: "#b8ad91", + textPrimary: "#123456", + textSecondary: "#586e75", + cursor: "#d33682", + }, +}; + +type ExitResult = { code: number | null; signal: NodeJS.Signals | null }; + +type WebTuiServer = { + url: string; + port: number; + allowExitCode(code: number): void; +}; + +type BrowserDiagnostics = { + consoleErrors: string[]; + pageErrors: string[]; + failedRequests: string[]; +}; + +type WebTuiFixtures = { + webTuiServer: WebTuiServer; + webTuiPage: { + diagnostics: BrowserDiagnostics; + page: Page; + server: WebTuiServer; + url: string; + }; +}; + +async function availablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate a browser-TUI test port")); + return; + } + server.close((error) => { + if (error) reject(error); + else resolve(address.port); + }); + }); + }); +} + +async function waitForHealth( + url: string, + exited: Promise, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const result = await Promise.race([ + exited.then((exit) => ({ type: "exit" as const, exit })), + fetch(`${url}/api/health`) + .then((response) => ({ type: "response" as const, response })) + .catch(() => ({ type: "retry" as const })), + ]); + if (result.type === "exit") { + throw new Error( + `Kit exited before becoming healthy (code=${result.exit.code}, signal=${result.exit.signal})`, + ); + } + if (result.type === "response" && result.response.ok) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for Kit browser-TUI health endpoint"); +} + +export const test = base.extend({ + // biome-ignore lint/correctness/noEmptyPattern: Playwright fixture signatures require object destructuring. + webTuiServer: async ({}, use, testInfo) => { + const root = await mkdtemp(path.join(tmpdir(), "kit-web-tui-e2e-")); + const allowedExitCodes = new Set([143]); + let child: ReturnType | null = null; + let exited: Promise | null = null; + let stdout = ""; + let stderr = ""; + let primaryError: unknown; + try { + const home = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + const themeDir = path.join(home, ".kit", "themes"); + await mkdir(themeDir, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await writeFile( + path.join(home, ".kit", "settings.json"), + JSON.stringify({ theme: "fidelity-light" }), + ); + await writeFile( + path.join(themeDir, "fidelity-light.json"), + JSON.stringify(fidelityTheme), + ); + + const port = await availablePort(); + const url = `http://127.0.0.1:${port}`; + child = spawn( + binary, + [ + "--web-tui", + "--no-session", + "--port", + String(port), + "--public-url", + url, + ], + { + cwd: workspace, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + KIT_DEBUG_SHUTDOWN: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout?.on("data", (chunk: Buffer) => { + stdout = (stdout + chunk.toString("utf8")).slice(-64 * 1024); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = (stderr + chunk.toString("utf8")).slice(-64 * 1024); + }); + exited = new Promise((resolve) => { + child?.once("exit", (code, signal) => resolve({ code, signal })); + }); + await waitForHealth(url, exited); + await use({ + url, + port, + allowExitCode: (code) => allowedExitCodes.add(code), + }); + } catch (error) { + primaryError = error; + } + + const cleanupErrors: unknown[] = []; + if (child && exited) { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + } + let exit = await Promise.race([ + exited, + new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 15_000), + ), + ]); + if (exit === "timeout") { + child.kill("SIGKILL"); + exit = await exited; + } + if (exit.code === null || !allowedExitCodes.has(exit.code)) { + cleanupErrors.push( + new Error( + `Kit browser-TUI teardown exited with code ${exit.code} and signal ${exit.signal}; expected ${[...allowedExitCodes].join(" or ")}\n${stderr}`, + ), + ); + } else if (!stderr.includes("[kit] web TUI shutdown complete")) { + cleanupErrors.push( + new Error(`Kit did not report completed shutdown\n${stderr}`), + ); + } + } + try { + await testInfo.attach("kit-stdout", { + body: Buffer.from(stdout), + contentType: "text/plain", + }); + await testInfo.attach("kit-stderr", { + body: Buffer.from(stderr), + contentType: "text/plain", + }); + } catch (error) { + cleanupErrors.push(error); + } + try { + await rm(root, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + const errors = [ + ...(primaryError === undefined ? [] : [primaryError]), + ...cleanupErrors, + ]; + if (errors.length > 1) { + throw new AggregateError(errors, "Browser-TUI test or cleanup failed"); + } + if (errors.length === 1) { + const error = errors[0]; + throw error instanceof Error ? error : new Error(String(error)); + } + }, + + webTuiPage: async ({ browser, webTuiServer }, use) => { + const context = await browser.newContext({ + viewport: { width: 1_000, height: 700 }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + const diagnostics: BrowserDiagnostics = { + consoleErrors: [], + pageErrors: [], + failedRequests: [], + }; + page.on("console", (message) => { + if (message.type() === "error") { + diagnostics.consoleErrors.push(message.text()); + } + }); + page.on("pageerror", (error) => diagnostics.pageErrors.push(error.message)); + page.on("requestfailed", (request) => + diagnostics.failedRequests.push(request.url()), + ); + try { + await use({ + diagnostics, + page, + server: webTuiServer, + url: webTuiServer.url, + }); + } finally { + await context.close(); + } + }, +}); + +export { expect } from "@playwright/test"; diff --git a/app/package.json b/app/package.json index 821be906..21016d4a 100644 --- a/app/package.json +++ b/app/package.json @@ -32,6 +32,8 @@ "start": "bun --preload=@opentui/solid/preload src/app/main.tsx", "build": "bun run script/build.ts", "smoke:print-mode": "bun run script/smoke-print-mode.ts", + "smoke:web-tui": "bun run script/smoke-web-tui.ts", + "test:web-tui-browser": "bun run build && playwright test", "pack:dry": "npm pack --dry-run", "prepack": "bun run build", "prepublishOnly": "bun run typecheck && bun test", @@ -53,6 +55,7 @@ "@resvg/resvg-wasm": "2.6.2", "diff": "^9.0.0", "dompurify": "^3.4.13", + "ghostty-web": "0.4.0", "glob": "^13.0.6", "ignore": "^7.0.5", "marked": "17.0.1", @@ -66,6 +69,7 @@ "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@biomejs/biome": "2.3.14", + "@playwright/test": "1.62.1", "@types/babel__core": "7.20.5", "@types/bun": "latest", "@types/diff": "^8.0.0", diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 00000000..49e0a844 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.e2e.ts", + outputDir: "./test-results/web-tui", + fullyParallel: false, + workers: 1, + timeout: 90_000, + expect: { timeout: 30_000 }, + reporter: process.env.CI ? "line" : "list", + use: { + screenshot: "only-on-failure", + trace: "retain-on-failure", + viewport: { width: 1_000, height: 700 }, + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], +}); diff --git a/app/script/build.ts b/app/script/build.ts index 4737f209..2fe08933 100644 --- a/app/script/build.ts +++ b/app/script/build.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import solidPlugin from "@opentui/solid/bun-plugin"; import { buildWebClient } from "../src/web/build-client"; +import { buildWebTuiClient } from "../src/web-tui/build-tui-client"; // Enforce minimum Bun version const MIN_BUN_VERSION = "1.3.0"; @@ -40,6 +41,9 @@ await fs.promises.mkdir(runtimeDir, { recursive: true }); console.log("Bundling web client..."); const webClientJavaScript = await buildWebClient({ minify: true }); +console.log("Bundling experimental web TUI client..."); +const webTuiClientJavaScript = await buildWebTuiClient({ minify: true }); + console.log("Compiling binary..."); const bundle = await Bun.build({ @@ -48,6 +52,7 @@ const bundle = await Bun.build({ plugins: [solidPlugin], define: { __KIT_WEB_CLIENT_JS__: JSON.stringify(webClientJavaScript), + __KIT_WEB_TUI_CLIENT_JS__: JSON.stringify(webTuiClientJavaScript), }, entrypoints: ["./src/app/main.tsx"], compile: { diff --git a/app/script/smoke-web-tui.ts b/app/script/smoke-web-tui.ts new file mode 100644 index 00000000..4a3b6117 --- /dev/null +++ b/app/script/smoke-web-tui.ts @@ -0,0 +1,256 @@ +#!/usr/bin/env bun +/** + * Smoke test for the experimental browser TUI mode (ghostty-web experiment). + * + * Boots `kit --web-tui` with an ephemeral session, then acts as + * the browser terminal over the real WebSocket protocol: init, streamed ANSI + * output, keyboard input, resize, and reconnect. Asserts the hosted OpenTUI + * application produces genuine terminal frames (alternate screen, repaints) + * rather than static output. + * + * Run from app/: bun run script/smoke-web-tui.ts + * Against the compiled binary: KIT_WEB_TUI_SMOKE_BIN=dist/kit bun run script/smoke-web-tui.ts + */ + +import path from "node:path"; +import { WEB_TUI_PROTOCOL_VERSION } from "../src/web-tui/browser-actions"; + +const dir = path.resolve(import.meta.dirname, ".."); +const port = 4000 + Math.floor(Math.random() * 2000); +const origin = `http://127.0.0.1:${port}`; + +const WebSocketWithOptions = WebSocket as unknown as new ( + url: string, + options?: Bun.WebSocketOptions, +) => WebSocket; + +function fail(message: string): never { + throw new Error(`SMOKE FAIL: ${message}`); +} + +type Connection = { + socket: WebSocket; + received: () => string; + waitForOutput: (needle: string, timeoutMs?: number) => Promise; + bytesSeen: () => number; + close: () => void; +}; + +async function connect(): Promise { + const socket = new WebSocketWithOptions(`ws://127.0.0.1:${port}/api/tui`, { + headers: { origin }, + }); + socket.binaryType = "arraybuffer"; + let received = ""; + let bytes = 0; + const decoder = new TextDecoder(); + socket.addEventListener("message", (event) => { + if (event.data instanceof ArrayBuffer) { + bytes += event.data.byteLength; + received += decoder.decode(new Uint8Array(event.data), { stream: true }); + } + }); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("ws error")), { + once: true, + }); + }); + return { + socket, + received: () => received, + bytesSeen: () => bytes, + waitForOutput: async (needle, timeoutMs = 20_000) => { + const deadline = Date.now() + timeoutMs; + while (!received.includes(needle)) { + if (Date.now() > deadline) { + fail( + `timed out waiting for ${JSON.stringify(needle)}; received ${bytes} bytes`, + ); + } + await Bun.sleep(50); + } + }, + close: () => socket.close(), + }; +} + +console.log(`Starting kit --web-tui on port ${port}...`); +const smokeBinary = process.env.KIT_WEB_TUI_SMOKE_BIN; +const spawnServer = () => + Bun.spawn({ + cmd: [ + ...(smokeBinary + ? [path.resolve(dir, smokeBinary)] + : ["bun", "--preload=@opentui/solid/preload", "src/app/main.tsx"]), + "--web-tui", + "--no-session", + "--port", + String(port), + "--public-url", + origin, + ], + cwd: dir, + env: { ...process.env, KIT_DEBUG_SHUTDOWN: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + +async function waitForHealth(): Promise { + const deadline = Date.now() + 20_000; + for (;;) { + try { + const health = await fetch(`${origin}/api/health`); + const body = (await health.json()) as { mode?: string }; + if (body.mode === "web-tui") return; + } catch {} + if (Date.now() > deadline) fail("server did not become healthy"); + await Bun.sleep(100); + } +} + +async function assertPortReusable(): Promise { + const probe = Bun.serve({ + hostname: "127.0.0.1", + port, + fetch: () => new Response("ok"), + }); + await probe.stop(true); +} + +const server = spawnServer(); +const serverExited = server.exited.then((code) => code); +let terminationServer: ReturnType | null = null; + +try { + // Wait for the health endpoint. + await waitForHealth(); + console.log("βœ“ health endpoint reports web-tui mode"); + + // Document + assets sanity. + const doc = await fetch(`${origin}/`); + if (doc.status !== 200) fail(`document status ${doc.status}`); + const csp = doc.headers.get("content-security-policy") ?? ""; + if (!csp.includes("'wasm-unsafe-eval'")) fail("CSP missing wasm-unsafe-eval"); + const clientJs = await fetch(`${origin}/assets/tui-client.js`); + const clientSource = await clientJs.text(); + if (clientSource.length < 100_000 || !clientSource.includes("ghostty-web")) { + fail("tui client bundle missing ghostty-web"); + } + const wasm = await fetch(`${origin}/assets/ghostty-vt.wasm`); + if ((await wasm.arrayBuffer()).byteLength < 100_000) { + fail("ghostty wasm asset looks truncated"); + } + console.log("βœ“ document, client bundle, and wasm asset served"); + + // First client: app boots lazily, enters the alternate screen, paints. + const first = await connect(); + first.socket.send( + JSON.stringify({ + type: "init", + cols: 100, + rows: 30, + protocolVersion: WEB_TUI_PROTOCOL_VERSION, + }), + ); + await first.waitForOutput("\x1b[?1049h", 30_000); + console.log("βœ“ OpenTUI entered the alternate screen after first init"); + const paintDeadline = Date.now() + 30_000; + while (first.bytesSeen() < 2_000) { + if (Date.now() > paintDeadline) fail("no substantial frame output"); + await Bun.sleep(100); + } + console.log(`βœ“ initial frames streamed (${first.bytesSeen()} bytes)`); + + // Keyboard input reaches the app: typing into the composer repaints. + const beforeTyping = first.bytesSeen(); + first.socket.send(new TextEncoder().encode("hello from ghostty-web")); + { + const deadline = Date.now() + 10_000; + while (first.bytesSeen() <= beforeTyping) { + if (Date.now() > deadline) fail("typing produced no repaint"); + await Bun.sleep(50); + } + } + console.log("βœ“ keyboard input produced output frames"); + + // Resize triggers a reflow. + const beforeResize = first.bytesSeen(); + first.socket.send(JSON.stringify({ type: "resize", cols: 80, rows: 24 })); + { + const deadline = Date.now() + 10_000; + while (first.bytesSeen() <= beforeResize) { + if (Date.now() > deadline) fail("resize produced no repaint"); + await Bun.sleep(50); + } + } + console.log("βœ“ resize produced a reflow"); + + // Reconnect: a fresh client must receive terminal setup + a full repaint. + first.close(); + await Bun.sleep(500); + const second = await connect(); + second.socket.send( + JSON.stringify({ + type: "init", + cols: 90, + rows: 28, + protocolVersion: WEB_TUI_PROTOCOL_VERSION, + }), + ); + await second.waitForOutput("\x1b[?1049h", 15_000); + { + const deadline = Date.now() + 15_000; + while (second.bytesSeen() < 2_000) { + if (Date.now() > deadline) fail("reconnect produced no full repaint"); + await Bun.sleep(100); + } + } + console.log( + `βœ“ reconnect replayed terminal setup and repainted (${second.bytesSeen()} bytes)`, + ); + + // Orderly shutdown while a browser client remains attached. + server.kill("SIGINT"); + const code = await Promise.race([ + serverExited, + Bun.sleep(10_000).then(() => "timeout" as const), + ]); + if (code === "timeout") fail("server did not exit after SIGINT"); + if (code !== 130) fail(`SIGINT exit code was ${code}, expected 130`); + const interruptDiagnostics = await new Response(server.stderr).text(); + if (!interruptDiagnostics.includes("[kit] web TUI shutdown complete")) { + fail("SIGINT process exited before reporting completed cleanup"); + } + await assertPortReusable(); + console.log("βœ“ SIGINT exited with code 130 after releasing the server port"); + + // SIGTERM before a browser attaches still owns and completes server cleanup. + terminationServer = spawnServer(); + await waitForHealth(); + terminationServer.kill("SIGTERM"); + const terminationCode = await Promise.race([ + terminationServer.exited, + Bun.sleep(10_000).then(() => "timeout" as const), + ]); + if (terminationCode === "timeout") fail("server did not exit after SIGTERM"); + if (terminationCode !== 143) { + fail(`SIGTERM exit code was ${terminationCode}, expected 143`); + } + const terminationDiagnostics = await new Response( + terminationServer.stderr, + ).text(); + if (!terminationDiagnostics.includes("[kit] web TUI shutdown complete")) { + fail("SIGTERM process exited before reporting completed cleanup"); + } + await assertPortReusable(); + console.log("βœ“ SIGTERM exited with code 143 after releasing the server port"); + console.log("SMOKE PASS"); +} finally { + server.kill(); + terminationServer?.kill(); + await Promise.allSettled([ + server.exited, + ...(terminationServer ? [terminationServer.exited] : []), + ]); +} diff --git a/app/src/app/App.tsx b/app/src/app/App.tsx index 72315da9..6eb1eef9 100644 --- a/app/src/app/App.tsx +++ b/app/src/app/App.tsx @@ -24,7 +24,11 @@ import { AgentRuntime, AuthenticationRequiredError, } from "../runtime/agent-runtime"; -import { refreshModelAvailability } from "../runtime/models"; +import { + hasCachedProviderAuth, + kitModels, + refreshModelAvailability, +} from "../runtime/models"; import type { Session } from "../session"; import { type LoadedSettings, loadSettings } from "../settings"; import { AppShell } from "../shell/AppShell"; @@ -40,15 +44,22 @@ import type { ToastInput } from "../state/toasts"; import { FilePersistence } from "../storage/file-persistence"; import { AuthGateScreen } from "./AuthGateScreen"; import { FatalScreen } from "./FatalScreen"; +import { + applyStartupModel, + StartupModelAuthenticationRequiredError, +} from "./headless-model"; import { createCustomOverlayHandler, type OverlayEntry } from "./overlay-ui"; import { createPluginUI } from "./plugin-ui"; export type AppProps = { settings: LoadedSettings; + startupModel?: string; session: Session; updateTerminalTitle: (sessionName: string | undefined, cwd: string) => void; setTerminalTurnActive: (active: boolean) => void; triggerNotification: (message: string, title?: string) => boolean; + triggerBell: (isError: boolean) => void; + copyText: (text: string) => Promise; quitAndDestroy: () => void; registerDispose?: (dispose: () => void | Promise) => void; persistSession: boolean; @@ -141,6 +152,7 @@ export function App(props: AppProps) { footer, header, triggerNotification: props.triggerNotification, + triggerBell: props.triggerBell, }; let pluginLoadGeneration = 0; let builtInReloadGeneration = 0; @@ -213,10 +225,16 @@ export function App(props: AppProps) { } try { + await applyStartupModel(runtime, props.startupModel, { + isKnown: (provider) => kitModels.getProvider(provider) !== undefined, + isAuthenticated: hasCachedProviderAuth, + }); initializePlugins(); } catch (error) { await disposePluginManagers(); + app.dispose(); releasesWorkspace.dispose(); + scratchpad.dispose(); persistence?.dispose(); runtime.dispose(); throw error; @@ -354,7 +372,10 @@ export function App(props: AppProps) { try { return await buildReadyState(); } catch (error) { - if (error instanceof AuthenticationRequiredError) { + if ( + error instanceof AuthenticationRequiredError || + error instanceof StartupModelAuthenticationRequiredError + ) { return { kind: "unauthenticated" }; } return { @@ -453,6 +474,7 @@ export function App(props: AppProps) { commands={current.commands} controller={current.controller} attachments={current.attachments} + copyText={props.copyText} footer={current.footer} header={current.header} releasesWorkspace={current.releasesWorkspace} diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index 58bd0b53..1ca26624 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -8,11 +8,13 @@ import { } from "@opentui/core"; import { KeymapProvider } from "@opentui/keymap/solid"; import { render } from "@opentui/solid"; +import { ringLocalBell } from "../features/notifications/notifications"; import { createKitKeymap } from "../keymap/setup"; import { safeProcessCwd } from "../process-cwd"; import { getInstalledRuntimeDir } from "../runtime/runtime-dir"; import type { Session } from "../session"; import { loadSettings } from "../settings"; +import { copyToClipboard } from "../shell/clipboard"; import { initTemplates } from "../shell/templates"; import { setTerminalProgress } from "../shell/terminal-progress"; import { @@ -22,38 +24,31 @@ import { } from "../shell/terminal-title"; import { getCurrentThemeConfig, resolveAndApplyTheme } from "../shell/theme"; import { App } from "./App"; - -type ProcessWithActiveHandles = NodeJS.Process & { - _getActiveHandles?: () => unknown[]; - _getActiveRequests?: () => unknown[]; -}; - -function describeActiveHandle(handle: unknown): string { - if (typeof handle !== "object" || handle === null) return typeof handle; - const constructorName = handle.constructor?.name; - return constructorName || Object.prototype.toString.call(handle); -} - -function reportDanglingHandlesForDebugging(): void { - if (!process.env.KIT_DEBUG_SHUTDOWN) return; - const proc = process as ProcessWithActiveHandles; - const handles = proc._getActiveHandles?.() ?? []; - const requests = proc._getActiveRequests?.() ?? []; - console.error( - `[kit] forcing shutdown with ${handles.length} active handle(s), ${requests.length} active request(s)`, - ); - for (const handle of handles) { - console.error(`[kit] active handle: ${describeActiveHandle(handle)}`); - } - for (const request of requests) { - console.error(`[kit] active request: ${describeActiveHandle(request)}`); - } -} +import { startShutdownWatchdog } from "./shutdown-watchdog"; type BootstrapOpts = { sessionId?: string; newSession?: boolean; noSession?: boolean; + startupModel?: string; + /** + * Experimental: host the OpenTUI application against custom terminal + * streams instead of the process TTY (browser-TUI bridge). + */ + terminal?: BootstrapTerminal; +}; + +export type BootstrapTerminal = { + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; + width: number; + height: number; + onRendererReady?: ( + renderer: Awaited>, + ) => void; + copyText?: (text: string) => Promise; + notify?: (message: string, title?: string) => boolean; + bell?: (isError: boolean) => void; }; async function loadSession(opts?: BootstrapOpts): Promise { @@ -162,24 +157,23 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { let disposeApp: (() => void | Promise) | null = null; let resolveAlive: (() => void) | null = null; let quitStarted = false; - let shutdownWatchdogStarted = false; - const startShutdownWatchdog = () => { - if (shutdownWatchdogStarted) return; - shutdownWatchdogStarted = true; - // OpenTUI generally discourages process.exit() because it can bypass - // terminal cleanup and leave the user's shell in raw/alternate-screen state. - // This watchdog only runs after renderer.destroy() has restored the terminal - // and the normal bootstrap promise has resolved. It exists because Bun or a - // native integration can still keep the process alive with no visible active - // handles, leaving users at a hung shell after quitting. - const shutdownWatchdog = setTimeout(() => { - reportDanglingHandlesForDebugging(); - process.exit(0); - }, 200); - shutdownWatchdog.unref?.(); - }; + // A custom-terminal host owns the surrounding process and any additional + // resources (for example, a web server). Only standalone TTY bootstrap may + // arm the fallback process watchdog directly. + const usesProcessStdio = opts?.terminal === undefined; const renderer = await createCliRenderer({ + ...(opts?.terminal + ? { + stdin: opts.terminal.stdin, + stdout: opts.terminal.stdout, + width: opts.terminal.width, + height: opts.terminal.height, + // Browser input is normalized by Kit's transport adapter. Keep a + // deterministic legacy protocol until that adapter tracks Kitty flags. + useKittyKeyboard: null, + } + : {}), exitOnCtrlC: false, exitSignals: [ "SIGTERM", @@ -205,7 +199,11 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { .finally(() => { resolveAlive?.(); resolveAlive = null; - startShutdownWatchdog(); + if (usesProcessStdio) { + startShutdownWatchdog( + typeof process.exitCode === "number" ? process.exitCode : 0, + ); + } }); }, consoleOptions: { @@ -213,84 +211,102 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { sizePercent: 30, }, }); - // Dev console toggle is opt-in β€” only enable when DEBUG is set, - // otherwise Ctrl+D is left free for other key handlers. - if (process.env.DEBUG) { - renderer.keyInput.on("keypress", (key) => { - if (key.ctrl && key.name === "d") { - renderer.console.toggle(); - } - }); - } - const keymap = createKitKeymap(renderer); + try { + // Dev console toggle is opt-in β€” only enable when DEBUG is set, + // otherwise Ctrl+D is left free for other key handlers. + if (process.env.DEBUG) { + renderer.keyInput.on("keypress", (key) => { + if (key.ctrl && key.name === "d") { + renderer.console.toggle(); + } + }); + } - // Resolve theme before rendering β€” "system" theme needs the renderer for palette detection - const themeName = settings.settings.theme ?? "system"; - await resolveAndApplyTheme(themeName, renderer); + const keymap = createKitKeymap(renderer); - // Re-resolve the theme when the terminal reports a color scheme change - // (e.g. the user switches between light and dark mode in their OS). - renderer.on(CliRenderEvents.THEME_MODE, () => { - const currentTheme = getCurrentThemeConfig().name; - void resolveAndApplyTheme(currentTheme, undefined, { - invalidateSystemCache: true, + // Resolve theme before rendering β€” "system" theme needs the renderer for palette detection + const themeName = settings.settings.theme ?? "system"; + await resolveAndApplyTheme(themeName, renderer); + + // Re-resolve the theme when the terminal reports a color scheme change + // (e.g. the user switches between light and dark mode in their OS). + renderer.on(CliRenderEvents.THEME_MODE, () => { + const currentTheme = getCurrentThemeConfig().name; + void resolveAndApplyTheme(currentTheme, undefined, { + invalidateSystemCache: true, + }); }); - }); - initTerminalTitle((title) => renderer.setTerminalTitle(title)); - updateTerminalTitle(session.name, session.cwd); - initTemplates(session.cwd); + initTerminalTitle((title) => renderer.setTerminalTitle(title)); + updateTerminalTitle(session.name, session.cwd); + initTemplates(session.cwd); - // Keep the process alive until the renderer is destroyed. - // In compiled binaries, the async bootstrap() returning would let - // the event loop drain and the process exit prematurely. - function quitAndDestroy(): void { - if (quitStarted) return; - quitStarted = true; - renderer.destroy(); - } + // Keep the process alive until the renderer is destroyed. + // In compiled binaries, the async bootstrap() returning would let + // the event loop drain and the process exit prematurely. + function quitAndDestroy(): void { + if (quitStarted) return; + quitStarted = true; + renderer.destroy(); + } - const stdioShutdown = () => quitAndDestroy(); - process.stdin.once("end", stdioShutdown); - process.stdin.once("close", stdioShutdown); - process.stdin.once("error", stdioShutdown); - process.stdout.once("error", stdioShutdown); - process.stderr.once("error", stdioShutdown); + const stdioShutdown = () => quitAndDestroy(); + if (usesProcessStdio) { + process.stdin.once("end", stdioShutdown); + process.stdin.once("close", stdioShutdown); + process.stdin.once("error", stdioShutdown); + process.stdout.once("error", stdioShutdown); + process.stderr.once("error", stdioShutdown); + } - const alive = new Promise((resolve) => { - resolveAlive = resolve; + const alive = new Promise((resolve) => { + resolveAlive = resolve; + // Wire the completion resolver before exposing the renderer. A remote + // shutdown can destroy it synchronously from this callback. + opts?.terminal?.onRendererReady?.(renderer); + if (renderer.isDestroyed) return; - render( - () => ( - - { - setTerminalTitleTurnActive(active); - setTerminalProgress(active ? "indeterminate" : "remove"); - }} - triggerNotification={(message, title) => - renderer.triggerNotification(message, title) - } - quitAndDestroy={quitAndDestroy} - registerDispose={(dispose) => { - disposeApp = dispose; - }} - /> - - ), - renderer, - ); - }); + render( + () => ( + + { + setTerminalTitleTurnActive(active); + setTerminalProgress(active ? "indeterminate" : "remove"); + }} + triggerNotification={(message, title) => + opts?.terminal?.notify?.(message, title) ?? + renderer.triggerNotification(message, title) + } + triggerBell={opts?.terminal?.bell ?? ringLocalBell} + copyText={opts?.terminal?.copyText ?? copyToClipboard} + quitAndDestroy={quitAndDestroy} + registerDispose={(dispose) => { + disposeApp = dispose; + }} + /> + + ), + renderer, + ); + }); - await alive; - process.stdin.off("end", stdioShutdown); - process.stdin.off("close", stdioShutdown); - process.stdin.off("error", stdioShutdown); - process.stdout.off("error", stdioShutdown); - process.stderr.off("error", stdioShutdown); + await alive; + if (usesProcessStdio) { + process.stdin.off("end", stdioShutdown); + process.stdin.off("close", stdioShutdown); + process.stdin.off("error", stdioShutdown); + process.stdout.off("error", stdioShutdown); + process.stderr.off("error", stdioShutdown); + } + } catch (error) { + if (!renderer.isDestroyed) renderer.destroy(); + throw error; + } } diff --git a/app/src/app/headless-host.ts b/app/src/app/headless-host.ts index 5c5bc588..9ed0c814 100644 --- a/app/src/app/headless-host.ts +++ b/app/src/app/headless-host.ts @@ -178,6 +178,7 @@ export async function createHeadlessHost( } : {}), triggerNotification: () => false, + triggerBell: () => {}, }; let builtInPlugins: PluginManager | null = null; let externalPlugins: ExternalPluginManager | null = null; diff --git a/app/src/app/headless-model.test.ts b/app/src/app/headless-model.test.ts index c6997707..2c1c3287 100644 --- a/app/src/app/headless-model.test.ts +++ b/app/src/app/headless-model.test.ts @@ -3,6 +3,7 @@ import type { AgentRuntime } from "../runtime/agent-runtime"; import { applyStartupModel, isValidModelSelector, + StartupModelAuthenticationRequiredError, selectStartupModel, } from "./headless-model"; @@ -33,6 +34,23 @@ describe("headless startup model selection", () => { ); }); + test("requests authentication when a known startup provider is unavailable", async () => { + const runtime = { + getAvailableModels: () => + models.filter((model) => model.provider !== "openai"), + setModel: mock(() => {}), + waitForModelAdaptation: mock(async () => {}), + }; + + await expect( + applyStartupModel(runtime as never, "openai/gpt-5.5", { + isKnown: (provider) => provider === "openai", + isAuthenticated: () => false, + }), + ).rejects.toBeInstanceOf(StartupModelAuthenticationRequiredError); + expect(runtime.setModel).not.toHaveBeenCalled(); + }); + test("sets the selected model before waiting for adaptation", async () => { const calls: string[] = []; const runtime = { diff --git a/app/src/app/headless-model.ts b/app/src/app/headless-model.ts index 0830561d..d340d7e0 100644 --- a/app/src/app/headless-model.ts +++ b/app/src/app/headless-model.ts @@ -1,6 +1,13 @@ import type { Api, Model } from "../runtime/agent"; import type { AgentRuntime } from "../runtime/agent-runtime"; +export class StartupModelAuthenticationRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "StartupModelAuthenticationRequiredError"; + } +} + export function isValidModelSelector(selector: string): boolean { return ( selector.includes("/") && @@ -43,8 +50,21 @@ export async function applyStartupModel( "getAvailableModels" | "setModel" | "waitForModelAdaptation" >, selector: string | undefined, + providerAuth?: { + isKnown(provider: string): boolean; + isAuthenticated(provider: string): boolean; + }, ): Promise { if (!selector) return; + const provider = selector.slice(0, selector.indexOf("/")); + if ( + providerAuth?.isKnown(provider) && + !providerAuth.isAuthenticated(provider) + ) { + throw new StartupModelAuthenticationRequiredError( + `Authenticate with ${provider} to use ${selector}.`, + ); + } const model = selectStartupModel(runtime.getAvailableModels(), selector); runtime.setModel(model); await runtime.waitForModelAdaptation(); diff --git a/app/src/app/main.test.ts b/app/src/app/main.test.ts index 6c53eeb2..a4506ee9 100644 --- a/app/src/app/main.test.ts +++ b/app/src/app/main.test.ts @@ -111,12 +111,38 @@ describe("web mode CLI", () => { } }); + test("rejects an invalid canonical public URL", async () => { + const results = await Promise.all([ + runMain(["--web", "--public-url", "ftp://kit.example.com"]), + runMain(["--web", "--public-url", "https://kit.example.com/subpath"]), + ]); + for (const result of results) { + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--public-url expects an HTTP(S) origin"); + } + }); + test("rejects an invalid startup model selector", async () => { const result = await runMain(["--web", "--model", "model-1"]); expect(result.exitCode).toBe(1); expect(result.stdout).toBe(""); expect(result.stderr).toContain("--model expects /"); }); + + test("accepts a startup model selector for the browser TUI", async () => { + const result = await runMain([ + "--web-tui", + "--model", + "openai/gpt-5.5", + "--port", + "0", + ]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "--port expects an integer from 1 to 65535", + ); + expect(result.stderr).not.toContain("does not support --model"); + }); }); describe("interactive mode CLI", () => { @@ -136,6 +162,8 @@ describe("mode selection", () => { runMain(["--print", "--rpc", "hello"]), runMain(["--print", "--web", "hello"]), runMain(["--rpc", "--web"]), + runMain(["--rpc", "--web-tui"]), + runMain(["--web", "--web-tui"]), ]); for (const result of results) { expect(result.exitCode).toBe(1); @@ -177,6 +205,7 @@ describe("RPC mode CLI", () => { const results = await Promise.all([ runMain(["--rpc", "--port", "4782"]), runMain(["--rpc", "--auth", "user:password"]), + runMain(["--rpc", "--public-url", "https://kit.example.com"]), ]); for (const result of results) { expect(result.exitCode).toBe(1); diff --git a/app/src/app/main.tsx b/app/src/app/main.tsx index 125736a8..3f69cc11 100644 --- a/app/src/app/main.tsx +++ b/app/src/app/main.tsx @@ -1,6 +1,7 @@ import { parseArgs } from "node:util"; import { isValidModelSelector } from "./headless-model"; import { buildPrintModePrompt } from "./print-mode-input"; +import { normalizePublicUrl } from "./web-access-policy"; const cliArgs = process.argv.slice(2); const { positionals, values } = parseArgs({ @@ -14,11 +15,13 @@ const { positionals, values } = parseArgs({ model: { type: "string" }, "no-session": { type: "boolean" }, port: { type: "string" }, + "public-url": { type: "string" }, print: { type: "boolean", short: "p" }, rpc: { type: "boolean" }, session: { type: "string", short: "s" }, version: { type: "boolean", short: "v" }, web: { type: "boolean" }, + "web-tui": { type: "boolean" }, }, strict: false, allowPositionals: true, @@ -32,11 +35,16 @@ const hasWebOnlyOptions = values.auth !== undefined || values.host !== undefined || values.port !== undefined || + values["public-url"] !== undefined || values["allow-host"] !== undefined || values["allow-origin"] !== undefined; -const selectedModes = [values.print, values.rpc, values.web].filter( - (value) => value === true, -).length; +const selectedModes = [ + values.print, + values.rpc, + values.web, + values["web-tui"], +].filter((value) => value === true).length; +const webModeFlag = values["web-tui"] === true ? "--web-tui" : "--web"; async function readPipedStdin(): Promise { if (process.stdin.isTTY) return undefined; @@ -76,47 +84,87 @@ function parseBasicAuth(value: unknown): } if (values.mode !== undefined) { - console.error("--mode is no longer supported; use --web or --rpc"); + console.error( + "--mode is no longer supported; use --web, --web-tui, or --rpc", + ); process.exitCode = 1; } else if (selectedModes > 1) { - console.error("kit --print, --rpc, and --web are mutually exclusive"); + console.error( + "kit --print, --rpc, --web, and --web-tui are mutually exclusive", + ); process.exitCode = 1; -} else if (values.web === true) { +} else if (values.web === true || values["web-tui"] === true) { const basicAuth = values.auth === undefined ? undefined : parseBasicAuth(values.auth); const port = typeof values.port === "string" && /^\d+$/.test(values.port) ? Number(values.port) : undefined; + const publicUrl = + typeof values["public-url"] === "string" + ? normalizePublicUrl(values["public-url"]) + : undefined; if ( values.version || (positionals.length > 0 && !hasOnlyNewSessionPositional) ) { console.error( - "kit --web cannot be combined with --version or positional arguments other than new", + `kit ${webModeFlag} cannot be combined with --version or positional arguments other than new`, ); process.exitCode = 1; } else if (selectsNewSession && values.session) { - console.error("kit new --web cannot combine with --session"); + console.error(`kit new ${webModeFlag} cannot combine with --session`); process.exitCode = 1; } else if (values["no-session"] && values.session) { - console.error("kit --web cannot combine --no-session with --session"); + console.error( + `kit ${webModeFlag} cannot combine --no-session with --session`, + ); process.exitCode = 1; } else if (values.auth !== undefined && !basicAuth) { - console.error("kit --web --auth expects :"); + console.error(`kit ${webModeFlag} --auth expects :`); + process.exitCode = 1; + } else if (values["public-url"] !== undefined && !publicUrl) { + console.error( + `kit ${webModeFlag} --public-url expects an HTTP(S) origin without a path, query, credentials, or fragment`, + ); process.exitCode = 1; } else if ( typeof values.model === "string" && !isValidModelSelector(values.model) ) { - console.error("kit --web --model expects /"); + console.error(`kit ${webModeFlag} --model expects /`); process.exitCode = 1; } else if ( values.port !== undefined && (port === undefined || port < 1 || port > 65535) ) { - console.error("kit --web --port expects an integer from 1 to 65535"); + console.error( + `kit ${webModeFlag} --port expects an integer from 1 to 65535`, + ); process.exitCode = 1; + } else if (values["web-tui"] === true) { + const { runWebTuiMode } = await import("./web-tui-mode"); + process.exitCode = await runWebTuiMode({ + allowedHosts: Array.isArray(values["allow-host"]) + ? values["allow-host"].filter( + (host): host is string => typeof host === "string", + ) + : undefined, + allowedOrigins: Array.isArray(values["allow-origin"]) + ? values["allow-origin"].filter( + (origin): origin is string => typeof origin === "string", + ) + : undefined, + basicAuth, + hostname: typeof values.host === "string" ? values.host : undefined, + port, + publicUrl: publicUrl ?? undefined, + model: typeof values.model === "string" ? values.model : undefined, + newSession: selectsNewSession, + noSession: values["no-session"] === true, + sessionId: + typeof values.session === "string" ? values.session : undefined, + }); } else { const { safeProcessCwd } = await import("../process-cwd"); const { runWebMode } = await import("./web-mode"); @@ -134,6 +182,7 @@ if (values.mode !== undefined) { basicAuth, hostname: typeof values.host === "string" ? values.host : undefined, port, + publicUrl: publicUrl ?? undefined, model: typeof values.model === "string" ? values.model : undefined, newSession: selectsNewSession, noSession: values["no-session"] === true, @@ -143,7 +192,7 @@ if (values.mode !== undefined) { } } else if (hasWebOnlyOptions) { console.error( - "--auth, --host, --port, --allow-host, and --allow-origin require --web", + "--auth, --host, --port, --public-url, --allow-host, and --allow-origin require --web or --web-tui", ); process.exitCode = 1; } else if (values.rpc === true) { diff --git a/app/src/app/shutdown-watchdog.ts b/app/src/app/shutdown-watchdog.ts new file mode 100644 index 00000000..591fd9e6 --- /dev/null +++ b/app/src/app/shutdown-watchdog.ts @@ -0,0 +1,42 @@ +type ProcessWithActiveHandles = NodeJS.Process & { + _getActiveHandles?: () => unknown[]; + _getActiveRequests?: () => unknown[]; +}; + +let shutdownWatchdogStarted = false; + +function describeActiveHandle(handle: unknown): string { + if (typeof handle !== "object" || handle === null) return typeof handle; + const constructorName = handle.constructor?.name; + return constructorName || Object.prototype.toString.call(handle); +} + +function reportDanglingHandlesForDebugging(): void { + if (!process.env.KIT_DEBUG_SHUTDOWN) return; + const proc = process as ProcessWithActiveHandles; + const handles = proc._getActiveHandles?.() ?? []; + const requests = proc._getActiveRequests?.() ?? []; + console.error( + `[kit] forcing shutdown with ${handles.length} active handle(s), ${requests.length} active request(s)`, + ); + for (const handle of handles) { + console.error(`[kit] active handle: ${describeActiveHandle(handle)}`); + } + for (const request of requests) { + console.error(`[kit] active request: ${describeActiveHandle(request)}`); + } +} + +/** + * Last-resort process shutdown after the lifecycle owner has completed all + * orderly cleanup. The unref'd timer never delays a naturally draining process. + */ +export function startShutdownWatchdog(exitCode: number): void { + if (shutdownWatchdogStarted) return; + shutdownWatchdogStarted = true; + const shutdownWatchdog = setTimeout(() => { + reportDanglingHandlesForDebugging(); + process.exit(exitCode); + }, 200); + shutdownWatchdog.unref?.(); +} diff --git a/app/src/app/web-access-policy.test.ts b/app/src/app/web-access-policy.test.ts new file mode 100644 index 00000000..60291987 --- /dev/null +++ b/app/src/app/web-access-policy.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { normalizePublicUrl, WebAccessPolicy } from "./web-access-policy"; + +function request( + url: string, + options: { origin?: string; authorization?: string; method?: string } = {}, +): Request { + return new Request(url, { + method: options.method, + headers: { + ...(options.origin ? { origin: options.origin } : {}), + ...(options.authorization + ? { authorization: options.authorization } + : {}), + }, + }); +} + +describe("normalizePublicUrl", () => { + test("accepts HTTP origins and strips a root path", () => { + expect(normalizePublicUrl("https://Kit.Example.com:8443/")).toBe( + "https://kit.example.com:8443", + ); + expect(normalizePublicUrl("http://localhost:4783")).toBe( + "http://localhost:4783", + ); + }); + + test("rejects unsupported or non-origin URLs", () => { + for (const value of [ + "ftp://kit.example.com", + "https://user:secret@kit.example.com", + "https://kit.example.com/subpath", + "https://kit.example.com/?query=yes", + "not a URL", + ]) { + expect(normalizePublicUrl(value)).toBeNull(); + } + }); +}); + +describe("WebAccessPolicy", () => { + test("combines the listener, public URL, and explicit allowlists", () => { + const policy = new WebAccessPolicy({ + authRealm: "Kit test", + publicUrl: "https://kit.example.com", + allowedHosts: ["extra.example.com"], + allowedOrigins: ["https://extra.example.com"], + }); + policy.setListenerAddress("127.0.0.1", 4783); + + expect(policy.isAllowedHost("127.0.0.1:4783")).toBe(true); + expect(policy.isAllowedHost("localhost:4783")).toBe(true); + expect(policy.isAllowedHost("kit.example.com")).toBe(true); + expect(policy.isAllowedHost("extra.example.com")).toBe(true); + expect(policy.isAllowedHost("other.example.com")).toBe(false); + + const internalUrl = new URL("http://127.0.0.1:4783/api/rpc"); + expect( + policy.isAllowedWebSocketRequest( + request(internalUrl.href, { origin: "https://kit.example.com" }), + internalUrl, + ), + ).toBe(true); + expect( + policy.isAllowedWebSocketRequest( + request(internalUrl.href, { origin: "https://attacker.example" }), + internalUrl, + ), + ).toBe(false); + }); + + test("keeps a canonical HTTPS origin authoritative over proxy-reconstructed HTTP", () => { + const policy = new WebAccessPolicy({ + authRealm: "Kit test", + publicUrl: "https://kit.example.com", + }); + const url = new URL("http://kit.example.com/api/rpc"); + expect( + policy.isAllowedWebSocketRequest( + request(url.href, { origin: "https://kit.example.com" }), + url, + ), + ).toBe(true); + expect( + policy.isAllowedWebSocketRequest( + request(url.href, { origin: "http://kit.example.com" }), + url, + ), + ).toBe(false); + }); + + test("does not trust forwarded topology headers", () => { + const policy = new WebAccessPolicy({ + authRealm: "Kit test", + publicUrl: "https://kit.example.com", + }); + policy.setListenerAddress("127.0.0.1", 4783); + const url = new URL("http://proxy.invalid:4783/api/rpc"); + const forwarded = new Request(url, { + headers: { + host: "proxy.invalid:4783", + origin: "https://kit.example.com", + forwarded: "host=kit.example.com;proto=https", + "x-forwarded-host": "kit.example.com", + "x-forwarded-proto": "https", + }, + }); + expect(policy.isAllowedHost(url.host)).toBe(false); + expect(policy.isAllowedWebSocketRequest(forwarded, url)).toBe(false); + }); + + test("validates Basic auth with a timing-safe digest", () => { + const policy = new WebAccessPolicy({ + authRealm: "Kit test", + basicAuth: { username: "user", password: "secret:extra" }, + }); + const authorization = `Basic ${Buffer.from("user:secret:extra").toString("base64")}`; + expect( + policy.isAuthorized(request("http://localhost", { authorization })), + ).toBe(true); + expect(policy.isAuthorized(request("http://localhost"))).toBe(false); + expect( + policy.authenticationRequiredResponse().headers.get("www-authenticate"), + ).toBe('Basic realm="Kit test", charset="UTF-8"'); + }); + + test("adds public and request hosts to CSP WebSocket sources", () => { + const policy = new WebAccessPolicy({ + authRealm: "Kit test", + publicUrl: "https://kit.example.com", + }); + expect( + policy.webSocketConnectSources(new URL("http://127.0.0.1:4783/")), + ).toBe("ws://127.0.0.1:4783 wss://127.0.0.1:4783 wss://kit.example.com"); + }); +}); diff --git a/app/src/app/web-access-policy.ts b/app/src/app/web-access-policy.ts new file mode 100644 index 00000000..bdc2fad6 --- /dev/null +++ b/app/src/app/web-access-policy.ts @@ -0,0 +1,215 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +export type WebBasicAuthCredentials = { + username: string; + password: string; +}; + +export type WebAccessPolicyOptions = { + hostname?: string; + port?: number; + publicUrl?: string; + allowedHosts?: string[]; + allowedOrigins?: string[]; + allowOriginless?: boolean; + basicAuth?: WebBasicAuthCredentials; + authRealm: string; +}; + +function credentialDigest(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function decodeBasicAuthorization(header: string | null): string | null { + const match = header?.match( + /^Basic ((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$/i, + ); + const encoded = match?.[1]; + if (!encoded) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + Buffer.from(encoded, "base64"), + ); + } catch { + return null; + } +} + +export function normalizeWebOrigin(value: string): string | null { + if (value === "null") return value; + try { + const origin = new URL(value).origin.toLowerCase(); + return origin === "null" ? null : origin; + } catch { + return null; + } +} + +/** Normalize a canonical external URL. Kit currently serves only at `/`. */ +export function normalizePublicUrl(value: string): string | null { + if (value.length > 2_048) return null; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + if (url.pathname !== "/" || url.search || url.hash) return null; + return url.origin.toLowerCase(); + } catch { + return null; + } +} + +export class WebAccessPolicy { + private readonly expectedBasicAuthDigest: Buffer | null; + private readonly publicOrigin: string | null; + private listenerHostname: string; + private listenerPort: number; + + constructor(private readonly options: WebAccessPolicyOptions) { + this.listenerHostname = options.hostname ?? "127.0.0.1"; + this.listenerPort = options.port ?? 4782; + this.expectedBasicAuthDigest = options.basicAuth + ? credentialDigest( + `${options.basicAuth.username}:${options.basicAuth.password}`, + ) + : null; + this.publicOrigin = options.publicUrl + ? normalizePublicUrl(options.publicUrl) + : null; + if (options.publicUrl && !this.publicOrigin) { + throw new Error(`Invalid public URL: ${options.publicUrl}`); + } + } + + setListenerAddress(hostname: string, port: number): void { + this.listenerHostname = hostname; + this.listenerPort = port; + } + + isAllowedHost(host: string): boolean { + return ( + this.options.allowedHosts?.includes("*") === true || + this.allowedHosts().has(host.toLowerCase()) + ); + } + + isAllowedWebSocketRequest(request: Request, url: URL): boolean { + return ( + this.isAllowedHost(url.host) && + this.isAllowedOrigin( + request.headers.get("origin"), + url, + this.options.allowOriginless === true, + ) + ); + } + + isAllowedHttpRequest(request: Request, url: URL): boolean { + return ( + this.isAllowedHost(url.host) && + this.isAllowedOrigin( + request.headers.get("origin"), + url, + request.method === "GET" || this.options.allowOriginless === true, + ) + ); + } + + isAuthorized(request: Request): boolean { + if (!this.expectedBasicAuthDigest) return true; + const credentials = decodeBasicAuthorization( + request.headers.get("authorization"), + ); + const actualDigest = credentialDigest(credentials ?? ""); + return timingSafeEqual(this.expectedBasicAuthDigest, actualDigest); + } + + authenticationRequiredResponse(): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "cache-control": "no-store", + "www-authenticate": `Basic realm="${this.options.authRealm}", charset="UTF-8"`, + }, + }); + } + + corsHeaders(request: Request): Record { + const header = request.headers.get("origin"); + const origin = header ? normalizeWebOrigin(header) : null; + return origin + ? { + ...(this.expectedBasicAuthDigest + ? { "access-control-allow-credentials": "true" } + : {}), + "access-control-allow-origin": origin, + vary: "origin", + } + : {}; + } + + webSocketConnectSources(requestUrl: URL): string { + const sources = new Set(); + const publicUrl = this.publicOrigin ? new URL(this.publicOrigin) : null; + if (!publicUrl || publicUrl.host !== requestUrl.host) { + sources.add(`ws://${requestUrl.host}`); + sources.add(`wss://${requestUrl.host}`); + } + if (publicUrl) { + sources.add( + `${publicUrl.protocol === "https:" ? "wss:" : "ws:"}//${publicUrl.host}`, + ); + } + return [...sources].join(" "); + } + + private isAllowedOrigin( + origin: string | null, + requestUrl: URL, + allowOriginless: boolean, + ): boolean { + if (!origin) return allowOriginless; + const normalizedOrigin = normalizeWebOrigin(origin); + if (!normalizedOrigin) return false; + return ( + this.options.allowedOrigins?.includes("*") === true || + this.allowedOrigins(requestUrl).has(normalizedOrigin) + ); + } + + private allowedOrigins(requestUrl: URL): Set { + const publicUrl = this.publicOrigin ? new URL(this.publicOrigin) : null; + // A TLS-terminating proxy may reconstruct the backend URL as HTTP while + // preserving the public Host. In that case the canonical public scheme is + // authoritative; accepting the reconstructed HTTP origin would weaken an + // explicitly configured HTTPS boundary. + const origins = new Set(); + if (!publicUrl || requestUrl.host !== publicUrl.host) { + origins.add(requestUrl.origin.toLowerCase()); + } + if (this.publicOrigin) origins.add(this.publicOrigin); + for (const value of this.options.allowedOrigins ?? []) { + if (value === "*") continue; + const origin = normalizeWebOrigin(value); + if (origin) origins.add(origin); + } + return origins; + } + + private allowedHosts(): Set { + const hosts = new Set( + (this.options.allowedHosts ?? []).map((host) => host.toLowerCase()), + ); + hosts.add(`${this.listenerHostname}:${this.listenerPort}`.toLowerCase()); + if ( + this.listenerHostname === "127.0.0.1" || + this.listenerHostname === "::1" + ) { + hosts.add(`localhost:${this.listenerPort}`); + hosts.add(`127.0.0.1:${this.listenerPort}`); + hosts.add(`[::1]:${this.listenerPort}`); + } + if (this.publicOrigin) hosts.add(new URL(this.publicOrigin).host); + return hosts; + } +} diff --git a/app/src/app/web-mode.ts b/app/src/app/web-mode.ts index 1da4517f..e49ff59f 100644 --- a/app/src/app/web-mode.ts +++ b/app/src/app/web-mode.ts @@ -13,6 +13,7 @@ export type WebModeOptions = { allowedOrigins?: string[]; hostname?: string; port?: number; + publicUrl?: string; model?: string; newSession?: boolean; noSession?: boolean; @@ -95,11 +96,17 @@ export async function runWebMode( port: options.port, allowedHosts: options.allowedHosts, allowedOrigins: options.allowedOrigins, + publicUrl: options.publicUrl, basicAuth: options.basicAuth, attachments, }); const address = webServer.start(); - console.log(`Kit web mode listening at ${address.url}`); + console.log( + `Kit web mode available at ${options.publicUrl ?? address.url}`, + ); + if (options.publicUrl) { + console.log(`Kit web mode listening internally at ${address.url}`); + } await stopped; exitCode = signalExitCode; } catch (error) { diff --git a/app/src/app/web-rpc-server.test.ts b/app/src/app/web-rpc-server.test.ts index 78369741..452faa30 100644 --- a/app/src/app/web-rpc-server.test.ts +++ b/app/src/app/web-rpc-server.test.ts @@ -155,6 +155,7 @@ describe("WebRpcServer", () => { function start( options: { + publicUrl?: string; allowedHosts?: string[]; allowedOrigins?: string[]; basicAuth?: WebBasicAuthCredentials; @@ -197,6 +198,34 @@ describe("WebRpcServer", () => { expect(clientJavaScript).toContain("solid-js"); }); + test("accepts a canonical public Host and Origin without forwarded headers", async () => { + const { address } = start({ publicUrl: "https://kit.example.com" }); + const page = await fetch(address.url, { + headers: { host: "kit.example.com" }, + }); + expect(page.status).toBe(200); + expect(page.headers.get("content-security-policy")).toContain( + "wss://kit.example.com", + ); + const connection = await openWebSocket( + `${address.url.replace("http://", "ws://")}/api/rpc`, + { + host: "kit.example.com", + origin: "https://kit.example.com", + }, + ); + sockets.push(connection.socket); + expect(connection.sync).toMatchObject({ type: "sync", mode: "snapshot" }); + + const forwardedOnly = await fetch(address.url, { + headers: { + host: "proxy.invalid", + forwarded: "host=kit.example.com;proto=https", + }, + }); + expect(forwardedOnly.status).toBe(403); + }); + test("protects HTTP resources and WebSocket upgrades with Basic auth", async () => { const basicAuth = { username: "remote-user", diff --git a/app/src/app/web-rpc-server.ts b/app/src/app/web-rpc-server.ts index 7f3aa539..0095a792 100644 --- a/app/src/app/web-rpc-server.ts +++ b/app/src/app/web-rpc-server.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; // @ts-expect-error: Bun's text loader embeds non-TypeScript browser assets. import micaCss from "@akonwi/mica/mica.css" with { type: "text" }; import jetbrainsMonoItalic from "@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2" with { @@ -27,6 +27,10 @@ import { type RpcEventListener, type RpcWriter, } from "./rpc-session-host"; +import { + WebAccessPolicy, + type WebBasicAuthCredentials, +} from "./web-access-policy"; export type WebRpcHost = { subscribe(listener: RpcEventListener): () => void; @@ -34,14 +38,12 @@ export type WebRpcHost = { getConnectionSnapshot(maxMessages?: number): RpcConnectionSnapshot; }; -export type WebBasicAuthCredentials = { - username: string; - password: string; -}; +export type { WebBasicAuthCredentials } from "./web-access-policy"; export type WebRpcServerOptions = { hostname?: string; port?: number; + publicUrl?: string; allowedHosts?: string[]; allowedOrigins?: string[]; allowOriginless?: boolean; @@ -142,37 +144,11 @@ function parseError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function credentialDigest(value: string): Buffer { - return createHash("sha256").update(value, "utf8").digest(); -} - -function normalizeOrigin(value: string): string | null { - if (value === "null") return value; - try { - const origin = new URL(value).origin.toLowerCase(); - return origin === "null" ? null : origin; - } catch { - return null; - } -} - -function decodeBasicAuthorization(header: string | null): string | null { - const match = header?.match( - /^Basic ((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$/i, - ); - const encoded = match?.[1]; - if (!encoded) return null; - try { - return new TextDecoder("utf-8", { fatal: true }).decode( - Buffer.from(encoded, "base64"), - ); - } catch { - return null; - } -} - -function webDocumentHeaders(url: URL): HeadersInit { - const webSocketOrigins = `ws://${url.host} wss://${url.host}`; +function webDocumentHeaders( + url: URL, + accessPolicy: WebAccessPolicy, +): HeadersInit { + const webSocketOrigins = accessPolicy.webSocketConnectSources(url); return { "content-type": "text/html; charset=utf-8", "content-security-policy": [ @@ -221,18 +197,17 @@ export class WebRpcServer { private readonly journal: RemoteEventJournal; private readonly persistentToasts = new Map(); private readonly messageChunkTokens = new Map(); - private readonly expectedBasicAuthDigest: Buffer | null; + private readonly accessPolicy: WebAccessPolicy; private messageChunkCacheBytes = 0; constructor( private readonly rpcHost: WebRpcHost, private readonly options: WebRpcServerOptions = {}, ) { - this.expectedBasicAuthDigest = options.basicAuth - ? credentialDigest( - `${options.basicAuth.username}:${options.basicAuth.password}`, - ) - : null; + this.accessPolicy = new WebAccessPolicy({ + ...options, + authRealm: "Kit web mode", + }); this.journal = new RemoteEventJournal({ streamId: options.eventStreamId, maxEvents: options.eventHistoryMaxEvents, @@ -252,7 +227,7 @@ export class WebRpcServer { maxRequestBodySize: MAX_MULTIPART_BODY_BYTES, fetch: async (request, bunServer) => { const url = new URL(request.url); - if (!this.isAllowedHost(url.host)) { + if (!this.accessPolicy.isAllowedHost(url.host)) { return new Response("Host not allowed", { status: 403 }); } const isAttachmentRequest = @@ -260,16 +235,18 @@ export class WebRpcServer { url.pathname.startsWith("/api/attachments/"); const isWebSocketRequest = url.pathname === "/api/rpc"; if ( - (isAttachmentRequest && !this.isAllowedHttpRequest(request, url)) || - (isWebSocketRequest && !this.isAllowedWebSocketRequest(request, url)) + (isAttachmentRequest && + !this.accessPolicy.isAllowedHttpRequest(request, url)) || + (isWebSocketRequest && + !this.accessPolicy.isAllowedWebSocketRequest(request, url)) ) { return new Response("Origin or host not allowed", { status: 403 }); } if (isAttachmentRequest && request.method === "OPTIONS") { return this.handleAttachmentRequest(request, url); } - if (!this.isAuthorized(request)) { - return this.authenticationRequiredResponse(); + if (!this.accessPolicy.isAuthorized(request)) { + return this.accessPolicy.authenticationRequiredResponse(); } if (url.pathname === "/assets/client.js") { return new Response(await webClientJavaScript(), { @@ -312,7 +289,7 @@ export class WebRpcServer { } if (url.pathname === "/") { return new Response(clientHtml as unknown as string, { - headers: webDocumentHeaders(url), + headers: webDocumentHeaders(url, this.accessPolicy), }); } return new Response("Not found", { status: 404 }); @@ -356,6 +333,7 @@ export class WebRpcServer { const port = server.port; if (port === undefined) throw new Error("Web RPC server did not bind a port"); + this.accessPolicy.setListenerAddress(hostname, port); return { hostname, port, url: server.url.origin }; } @@ -911,10 +889,10 @@ export class WebRpcServer { request: Request, url: URL, ): Promise { - if (!this.isAllowedHttpRequest(request, url)) { + if (!this.accessPolicy.isAllowedHttpRequest(request, url)) { return new Response("Origin or host not allowed", { status: 403 }); } - const corsHeaders = this.corsHeaders(request); + const corsHeaders = this.accessPolicy.corsHeaders(request); if (request.method === "OPTIONS") { return new Response(null, { status: 204, @@ -1022,85 +1000,6 @@ export class WebRpcServer { }); } - private isAuthorized(request: Request): boolean { - if (!this.expectedBasicAuthDigest) return true; - const credentials = decodeBasicAuthorization( - request.headers.get("authorization"), - ); - const actualDigest = credentialDigest(credentials ?? ""); - return timingSafeEqual(this.expectedBasicAuthDigest, actualDigest); - } - - private authenticationRequiredResponse(): Response { - return new Response("Authentication required", { - status: 401, - headers: { - "cache-control": "no-store", - "www-authenticate": 'Basic realm="Kit web mode", charset="UTF-8"', - }, - }); - } - - private isAllowedWebSocketRequest(request: Request, url: URL): boolean { - return ( - this.isAllowedHost(url.host) && - this.isAllowedOrigin( - request.headers.get("origin"), - url, - this.options.allowOriginless === true, - ) - ); - } - - private isAllowedHttpRequest(request: Request, url: URL): boolean { - return ( - this.isAllowedHost(url.host) && - this.isAllowedOrigin( - request.headers.get("origin"), - url, - request.method === "GET" || this.options.allowOriginless === true, - ) - ); - } - - private isAllowedOrigin( - origin: string | null, - url: URL, - allowOriginless: boolean, - ): boolean { - if (!origin) return allowOriginless; - const normalizedOrigin = normalizeOrigin(origin); - if (!normalizedOrigin) return false; - return ( - this.options.allowedOrigins?.includes("*") === true || - this.allowedOrigins(url).has(normalizedOrigin) - ); - } - - private allowedOrigins(url: URL): Set { - const origins = new Set([url.origin.toLowerCase()]); - for (const value of this.options.allowedOrigins ?? []) { - if (value === "*") continue; - const origin = normalizeOrigin(value); - if (origin) origins.add(origin); - } - return origins; - } - - private corsHeaders(request: Request): Record { - const header = request.headers.get("origin"); - const origin = header ? normalizeOrigin(header) : null; - return origin - ? { - ...(this.expectedBasicAuthDigest - ? { "access-control-allow-credentials": "true" } - : {}), - "access-control-allow-origin": origin, - vary: "origin", - } - : {}; - } - private projectRecord(record: unknown): unknown { if (isRecord(record) && record.type === "agent.end") { const projected = { ...record }; @@ -1144,27 +1043,4 @@ export class WebRpcServer { ancestors.delete(value); } } - - private isAllowedHost(host: string): boolean { - return ( - this.options.allowedHosts?.includes("*") === true || - this.allowedHosts().has(host.toLowerCase()) - ); - } - - private allowedHosts(): Set { - const hostname = - this.server?.hostname ?? this.options.hostname ?? "127.0.0.1"; - const port = this.server?.port ?? this.options.port ?? 4782; - const hosts = new Set( - (this.options.allowedHosts ?? []).map((host) => host.toLowerCase()), - ); - hosts.add(`${hostname}:${port}`.toLowerCase()); - if (hostname === "127.0.0.1" || hostname === "::1") { - hosts.add(`localhost:${port}`); - hosts.add(`127.0.0.1:${port}`); - hosts.add(`[::1]:${port}`); - } - return hosts; - } } diff --git a/app/src/app/web-tui-bridge.test.ts b/app/src/app/web-tui-bridge.test.ts new file mode 100644 index 00000000..4024ff81 --- /dev/null +++ b/app/src/app/web-tui-bridge.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { + clampTuiSize, + type TuiOutputSink, + type TuiRendererControl, + WebTuiBridge, +} from "./web-tui-bridge"; + +function collectingSink(): TuiOutputSink & { chunks: Uint8Array[] } { + const chunks: Uint8Array[] = []; + return { + chunks, + send: (bytes) => { + chunks.push(bytes); + }, + }; +} + +function rendererStub(): TuiRendererControl & { calls: string[] } { + const calls: string[] = []; + return { + calls, + resize: (width, height) => calls.push(`resize:${width}x${height}`), + suspend: () => calls.push("suspend"), + resume: () => calls.push("resume"), + destroy: () => calls.push("destroy"), + }; +} + +describe("clampTuiSize", () => { + test("clamps and floors dimensions", () => { + expect(clampTuiSize(0, 0)).toEqual({ cols: 20, rows: 5 }); + expect(clampTuiSize(10_000, 10_000)).toEqual({ cols: 500, rows: 300 }); + expect(clampTuiSize(120.9, 40.2)).toEqual({ cols: 120, rows: 40 }); + }); +}); + +describe("WebTuiBridge", () => { + test("forwards stdout writes to the attached sink only", async () => { + const bridge = new WebTuiBridge(); + const sink = collectingSink(); + bridge.terminal.stdout.write("dropped before attach"); + bridge.attach(sink, 100, 30); + await new Promise((resolve) => { + bridge.terminal.stdout.write("hello", () => resolve()); + }); + const text = Buffer.concat(sink.chunks).toString("utf8"); + expect(text).toBe("hello"); + }); + + test("updates stdout geometry from attach and resize", () => { + const bridge = new WebTuiBridge(); + const terminal = bridge.terminal; + bridge.attach(collectingSink(), 132, 45); + expect(terminal.stdout.columns).toBe(132); + expect(terminal.stdout.rows).toBe(45); + bridge.resize(90, 28); + expect(terminal.stdout.columns).toBe(90); + expect(terminal.stdout.rows).toBe(28); + }); + + test("delivers input bytes through the virtual stdin", async () => { + const bridge = new WebTuiBridge(); + const received: Buffer[] = []; + bridge.terminal.stdin.on("data", (chunk: Buffer) => received.push(chunk)); + expect(bridge.input(new TextEncoder().encode("abc"))).toBe(true); + await new Promise((resolve) => setImmediate(resolve)); + expect(Buffer.concat(received).toString("utf8")).toBe("abc"); + }); + + test("bounds input queued before the renderer is ready", () => { + const bridge = new WebTuiBridge(); + expect(bridge.input(new Uint8Array(64 * 1024))).toBe(true); + expect(bridge.input(new Uint8Array([1]))).toBe(false); + }); + + test("suspends on detach and resumes with resize on reattach", () => { + const bridge = new WebTuiBridge(); + const renderer = rendererStub(); + const sink = collectingSink(); + bridge.attach(sink, 100, 30); + bridge.terminal.onRendererReady?.(renderer); + expect(renderer.calls).toEqual([]); + + bridge.detach(sink); + expect(renderer.calls).toEqual(["suspend"]); + + const nextSink = collectingSink(); + bridge.attach(nextSink, 110, 32); + expect(renderer.calls).toEqual(["suspend", "resume", "resize:110x32"]); + }); + + test("ignores detach from a stale sink", () => { + const bridge = new WebTuiBridge(); + const renderer = rendererStub(); + const active = collectingSink(); + bridge.attach(active, 100, 30); + bridge.terminal.onRendererReady?.(renderer); + bridge.detach(collectingSink()); + expect(renderer.calls).toEqual([]); + }); + + test("parks the renderer when it becomes ready without a client", () => { + const bridge = new WebTuiBridge(); + const renderer = rendererStub(); + bridge.terminal.onRendererReady?.(renderer); + expect(renderer.calls).toEqual(["suspend"]); + bridge.attach(collectingSink(), 100, 30); + expect(renderer.calls).toEqual(["suspend", "resume", "resize:100x30"]); + }); + + test("live resize reaches the renderer, suspended resize does not", () => { + const bridge = new WebTuiBridge(); + const renderer = rendererStub(); + const sink = collectingSink(); + bridge.attach(sink, 100, 30); + bridge.terminal.onRendererReady?.(renderer); + bridge.resize(80, 24); + expect(renderer.calls).toEqual(["resize:80x24"]); + bridge.detach(sink); + bridge.resize(60, 20); + expect(renderer.calls).toEqual(["resize:80x24", "suspend"]); + }); + + test("destroys a renderer that becomes ready after shutdown starts", () => { + const bridge = new WebTuiBridge(); + expect(bridge.shutdown()).toBe(false); + const renderer = rendererStub(); + bridge.terminal.onRendererReady?.(renderer); + expect(renderer.calls).toEqual(["destroy"]); + }); + + test("shutdown resumes a suspended renderer before destroying it", () => { + const bridge = new WebTuiBridge(); + const renderer = rendererStub(); + bridge.terminal.onRendererReady?.(renderer); + expect(bridge.shutdown()).toBe(true); + expect(bridge.shutdown()).toBe(false); + expect(renderer.calls).toEqual(["suspend", "resume", "destroy"]); + }); +}); diff --git a/app/src/app/web-tui-bridge.ts b/app/src/app/web-tui-bridge.ts new file mode 100644 index 00000000..31f2f449 --- /dev/null +++ b/app/src/app/web-tui-bridge.ts @@ -0,0 +1,215 @@ +/** + * Experimental browser-TUI bridge (ghostty-web experiment). + * + * Hosts Kit's real OpenTUI application against virtual terminal streams so a + * browser terminal emulator (ghostty-web + Ghostty WASM) can present it. OpenTUI + * supports custom stdin/stdout streams: with a non-process stdout it pipes + * rendered bytes through a NativeSpanFeed and enables remote mode, which is + * exactly the SSH-like transport this bridge provides. + * + * The bridge owns: + * - a virtual raw-mode stdin the server feeds with browser input bytes + * - a virtual stdout whose writes are forwarded to the attached client + * - attach/detach lifecycle mapped onto OpenTUI suspend/resume so a + * reconnecting browser terminal receives fresh terminal setup sequences and + * a forced full repaint + */ + +import { PassThrough, Writable } from "node:stream"; + +export type TuiOutputSink = { + send(bytes: Uint8Array): void; +}; + +/** Subset of OpenTUI's CliRenderer the bridge drives. */ +export type TuiRendererControl = { + resize(width: number, height: number): void; + suspend(): void; + resume(): void; + destroy(): void; +}; + +export type BridgeTerminal = { + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; + width: number; + height: number; + onRendererReady: (renderer: TuiRendererControl) => void; +}; + +class VirtualStdin extends PassThrough { + readonly isTTY = true; + setRawMode(_mode: boolean): this { + return this; + } + ref(): this { + return this; + } + unref(): this { + return this; + } +} + +class VirtualStdout extends Writable { + readonly isTTY = true; + columns: number; + rows: number; + + constructor( + columns: number, + rows: number, + private readonly deliver: (bytes: Uint8Array) => void, + ) { + super(); + this.columns = columns; + this.rows = rows; + } + + override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + const bytes = + typeof chunk === "string" + ? new TextEncoder().encode(chunk) + : new Uint8Array(chunk as Uint8Array); + this.deliver(bytes); + callback(); + } +} + +export const MIN_TUI_COLS = 20; +export const MAX_TUI_COLS = 500; +export const MIN_TUI_ROWS = 5; +export const MAX_TUI_ROWS = 300; + +export function clampTuiSize( + cols: number, + rows: number, +): { cols: number; rows: number } { + const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, Math.floor(value))); + return { + cols: clamp(cols, MIN_TUI_COLS, MAX_TUI_COLS), + rows: clamp(rows, MIN_TUI_ROWS, MAX_TUI_ROWS), + }; +} + +export class WebTuiBridge { + private sink: TuiOutputSink | null = null; + private renderer: TuiRendererControl | null = null; + private suspended = false; + private shutdownRequested = false; + private cols: number; + private rows: number; + private readonly virtualStdin = new VirtualStdin(); + private readonly virtualStdout: VirtualStdout; + + constructor(cols = 80, rows = 24) { + const size = clampTuiSize(cols, rows); + this.cols = size.cols; + this.rows = size.rows; + this.virtualStdout = new VirtualStdout(this.cols, this.rows, (bytes) => { + this.sink?.send(bytes); + }); + } + + /** Streams and initial geometry for OpenTUI bootstrap. */ + get terminal(): BridgeTerminal { + return { + stdin: this.virtualStdin as unknown as NodeJS.ReadStream, + stdout: this.virtualStdout as unknown as NodeJS.WriteStream, + width: this.cols, + height: this.rows, + onRendererReady: (renderer) => { + this.renderer = renderer; + if (this.shutdownRequested) { + this.renderer = null; + renderer.destroy(); + return; + } + // The client can disconnect while the app is still booting; park + // the renderer until the next attach. + if (!this.sink) { + this.suspended = true; + renderer.suspend(); + } + }, + }; + } + + get hasRenderer(): boolean { + return this.renderer !== null; + } + + get size(): { cols: number; rows: number } { + return { cols: this.cols, rows: this.rows }; + } + + /** + * Attach the single active client. Resuming a suspended renderer replays + * terminal setup (alternate screen, mouse tracking) and forces a full + * repaint, which a freshly created browser-side terminal needs. + */ + attach(sink: TuiOutputSink, cols: number, rows: number): void { + this.sink = sink; + this.setSize(cols, rows); + if (this.renderer && this.suspended) { + this.suspended = false; + this.renderer.resume(); + // Resume restores the previous geometry; apply the (possibly + // unchanged) client geometry after setup has been replayed. + this.renderer.resize(this.cols, this.rows); + } + } + + detach(sink: TuiOutputSink): void { + if (this.sink !== sink) return; + this.sink = null; + if (this.renderer && !this.suspended) { + this.suspended = true; + this.renderer.suspend(); + } + } + + input(bytes: Uint8Array): boolean { + if ( + !this.renderer && + this.virtualStdin.readableLength + bytes.byteLength > 64 * 1024 + ) { + return false; + } + this.virtualStdin.write(Buffer.from(bytes)); + return true; + } + + resize(cols: number, rows: number): void { + this.setSize(cols, rows); + if (this.renderer && !this.suspended) { + this.renderer.resize(this.cols, this.rows); + } + } + + /** Request an orderly application shutdown (renderer owns cleanup). */ + shutdown(): boolean { + this.shutdownRequested = true; + const renderer = this.renderer; + if (!renderer) return false; + this.renderer = null; + if (this.suspended) { + this.suspended = false; + renderer.resume(); + } + renderer.destroy(); + return true; + } + + private setSize(cols: number, rows: number): void { + const size = clampTuiSize(cols, rows); + this.cols = size.cols; + this.rows = size.rows; + this.virtualStdout.columns = this.cols; + this.virtualStdout.rows = this.rows; + } +} diff --git a/app/src/app/web-tui-mode.ts b/app/src/app/web-tui-mode.ts new file mode 100644 index 00000000..4e726657 --- /dev/null +++ b/app/src/app/web-tui-mode.ts @@ -0,0 +1,209 @@ +/** + * Experimental `kit --web-tui` mode (ghostty-web experiment). + * + * Hosts Kit's real OpenTUI application in-process against virtual terminal + * streams and exposes it to a browser terminal (ghostty-web + Ghostty WASM) over a + * WebSocket. Unlike the semantic web mode this process runs exactly one + * authoritative runtime: the OpenTUI App itself. No headless host is created, + * so the persisted session has a single owner. + * + * The application boots lazily on the first client `init` so the browser-side + * Ghostty core is connected while OpenTUI probes terminal capabilities and + * queries the palette. + */ + +import { subscribeThemeConfig } from "../shell/theme"; +import type { ThemeConfig } from "../shell/themes/types"; +import type { BrowserTheme } from "../web-tui/browser-theme"; +import { startShutdownWatchdog } from "./shutdown-watchdog"; +import { WebTuiBridge } from "./web-tui-bridge"; +import { + type WebTuiBasicAuthCredentials, + WebTuiServer, +} from "./web-tui-server"; + +function browserThemeFromConfig(config: ThemeConfig): BrowserTheme { + return { + background: config.tokens.bg, + foreground: config.tokens.textPrimary, + cursor: config.tokens.cursor, + selectionBackground: config.tokens.bgAccent, + statusBackground: config.tokens.bgSurface, + statusForeground: config.tokens.textSecondary, + statusBorder: config.tokens.borderDefault, + }; +} + +export type WebTuiModeOptions = { + allowedHosts?: string[]; + allowedOrigins?: string[]; + basicAuth?: WebTuiBasicAuthCredentials; + hostname?: string; + port?: number; + publicUrl?: string; + model?: string; + newSession?: boolean; + noSession?: boolean; + sessionId?: string; +}; + +export async function runWebTuiMode( + options: WebTuiModeOptions = {}, +): Promise { + const bridge = new WebTuiBridge(); + let appPromise: Promise | null = null; + let appFailed = false; + let signalExitCode = 0; + let forcedShutdownTimer: ReturnType | null = null; + let stop: (() => void) | undefined; + const stopped = new Promise((resolve) => { + stop = resolve; + }); + + const startApp = () => { + if (appPromise) return; + appPromise = import("./bootstrap") + .then(({ bootstrap }) => + bootstrap({ + startupModel: options.model, + newSession: options.newSession, + noSession: options.noSession, + sessionId: options.sessionId, + terminal: { + ...bridge.terminal, + copyText: (text) => server.copyText(text), + notify: (message, title) => server.notify(message, title), + bell: (isError) => server.bell(isError), + }, + }), + ) + .catch((error) => { + appFailed = true; + console.error( + `[kit] web TUI application failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + stop?.(); + }); + }; + + const server = new WebTuiServer( + { + attach: (client, cols, rows) => { + bridge.attach(client, cols, rows); + startApp(); + }, + detach: (client) => bridge.detach(client), + input: (bytes) => bridge.input(bytes), + resize: (cols, rows) => bridge.resize(cols, rows), + }, + { + hostname: options.hostname, + port: options.port, + allowedHosts: options.allowedHosts, + allowedOrigins: options.allowedOrigins, + publicUrl: options.publicUrl, + basicAuth: options.basicAuth, + }, + ); + + const unsubscribeTheme = subscribeThemeConfig( + (config) => { + server.setBrowserTheme(browserThemeFromConfig(config)); + }, + { emitCurrent: false }, + ); + + const handleSignal = (signal: "SIGINT" | "SIGTERM") => { + if (signalExitCode !== 0) { + console.error("[kit] forcing web TUI shutdown after repeated signal"); + process.exit(signalExitCode); + } + signalExitCode = signal === "SIGINT" ? 130 : 143; + // A broken native integration or app disposer must not make the process + // permanently unkillable. Normal cleanup cancels this emergency fallback. + forcedShutdownTimer = setTimeout(() => { + console.error("[kit] forcing web TUI shutdown after cleanup timed out"); + process.exit(signalExitCode); + }, 10_000); + // Ask the hosted app to shut down cleanly. If bootstrap is still + // creating the renderer, the bridge remembers the request and destroys + // it from onRendererReady; appPromise then resolves `stopped`. + if (!bridge.shutdown() && !appPromise) stop?.(); + }; + const handleSigint = () => handleSignal("SIGINT"); + const handleSigterm = () => handleSignal("SIGTERM"); + process.on("SIGINT", handleSigint); + process.on("SIGTERM", handleSigterm); + + let exitCode = 0; + try { + if ( + !options.basicAuth && + (options.allowedHosts?.includes("*") === true || + options.allowedOrigins?.includes("*") === true) + ) { + console.warn( + "Warning: wildcard web access is enabled without --auth; rely on a trusted network or access-control proxy.", + ); + } + const started = server.start(); + console.error( + `kit web TUI mode (experimental) available at ${options.publicUrl ?? started.url}`, + ); + if (options.publicUrl) { + console.error(`Internal listener: ${started.url}`); + } + console.error( + "The OpenTUI application starts when the first browser client connects.", + ); + await stopped; + if (appFailed) exitCode = 1; + } catch (error) { + console.error( + `kit --web-tui failed: ${error instanceof Error ? error.message : String(error)}`, + ); + exitCode = 1; + } finally { + unsubscribeTheme(); + const shutdownErrors: unknown[] = []; + try { + bridge.shutdown(); + } catch (error) { + shutdownErrors.push(error); + } + try { + await appPromise; + } catch (error) { + shutdownErrors.push(error); + } + try { + await server.stop(); + } catch (error) { + shutdownErrors.push(error); + } + if (forcedShutdownTimer) clearTimeout(forcedShutdownTimer); + // Keep intercepting repeated signals until every cleanup attempt finishes. + process.off("SIGINT", handleSigint); + process.off("SIGTERM", handleSigterm); + if (shutdownErrors.length === 0) { + if (process.env.KIT_DEBUG_SHUTDOWN) { + console.error("[kit] web TUI shutdown complete"); + } + } else { + exitCode = 1; + for (const error of shutdownErrors) { + console.error( + `[kit] web TUI shutdown failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + const finalExitCode = signalExitCode !== 0 ? signalExitCode : exitCode; + process.exitCode = finalExitCode; + // Bootstrap deliberately does not own process exit for custom terminals. + // Arm the fallback only after renderer/session and server cleanup complete. + startShutdownWatchdog(finalExitCode); + return finalExitCode; +} diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts new file mode 100644 index 00000000..96912858 --- /dev/null +++ b/app/src/app/web-tui-server.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { WEB_TUI_PROTOCOL_VERSION } from "../web-tui/browser-actions"; +import { + type WebTuiClient, + type WebTuiHost, + WebTuiServer, +} from "./web-tui-server"; + +type HostLog = { + attached: { client: WebTuiClient; cols: number; rows: number }[]; + detached: WebTuiClient[]; + inputs: Uint8Array[]; + resizes: { cols: number; rows: number }[]; +}; + +function recordingHost(): WebTuiHost & { log: HostLog } { + const log: HostLog = { attached: [], detached: [], inputs: [], resizes: [] }; + return { + log, + attach: (client, cols, rows) => log.attached.push({ client, cols, rows }), + detach: (client) => log.detached.push(client), + input: (bytes) => { + log.inputs.push(bytes); + return true; + }, + resize: (cols, rows) => log.resizes.push({ cols, rows }), + }; +} + +const servers: WebTuiServer[] = []; + +function startServer( + host: WebTuiHost, + options?: ConstructorParameters[1], +): { server: WebTuiServer; origin: string; wsUrl: string } { + const server = new WebTuiServer(host, { port: 0, ...options }); + servers.push(server); + const started = server.start(); + const origin = `http://127.0.0.1:${started.port}`; + return { server, origin, wsUrl: `ws://127.0.0.1:${started.port}/api/tui` }; +} + +afterEach(async () => { + for (const server of servers.splice(0)) await server.stop(); +}); + +const WebSocketWithOptions = WebSocket as unknown as new ( + url: string, + options?: Bun.WebSocketOptions, +) => WebSocket; + +function openSocket( + url: string, + options?: { headers?: Record }, +): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocketWithOptions(url, options); + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => resolve(socket)); + socket.addEventListener("error", (event) => + reject(new Error(`WebSocket failed: ${String(event)}`)), + ); + }); +} + +function initControl(cols: number, rows: number): string { + return JSON.stringify({ + type: "init", + cols, + rows, + protocolVersion: WEB_TUI_PROTOCOL_VERSION, + }); +} + +function nextClose(socket: WebSocket): Promise { + return new Promise((resolve) => + socket.addEventListener("close", resolve, { once: true }), + ); +} + +async function waitFor( + condition: () => boolean, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error("timed out waiting"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe("WebTuiServer HTTP", () => { + test("serves the TUI document with a wasm-capable same-origin CSP", async () => { + const { origin } = startServer(recordingHost()); + const response = await fetch(`${origin}/`); + expect(response.status).toBe(200); + const csp = response.headers.get("content-security-policy") ?? ""; + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(csp).toContain("frame-ancestors 'none'"); + const html = await response.text(); + expect(html).toContain('id="terminal"'); + expect(html).toContain("/assets/tui-client.js"); + }); + + test("serves first-party terminal assets", async () => { + const { origin } = startServer(recordingHost()); + const css = await fetch(`${origin}/assets/tui.css`); + expect(css.status).toBe(200); + expect(await css.text()).toContain("#terminal"); + const wasm = await fetch(`${origin}/assets/ghostty-vt.wasm`); + expect(wasm.status).toBe(200); + expect(wasm.headers.get("content-type")).toBe("application/wasm"); + const bytes = new Uint8Array(await wasm.arrayBuffer()); + // WASM magic number: \0asm + expect([...bytes.slice(0, 4)]).toEqual([0x00, 0x61, 0x73, 0x6d]); + }); + + test("reports health with experimental marker", async () => { + const { origin } = startServer(recordingHost()); + const health = await fetch(`${origin}/api/health`); + expect(await health.json()).toEqual({ + ok: true, + mode: "web-tui", + experimental: true, + clients: 0, + }); + }); + + test("derives hosted Host, Origin, and CSP sources from a public URL", async () => { + const { origin, wsUrl } = startServer(recordingHost(), { + publicUrl: "https://kit.example.com", + }); + const page = await fetch(origin, { + headers: { host: "kit.example.com" }, + }); + expect(page.status).toBe(200); + expect(page.headers.get("content-security-policy")).toContain( + "wss://kit.example.com", + ); + const socket = await openSocket(wsUrl, { + headers: { + host: "kit.example.com", + origin: "https://kit.example.com", + }, + }); + expect(socket.readyState).toBe(WebSocket.OPEN); + socket.close(); + const insecureOrigin = await fetch(`${origin}/api/tui`, { + headers: { + host: "kit.example.com", + origin: "http://kit.example.com", + }, + }); + expect(insecureOrigin.status).toBe(403); + + const forwardedOnly = await fetch(origin, { + headers: { + host: "proxy.invalid", + "x-forwarded-host": "kit.example.com", + "x-forwarded-proto": "https", + }, + }); + expect(forwardedOnly.status).toBe(403); + }); + + test("rejects disallowed Host headers", async () => { + const { origin } = startServer(recordingHost()); + const response = await fetch(`${origin}/`, { + headers: { host: "evil.example:80" }, + }); + expect(response.status).toBe(403); + }); + + test("requires basic authentication when configured", async () => { + const { origin } = startServer(recordingHost(), { + basicAuth: { username: "kit", password: "secret" }, + }); + const denied = await fetch(`${origin}/`); + expect(denied.status).toBe(401); + expect(denied.headers.get("www-authenticate")).toContain("Basic"); + const allowed = await fetch(`${origin}/`, { + headers: { + authorization: `Basic ${Buffer.from("kit:secret").toString("base64")}`, + }, + }); + expect(allowed.status).toBe(200); + }); + + test("returns 404 for unknown paths", async () => { + const { origin } = startServer(recordingHost()); + expect((await fetch(`${origin}/unknown`)).status).toBe(404); + }); +}); + +describe("WebTuiServer WebSocket", () => { + test("rejects upgrades from disallowed origins", async () => { + const { origin } = startServer(recordingHost()); + const response = await fetch(`${origin}/api/tui`, { + headers: { + origin: "https://evil.example", + upgrade: "websocket", + connection: "Upgrade", + }, + }); + expect(response.status).toBe(403); + }); + + test("rejects stale protocols without evicting the active browser", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const active = await openSocket(wsUrl, { headers: { origin } }); + active.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + + const legacy = await openSocket(wsUrl, { headers: { origin } }); + const legacyClosed = nextClose(legacy); + legacy.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + expect((await legacyClosed).code).toBe(4001); + + const incompatible = await openSocket(wsUrl, { headers: { origin } }); + const incompatibleClosed = nextClose(incompatible); + incompatible.send( + JSON.stringify({ + type: "init", + cols: 80, + rows: 24, + protocolVersion: WEB_TUI_PROTOCOL_VERSION + 1, + }), + ); + expect((await incompatibleClosed).code).toBe(4002); + expect(host.log.attached).toHaveLength(1); + expect(host.log.detached).toHaveLength(0); + + active.send(new TextEncoder().encode("still active")); + await waitFor(() => host.log.inputs.length === 1); + active.close(); + }); + + test("caps sockets that have not completed the init handshake", async () => { + const { wsUrl, origin } = startServer(recordingHost()); + const sockets: WebSocket[] = []; + for (let index = 0; index < 8; index += 1) { + sockets.push(await openSocket(wsUrl, { headers: { origin } })); + } + const overflow = await openSocket(wsUrl, { headers: { origin } }); + const overflowClosed = nextClose(overflow); + expect((await overflowClosed).code).toBe(1013); + for (const socket of sockets) socket.close(); + }); + + test("attaches on init, forwards input and resize, detaches on close", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + + socket.send(initControl(120, 40)); + await waitFor(() => host.log.attached.length === 1); + expect(host.log.attached[0]).toMatchObject({ cols: 120, rows: 40 }); + + socket.send(new TextEncoder().encode("k")); + await waitFor(() => host.log.inputs.length === 1); + expect(Buffer.from(host.log.inputs[0] ?? []).toString("utf8")).toBe("k"); + + socket.send(JSON.stringify({ type: "resize", cols: 90, rows: 30 })); + await waitFor(() => host.log.resizes.length === 1); + expect(host.log.resizes[0]).toEqual({ cols: 90, rows: 30 }); + + const closed = nextClose(socket); + socket.close(); + await closed; + await waitFor(() => host.log.detached.length === 1); + expect(host.log.detached[0]).toBe(host.log.attached[0]?.client); + }); + + test("sends the current browser theme on init and on later changes", async () => { + const host = recordingHost(); + const { server, wsUrl, origin } = startServer(host); + const firstTheme = { + background: "#0a0a0a", + foreground: "#fafafa", + cursor: "#fafafa", + selectionBackground: "#404040", + statusBackground: "#171717", + statusForeground: "#d4d4d4", + statusBorder: "#404040", + }; + server.setBrowserTheme(firstTheme); + const socket = await openSocket(wsUrl, { headers: { origin } }); + const messages: string[] = []; + socket.addEventListener("message", (event) => { + if (typeof event.data === "string") messages.push(event.data); + }); + socket.send(initControl(80, 24)); + await waitFor(() => messages.length === 1); + expect(JSON.parse(messages[0] ?? "null")).toEqual({ + type: "theme", + theme: firstTheme, + }); + + const nextTheme = { ...firstTheme, background: "#fdf6e3" }; + server.setBrowserTheme(nextTheme); + await waitFor(() => messages.length === 2); + expect(JSON.parse(messages[1] ?? "null")).toEqual({ + type: "theme", + theme: nextTheme, + }); + socket.close(); + }); + + test("routes bounded notifications and bells only to the active browser", async () => { + const host = recordingHost(); + const { server, wsUrl, origin } = startServer(host); + expect(server.notify("before init")).toBe(false); + server.bell(false); + + const socket = await openSocket(wsUrl, { headers: { origin } }); + const messages: unknown[] = []; + socket.addEventListener("message", (event) => { + if (typeof event.data === "string") messages.push(JSON.parse(event.data)); + }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + expect(server.notify(" Agent turn complete ", " Kit ")).toBe(true); + server.bell(true); + await waitFor(() => messages.length === 2); + expect(messages).toEqual([ + { + type: "notification", + title: "Kit", + message: "Agent turn complete", + }, + { type: "bell", kind: "error" }, + ]); + socket.close(); + }); + + test("routes clipboard writes to the active browser and waits for acknowledgement", async () => { + const host = recordingHost(); + const { server, wsUrl, origin } = startServer(host); + await expect(server.copyText("before init")).rejects.toThrow( + "No browser is connected", + ); + const socket = await openSocket(wsUrl, { headers: { origin } }); + const messages: Array<{ type: string; id: number; text?: string }> = []; + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string") return; + const message = JSON.parse(event.data) as { + type: string; + id: number; + text?: string; + }; + if (message.type === "clipboard-write") messages.push(message); + }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + + const copied = server.copyText("**whole message**"); + await waitFor(() => messages.length === 1); + expect(messages[0]?.text).toBe("**whole message**"); + socket.send( + JSON.stringify({ + type: "clipboard-result", + id: messages[0]?.id, + ok: true, + }), + ); + await expect(copied).resolves.toBeUndefined(); + + const denied = server.copyText("denied"); + await waitFor(() => messages.length === 2); + socket.send( + JSON.stringify({ + type: "clipboard-result", + id: messages[1]?.id, + ok: false, + error: "clipboard permission denied", + }), + ); + await expect(denied).rejects.toThrow("clipboard permission denied"); + socket.close(); + }); + + test("rejects a pending clipboard write when its browser disconnects", async () => { + const host = recordingHost(); + const { server, wsUrl, origin } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + const copied = server.copyText("message"); + const closed = nextClose(socket); + socket.close(); + await closed; + await expect(copied).rejects.toThrow("Browser disconnected before copying"); + }); + + test("delivers host output to the attached client as binary frames", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + const frames: Uint8Array[] = []; + socket.addEventListener("message", (event) => { + if (event.data instanceof ArrayBuffer) { + frames.push(new Uint8Array(event.data)); + } + }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + host.log.attached[0]?.client.send(new TextEncoder().encode("\x1b[2Jhi")); + await waitFor(() => frames.length === 1); + expect(Buffer.from(frames[0] ?? []).toString("utf8")).toBe("\x1b[2Jhi"); + socket.close(); + }); + + test("ignores input and resize before init", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + socket.send(new TextEncoder().encode("early")); + socket.send(JSON.stringify({ type: "resize", cols: 90, rows: 30 })); + socket.send(initControl(100, 30)); + await waitFor(() => host.log.attached.length === 1); + expect(host.log.inputs.length).toBe(0); + expect(host.log.resizes.length).toBe(0); + socket.close(); + }); + + test("clamps init geometry and treats repeat init as resize", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + socket.send(initControl(10_000, 1)); + await waitFor(() => host.log.attached.length === 1); + expect(host.log.attached[0]).toMatchObject({ cols: 500, rows: 5 }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.resizes.length === 1); + expect(host.log.attached.length).toBe(1); + socket.close(); + }); + + test("stop terminates an active client and releases the listener", async () => { + const host = recordingHost(); + const { server, origin, wsUrl } = startServer(host); + const socket = await openSocket(wsUrl, { headers: { origin } }); + socket.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + + await Promise.race([ + server.stop(), + Bun.sleep(2_000).then(() => { + throw new Error("server stop timed out"); + }), + ]); + expect(host.log.detached).toHaveLength(1); + const url = new URL(origin); + const probe = Bun.serve({ + hostname: url.hostname, + port: Number(url.port), + fetch: () => new Response("ok"), + }); + await probe.stop(true); + }); + + test("a newer client replaces the active one with close code 4001", async () => { + const host = recordingHost(); + const { wsUrl, origin } = startServer(host); + const first = await openSocket(wsUrl, { headers: { origin } }); + first.send(initControl(80, 24)); + await waitFor(() => host.log.attached.length === 1); + + const firstClosed = nextClose(first); + const second = await openSocket(wsUrl, { headers: { origin } }); + expect(host.log.detached).toHaveLength(0); + second.send(initControl(100, 40)); + const closeEvent = await firstClosed; + expect(closeEvent.code).toBe(4001); + await waitFor(() => host.log.detached.length === 1); + await waitFor(() => host.log.attached.length === 2); + second.close(); + }); +}); diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts new file mode 100644 index 00000000..adb67a13 --- /dev/null +++ b/app/src/app/web-tui-server.ts @@ -0,0 +1,562 @@ +/** + * Experimental browser-TUI server (ghostty-web experiment). + * + * Serves a same-origin ghostty-web terminal page and bridges one WebSocket client to + * the in-process OpenTUI application hosted by `WebTuiBridge`. Request + * security intentionally mirrors `WebRpcServer`: Host allowlisting, Origin + * validation, optional Basic authentication, restrictive CSP, and + * first-party-only asset delivery. The only CSP delta from the SPA document is + * `'wasm-unsafe-eval'`, required to instantiate the Ghostty terminal core. + */ + +import jetbrainsMonoItalic from "@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2" with { + type: "file", +}; +import jetbrainsMonoNormal from "@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2" with { + type: "file", +}; +import type { Server, ServerWebSocket } from "bun"; +import ghosttyWasm from "ghostty-web/ghostty-vt.wasm" with { type: "file" }; +import { + MAX_BROWSER_CLIPBOARD_BYTES, + WEB_TUI_PROTOCOL_VERSION, +} from "../web-tui/browser-actions"; +import type { BrowserTheme } from "../web-tui/browser-theme"; +import tuiHtml from "../web-tui/index.html" with { type: "text" }; +// @ts-expect-error: Bun's text loader embeds non-TypeScript browser assets. +import tuiCss from "../web-tui/tui.css" with { type: "text" }; +import { + WebAccessPolicy, + type WebBasicAuthCredentials, +} from "./web-access-policy"; +import { clampTuiSize } from "./web-tui-bridge"; + +export type { WebBasicAuthCredentials as WebTuiBasicAuthCredentials } from "./web-access-policy"; + +export type WebTuiClient = { + send(bytes: Uint8Array): void; +}; + +/** Terminal host driven by the server; implemented by WebTuiBridge. */ +export type WebTuiHost = { + attach(client: WebTuiClient, cols: number, rows: number): void; + detach(client: WebTuiClient): void; + input(bytes: Uint8Array): boolean; + resize(cols: number, rows: number): void; +}; + +export type WebTuiServerOptions = { + hostname?: string; + port?: number; + publicUrl?: string; + allowedHosts?: string[]; + allowedOrigins?: string[]; + allowOriginless?: boolean; + basicAuth?: WebBasicAuthCredentials; +}; + +type WebSocketData = { + client: WebTuiClient | null; + initTimer: ReturnType | null; +}; + +declare const __KIT_WEB_TUI_CLIENT_JS__: string | undefined; + +let developmentTuiClient: Promise | null = null; + +function webTuiClientJavaScript(): Promise { + if (typeof __KIT_WEB_TUI_CLIENT_JS__ === "string") { + return Promise.resolve(__KIT_WEB_TUI_CLIENT_JS__); + } + const developmentBuilderUrl = new URL( + "../web-tui/build-tui-client.ts", + import.meta.url, + ).href; + developmentTuiClient ??= import(developmentBuilderUrl).then( + ({ buildWebTuiClient }: typeof import("../web-tui/build-tui-client")) => + buildWebTuiClient(), + ); + return developmentTuiClient; +} + +const MAX_WEB_TUI_CONNECTIONS = 8; +const WEB_TUI_INIT_TIMEOUT_MS = 5_000; + +const TUI_ASSETS = new Map< + string, + { body: string | Blob; contentType: string } +>([ + ["/assets/tui.css", { body: tuiCss, contentType: "text/css; charset=utf-8" }], + [ + "/assets/ghostty-vt.wasm", + { + body: Bun.file(new URL(ghosttyWasm, import.meta.url)), + contentType: "application/wasm", + }, + ], + [ + "/assets/jetbrains-mono-normal.woff2", + { + body: Bun.file(new URL(jetbrainsMonoNormal, import.meta.url)), + contentType: "font/woff2", + }, + ], + [ + "/assets/jetbrains-mono-italic.woff2", + { + body: Bun.file(new URL(jetbrainsMonoItalic, import.meta.url)), + contentType: "font/woff2", + }, + ], +]); + +function tuiDocumentHeaders( + url: URL, + accessPolicy: WebAccessPolicy, +): HeadersInit { + const webSocketOrigins = accessPolicy.webSocketConnectSources(url); + return { + "content-type": "text/html; charset=utf-8", + "content-security-policy": [ + "default-src 'self'", + "base-uri 'none'", + `connect-src 'self' ${webSocketOrigins}`, + "font-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "object-src 'none'", + // wasm-unsafe-eval is required to instantiate the Ghostty VT core. + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self'", + ].join("; "), + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }; +} + +type TerminalControlMessage = { + type: "init" | "resize"; + cols: number; + rows: number; + protocolVersion?: number; +}; +type ClipboardResultMessage = { + type: "clipboard-result"; + id: number; + ok: boolean; + error?: string; +}; +type ControlMessage = TerminalControlMessage | ClipboardResultMessage; + +function parseControlMessage(message: string): ControlMessage | null { + if (message.length > 512) return null; + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if (record.type === "clipboard-result") { + if ( + !Number.isSafeInteger(record.id) || + (record.id as number) <= 0 || + typeof record.ok !== "boolean" || + (record.error !== undefined && + (typeof record.error !== "string" || record.error.length > 256)) + ) { + return null; + } + return record as ClipboardResultMessage; + } + if (record.type !== "init" && record.type !== "resize") return null; + if ( + typeof record.cols !== "number" || + typeof record.rows !== "number" || + !Number.isFinite(record.cols) || + !Number.isFinite(record.rows) + ) { + return null; + } + if ( + record.protocolVersion !== undefined && + !Number.isSafeInteger(record.protocolVersion) + ) { + return null; + } + const size = clampTuiSize(record.cols, record.rows); + return { + type: record.type, + cols: size.cols, + rows: size.rows, + ...(typeof record.protocolVersion === "number" + ? { protocolVersion: record.protocolVersion } + : {}), + }; +} + +export class WebTuiServer { + private server: Server | null = null; + private activeSocket: ServerWebSocket | null = null; + private readonly clients = new Set>(); + private readonly accessPolicy: WebAccessPolicy; + private browserTheme: BrowserTheme | null = null; + private nextClipboardId = 1; + private readonly pendingClipboard = new Map< + number, + { + socket: ServerWebSocket; + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + + constructor( + private readonly host: WebTuiHost, + private readonly options: WebTuiServerOptions = {}, + ) { + this.accessPolicy = new WebAccessPolicy({ + ...options, + authRealm: "Kit web TUI mode", + }); + } + + get clientCount(): number { + return this.activeSocket ? 1 : 0; + } + + setBrowserTheme(theme: BrowserTheme): void { + this.browserTheme = { ...theme }; + const socket = this.activeSocket; + if (socket) this.sendTheme(socket); + } + + notify(message: string, title = "Kit"): boolean { + const socket = this.activeSocket; + if (!socket?.data.client) return false; + const safeTitle = title.trim().slice(0, 100) || "Kit"; + const safeMessage = message.trim().slice(0, 500); + if (!safeMessage) return false; + return this.send( + socket, + JSON.stringify({ + type: "notification", + title: safeTitle, + message: safeMessage, + }), + ); + } + + bell(isError: boolean): void { + const socket = this.activeSocket; + if (!socket?.data.client) return; + this.send( + socket, + JSON.stringify({ + type: "bell", + kind: isError ? "error" : "attention", + }), + ); + } + + copyText(text: string): Promise { + const byteLength = new TextEncoder().encode(text).byteLength; + if (byteLength > MAX_BROWSER_CLIPBOARD_BYTES) { + return Promise.reject( + new Error("Clipboard content exceeds the 1 MiB browser limit"), + ); + } + const socket = this.activeSocket; + if (!socket?.data.client || socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("No browser is connected")); + } + const id = this.nextClipboardId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingClipboard.delete(id); + reject(new Error("Browser clipboard request timed out")); + }, 5_000); + this.pendingClipboard.set(id, { socket, resolve, reject, timer }); + this.send(socket, JSON.stringify({ type: "clipboard-write", id, text })); + }); + } + + start(): { hostname: string; port: number; url: string } { + if (this.server) throw new Error("Web TUI server is already running"); + const server = Bun.serve({ + hostname: this.options.hostname ?? "127.0.0.1", + port: this.options.port ?? 4783, + fetch: async (request, bunServer) => { + const url = new URL(request.url); + if (!this.accessPolicy.isAllowedHost(url.host)) { + return new Response("Host not allowed", { status: 403 }); + } + const isWebSocketRequest = url.pathname === "/api/tui"; + if ( + isWebSocketRequest && + !this.accessPolicy.isAllowedWebSocketRequest(request, url) + ) { + return new Response("Origin or host not allowed", { status: 403 }); + } + if (!this.accessPolicy.isAuthorized(request)) { + return this.accessPolicy.authenticationRequiredResponse(); + } + if (url.pathname === "/assets/tui-client.js") { + return new Response(await webTuiClientJavaScript(), { + headers: { + "cache-control": "no-cache", + "content-type": "text/javascript; charset=utf-8", + "x-content-type-options": "nosniff", + }, + }); + } + const asset = TUI_ASSETS.get(url.pathname); + if (asset) { + return new Response(asset.body, { + headers: { + "cache-control": "no-cache", + "content-type": asset.contentType, + "x-content-type-options": "nosniff", + }, + }); + } + if (url.pathname === "/api/health") { + return Response.json({ + ok: true, + mode: "web-tui", + experimental: true, + clients: this.clientCount, + }); + } + if (isWebSocketRequest) { + if ( + bunServer.upgrade(request, { + data: { client: null, initTimer: null }, + }) + ) { + return undefined; + } + return new Response("WebSocket upgrade required", { status: 426 }); + } + if (url.pathname === "/") { + return new Response(tuiHtml as unknown as string, { + headers: tuiDocumentHeaders(url, this.accessPolicy), + }); + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + maxPayloadLength: 64 * 1024, + backpressureLimit: 16 * 1024 * 1024, + closeOnBackpressureLimit: true, + open: (socket) => { + // A socket is only promoted after a version-compatible init so stale + // reconnect loops cannot evict the valid active browser. + if (this.clients.size >= MAX_WEB_TUI_CONNECTIONS) { + socket.close(1013, "too many browser connections"); + return; + } + this.clients.add(socket); + socket.data.initTimer = setTimeout(() => { + socket.data.initTimer = null; + if (!socket.data.client) socket.close(4003, "init timed out"); + }, WEB_TUI_INIT_TIMEOUT_MS); + }, + message: (socket, message) => { + if (typeof message === "string") { + const control = parseControlMessage(message); + if (!control) return; + if (control.type === "init") { + if (control.protocolVersion !== WEB_TUI_PROTOCOL_VERSION) { + // Pre-version clients understand 4001 as a terminal close and stop + // reconnecting. Version-aware clients reload on 4002. + socket.close( + control.protocolVersion === undefined ? 4001 : 4002, + "browser client update required", + ); + return; + } + if (socket.data.initTimer) { + clearTimeout(socket.data.initTimer); + socket.data.initTimer = null; + } + if (socket.data.client) { + if (this.activeSocket === socket) { + this.host.resize(control.cols, control.rows); + } + return; + } + const previous = this.activeSocket; + this.activeSocket = socket; + if (previous && previous !== socket) { + this.releaseSocket(previous); + previous.close(4001, "replaced by a newer client"); + } + const client: WebTuiClient = { + send: (bytes) => this.send(socket, bytes), + }; + socket.data.client = client; + this.sendTheme(socket); + this.host.attach(client, control.cols, control.rows); + return; + } + if (this.activeSocket !== socket) return; + if (control.type === "clipboard-result") { + this.resolveClipboard(control); + return; + } + if (socket.data.client) { + this.host.resize(control.cols, control.rows); + } + return; + } + if (this.activeSocket !== socket) return; + if (socket.data.client && !this.host.input(new Uint8Array(message))) { + socket.close(1009, "terminal input buffer exceeded"); + } + }, + close: (socket) => { + this.clients.delete(socket); + if (this.activeSocket === socket) this.activeSocket = null; + this.releaseSocket(socket); + }, + }, + }); + this.server = server; + const hostname = server.hostname ?? this.options.hostname ?? "127.0.0.1"; + const port = server.port ?? this.options.port ?? 4783; + this.accessPolicy.setListenerAddress(hostname, port); + return { + hostname, + port, + url: server.url.toString(), + }; + } + + async stop(): Promise { + this.activeSocket = null; + for (const client of this.clients) { + this.releaseSocket(client); + client.terminate(); + } + this.clients.clear(); + const server = this.server; + this.server = null; + if (server) { + let stopError: unknown; + const stopped = server.stop(true).then( + () => true, + (error) => { + stopError = error; + return false; + }, + ); + // Bun can leave this promise pending for a terminated WebSocket peer + // even though force-stop has already closed the listening socket. + const completed = await Promise.race([ + stopped, + Bun.sleep(250).then(() => false), + ]); + if (!completed) { + await this.verifyListenerReleased( + server.hostname ?? this.options.hostname ?? "127.0.0.1", + server.port ?? this.options.port ?? 4783, + ); + } + if (stopError) { + console.error( + `Web TUI server stop failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`, + ); + } + } + } + + private async verifyListenerReleased( + hostname: string, + port: number, + ): Promise { + let probe: Server; + try { + probe = Bun.serve({ + hostname, + port, + fetch: () => new Response("shutdown probe"), + }); + } catch (error) { + throw new Error(`Web TUI server did not release ${hostname}:${port}`, { + cause: error, + }); + } + await probe.stop(true); + } + + private resolveClipboard(result: ClipboardResultMessage): void { + const pending = this.pendingClipboard.get(result.id); + if (!pending || pending.socket !== this.activeSocket) return; + this.pendingClipboard.delete(result.id); + clearTimeout(pending.timer); + if (result.ok) pending.resolve(); + else + pending.reject( + new Error(result.error || "Browser clipboard write failed"), + ); + } + + private rejectClipboardForSocket( + socket: ServerWebSocket, + reason: string, + ): void { + for (const [id, pending] of this.pendingClipboard) { + if (pending.socket !== socket) continue; + this.pendingClipboard.delete(id); + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + } + + private sendTheme(socket: ServerWebSocket): void { + if (!this.browserTheme) return; + this.send( + socket, + JSON.stringify({ type: "theme", theme: this.browserTheme }), + ); + } + + private send( + socket: ServerWebSocket, + data: string | Uint8Array, + ): boolean { + if (socket.readyState !== WebSocket.OPEN) return false; + try { + // Bun returns -1 when the frame was accepted under backpressure and 0 + // only when it was dropped. The configured limit owns hard failure. + if (socket.send(data) !== 0) return true; + } catch (error) { + console.error( + `Web TUI send failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.releaseSocket(socket); + this.clients.delete(socket); + if (this.activeSocket === socket) this.activeSocket = null; + socket.terminate(); + return false; + } + + private releaseSocket(socket: ServerWebSocket): void { + if (socket.data.initTimer) { + clearTimeout(socket.data.initTimer); + socket.data.initTimer = null; + } + this.rejectClipboardForSocket( + socket, + "Browser disconnected before copying", + ); + const client = socket.data.client; + socket.data.client = null; + if (client) this.host.detach(client); + } +} diff --git a/app/src/features/guided-questions/index.tsx b/app/src/features/guided-questions/index.tsx index 7226ea0a..2c84b2c4 100644 --- a/app/src/features/guided-questions/index.tsx +++ b/app/src/features/guided-questions/index.tsx @@ -29,6 +29,7 @@ export function GuidedQuestionsPlugin(kit: InternalPluginAPI): () => void { notify: () => ringBell(false, { notify: kit.system.notify, + bell: kit.system.bell, title: "Kit", message: "Input needed", }), diff --git a/app/src/features/notifications/index.tsx b/app/src/features/notifications/index.tsx index 36f0a986..16748c4d 100644 --- a/app/src/features/notifications/index.tsx +++ b/app/src/features/notifications/index.tsx @@ -10,6 +10,7 @@ function notifyTurnComplete(kit: InternalPluginAPI, turn: Turn | null): void { ); ringBell(isError, { notify: kit.system.notify, + bell: kit.system.bell, title: "Kit", message: isError ? "Agent turn failed" : "Agent turn complete", }); diff --git a/app/src/features/notifications/notifications.test.ts b/app/src/features/notifications/notifications.test.ts new file mode 100644 index 00000000..40c755b9 --- /dev/null +++ b/app/src/features/notifications/notifications.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { ringBell } from "./notifications"; + +describe("ringBell", () => { + test("delegates bell and notification effects to the active host", () => { + const bells: boolean[] = []; + const notifications: Array<{ message: string; title?: string }> = []; + ringBell(true, { + bell: (isError) => bells.push(isError), + notify: (message, title) => { + notifications.push({ message, title }); + return true; + }, + message: "Agent turn failed", + title: "Kit", + }); + expect(bells).toEqual([true]); + expect(notifications).toEqual([ + { message: "Agent turn failed", title: "Kit" }, + ]); + }); +}); diff --git a/app/src/features/notifications/notifications.ts b/app/src/features/notifications/notifications.ts index e2f56059..83e7e744 100644 --- a/app/src/features/notifications/notifications.ts +++ b/app/src/features/notifications/notifications.ts @@ -12,6 +12,7 @@ import { platform } from "node:os"; const FUNK_SOUND_PATH = "/System/Library/Sounds/Funk.aiff"; type TerminalNotifier = (message: string, title?: string) => boolean; +type BellNotifier = (isError: boolean) => void; // ── Bell ──────────────────────────────────────────────────────────── @@ -59,6 +60,11 @@ function triggerTerminalNotification( } } +export function ringLocalBell(isError: boolean): void { + writeBell(); + if (isError) playErrorSound(); +} + /** * Emit BEL for terminal bell/tab indicators and also request a terminal-mediated * notification when available. @@ -67,20 +73,18 @@ export function ringBell( isError: boolean, options?: { notify?: TerminalNotifier; + bell?: BellNotifier; message?: string; title?: string; }, ): void { // OpenTUI notifications are terminal/OS dependent and may be quiet or hidden - // while focused, so keep BEL as the reliable bell/tab indicator. - writeBell(); + // while focused, so keep a host-specific bell/tab indicator. + if (options?.bell) options.bell(isError); + else ringLocalBell(isError); triggerTerminalNotification( options?.notify, options?.message ?? (isError ? "Turn failed" : "Turn complete"), options?.title ?? "Kit", ); - - if (isError) { - playErrorSound(); - } } diff --git a/app/src/features/user-interaction-tools/index.ts b/app/src/features/user-interaction-tools/index.ts index 673e699b..1176eabd 100644 --- a/app/src/features/user-interaction-tools/index.ts +++ b/app/src/features/user-interaction-tools/index.ts @@ -19,6 +19,7 @@ export function UserInteractionToolsPlugin(kit: InternalPluginAPI): void { registerUserInteractionTools(kit, () => ringBell(false, { notify: kit.system.notify, + bell: kit.system.bell, title: "Kit", message: "Input needed", }), diff --git a/app/src/plugins/PluginManager.test.ts b/app/src/plugins/PluginManager.test.ts index 27ce09f3..5b1667e6 100644 --- a/app/src/plugins/PluginManager.test.ts +++ b/app/src/plugins/PluginManager.test.ts @@ -66,6 +66,7 @@ function createPluginContext( subscribe: () => () => {}, }, triggerNotification: () => false, + triggerBell: () => {}, }; } diff --git a/app/src/plugins/api.ts b/app/src/plugins/api.ts index e8c0cd4b..620b555a 100644 --- a/app/src/plugins/api.ts +++ b/app/src/plugins/api.ts @@ -247,11 +247,13 @@ export function createPluginAPI( }, notify: (message: string, title?: string) => ctx.triggerNotification(message, title), + bell: (isError = false) => ctx.triggerBell(isError), }; function notifyUserInteraction(): void { ringBell(false, { notify: ctx.triggerNotification, + bell: ctx.triggerBell, title: "Kit", message: "Input needed", }); diff --git a/app/src/plugins/types.ts b/app/src/plugins/types.ts index 7a47e968..c69575da 100644 --- a/app/src/plugins/types.ts +++ b/app/src/plugins/types.ts @@ -88,6 +88,7 @@ export type PluginContext = { signal?: AbortSignal, ) => Promise; triggerNotification: (message: string, title?: string) => boolean; + triggerBell: (isError: boolean) => void; }; export type InternalPluginSessionAPI = { @@ -123,6 +124,7 @@ export type InternalPluginSystemAPI = { readonly cwd: string; open: (url: string | URL) => Promise; notify: (message: string, title?: string) => boolean; + bell: (isError?: boolean) => void; }; export type InternalPluginEventContext = { diff --git a/app/src/shell/AppShell.tsx b/app/src/shell/AppShell.tsx index 9b9d419c..4b1701f8 100644 --- a/app/src/shell/AppShell.tsx +++ b/app/src/shell/AppShell.tsx @@ -40,7 +40,6 @@ import { import { CommandPalette } from "./CommandPalette"; import { ComposerDock, type ComposerInputMode } from "./ComposerDock"; import type { ChromeContribution } from "./chrome-contributions"; -import { copyToClipboard } from "./clipboard"; import type { ComposerController } from "./composer-controller"; import type { FooterStatusController } from "./footer-status"; import { HeaderBar } from "./HeaderBar"; @@ -93,6 +92,7 @@ export type AppShellProps = { commands: CommandRegistry; controller: ComposerController; attachments: AttachmentsController; + copyText: (text: string) => Promise; footer: FooterStatusController; header: HeaderStatusController; releasesWorkspace: ReleasesWorkspaceController; @@ -291,7 +291,7 @@ function AppShellContent(props: AppShellContentProps) { const selected = selectionMenu(); if (!selected) return; closeSelectionMenu(); - void copyToClipboard(selected.text).catch((error) => { + void props.copyText(selected.text).catch((error) => { props.showToast({ title: "Could not copy selection", subtitle: error instanceof Error ? error.message : String(error), @@ -319,7 +319,7 @@ function AppShellContent(props: AppShellContentProps) { const menu = messageContextMenu(); if (!menu) return; setMessageContextMenu(null); - void copyToClipboard(menu.markdown).catch((error) => { + void props.copyText(menu.markdown).catch((error) => { props.showToast({ title: "Could not copy message", subtitle: error instanceof Error ? error.message : String(error), @@ -1243,6 +1243,7 @@ export function AppShell(props: AppShellProps) { commands={props.commands} controller={props.controller} attachments={props.attachments} + copyText={props.copyText} footer={props.footer} header={props.header} releasesWorkspace={props.releasesWorkspace} diff --git a/app/src/shell/SelectionContextMenu.test.tsx b/app/src/shell/SelectionContextMenu.test.tsx index 4682968d..85947139 100644 --- a/app/src/shell/SelectionContextMenu.test.tsx +++ b/app/src/shell/SelectionContextMenu.test.tsx @@ -92,11 +92,18 @@ test("opens a message menu only for a completed secondary click", () => { gesture.onMouseUp(event); expect(requests).toEqual([{ x: 3, y: 4, markdown: "**original**" }]); + // All-motion mouse tracking may report a drag event for browser jitter. + // Keep the message click valid within a one-cell tolerance. gesture.onMouseDown(event); - gesture.onMouseDrag(event); - gesture.onMouseUp(event); + gesture.onMouseDrag({ ...event, x: 4 } as MouseEvent); + gesture.onMouseUp({ ...event, x: 4 } as MouseEvent); + expect(requests).toHaveLength(2); + + gesture.onMouseDown(event); + gesture.onMouseDrag({ ...event, x: 5 } as MouseEvent); + gesture.onMouseUp({ ...event, x: 5 } as MouseEvent); gesture.onMouseUp(event); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(2); }); test("copies a whole transcript message as Markdown", async () => { diff --git a/app/src/shell/theme.test.ts b/app/src/shell/theme.test.ts index 5b24edd4..23e88337 100644 --- a/app/src/shell/theme.test.ts +++ b/app/src/shell/theme.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { getCurrentThemeConfig, resolveAndApplyTheme } from "./theme"; +import { + getCurrentThemeConfig, + resolveAndApplyTheme, + subscribeThemeConfig, +} from "./theme"; describe("theme config", () => { test("returns the current resolved theme config", async () => { @@ -13,6 +17,21 @@ describe("theme config", () => { expect(config.syntaxPalette.text).toBeString(); }); + test("publishes defensive theme snapshots", async () => { + await resolveAndApplyTheme("system"); + const snapshots: string[] = []; + const unsubscribe = subscribeThemeConfig((config) => { + snapshots.push(config.tokens.bg); + config.tokens.bg = "#123456"; + }); + await resolveAndApplyTheme("system"); + unsubscribe(); + await resolveAndApplyTheme("system"); + + expect(snapshots).toHaveLength(2); + expect(getCurrentThemeConfig().tokens.bg).not.toBe("#123456"); + }); + test("returns defensive copies", async () => { await resolveAndApplyTheme("system"); diff --git a/app/src/shell/theme.ts b/app/src/shell/theme.ts index 10b8e55a..c706368f 100644 --- a/app/src/shell/theme.ts +++ b/app/src/shell/theme.ts @@ -38,6 +38,8 @@ let currentThemeConfig: ThemeConfig = { syntaxPalette: { ...initialTheme.syntaxPalette }, }; +const themeConfigListeners = new Set<(config: ThemeConfig) => void>(); + const [theme, setTheme] = createStore({ ...initialTheme.tokens, modalBackdrop: modalBackdropFromBg(initialTheme.tokens.bg), @@ -275,6 +277,28 @@ export function getCurrentThemeConfig(): ThemeConfig { }; } +/** Subscribe to resolved theme changes without coupling consumers to Solid. */ +export function subscribeThemeConfig( + listener: (config: ThemeConfig) => void, + options: { emitCurrent?: boolean } = {}, +): () => void { + themeConfigListeners.add(listener); + if (options.emitCurrent !== false) listener(getCurrentThemeConfig()); + return () => themeConfigListeners.delete(listener); +} + +function publishThemeConfig(): void { + for (const listener of themeConfigListeners) { + try { + listener(getCurrentThemeConfig()); + } catch (error) { + console.error( + `Theme subscriber failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + async function resolveSystemThemeBase(): Promise { if (cachedSystemTheme) return cloneResolvedTheme(cachedSystemTheme); @@ -362,4 +386,5 @@ export async function resolveAndApplyTheme( }), ); setSyntaxStyle(buildSyntaxStyle(resolved.syntaxPalette)); + publishThemeConfig(); } diff --git a/app/src/shell/transcript/message-context-menu.ts b/app/src/shell/transcript/message-context-menu.ts index 2e251d81..3556243b 100644 --- a/app/src/shell/transcript/message-context-menu.ts +++ b/app/src/shell/transcript/message-context-menu.ts @@ -11,14 +11,24 @@ export function createMessageContextMenuGesture( } { let pressed = false; let dragged = false; + let pressX = 0; + let pressY = 0; return { onMouseDown(event) { pressed = event.button === 2; dragged = false; + pressX = event.x; + pressY = event.y; }, onMouseDrag(event) { - if (pressed && event.button === 2) dragged = true; + if ( + pressed && + event.button === 2 && + (Math.abs(event.x - pressX) > 1 || Math.abs(event.y - pressY) > 1) + ) { + dragged = true; + } }, onMouseUp(event) { const source = markdown(); diff --git a/app/src/web-tui/browser-actions.test.ts b/app/src/web-tui/browser-actions.test.ts new file mode 100644 index 00000000..67ac3969 --- /dev/null +++ b/app/src/web-tui/browser-actions.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_BROWSER_CLIPBOARD_BYTES, + parseBrowserBell, + parseBrowserClipboardWrite, + parseBrowserNotification, +} from "./browser-actions"; + +describe("parseBrowserClipboardWrite", () => { + test("accepts a bounded clipboard action", () => { + expect( + parseBrowserClipboardWrite( + JSON.stringify({ type: "clipboard-write", id: 7, text: "**message**" }), + ), + ).toEqual({ type: "clipboard-write", id: 7, text: "**message**" }); + }); + + test("rejects malformed and oversized actions", () => { + expect(parseBrowserClipboardWrite("not json")).toBeNull(); + expect( + parseBrowserClipboardWrite( + JSON.stringify({ type: "clipboard-write", id: 0, text: "message" }), + ), + ).toBeNull(); + expect( + parseBrowserClipboardWrite( + JSON.stringify({ + type: "clipboard-write", + id: 1, + text: "x".repeat(MAX_BROWSER_CLIPBOARD_BYTES + 1), + }), + ), + ).toBeNull(); + }); +}); + +describe("browser notification actions", () => { + test("parses bounded notification and bell controls", () => { + expect( + parseBrowserNotification( + JSON.stringify({ + type: "notification", + title: "Kit", + message: "Agent turn complete", + }), + ), + ).toEqual({ + type: "notification", + title: "Kit", + message: "Agent turn complete", + }); + expect(parseBrowserBell('{"type":"bell","kind":"error"}')).toEqual({ + type: "bell", + kind: "error", + }); + }); + + test("rejects malformed and oversized notification controls", () => { + expect( + parseBrowserNotification( + JSON.stringify({ type: "notification", title: "Kit", message: "" }), + ), + ).toBeNull(); + expect( + parseBrowserNotification( + JSON.stringify({ + type: "notification", + title: "Kit", + message: "x".repeat(501), + }), + ), + ).toBeNull(); + expect(parseBrowserBell('{"type":"bell","kind":"unknown"}')).toBeNull(); + }); +}); diff --git a/app/src/web-tui/browser-actions.ts b/app/src/web-tui/browser-actions.ts new file mode 100644 index 00000000..ced7f753 --- /dev/null +++ b/app/src/web-tui/browser-actions.ts @@ -0,0 +1,90 @@ +export const WEB_TUI_PROTOCOL_VERSION = 3; +export const MAX_BROWSER_CLIPBOARD_BYTES = 1024 * 1024; + +export type BrowserClipboardWrite = { + type: "clipboard-write"; + id: number; + text: string; +}; + +export type BrowserNotification = { + type: "notification"; + title: string; + message: string; +}; + +export type BrowserBell = { + type: "bell"; + kind: "attention" | "error"; +}; + +export function parseBrowserClipboardWrite( + message: string, +): BrowserClipboardWrite | null { + // JSON escaping can expand one-byte control characters to six ASCII bytes. + if (message.length > MAX_BROWSER_CLIPBOARD_BYTES * 6 + 256) return null; + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if ( + record.type !== "clipboard-write" || + !Number.isSafeInteger(record.id) || + (record.id as number) <= 0 || + typeof record.text !== "string" || + new TextEncoder().encode(record.text).byteLength > + MAX_BROWSER_CLIPBOARD_BYTES + ) { + return null; + } + return record as BrowserClipboardWrite; +} + +export function parseBrowserNotification( + message: string, +): BrowserNotification | null { + if (message.length > 4096) return null; + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if ( + record.type !== "notification" || + typeof record.title !== "string" || + record.title.length === 0 || + record.title.length > 100 || + typeof record.message !== "string" || + record.message.length === 0 || + record.message.length > 500 + ) { + return null; + } + return record as BrowserNotification; +} + +export function parseBrowserBell(message: string): BrowserBell | null { + if (message.length > 128) return null; + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if ( + record.type !== "bell" || + (record.kind !== "attention" && record.kind !== "error") + ) { + return null; + } + return record as BrowserBell; +} diff --git a/app/src/web-tui/browser-terminal-input.test.ts b/app/src/web-tui/browser-terminal-input.test.ts new file mode 100644 index 00000000..a1af4e92 --- /dev/null +++ b/app/src/web-tui/browser-terminal-input.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { + type BrowserKeyLike, + classifyBrowserPlatform, + encodeBrowserKey, + TerminalProtocolState, +} from "./browser-terminal-input"; + +function key( + value: string, + overrides: Partial = {}, +): BrowserKeyLike { + return { + key: value, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + ...overrides, + }; +} + +describe("encodeBrowserKey", () => { + test("normalizes the complete control-character matrix", () => { + expect(encodeBrowserKey(key("Escape"))).toBe("\x1b"); + for (let code = 1; code <= 26; code += 1) { + const letter = String.fromCharCode(96 + code); + if (letter === "v") continue; + expect(encodeBrowserKey(key(letter, { ctrlKey: true }))).toBe( + String.fromCharCode(code), + ); + } + for (const [value, expected] of [ + [" ", "\x00"], + ["[", "\x1b"], + ["\\", "\x1c"], + ["]", "\x1d"], + ["^", "\x1e"], + ["_", "\x1f"], + ["?", "\x7f"], + ] as const) { + expect(encodeBrowserKey(key(value, { ctrlKey: true }))).toBe(expected); + } + }); + + test("encodes fixed and modified navigation keys", () => { + for (const [value, expected] of [ + ["Enter", "\r"], + ["Backspace", "\x7f"], + ["Tab", "\t"], + ["Insert", "\x1b[2~"], + ["Delete", "\x1b[3~"], + ["PageUp", "\x1b[5~"], + ["PageDown", "\x1b[6~"], + ["F1", "\x1bOP"], + ["F12", "\x1b[24~"], + ] as const) { + expect(encodeBrowserKey(key(value))).toBe(expected); + } + expect(encodeBrowserKey(key("ArrowUp"))).toBe("\x1b[A"); + expect(encodeBrowserKey(key("ArrowRight", { altKey: true }))).toBe( + "\x1b[1;3C", + ); + expect(encodeBrowserKey(key("Home", { ctrlKey: true }))).toBe("\x1b[1;5H"); + expect( + encodeBrowserKey(key("ArrowLeft", { ctrlKey: true, shiftKey: true })), + ).toBe("\x1b[1;6D"); + expect(encodeBrowserKey(key("Tab", { shiftKey: true }))).toBe("\x1b[Z"); + }); + + test("uses Linux Alt prefixes without breaking macOS Option composition", () => { + expect(encodeBrowserKey(key("x", { altKey: true }), "other")).toBe("\x1bx"); + expect(encodeBrowserKey(key("Γ₯", { altKey: true }), "mac")).toBeNull(); + expect(classifyBrowserPlatform("MacIntel")).toBe("mac"); + expect(classifyBrowserPlatform("Linux x86_64")).toBe("other"); + }); + + test("leaves printable, composition, copy, and paste events native", () => { + expect(encodeBrowserKey(key("x"))).toBeNull(); + expect(encodeBrowserKey(key("x", { isComposing: true }))).toBeNull(); + expect(encodeBrowserKey(key("c", { metaKey: true }))).toBeNull(); + expect( + encodeBrowserKey(key("c", { ctrlKey: true, shiftKey: true })), + ).toBeNull(); + expect(encodeBrowserKey(key("v", { ctrlKey: true }))).toBeNull(); + expect(encodeBrowserKey(key("v", { metaKey: true }))).toBeNull(); + expect(encodeBrowserKey(key("Insert", { shiftKey: true }))).toBeNull(); + expect(encodeBrowserKey(key("Insert", { ctrlKey: true }))).toBeNull(); + }); +}); + +describe("TerminalProtocolState", () => { + test("tracks OpenTUI mouse modes including all-motion mode", () => { + const state = new TerminalProtocolState(); + state.feed( + new TextEncoder().encode( + "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?2004h", + ), + ); + expect(state.mouseTracking).toBe(1003); + expect(state.mouseSgr).toBe(true); + expect(state.bracketedPaste).toBe(true); + state.feed(new TextEncoder().encode("\x1b[?1003l\x1b[?2004l")); + expect(state.mouseTracking).toBe(1002); + expect(state.bracketedPaste).toBe(false); + }); + + test("parses mode sequences split across websocket frames", () => { + const state = new TerminalProtocolState(); + state.feed(new TextEncoder().encode("\x1b[?10")); + state.feed(new TextEncoder().encode("03;1006h")); + expect(state.mouseTracking).toBe(1003); + expect(state.mouseSgr).toBe(true); + state.feed(new TextEncoder().encode("\x1b[?1000;1002;1003;1006l")); + expect(state.mouseTracking).toBe(0); + expect(state.mouseSgr).toBe(false); + }); +}); diff --git a/app/src/web-tui/browser-terminal-input.ts b/app/src/web-tui/browser-terminal-input.ts new file mode 100644 index 00000000..67272f6c --- /dev/null +++ b/app/src/web-tui/browser-terminal-input.ts @@ -0,0 +1,405 @@ +export type BrowserKeyLike = { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; + isComposing?: boolean; +}; + +const FIXED_KEYS: Record = { + Enter: "\r", + Backspace: "\x7f", + Tab: "\t", + Escape: "\x1b", + Insert: "\x1b[2~", + Delete: "\x1b[3~", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", + F1: "\x1bOP", + F2: "\x1bOQ", + F3: "\x1bOR", + F4: "\x1bOS", + F5: "\x1b[15~", + F6: "\x1b[17~", + F7: "\x1b[18~", + F8: "\x1b[19~", + F9: "\x1b[20~", + F10: "\x1b[21~", + F11: "\x1b[23~", + F12: "\x1b[24~", +}; + +const NAVIGATION_KEYS: Record = { + ArrowUp: "A", + ArrowDown: "B", + ArrowRight: "C", + ArrowLeft: "D", + Home: "H", + End: "F", +}; + +function modifierParameter(key: BrowserKeyLike): number { + return ( + 1 + + (key.shiftKey ? 1 : 0) + + (key.altKey ? 2 : 0) + + (key.ctrlKey ? 4 : 0) + + (key.metaKey ? 8 : 0) + ); +} + +export type BrowserPlatform = "mac" | "other"; + +export function classifyBrowserPlatform(value: string): BrowserPlatform { + return /mac|iphone|ipad|ipod/i.test(value) ? "mac" : "other"; +} + +function browserPlatform(): BrowserPlatform { + if (typeof navigator === "undefined") return "other"; + const navigatorWithData = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return classifyBrowserPlatform( + navigatorWithData.userAgentData?.platform ?? navigator.platform, + ); +} + +export function isBrowserCopyKey(event: BrowserKeyLike): boolean { + return ( + (event.metaKey && event.key.toLowerCase() === "c") || + (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "c") || + (event.key === "Insert" && event.ctrlKey) + ); +} + +export function isBrowserOwnedKey(event: BrowserKeyLike): boolean { + return ( + isBrowserCopyKey(event) || + ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") || + (event.key === "Insert" && event.shiftKey) + ); +} + +function isMacOptionText( + event: BrowserKeyLike, + platform: BrowserPlatform, +): boolean { + return ( + platform === "mac" && + event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key.length === 1 || event.key === "Dead") + ); +} + +/** Encode browser keys whose native handling is unreliable or browser-owned. */ +export function encodeBrowserKey( + event: BrowserKeyLike, + platform: BrowserPlatform = "other", +): string | null { + if (event.isComposing) return null; + + // Keep browser clipboard conventions available. Ctrl+C remains the TUI + // interrupt; Cmd+C and Ctrl+Shift+C copy browser selection. + if (isBrowserOwnedKey(event)) return null; + + if (event.ctrlKey && !event.altKey && !event.metaKey) { + if (event.key.length === 1) { + const character = event.key.toLowerCase(); + const code = character.charCodeAt(0); + if (code >= 97 && code <= 122) return String.fromCharCode(code - 96); + const controls: Record = { + "[": "\x1b", + "\\": "\x1c", + "]": "\x1d", + "^": "\x1e", + _: "\x1f", + "?": "\x7f", + " ": "\x00", + }; + if (character in controls) return controls[character] ?? null; + } + } + + if (event.key === "Tab" && event.shiftKey) return "\x1b[Z"; + + // Linux terminals conventionally encode Alt+printable as an Escape prefix. + // macOS Option is left native because it commonly drives dead keys and IME. + if ( + platform !== "mac" && + event.altKey && + !event.ctrlKey && + !event.metaKey && + event.key.length === 1 + ) { + return `\x1b${event.key}`; + } + + const navigation = NAVIGATION_KEYS[event.key]; + if (navigation) { + const modifiers = modifierParameter(event); + return modifiers === 1 + ? `\x1b[${navigation}` + : `\x1b[1;${modifiers}${navigation}`; + } + + const fixed = FIXED_KEYS[event.key]; + if (!fixed) return null; + if (event.altKey && !event.ctrlKey && !event.metaKey) return `\x1b${fixed}`; + return fixed; +} + +export type MouseTrackingMode = 0 | 1000 | 1002 | 1003; + +const DEC_PRIVATE_MODE_PATTERN = new RegExp( + `${String.fromCharCode(27)}\\[\\?([\\d;]+)([hl])`, + "g", +); + +/** Tracks the DEC modes needed to encode browser pointer events. */ +export class TerminalProtocolState { + private readonly mouseModes = new Set(); + private tail = ""; + private readonly decoder = new TextDecoder(); + mouseSgr = false; + bracketedPaste = false; + + get mouseTracking(): MouseTrackingMode { + if (this.mouseModes.has(1003)) return 1003; + if (this.mouseModes.has(1002)) return 1002; + if (this.mouseModes.has(1000)) return 1000; + return 0; + } + + feed(bytes: Uint8Array): void { + const text = this.tail + this.decoder.decode(bytes, { stream: true }); + for (const match of text.matchAll(DEC_PRIVATE_MODE_PATTERN)) { + const enabled = match[2] === "h"; + for (const rawMode of match[1]?.split(";") ?? []) { + const mode = Number(rawMode); + if (mode === 1000 || mode === 1002 || mode === 1003) { + if (enabled) this.mouseModes.add(mode); + else this.mouseModes.delete(mode); + } else if (mode === 1006) { + this.mouseSgr = enabled; + } else if (mode === 2004) { + this.bracketedPaste = enabled; + } + } + } + this.tail = text.slice(-96); + } +} + +export type TerminalGeometry = { + columns: number; + rows: number; + bounds: DOMRect; +}; + +export type BrowserTerminalInputOptions = { + root: HTMLElement; + protocol: TerminalProtocolState; + platform?: BrowserPlatform; + geometry: () => TerminalGeometry | null; + send: (data: string) => void; + focus: () => void; + copySelection?: () => string; + writeClipboard?: (text: string) => void | Promise; +}; + +function mouseModifiers(event: MouseEvent): number { + return ( + (event.shiftKey ? 4 : 0) | (event.altKey ? 8 : 0) | (event.ctrlKey ? 16 : 0) + ); +} + +function mouseButton(event: MouseEvent): number | null { + if (event.button === 0) return 0; + if (event.button === 1) return 1; + if (event.button === 2) return 2; + return null; +} + +/** Renderer-independent browser keyboard and SGR mouse adapter. */ +export class BrowserTerminalInput { + private readonly root: HTMLElement; + private readonly protocol: TerminalProtocolState; + private readonly platform: BrowserPlatform; + private readonly geometry: () => TerminalGeometry | null; + private readonly send: (data: string) => void; + private readonly focus: () => void; + private readonly copySelection: () => string; + private readonly writeClipboard: (text: string) => void | Promise; + private disposed = false; + + constructor(options: BrowserTerminalInputOptions) { + this.root = options.root; + this.protocol = options.protocol; + this.platform = options.platform ?? browserPlatform(); + this.geometry = options.geometry; + this.send = options.send; + this.focus = options.focus; + this.copySelection = options.copySelection ?? (() => ""); + this.writeClipboard = options.writeClipboard ?? (() => {}); + window.addEventListener("keydown", this.onKeyDown, true); + window.addEventListener("mousedown", this.onMouseDown, true); + window.addEventListener("mouseup", this.onMouseUp, true); + window.addEventListener("mousemove", this.onMouseMove, true); + window.addEventListener("contextmenu", this.onContextMenu, true); + window.addEventListener("paste", this.onPaste, true); + window.addEventListener("compositionend", this.onCompositionEnd, true); + window.addEventListener("wheel", this.onWheel, { + capture: true, + passive: false, + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + window.removeEventListener("keydown", this.onKeyDown, true); + window.removeEventListener("mousedown", this.onMouseDown, true); + window.removeEventListener("mouseup", this.onMouseUp, true); + window.removeEventListener("mousemove", this.onMouseMove, true); + window.removeEventListener("contextmenu", this.onContextMenu, true); + window.removeEventListener("paste", this.onPaste, true); + window.removeEventListener("compositionend", this.onCompositionEnd, true); + window.removeEventListener("wheel", this.onWheel, true); + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + if (isBrowserOwnedKey(event)) { + // Preserve paste defaults while preventing ghostty's hidden textarea + // from translating the same shortcut into terminal input. Canvas + // selections require an explicit clipboard write. + event.stopImmediatePropagation(); + if (isBrowserCopyKey(event)) { + const selection = this.copySelection(); + if (selection) { + event.preventDefault(); + void Promise.resolve(this.writeClipboard(selection)).catch(() => {}); + } + } + return; + } + if (event.isComposing) { + event.stopImmediatePropagation(); + return; + } + if (isMacOptionText(event, this.platform)) { + // A completed Option character is already represented by event.key. + // Dead keys continue through the browser composition pipeline. + event.stopImmediatePropagation(); + if (event.key !== "Dead") { + event.preventDefault(); + this.send(event.key); + } + return; + } + const sequence = encodeBrowserKey(event, this.platform); + if (sequence === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + this.send(sequence); + }; + + private readonly onMouseDown = (event: MouseEvent) => { + if (!this.shouldForwardMouse(event)) return; + const button = mouseButton(event); + if (button === null) return; + this.focus(); + this.forwardMouse(event, button | mouseModifiers(event), "M"); + }; + + private readonly onMouseUp = (event: MouseEvent) => { + if (!this.shouldForwardMouse(event)) return; + const button = mouseButton(event); + if (button === null) return; + this.forwardMouse(event, button | mouseModifiers(event), "m"); + }; + + private readonly onMouseMove = (event: MouseEvent) => { + if (!this.shouldForwardMouse(event)) return; + const tracking = this.protocol.mouseTracking; + if (tracking !== 1003 && (tracking !== 1002 || event.buttons === 0)) return; + let button = 3; + if (event.buttons & 1) button = 0; + else if (event.buttons & 4) button = 1; + else if (event.buttons & 2) button = 2; + this.forwardMouse(event, 32 | button | mouseModifiers(event), "M"); + }; + + private readonly onContextMenu = (event: MouseEvent) => { + if (!this.shouldForwardMouse(event)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + private readonly onCompositionEnd = (event: CompositionEvent) => { + if (!this.root.contains(event.target as Node) || !event.data) return; + event.stopImmediatePropagation(); + this.send(event.data); + }; + + private readonly onPaste = (event: ClipboardEvent) => { + if (!this.root.contains(event.target as Node)) return; + const text = event.clipboardData?.getData("text/plain"); + if (!text) return; + event.preventDefault(); + event.stopImmediatePropagation(); + this.send( + this.protocol.bracketedPaste ? `\x1b[200~${text}\x1b[201~` : text, + ); + }; + + private readonly onWheel = (event: WheelEvent) => { + if (!this.shouldForwardMouse(event) || event.deltaY === 0) return; + const code = (event.deltaY < 0 ? 64 : 65) | mouseModifiers(event); + this.forwardMouse(event, code, "M"); + }; + + private shouldForwardMouse(event: MouseEvent): boolean { + return ( + this.protocol.mouseTracking !== 0 && + this.protocol.mouseSgr && + !event.shiftKey && + this.root.contains(event.target as Node) + ); + } + + private forwardMouse( + event: MouseEvent, + code: number, + suffix: "M" | "m", + ): void { + const geometry = this.geometry(); + if (!geometry || geometry.bounds.width <= 0 || geometry.bounds.height <= 0) + return; + const column = Math.max( + 1, + Math.min( + geometry.columns, + Math.floor( + ((event.clientX - geometry.bounds.left) / geometry.bounds.width) * + geometry.columns, + ) + 1, + ), + ); + const row = Math.max( + 1, + Math.min( + geometry.rows, + Math.floor( + ((event.clientY - geometry.bounds.top) / geometry.bounds.height) * + geometry.rows, + ) + 1, + ), + ); + event.preventDefault(); + event.stopImmediatePropagation(); + this.send(`\x1b[<${code};${column};${row}${suffix}`); + } +} diff --git a/app/src/web-tui/browser-theme.test.ts b/app/src/web-tui/browser-theme.test.ts new file mode 100644 index 00000000..456c237b --- /dev/null +++ b/app/src/web-tui/browser-theme.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { + colorSchemeForBackground, + parseBrowserThemeMessage, +} from "./browser-theme"; + +const theme = { + background: "#fdf6e3", + foreground: "#123456", + cursor: "#d33682", + selectionBackground: "#c8bea4", + statusBackground: "#eee8d5", + statusForeground: "#586e75", + statusBorder: "#b8ad91", +}; + +describe("browser theme controls", () => { + test("parses a bounded theme message", () => { + expect( + parseBrowserThemeMessage(JSON.stringify({ type: "theme", theme })), + ).toEqual(theme); + }); + + test("rejects malformed and non-hex theme values", () => { + expect(parseBrowserThemeMessage("not json")).toBeNull(); + expect( + parseBrowserThemeMessage( + JSON.stringify({ + type: "theme", + theme: { ...theme, background: "url(javascript:bad)" }, + }), + ), + ).toBeNull(); + expect( + parseBrowserThemeMessage(JSON.stringify({ type: "other", theme })), + ).toBeNull(); + }); + + test("derives browser color scheme from background luminance", () => { + expect(colorSchemeForBackground("#0a0a0a")).toBe("dark"); + expect(colorSchemeForBackground("#fdf6e3")).toBe("light"); + }); +}); diff --git a/app/src/web-tui/browser-theme.ts b/app/src/web-tui/browser-theme.ts new file mode 100644 index 00000000..59f61b2d --- /dev/null +++ b/app/src/web-tui/browser-theme.ts @@ -0,0 +1,70 @@ +export type BrowserTheme = { + background: string; + foreground: string; + cursor: string; + selectionBackground: string; + statusBackground: string; + statusForeground: string; + statusBorder: string; +}; + +export type BrowserThemeMessage = { + type: "theme"; + theme: BrowserTheme; +}; + +const HEX_COLOR = /^#[\da-f]{6}$/i; +const THEME_KEYS = [ + "background", + "foreground", + "cursor", + "selectionBackground", + "statusBackground", + "statusForeground", + "statusBorder", +] as const; + +export function parseBrowserThemeMessage(value: string): BrowserTheme | null { + if (value.length > 1024) return null; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const message = parsed as Record; + if (message.type !== "theme") return null; + if (typeof message.theme !== "object" || message.theme === null) return null; + const theme = message.theme as Record; + for (const key of THEME_KEYS) { + if (typeof theme[key] !== "string" || !HEX_COLOR.test(theme[key])) { + return null; + } + } + return theme as BrowserTheme; +} + +export function colorSchemeForBackground(background: string): "dark" | "light" { + const red = Number.parseInt(background.slice(1, 3), 16); + const green = Number.parseInt(background.slice(3, 5), 16); + const blue = Number.parseInt(background.slice(5, 7), 16); + return (red * 299 + green * 587 + blue * 114) / 1000 < 128 ? "dark" : "light"; +} + +export function applyBrowserTheme( + theme: BrowserTheme, + root: HTMLElement = document.documentElement, +): void { + root.style.setProperty("--kit-terminal-bg", theme.background); + root.style.setProperty("--kit-terminal-fg", theme.foreground); + root.style.setProperty("--kit-terminal-cursor", theme.cursor); + root.style.setProperty( + "--kit-terminal-selection-bg", + theme.selectionBackground, + ); + root.style.setProperty("--kit-status-bg", theme.statusBackground); + root.style.setProperty("--kit-status-fg", theme.statusForeground); + root.style.setProperty("--kit-status-border", theme.statusBorder); + root.style.colorScheme = colorSchemeForBackground(theme.background); +} diff --git a/app/src/web-tui/build-tui-client.ts b/app/src/web-tui/build-tui-client.ts new file mode 100644 index 00000000..e70ff237 --- /dev/null +++ b/app/src/web-tui/build-tui-client.ts @@ -0,0 +1,19 @@ +export async function buildWebTuiClient(options?: { + minify?: boolean; +}): Promise { + const result = await Bun.build({ + entrypoints: [new URL("./client.ts", import.meta.url).pathname], + target: "browser", + format: "esm", + conditions: ["browser", "production"], + minify: options?.minify ?? false, + }); + if (!result.success) { + throw new AggregateError(result.logs, "Web TUI client bundle failed"); + } + const output = result.outputs.find((candidate) => + candidate.path.endsWith("client.js"), + ); + if (!output) throw new Error("Web TUI client bundle produced no JavaScript"); + return output.text(); +} diff --git a/app/src/web-tui/client.ts b/app/src/web-tui/client.ts new file mode 100644 index 00000000..d116abad --- /dev/null +++ b/app/src/web-tui/client.ts @@ -0,0 +1,391 @@ +import { FitAddon, Ghostty, Terminal } from "ghostty-web"; +import { + type BrowserBell, + type BrowserClipboardWrite, + type BrowserNotification, + parseBrowserBell, + parseBrowserClipboardWrite, + parseBrowserNotification, + WEB_TUI_PROTOCOL_VERSION, +} from "./browser-actions"; +import { + BrowserTerminalInput, + TerminalProtocolState, +} from "./browser-terminal-input"; +import { applyBrowserTheme, parseBrowserThemeMessage } from "./browser-theme"; +import { terminalInputFrames } from "./terminal-input-frames"; + +const RECONNECT_MIN_MS = 500; +const RECONNECT_MAX_MS = 5_000; + +function statusElement(): HTMLElement | null { + return document.getElementById("status"); +} + +function showStatus(text: string): void { + const status = statusElement(); + if (!status) return; + status.textContent = text; + status.hidden = false; +} + +function hideStatus(): void { + const status = statusElement(); + if (status) status.hidden = true; +} + +let notificationTimer: number | null = null; +let audioContext: AudioContext | null = null; + +function showInPageNotification(notification: BrowserNotification): void { + const element = document.getElementById("notification"); + if (!element) return; + element.textContent = `${notification.title}: ${notification.message}`; + element.hidden = false; + if (notificationTimer !== null) window.clearTimeout(notificationTimer); + notificationTimer = window.setTimeout(() => { + notificationTimer = null; + element.hidden = true; + }, 5_000); +} + +function deliverBrowserNotification(notification: BrowserNotification): void { + showInPageNotification(notification); + const permissionButton = document.getElementById("enable-notifications"); + if ( + permissionButton && + "Notification" in window && + Notification.permission === "default" + ) { + permissionButton.hidden = false; + } + if ( + "Notification" in window && + Notification.permission === "granted" && + (document.visibilityState === "hidden" || !document.hasFocus()) + ) { + try { + new Notification(notification.title, { + body: notification.message, + tag: "kit-browser-tui", + }); + } catch { + // The in-page notification remains available if the browser rejects it. + } + } +} + +function playBrowserBell(bell: BrowserBell): void { + if (!audioContext || audioContext.state !== "running") return; + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const now = audioContext.currentTime; + oscillator.type = "sine"; + oscillator.frequency.value = bell.kind === "error" ? 220 : 660; + gain.gain.setValueAtTime(0.025, now); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08); + oscillator.connect(gain); + gain.connect(audioContext.destination); + oscillator.start(now); + oscillator.stop(now + 0.08); +} + +function setupBrowserNotifications(focusTerminal: () => void): void { + const button = document.getElementById( + "enable-notifications", + ) as HTMLButtonElement | null; + if (button && "Notification" in window) { + button.hidden = true; + button.addEventListener("click", () => { + void (async () => { + let message = "Browser notification permission unavailable"; + try { + const permission = await Notification.requestPermission(); + button.hidden = permission !== "default"; + message = + permission === "granted" + ? "Browser notifications enabled" + : "Browser notifications remain disabled"; + } catch { + button.hidden = true; + } finally { + showInPageNotification({ + type: "notification", + title: "Kit", + message, + }); + focusTerminal(); + } + })(); + }); + } + + const unlockAudio = () => { + if (!audioContext && "AudioContext" in window) { + audioContext = new AudioContext(); + } + if (audioContext?.state === "suspended") void audioContext.resume(); + }; + window.addEventListener("pointerdown", unlockAudio, { + capture: true, + once: true, + }); + window.addEventListener("keydown", unlockAudio, { + capture: true, + once: true, + }); +} + +function webSocketUrl(): string { + const scheme = location.protocol === "https:" ? "wss" : "ws"; + return `${scheme}://${location.host}/api/tui`; +} + +function writeBrowserClipboardFallback(text: string): void { + const previouslyFocused = document.activeElement; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + try { + if (!document.execCommand("copy")) { + throw new Error("Browser denied clipboard access"); + } + } finally { + textarea.remove(); + if (previouslyFocused instanceof HTMLElement) { + previouslyFocused.focus({ preventScroll: true }); + } + } +} + +async function writeBrowserClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall through for browsers or deployment contexts that deny the API. + } + } + writeBrowserClipboardFallback(text); +} + +function writeBrowserActionClipboard(text: string): void { + // Server actions arrive after the terminal click's transient activation has + // expired. Avoid permission-gated Clipboard API promises that browsers may + // leave pending indefinitely; the synchronous copy command either succeeds + // or can be acknowledged as denied immediately. + writeBrowserClipboardFallback(text); +} + +class TuiConnection { + private socket: WebSocket | null = null; + private reconnectDelay = RECONNECT_MIN_MS; + private reconnectTimer: number | null = null; + private closedByPage = false; + + constructor( + private readonly terminal: Terminal, + private readonly fit: FitAddon, + private readonly protocol: TerminalProtocolState, + ) { + terminal.onData((data) => this.sendInput(data)); + terminal.onResize(({ cols, rows }) => + this.sendControl("resize", cols, rows), + ); + terminal.onTitleChange((title) => { + document.title = title || "Kit (terminal)"; + }); + window.addEventListener("pagehide", () => { + this.closedByPage = true; + if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.socket?.close(1000, "page closed"); + }); + window.addEventListener("pageshow", (event) => { + if (!event.persisted) return; + this.closedByPage = false; + if (!this.socket) this.connect(); + }); + } + + connect(): void { + if (this.socket) return; + showStatus("connecting…"); + const socket = new WebSocket(webSocketUrl()); + socket.binaryType = "arraybuffer"; + this.socket = socket; + socket.addEventListener("open", () => { + this.reconnectDelay = RECONNECT_MIN_MS; + hideStatus(); + this.fit.fit(); + this.sendControl("init", this.terminal.cols, this.terminal.rows); + this.terminal.focus(); + }); + socket.addEventListener("message", (event) => { + if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data); + this.protocol.feed(bytes); + this.terminal.write(bytes); + return; + } + if (typeof event.data === "string") { + const theme = parseBrowserThemeMessage(event.data); + if (theme) { + applyBrowserTheme(theme); + return; + } + const clipboard = parseBrowserClipboardWrite(event.data); + if (clipboard) { + this.writeClipboard(socket, clipboard); + return; + } + const notification = parseBrowserNotification(event.data); + if (notification) { + deliverBrowserNotification(notification); + return; + } + const bell = parseBrowserBell(event.data); + if (bell) playBrowserBell(bell); + } + }); + socket.addEventListener("close", (event) => { + if (this.socket !== socket) return; + this.socket = null; + if (this.closedByPage) return; + if (event.code === 4001) { + showStatus("disconnected β€” another tab took over this terminal"); + return; + } + if (event.code === 4002) { + showStatus("client update required β€” reloading…"); + window.setTimeout(() => location.reload(), 100); + return; + } + this.scheduleReconnect(); + }); + socket.addEventListener("error", () => socket.close()); + } + + private scheduleReconnect(): void { + showStatus("disconnected β€” reconnecting…"); + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(RECONNECT_MAX_MS, delay * 2); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + sendInput(data: string): void { + if (this.socket?.readyState !== WebSocket.OPEN) return; + for (const frame of terminalInputFrames(data)) this.socket.send(frame); + } + + private writeClipboard( + socket: WebSocket, + action: BrowserClipboardWrite, + ): void { + try { + writeBrowserActionClipboard(action.text); + this.sendClipboardResult(socket, action.id, true); + } catch (error) { + this.sendClipboardResult( + socket, + action.id, + false, + error instanceof Error ? error.message : String(error), + ); + } + } + + private sendClipboardResult( + socket: WebSocket, + id: number, + ok: boolean, + error?: string, + ): void { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send( + JSON.stringify({ + type: "clipboard-result", + id, + ok, + ...(error ? { error: error.slice(0, 256) } : {}), + }), + ); + } + + private sendControl( + type: "init" | "resize", + cols: number, + rows: number, + ): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send( + JSON.stringify({ + type, + cols, + rows, + ...(type === "init" + ? { protocolVersion: WEB_TUI_PROTOCOL_VERSION } + : {}), + }), + ); + } + } +} + +async function main(): Promise { + const element = document.getElementById("terminal"); + if (!element) throw new Error("terminal element missing"); + const ghostty = await Ghostty.load("/assets/ghostty-vt.wasm"); + const terminal = new Terminal({ + ghostty, + cursorBlink: true, + fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace', + fontSize: 14, + scrollback: 10_000, + theme: { + background: "#0a0a0a", + foreground: "#fafafa", + cursor: "#fafafa", + selectionBackground: "#404040", + }, + }); + const fit = new FitAddon(); + terminal.loadAddon(fit); + terminal.open(element); + fit.observeResize(); + fit.fit(); + const protocol = new TerminalProtocolState(); + const connection = new TuiConnection(terminal, fit, protocol); + setupBrowserNotifications(() => terminal.focus()); + new BrowserTerminalInput({ + root: element, + protocol, + geometry: () => { + const canvas = element.querySelector("canvas"); + if (!canvas) return null; + return { + columns: terminal.cols, + rows: terminal.rows, + bounds: canvas.getBoundingClientRect(), + }; + }, + send: (data) => connection.sendInput(data), + focus: () => terminal.focus(), + copySelection: () => terminal.getSelection(), + writeClipboard: writeBrowserClipboard, + }); + connection.connect(); +} + +void main().catch((error) => { + showStatus( + `failed to start terminal: ${error instanceof Error ? error.message : String(error)}`, + ); +}); diff --git a/app/src/web-tui/index.html b/app/src/web-tui/index.html new file mode 100644 index 00000000..3925ea61 --- /dev/null +++ b/app/src/web-tui/index.html @@ -0,0 +1,17 @@ + + + + + + + Kit TUI Β· ghostty-web experiment + + + + +
+
Connecting…
+ + + + diff --git a/app/src/web-tui/terminal-input-frames.test.ts b/app/src/web-tui/terminal-input-frames.test.ts new file mode 100644 index 00000000..b4367b15 --- /dev/null +++ b/app/src/web-tui/terminal-input-frames.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_TERMINAL_INPUT_FRAME_BYTES, + terminalInputFrames, +} from "./terminal-input-frames"; + +describe("terminalInputFrames", () => { + test("keeps ordinary input in one frame", () => { + const frames = terminalInputFrames("hello"); + expect(frames).toHaveLength(1); + expect(new TextDecoder().decode(frames[0])).toBe("hello"); + }); + + test("chunks large Unicode paste without losing bytes", () => { + const value = `\x1b[200~${"Ξ»πŸ™‚\n".repeat(20_000)}\x1b[201~`; + const expected = new TextEncoder().encode(value); + const frames = terminalInputFrames(value); + expect(frames.length).toBeGreaterThan(1); + expect( + frames.every( + (frame) => frame.byteLength <= MAX_TERMINAL_INPUT_FRAME_BYTES, + ), + ).toBe(true); + expect(Buffer.concat(frames.map((frame) => Buffer.from(frame)))).toEqual( + Buffer.from(expected), + ); + }); + + test("does not emit an empty WebSocket frame", () => { + expect(terminalInputFrames("")).toEqual([]); + }); +}); diff --git a/app/src/web-tui/terminal-input-frames.ts b/app/src/web-tui/terminal-input-frames.ts new file mode 100644 index 00000000..c415e6d6 --- /dev/null +++ b/app/src/web-tui/terminal-input-frames.ts @@ -0,0 +1,18 @@ +export const MAX_TERMINAL_INPUT_FRAME_BYTES = 16 * 1024; + +/** Split browser input below the server's WebSocket payload ceiling. */ +export function terminalInputFrames( + data: string, + encoder = new TextEncoder(), +): Uint8Array[] { + const bytes = encoder.encode(data); + const frames: Uint8Array[] = []; + for ( + let offset = 0; + offset < bytes.byteLength; + offset += MAX_TERMINAL_INPUT_FRAME_BYTES + ) { + frames.push(bytes.slice(offset, offset + MAX_TERMINAL_INPUT_FRAME_BYTES)); + } + return frames; +} diff --git a/app/src/web-tui/tui.css b/app/src/web-tui/tui.css new file mode 100644 index 00000000..08483fc0 --- /dev/null +++ b/app/src/web-tui/tui.css @@ -0,0 +1,94 @@ +@font-face { + font-family: "JetBrains Mono"; + src: url("/assets/jetbrains-mono-normal.woff2") format("woff2"); + font-style: normal; + font-weight: 100 800; + font-display: swap; +} + +:root { + --kit-terminal-bg: #0a0a0a; + --kit-terminal-fg: #fafafa; + --kit-terminal-cursor: #fafafa; + --kit-terminal-selection-bg: #404040; + --kit-status-bg: #171717; + --kit-status-fg: #d4d4d4; + --kit-status-border: #404040; + color-scheme: dark; + background: var(--kit-terminal-bg); +} + +* { + box-sizing: border-box; +} + +html, +body, +#terminal { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: var(--kit-terminal-bg); + color: var(--kit-terminal-fg); + font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; +} + +#terminal { + position: fixed; + inset: 0; + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +#status { + position: fixed; + top: max(0.75rem, env(safe-area-inset-top)); + right: max(0.75rem, env(safe-area-inset-right)); + padding: 0.35rem 0.55rem; + border: 1px solid var(--kit-status-border); + border-radius: 0.25rem; + background: var(--kit-status-bg); + color: var(--kit-status-fg); + font: + 0.75rem / 1.2 system-ui, + sans-serif; +} + +#status[hidden], +#notification[hidden], +#enable-notifications[hidden] { + display: none; +} + +#notification, +#enable-notifications { + position: fixed; + right: max(0.75rem, env(safe-area-inset-right)); + max-width: min(24rem, calc(100vw - 1.5rem)); + padding: 0.35rem 0.55rem; + border: 0; + border-radius: 0.25rem; + background: var(--kit-status-bg); + color: var(--kit-status-fg); + font: + 0.75rem / 1.3 system-ui, + sans-serif; +} + +#notification { + top: max(0.75rem, env(safe-area-inset-top)); +} + +#enable-notifications { + bottom: max(0.75rem, env(safe-area-inset-bottom)); + cursor: pointer; +} + +#enable-notifications:focus-visible { + outline: 1px solid var(--kit-status-border); + outline-offset: 2px; +} diff --git a/app/tsconfig.json b/app/tsconfig.json index 0e4814a6..7b542627 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -12,5 +12,11 @@ "noEmit": true, "allowJs": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/vendor/**/*.js"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/vendor/**/*.js", + "e2e/**/*.ts", + "playwright.config.ts" + ] } diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md new file mode 100644 index 00000000..09275d80 --- /dev/null +++ b/docs/experiments/web-tui-ghostty-web.md @@ -0,0 +1,158 @@ +# Experiment: browser TUI via ghostty-web + +Status: experiment on `experiment/web-tui-ghostty`. This is paired with the wterm DOM experiment on `experiment/web-tui-wterm`. + +## Goal + +Expose Kit's existing OpenTUI interface in a browser without rebuilding it in the semantic Solid SPA, using ghostty-web's Ghostty WASM core and Canvas renderer. + +## Architecture + +Run: + +```bash +kit --web-tui +kit --web-tui --model provider/model-id +``` + +Normal `kit --web` behavior is unchanged. The experimental mode has one runtime owner: + +```text +browser server +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ghostty-web Canvas β”‚ ws bytes β”‚ WebTuiServer β”‚ +β”‚ Ghostty VT renderer │◄────────►│ WebTuiBridge virtual tty β”‚ +β”‚ Kit input/mouse adapter β”‚ resize β”‚ OpenTUI CliRenderer(remote) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ real Kit App / AppShell/runtime β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +OpenTUI 0.5.1 accepts custom stdin/stdout streams and explicit dimensions. A non-process stdout activates its remote `NativeSpanFeed`, so no PTY, child Kit process, fixture, or second session runtime is needed. + +The app starts lazily after the browser initializes its terminal. On disconnect, the bridge suspends OpenTUI. Reattach resumes it, replays terminal setup, reapplies dimensions, and forces a full repaint. This is deterministic and does not require a VT output journal. + +The WebSocket protocol uses raw binary terminal bytes in both directions and small JSON controls. The `init` control carries an explicit protocol version, validated before a socket can replace the active client. Version-aware mismatches close with code 4002 and reload automatically; legacy pre-version clients close without entering a reconnect loop and require one manual reload. A validated newer browser tab supersedes the old tab with close code 4001. + +## What works + +- The complete real `AppShell`, including dialogs, pickers, workspace panes, review UI, plugin chrome, and themes +- One authoritative session/runtime owner +- Kit-owned browser keyboard normalization for Escape, Ctrl combinations, navigation, function keys, Linux Alt prefixes, and macOS Option/composition +- Browser-owned copy/paste shortcuts with explicit Canvas-selection copying, browser-routed whole-message Markdown copying, bracketed paste, Unicode preservation, and bounded input frames +- Browser-owned in-page notifications, opt-in Web Notifications, and user-gesture-unlocked bell audio without host `/dev/tty` or `afplay` effects +- Optional startup model selection through the standard `--model /` selector +- Kit-owned SGR mouse encoding for click, release, drag, all-motion, and wheel events, with Shift-selection bypass and CSS-space DPR-safe coordinates +- Focus/bracketed-paste/synchronized-output/alternate-screen mode negotiation +- Resize and reconnect with full repaint +- Canvas selection, links, scrollback, titles, and a hidden textarea for browser input +- Shared web access policy with Host allowlisting, browser Origin validation, timing-safe Basic auth, and same-origin assets +- Canonical `--public-url` support for hosted Host/Origin validation and CSP WebSocket sources without trusting forwarded headers +- Route-specific CSP; only the terminal document gains `'wasm-unsafe-eval'` +- Existing semantic SPA remains the default web mode + +## Validation + +- `bun run typecheck` +- `bun run check` +- `bun test`: 742 passing +- Production `bun run build` +- `script/smoke-web-tui.ts` against both source and compiled modes: + - health/document/assets + - real alternate-screen frames + - keyboard repaint + - resize reflow + - suspend/resume reconnect repaint + - orderly SIGINT/SIGTERM shutdown, exact `130`/`143` exit codes, and immediate server-port release +- Automated Playwright coverage against the compiled binary: Chromium and Firefox on Linux, plus WebKit on macOS (18 tests total): + - real ghostty-web/WASM startup with first-party CSP and asset checks + - meaningful Canvas frames with exact custom-theme pixels and no hardcoded dark background + - browser CSS variables and light `color-scheme` + - exact Escape, Ctrl+C, navigation, platform Alt/Option, SGR left/right click/release/move/wheel, and resize WebSocket frames + - Canvas selection bypass and clipboard content, bracketed Unicode paste chunking, synthetic IME completion, and DPR 1/2 coordinate parity + - focused and hidden browser notifications, explicit permission prompting, denied-permission focus restoration, and bell controls + - forced WebSocket reconnect and page reload with verified full repaint and no duplicate Canvas or textarea state + - protocol-version rejection before active-client promotion so stale browser bundles cannot silently drop controls or evict a compatible client + - no failed requests, browser console errors, or page errors + - isolated temporary HOME/workspace and orderly process teardown +- `bun run test:web-tui-browser` builds the binary and runs all installed browser projects locally; `.github/workflows/web-tui-browser.yml` runs the platform matrix for relevant pull requests and `main`, and the release workflow gates publication on it. + +## Desktop input compatibility + +The browser TUI targets macOS and Linux desktops. Linux Alt+printable keys use the conventional Escape prefix. macOS Option-produced characters are sent as text without an Alt prefix; dead keys and IME completion use the composition path. Cmd+C/Cmd+V on macOS and Ctrl+Shift+C/Ctrl+V on Linux remain browser-owned. Canvas selections are copied explicitly because they are not DOM selections. Shift+mouse remains local selection rather than SGR input. + +Input uses deterministic legacy terminal sequences. This covers Kit's control-letter, navigation, function-key, mouse, paste, and selection workflows, but cannot represent every modified key, key release, or browser-reserved shortcut. Cmd/Ctrl shortcuts owned by browser chrome remain unavailable. Kitty keyboard mode stays disabled until Kit can track negotiated Kitty flags and encode them consistently. + +Playwright's macOS WebKit build is not the installed Safari application, and synthetic composition does not replace native OS IME validation. Actual Safari, macOS dead-key/IME, and Linux desktop IME behavior remain a short manual release checklist. Windows is not in the supported browser-TUI matrix. The semantic web app remains the supported mobile and touch interface. + +## Startup model + +`--model /` uses the same exact provider/model selector and model-adaptation path as print, RPC, and semantic web modes. Browser-TUI startup applies the selection before plugins and the composer become ready. If the requested known provider is not authenticated, Kit remains at the authentication gate so the user can log into the correct provider and retry; unknown providers or models produce the normal fatal startup diagnostic. + +## Browser notifications + +Browser-TUI notification and bell effects are explicit host capabilities. Local terminal mode retains BEL, terminal notifications, and the macOS error sound; browser-TUI mode sends bounded controls only to the active initialized browser and drops them while disconnected. This prevents the server workstation from ringing or displaying completion notifications for a remote browser session. + +Every notification appears briefly in browser-owned chrome while the page is focused. Web Notifications are created only while hidden/unfocused and only after the user explicitly selects **Enable notifications**; Kit never prompts automatically. Bell audio is local to the browser and remains silent until a pointer or keyboard gesture unlocks Web Audio. Permission denial or policy failure leaves the in-page indicator available and restores terminal focus. + +## Hosting boundary + +Loopback remains the safe default and an explicit `--host` controls other bindings. Kit does not infer public reachability or own deployment-specific TLS, identity, network ACL, ingress, rate-limit, resource-limit, or egress policy. Hosted deployments can provide a canonical external origin with `--public-url https://kit.example.com`; this configures browser Host/Origin boundaries without implicitly trusting `Forwarded` or `X-Forwarded-*` headers. + +Application-level Origin validation remains active for cross-site WebSocket protection, with Host validation as defense-in-depth. Optional Basic Auth is confidential only behind HTTPS. A hosted Kit process is single-principal and must be isolated from other principals by the hosting environment. + +## Theme fidelity + +Custom themes are supported because this mode renders the real reactive OpenTUI shell. Theme tokens and syntax palettes resolve server-side exactly as in a local terminal, then OpenTUI emits truecolor VT cells for ghostty-web to render. + +A controlled light-theme test verified all three stages: + +| Stage | Result | +| --- | --- | +| `~/.kit/themes/{name}.json` resolution | Custom token values loaded and merged over the system theme | +| OpenTUI VT output | Exact custom background `48;2;253;246;227` and foreground `38;2;18;52;86` sequences observed | +| ghostty-web Canvas | 315,903 of 319,950 sampled pixels used the exact custom background | +| `/theme` live preview | Canvas switched from system dark to the custom light palette without reconnecting | +| Preview dismissal | Canvas restored the exact system background | +| Browser-owned chrome | Page background, status overlay, and `color-scheme` update from live theme controls | + +This is materially more capable than the semantic SPA's light/dark mapping. Shell colors, overlays, diffs, Markdown, code syntax, and plugin-exposed theme tokens all use the actual selected theme. + +Resolved theme changes are also sent to the browser as bounded control messages. The client updates CSS variables for the page background, foreground, status overlay, and browser `color-scheme`. A controlled light-theme run verified the exact expected CSS values and no residual dark pixels in the connected Canvas. + +Remaining browser-owned colors: + +- ghostty-web's Canvas selection background and terminal fallback cursor are initialized from the startup palette. +- ghostty-web 0.4.0 exposes a renderer-level `setTheme`, but its public `Terminal` API warns that theme changes after `open()` are not fully supported. +- Before the first resolved theme event, loading chrome uses the safe default dark palette. + +These do not affect connected OpenTUI cells because they are painted with truecolor values. Full selection/fallback-cursor synchronization should wait for a supported ghostty-web terminal API rather than reaching into its private renderer. + +## Footprint + +| Artifact | Bytes | +| --- | ---: | +| Minified ghostty-web client JS | 640,072 | +| Ghostty WASM asset | 423,045 | +| Total before compression | 1,063,117 | +| Existing minified semantic SPA JS | 10,092,169 | +| Clean Kit binary | 93,136,672 | +| Kit binary with experiment | 93,826,144 | +| Binary increase | 689,472 | + +The ghostty-web module contains an embedded base64 WASM fallback even when Kit loads the same-origin WASM URL, so this package version duplicates part of the WASM payload in JS. This is the main footprint disadvantage relative to wterm. + +## Gaps + +- This mode replaces the SPA in the process. Hosting both interfaces on one session still needs ADR 0027's attach/session-host boundary. +- Single active browser terminal only. Multiple independent clients require one renderer and geometry per client over a shared session host. +- Canvas is poor for accessibility and browser-native find compared with DOM. It cannot replace the SPA's semantic message and form structure. +- The browser TUI is desktop-focused; the semantic web app owns mobile/touch, native uploads, and mobile-specific layout. +- Actual Safari and native macOS/Linux IME remain manual compatibility checks; browser-reserved shortcuts are documented limitations. +- Browser input currently uses deterministic legacy key sequences. Kitty keyboard mode remains disabled until the adapter tracks Kitty protocol flags. +- ghostty-web 0.4.0 is unofficial and young. + +## Recommendation + +The remote-stream architecture is viable and removes almost all presentation duplication. Keep the semantic SPA as Kit's accessible/mobile/browser-native interface, and consider a browser TUI as an optional desktop parity surface. + +Use ghostty-web for Kit's browser TUI. It provides the strongest rendering fidelity and its Canvas output preserves Kit's full custom-theme system. Keep the semantic SPA available where accessibility and browser-native content matter more than terminal parity. Before promotion, retain the Kit-owned input and theme adapters, pursue a supported dynamic selection/cursor API upstream, and address the remaining lifecycle and security gaps above. diff --git a/docs/features/rpc-mode.md b/docs/features/rpc-mode.md index 674377be..6a665fdf 100644 --- a/docs/features/rpc-mode.md +++ b/docs/features/rpc-mode.md @@ -84,12 +84,27 @@ validation remains active when authentication is enabled. ## Web mode network validation Kit binds to `127.0.0.1` by default and permits the bound address, localhost -aliases, and each request's same origin. Extra reverse-proxy or tunnel addresses -can be added without losing local browser access: +aliases, and each request's same origin. A hosted deployment can declare one +canonical external HTTP(S) origin: + +```sh +kit --web \ + --public-url https://kit.example.com \ + --auth 'username:password' +``` + +`--public-url` must be an origin without credentials, a subpath, query, or +fragment. It adds the external hostname and browser Origin to Kit's validation +and adds the corresponding `ws://` or `wss://` source to the document CSP. It +does not change the listener; combine it with `--host` when the process must +bind another interface. Kit does not implicitly trust `Forwarded` or +`X-Forwarded-*` headers. + +Additional reverse-proxy or tunnel addresses can still be added without losing +local browser access: ```sh kit --web \ - --auth 'username:password' \ --allow-host kit.example.internal \ --allow-origin https://kit.example.internal ``` @@ -108,10 +123,27 @@ These wildcards are opt-in rather than defaults. `--allow-host '*'` accepts any request Host header. `--allow-origin '*'` accepts any valid Origin value, including the opaque `null` origin, for protected HTTP mutations and WebSocket upgrades; originless requests remain rejected where they were previously -required to carry an Origin. Kit warns when -a wildcard is used without `--auth`. Even with authentication, only use an -origin wildcard behind HTTPS and a trusted network or access-control proxy, -because it disables Kit's cross-site request protection. +required to carry an Origin. Kit warns when a wildcard is used without +`--auth`. Even with authentication, only use an origin wildcard behind HTTPS +and a trusted network or access-control proxy, because it disables Kit's +cross-site request protection. + +### Hosted deployment boundary + +Kit's web server deliberately does not infer whether a listener is publicly +reachable. An explicit `--host` controls binding and loopback remains the safe +default. The hosting environment owns TLS termination, user identity, network +ACLs, ingress exposure, rate limiting, resource limits, and egress policy. +Kit retains application-level Origin validation because browsers can +implicitly send proxy cookies or HTTP credentials during a cross-site +WebSocket attempt; Host validation remains inexpensive defense-in-depth. + +A hosted Kit process is single-principal. It can execute commands and access its +process user's workspace, credentials, tools, and session storage. Multi-user +deployments must isolate principals into separate processes or containers with +scoped workspaces and homes. Kit's optional Basic Auth is not a tenancy or +sandbox boundary and is confidential only when the browser connects over +HTTPS. ### Mobile browser layout