From 08e3729679b30cf426105d894a8c01b5b61c901d Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 16:27:19 -0400 Subject: [PATCH 01/16] feat(web-tui): experiment with ghostty-web canvas --- app/bun.lock | 3 + app/package.json | 1 + app/script/build.ts | 5 + app/script/smoke-web-tui.ts | 196 +++++++++++ app/src/app/bootstrap.tsx | 180 ++++++---- app/src/app/main.tsx | 34 +- app/src/app/web-tui-bridge.test.ts | 141 ++++++++ app/src/app/web-tui-bridge.ts | 215 ++++++++++++ app/src/app/web-tui-mode.ts | 132 ++++++++ app/src/app/web-tui-server.test.ts | 247 ++++++++++++++ app/src/app/web-tui-server.ts | 430 ++++++++++++++++++++++++ app/src/web-tui/build-tui-client.ts | 19 ++ app/src/web-tui/client.ts | 145 ++++++++ app/src/web-tui/index.html | 15 + app/src/web-tui/tui.css | 56 +++ docs/experiments/web-tui-ghostty-web.md | 96 ++++++ 16 files changed, 1843 insertions(+), 72 deletions(-) create mode 100644 app/script/smoke-web-tui.ts create mode 100644 app/src/app/web-tui-bridge.test.ts create mode 100644 app/src/app/web-tui-bridge.ts create mode 100644 app/src/app/web-tui-mode.ts create mode 100644 app/src/app/web-tui-server.test.ts create mode 100644 app/src/app/web-tui-server.ts create mode 100644 app/src/web-tui/build-tui-client.ts create mode 100644 app/src/web-tui/client.ts create mode 100644 app/src/web-tui/index.html create mode 100644 app/src/web-tui/tui.css create mode 100644 docs/experiments/web-tui-ghostty-web.md diff --git a/app/bun.lock b/app/bun.lock index 872fd91e..faec5eca 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", @@ -468,6 +469,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=="], diff --git a/app/package.json b/app/package.json index 821be906..50e524ef 100644 --- a/app/package.json +++ b/app/package.json @@ -53,6 +53,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", 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..bf9b66ad --- /dev/null +++ b/app/script/smoke-web-tui.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env bun +/** + * Smoke test for the experimental browser TUI mode (ghostty-web experiment). + * + * Boots `kit --web --experimental-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"; + +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 { + console.error(`SMOKE FAIL: ${message}`); + process.exit(1); +} + +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 --experimental-tui on port ${port}...`); +const smokeBinary = process.env.KIT_WEB_TUI_SMOKE_BIN; +const server = Bun.spawn({ + cmd: [ + ...(smokeBinary + ? [path.resolve(dir, smokeBinary)] + : ["bun", "--preload=@opentui/solid/preload", "src/app/main.tsx"]), + "--web", + "--experimental-tui", + "--no-session", + "--port", + String(port), + ], + cwd: dir, + stdout: "pipe", + stderr: "pipe", +}); + +const serverExited = server.exited.then((code) => code); + +try { + // Wait for the health endpoint. + { + 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") break; + } catch {} + if (Date.now() > deadline) fail("server did not become healthy"); + await Bun.sleep(100); + } + } + 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 })); + 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 })); + 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)`, + ); + second.close(); + + // Orderly shutdown. + 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"); + console.log(`✓ server exited after SIGINT (code ${code})`); + console.log("SMOKE PASS"); + process.exit(0); +} finally { + server.kill(); +} diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index 58bd0b53..d5c49923 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -54,6 +54,21 @@ type BootstrapOpts = { sessionId?: string; newSession?: boolean; noSession?: boolean; + /** + * 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; }; async function loadSession(opts?: BootstrapOpts): Promise { @@ -180,6 +195,14 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { }; const renderer = await createCliRenderer({ + ...(opts?.terminal + ? { + stdin: opts.terminal.stdin, + stdout: opts.terminal.stdout, + width: opts.terminal.width, + height: opts.terminal.height, + } + : {}), exitOnCtrlC: false, exitSignals: [ "SIGTERM", @@ -213,84 +236,101 @@ 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); + // With a custom terminal (browser-TUI bridge), the process TTY does not + // own the application lifecycle; the bridge/server does. + const usesProcessStdio = opts?.terminal === undefined; + 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) => + renderer.triggerNotification(message, title) + } + 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/main.tsx b/app/src/app/main.tsx index 125736a8..5ac85faf 100644 --- a/app/src/app/main.tsx +++ b/app/src/app/main.tsx @@ -8,6 +8,7 @@ const { positionals, values } = parseArgs({ options: { "allow-host": { type: "string", multiple: true }, auth: { type: "string" }, + "experimental-tui": { type: "boolean" }, "allow-origin": { type: "string", multiple: true }, host: { type: "string" }, mode: { type: "string" }, @@ -33,7 +34,8 @@ const hasWebOnlyOptions = values.host !== undefined || values.port !== undefined || values["allow-host"] !== undefined || - values["allow-origin"] !== undefined; + values["allow-origin"] !== undefined || + values["experimental-tui"] !== undefined; const selectedModes = [values.print, values.rpc, values.web].filter( (value) => value === true, ).length; @@ -117,6 +119,34 @@ if (values.mode !== undefined) { ) { console.error("kit --web --port expects an integer from 1 to 65535"); process.exitCode = 1; + } else if (values["experimental-tui"] === true) { + if (typeof values.model === "string") { + console.error( + "kit --web --experimental-tui does not support --model; select the model inside the TUI", + ); + process.exitCode = 1; + } else { + 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, + 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"); @@ -143,7 +173,7 @@ if (values.mode !== undefined) { } } else if (hasWebOnlyOptions) { console.error( - "--auth, --host, --port, --allow-host, and --allow-origin require --web", + "--auth, --host, --port, --allow-host, --allow-origin, and --experimental-tui require --web", ); process.exitCode = 1; } else if (values.rpc === true) { 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..384711b4 --- /dev/null +++ b/app/src/app/web-tui-mode.ts @@ -0,0 +1,132 @@ +/** + * Experimental `kit --web --experimental-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 { WebTuiBridge } from "./web-tui-bridge"; +import { + type WebTuiBasicAuthCredentials, + WebTuiServer, +} from "./web-tui-server"; + +export type WebTuiModeOptions = { + allowedHosts?: string[]; + allowedOrigins?: string[]; + basicAuth?: WebTuiBasicAuthCredentials; + hostname?: string; + port?: number; + 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 stop: (() => void) | undefined; + const stopped = new Promise((resolve) => { + stop = resolve; + }); + + const startApp = () => { + if (appPromise) return; + appPromise = import("./bootstrap") + .then(({ bootstrap }) => + bootstrap({ + newSession: options.newSession, + noSession: options.noSession, + sessionId: options.sessionId, + terminal: bridge.terminal, + }), + ) + .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, + basicAuth: options.basicAuth, + }, + ); + + const handleSignal = (signal: "SIGINT" | "SIGTERM") => { + const exitCode = signal === "SIGINT" ? 130 : 143; + if (signalExitCode !== 0) process.exit(exitCode); + signalExitCode = exitCode; + // 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) listening on ${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 --experimental-tui failed: ${error instanceof Error ? error.message : String(error)}`, + ); + exitCode = 1; + } finally { + process.off("SIGINT", handleSigint); + process.off("SIGTERM", handleSigterm); + bridge.shutdown(); + await appPromise; + await server.stop(); + } + return signalExitCode !== 0 ? signalExitCode : exitCode; +} 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..27bd1569 --- /dev/null +++ b/app/src/app/web-tui-server.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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 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("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("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(JSON.stringify({ type: "init", cols: 120, rows: 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("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(JSON.stringify({ type: "init", cols: 80, rows: 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(JSON.stringify({ type: "init", cols: 100, rows: 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(JSON.stringify({ type: "init", cols: 10_000, rows: 1 })); + await waitFor(() => host.log.attached.length === 1); + expect(host.log.attached[0]).toMatchObject({ cols: 500, rows: 5 }); + socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + await waitFor(() => host.log.resizes.length === 1); + expect(host.log.attached.length).toBe(1); + socket.close(); + }); + + 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(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + await waitFor(() => host.log.attached.length === 1); + + const firstClosed = nextClose(first); + const second = await openSocket(wsUrl, { headers: { origin } }); + const closeEvent = await firstClosed; + expect(closeEvent.code).toBe(4001); + await waitFor(() => host.log.detached.length === 1); + + second.send(JSON.stringify({ type: "init", cols: 100, rows: 40 })); + 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..8f361166 --- /dev/null +++ b/app/src/app/web-tui-server.ts @@ -0,0 +1,430 @@ +/** + * 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 { createHash, timingSafeEqual } from "node:crypto"; +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 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 { clampTuiSize } from "./web-tui-bridge"; + +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 WebTuiBasicAuthCredentials = { + username: string; + password: string; +}; + +export type WebTuiServerOptions = { + hostname?: string; + port?: number; + allowedHosts?: string[]; + allowedOrigins?: string[]; + allowOriginless?: boolean; + basicAuth?: WebTuiBasicAuthCredentials; +}; + +type WebSocketData = { + client: WebTuiClient | 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 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): HeadersInit { + const webSocketOrigins = `ws://${url.host} wss://${url.host}`; + 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", + }; +} + +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\s+([A-Za-z0-9+/=]+)$/i); + if (!match?.[1]) return null; + try { + return Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return null; + } +} + +type ControlMessage = { type: "init" | "resize"; cols: number; rows: number }; + +function parseControlMessage(message: string): ControlMessage | null { + if (message.length > 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 !== "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; + } + const size = clampTuiSize(record.cols, record.rows); + return { type: record.type, cols: size.cols, rows: size.rows }; +} + +export class WebTuiServer { + private server: Server | null = null; + private activeSocket: ServerWebSocket | null = null; + private readonly clients = new Set>(); + private readonly expectedBasicAuthDigest: Buffer | null; + + constructor( + private readonly host: WebTuiHost, + private readonly options: WebTuiServerOptions = {}, + ) { + this.expectedBasicAuthDigest = options.basicAuth + ? credentialDigest( + `${options.basicAuth.username}:${options.basicAuth.password}`, + ) + : null; + } + + get clientCount(): number { + return this.activeSocket ? 1 : 0; + } + + 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.isAllowedHost(url.host)) { + return new Response("Host not allowed", { status: 403 }); + } + const isWebSocketRequest = url.pathname === "/api/tui"; + if ( + isWebSocketRequest && + !this.isAllowedWebSocketRequest(request, url) + ) { + return new Response("Origin or host not allowed", { status: 403 }); + } + if (!this.isAuthorized(request)) { + return this.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 } })) { + return undefined; + } + return new Response("WebSocket upgrade required", { status: 426 }); + } + if (url.pathname === "/") { + return new Response(tuiHtml as unknown as string, { + headers: tuiDocumentHeaders(url), + }); + } + return new Response("Not found", { status: 404 }); + }, + websocket: { + maxPayloadLength: 64 * 1024, + backpressureLimit: 16 * 1024 * 1024, + closeOnBackpressureLimit: true, + open: (socket) => { + this.clients.add(socket); + // Single-terminal policy: a new connection replaces the old one. + const previous = this.activeSocket; + this.activeSocket = socket; + if (previous) { + this.releaseSocket(previous); + previous.close(4001, "replaced by a newer client"); + } + }, + message: (socket, message) => { + if (this.activeSocket !== socket) return; + if (typeof message === "string") { + const control = parseControlMessage(message); + if (!control) return; + if (control.type === "init") { + if (socket.data.client) { + this.host.resize(control.cols, control.rows); + return; + } + const client: WebTuiClient = { + send: (bytes) => this.send(socket, bytes), + }; + socket.data.client = client; + this.host.attach(client, control.cols, control.rows); + return; + } + if (socket.data.client) { + this.host.resize(control.cols, control.rows); + } + 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; + return { + hostname: server.hostname ?? this.options.hostname ?? "127.0.0.1", + port: server.port ?? this.options.port ?? 4783, + 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) { + const stopped = server.stop(true).catch((error) => { + console.error( + `Web TUI server stop failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + // Bun may await a browser peer's close handshake despite force=true. + await Promise.race([stopped, Bun.sleep(250)]); + } + } + + private send( + socket: ServerWebSocket, + bytes: Uint8Array, + ): void { + if (socket.readyState !== WebSocket.OPEN) return; + try { + if (socket.send(bytes) > 0) return; + } 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(); + } + + private releaseSocket(socket: ServerWebSocket): void { + const client = socket.data.client; + socket.data.client = null; + if (client) this.host.detach(client); + } + + 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 TUI 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 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 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 ?? 4783; + 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/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..db2e6ce1 --- /dev/null +++ b/app/src/web-tui/client.ts @@ -0,0 +1,145 @@ +import { FitAddon, Ghostty, Terminal } from "ghostty-web"; + +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; +} + +function webSocketUrl(): string { + const scheme = location.protocol === "https:" ? "wss" : "ws"; + return `${scheme}://${location.host}/api/tui`; +} + +class TuiConnection { + private socket: WebSocket | null = null; + private reconnectDelay = RECONNECT_MIN_MS; + private reconnectTimer: number | null = null; + private closedByPage = false; + private readonly encoder = new TextEncoder(); + + constructor( + private readonly terminal: Terminal, + private readonly fit: FitAddon, + ) { + 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) { + this.terminal.write(new Uint8Array(event.data)); + } + }); + 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; + } + 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); + } + + private sendInput(data: string): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(this.encoder.encode(data)); + } + } + + private sendControl( + type: "init" | "resize", + cols: number, + rows: number, + ): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ type, cols, rows })); + } + } +} + +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(); + new TuiConnection(terminal, fit).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..e073abff --- /dev/null +++ b/app/src/web-tui/index.html @@ -0,0 +1,15 @@ + + + + + + + Kit TUI · ghostty-web experiment + + + + +
+
Connecting…
+ + diff --git a/app/src/web-tui/tui.css b/app/src/web-tui/tui.css new file mode 100644 index 00000000..ead68467 --- /dev/null +++ b/app/src/web-tui/tui.css @@ -0,0 +1,56 @@ +@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 { + color-scheme: dark; + background: #0a0a0a; +} + +* { + box-sizing: border-box; +} + +html, +body, +#terminal { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: #0a0a0a; + color: #fafafa; + 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 #404040; + border-radius: 0.25rem; + background: #171717; + color: #d4d4d4; + font: + 0.75rem / 1.2 system-ui, + sans-serif; +} + +#status[hidden] { + display: none; +} diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md new file mode 100644 index 00000000..957b8846 --- /dev/null +++ b/docs/experiments/web-tui-ghostty-web.md @@ -0,0 +1,96 @@ +# 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 --experimental-tui +``` + +Normal `kit --web` behavior is unchanged. The experimental mode has one runtime owner: + +```text +browser server +┌─────────────────────────┐ ┌──────────────────────────────────┐ +│ ghostty-web Canvas │ ws bytes │ WebTuiServer │ +│ Ghostty VT + key encoder│◄────────►│ WebTuiBridge virtual tty │ +│ fit/input/mouse/paste │ 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 `init`/`resize` controls. A 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 +- Keyboard input using ghostty-web's Ghostty key encoder, including Kitty keyboard negotiation +- Mouse/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 +- Existing Host allowlist, Origin validation, timing-safe Basic auth, and same-origin assets +- 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`: 704 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 + - clean SIGINT shutdown +- Real Chromium against the compiled binary: + - one Canvas and one focused hidden textarea + - WebSocket connected without console errors + - typing accepted + - page reload reconnected and recreated the Canvas without errors + +## 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. +- Mobile gets a hidden textarea and viewport fitting but no touch shortcut bar, native upload flow, or mobile-specific layout. +- Terminal bell and OSC 52 paths that write directly to process stdout do not reach the browser renderer. +- Security policy is mirrored from `WebRpcServer` rather than extracted into one shared implementation. +- `--model` is not accepted in this mode; select the model inside the TUI. +- Clipboard permissions, complex IME, selection, links, mouse gestures, and long-running reconnects need broader browser testing. +- 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. + +Between the two terminal renderers, ghostty-web offers the strongest terminal fidelity and Ghostty keyboard encoding, but its Canvas accessibility and duplicated WASM packaging are meaningful costs. The paired wterm experiment is likely the better default browser surface if native selection/find and a much smaller payload matter more than Canvas rendering fidelity. From 9720cb8c5fe684713aea503a0f0fba49eba42f61 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 17:46:35 -0400 Subject: [PATCH 02/16] fix(web-tui): normalize browser keyboard and mouse input --- app/src/app/bootstrap.tsx | 3 + .../web-tui/browser-terminal-input.test.ts | 70 +++++ app/src/web-tui/browser-terminal-input.ts | 290 ++++++++++++++++++ app/src/web-tui/client.ts | 30 +- docs/experiments/web-tui-ghostty-web.md | 12 +- 5 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 app/src/web-tui/browser-terminal-input.test.ts create mode 100644 app/src/web-tui/browser-terminal-input.ts diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index d5c49923..ee702a50 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -201,6 +201,9 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { 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, 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..4b4d6f64 --- /dev/null +++ b/app/src/web-tui/browser-terminal-input.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { + type BrowserKeyLike, + 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 Escape and control characters", () => { + expect(encodeBrowserKey(key("Escape"))).toBe("\x1b"); + expect(encodeBrowserKey(key("c", { ctrlKey: true }))).toBe("\x03"); + expect(encodeBrowserKey(key("[", { ctrlKey: true }))).toBe("\x1b"); + }); + + test("encodes navigation keys and their modifiers", () => { + expect(encodeBrowserKey(key("ArrowUp"))).toBe("\x1b[A"); + expect( + encodeBrowserKey(key("ArrowLeft", { ctrlKey: true, shiftKey: true })), + ).toBe("\x1b[1;6D"); + expect(encodeBrowserKey(key("Tab", { shiftKey: true }))).toBe("\x1b[Z"); + }); + + 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(); + }); +}); + +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"), + ); + expect(state.mouseTracking).toBe(1003); + expect(state.mouseSgr).toBe(true); + state.feed(new TextEncoder().encode("\x1b[?1003l")); + expect(state.mouseTracking).toBe(1002); + }); + + 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..930209f8 --- /dev/null +++ b/app/src/web-tui/browser-terminal-input.ts @@ -0,0 +1,290 @@ +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) + ); +} + +/** Encode browser keys whose native handling is unreliable or browser-owned. */ +export function encodeBrowserKey(event: BrowserKeyLike): 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 ( + (event.metaKey && event.key.toLowerCase() === "c") || + (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "c") || + ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") + ) { + 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"; + + 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; + + 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; + } + } + } + this.tail = text.slice(-96); + } +} + +export type TerminalGeometry = { + columns: number; + rows: number; + bounds: DOMRect; +}; + +export type BrowserTerminalInputOptions = { + root: HTMLElement; + protocol: TerminalProtocolState; + geometry: () => TerminalGeometry | null; + send: (data: string) => void; + focus: () => void; +}; + +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 geometry: () => TerminalGeometry | null; + private readonly send: (data: string) => void; + private readonly focus: () => void; + private disposed = false; + + constructor(options: BrowserTerminalInputOptions) { + this.root = options.root; + this.protocol = options.protocol; + this.geometry = options.geometry; + this.send = options.send; + this.focus = options.focus; + 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("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("wheel", this.onWheel, true); + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + const sequence = encodeBrowserKey(event); + 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 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/client.ts b/app/src/web-tui/client.ts index db2e6ce1..fc138740 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -1,4 +1,8 @@ import { FitAddon, Ghostty, Terminal } from "ghostty-web"; +import { + BrowserTerminalInput, + TerminalProtocolState, +} from "./browser-terminal-input"; const RECONNECT_MIN_MS = 500; const RECONNECT_MAX_MS = 5_000; @@ -34,6 +38,7 @@ class TuiConnection { constructor( private readonly terminal: Terminal, private readonly fit: FitAddon, + private readonly protocol: TerminalProtocolState, ) { terminal.onData((data) => this.sendInput(data)); terminal.onResize(({ cols, rows }) => @@ -70,7 +75,9 @@ class TuiConnection { }); socket.addEventListener("message", (event) => { if (event.data instanceof ArrayBuffer) { - this.terminal.write(new Uint8Array(event.data)); + const bytes = new Uint8Array(event.data); + this.protocol.feed(bytes); + this.terminal.write(bytes); } }); socket.addEventListener("close", (event) => { @@ -96,7 +103,7 @@ class TuiConnection { }, delay); } - private sendInput(data: string): void { + sendInput(data: string): void { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(this.encoder.encode(data)); } @@ -135,7 +142,24 @@ async function main(): Promise { terminal.open(element); fit.observeResize(); fit.fit(); - new TuiConnection(terminal, fit).connect(); + const protocol = new TerminalProtocolState(); + const connection = new TuiConnection(terminal, fit, protocol); + 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(), + }); + connection.connect(); } void main().catch((error) => { diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 957b8846..f8ed766f 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -36,8 +36,9 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - The complete real `AppShell`, including dialogs, pickers, workspace panes, review UI, plugin chrome, and themes - One authoritative session/runtime owner -- Keyboard input using ghostty-web's Ghostty key encoder, including Kitty keyboard negotiation -- Mouse/focus/bracketed-paste/synchronized-output/alternate-screen mode negotiation +- Kit-owned browser keyboard normalization for Escape, Ctrl combinations, navigation, and function keys +- Kit-owned SGR mouse encoding for click, release, drag, all-motion, and wheel events +- 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 - Existing Host allowlist, Origin validation, timing-safe Basic auth, and same-origin assets @@ -48,7 +49,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 704 passing +- `bun test`: 709 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -86,11 +87,12 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - Terminal bell and OSC 52 paths that write directly to process stdout do not reach the browser renderer. - Security policy is mirrored from `WebRpcServer` rather than extracted into one shared implementation. - `--model` is not accepted in this mode; select the model inside the TUI. -- Clipboard permissions, complex IME, selection, links, mouse gestures, and long-running reconnects need broader browser testing. +- Complex IME, selection, links, touch gestures, browser-reserved shortcuts, and long-running reconnects need broader browser testing. +- 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. -Between the two terminal renderers, ghostty-web offers the strongest terminal fidelity and Ghostty keyboard encoding, but its Canvas accessibility and duplicated WASM packaging are meaningful costs. The paired wterm experiment is likely the better default browser surface if native selection/find and a much smaller payload matter more than Canvas rendering fidelity. +Between the two terminal renderers, ghostty-web offers the strongest rendering fidelity, but its Canvas accessibility and duplicated WASM packaging are meaningful costs. The paired wterm experiment is likely the better default browser surface if native selection/find and a much smaller payload matter more than Canvas rendering fidelity. From 40fd933299bc020d69cbb6a409173fd433059b8c Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 18:56:00 -0400 Subject: [PATCH 03/16] docs(web-tui): record renderer and theme fidelity decision --- docs/experiments/web-tui-ghostty-web.md | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index f8ed766f..bad0a9e0 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -20,8 +20,8 @@ Normal `kit --web` behavior is unchanged. The experimental mode has one runtime browser server ┌─────────────────────────┐ ┌──────────────────────────────────┐ │ ghostty-web Canvas │ ws bytes │ WebTuiServer │ -│ Ghostty VT + key encoder│◄────────►│ WebTuiBridge virtual tty │ -│ fit/input/mouse/paste │ resize │ OpenTUI CliRenderer(remote) │ +│ Ghostty VT renderer │◄────────►│ WebTuiBridge virtual tty │ +│ Kit input/mouse adapter │ resize │ OpenTUI CliRenderer(remote) │ └─────────────────────────┘ │ real Kit App / AppShell/runtime │ └──────────────────────────────────┘ ``` @@ -64,6 +64,30 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - typing accepted - page reload reconnected and recreated the Canvas without errors +## 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 | + +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. + +Remaining browser-owned colors are not yet synchronized: + +- The loading/disconnected page and `color-scheme` CSS are hardcoded dark. +- ghostty-web's selection background and terminal fallback cursor are initialized from a dark palette. +- ghostty-web 0.4.0 warns that changing its renderer theme after `open()` is not fully supported. + +These do not affect connected OpenTUI cells because they are painted with truecolor values, but they can produce mismatched browser selection and disconnected/loading chrome. A small server-to-client theme control message should update CSS variables and browser-owned colors when the resolved Kit theme changes. + ## Footprint | Artifact | Bytes | @@ -95,4 +119,4 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l 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. -Between the two terminal renderers, ghostty-web offers the strongest rendering fidelity, but its Canvas accessibility and duplicated WASM packaging are meaningful costs. The paired wterm experiment is likely the better default browser surface if native selection/find and a much smaller payload matter more than Canvas rendering fidelity. +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, synchronize browser-owned theme colors, retain the Kit-owned input adapter, and address the remaining lifecycle and security gaps above. From 18a3bc119b75618d240fc8e2b4b52001d8c173dd Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 19:07:29 -0400 Subject: [PATCH 04/16] feat(web-tui): synchronize browser theme chrome --- app/src/app/web-tui-mode.ts | 23 ++++++++ app/src/app/web-tui-server.test.ts | 35 +++++++++++++ app/src/app/web-tui-server.ts | 21 +++++++- app/src/shell/theme.test.ts | 21 +++++++- app/src/shell/theme.ts | 25 +++++++++ app/src/web-tui/browser-theme.test.ts | 43 +++++++++++++++ app/src/web-tui/browser-theme.ts | 70 +++++++++++++++++++++++++ app/src/web-tui/client.ts | 6 +++ app/src/web-tui/tui.css | 19 ++++--- docs/experiments/web-tui-ghostty-web.md | 17 +++--- 10 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 app/src/web-tui/browser-theme.test.ts create mode 100644 app/src/web-tui/browser-theme.ts diff --git a/app/src/app/web-tui-mode.ts b/app/src/app/web-tui-mode.ts index 384711b4..34633691 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -12,12 +12,27 @@ * queries the palette. */ +import { subscribeThemeConfig } from "../shell/theme"; +import type { ThemeConfig } from "../shell/themes/types"; +import type { BrowserTheme } from "../web-tui/browser-theme"; 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[]; @@ -82,6 +97,13 @@ export async function runWebTuiMode( }, ); + const unsubscribeTheme = subscribeThemeConfig( + (config) => { + server.setBrowserTheme(browserThemeFromConfig(config)); + }, + { emitCurrent: false }, + ); + const handleSignal = (signal: "SIGINT" | "SIGTERM") => { const exitCode = signal === "SIGINT" ? 130 : 143; if (signalExitCode !== 0) process.exit(exitCode); @@ -122,6 +144,7 @@ export async function runWebTuiMode( ); exitCode = 1; } finally { + unsubscribeTheme(); process.off("SIGINT", handleSigint); process.off("SIGTERM", handleSigterm); bridge.shutdown(); diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts index 27bd1569..b499e585 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -183,6 +183,41 @@ describe("WebTuiServer WebSocket", () => { 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(JSON.stringify({ type: "init", cols: 80, rows: 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("delivers host output to the attached client as binary frames", async () => { const host = recordingHost(); const { wsUrl, origin } = startServer(host); diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts index 8f361166..be847d8a 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -18,6 +18,7 @@ import jetbrainsMonoNormal from "@fontsource-variable/jetbrains-mono/files/jetbr }; import type { Server, ServerWebSocket } from "bun"; import ghosttyWasm from "ghostty-web/ghostty-vt.wasm" with { type: "file" }; +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" }; @@ -176,6 +177,7 @@ export class WebTuiServer { private activeSocket: ServerWebSocket | null = null; private readonly clients = new Set>(); private readonly expectedBasicAuthDigest: Buffer | null; + private browserTheme: BrowserTheme | null = null; constructor( private readonly host: WebTuiHost, @@ -192,6 +194,12 @@ export class WebTuiServer { return this.activeSocket ? 1 : 0; } + setBrowserTheme(theme: BrowserTheme): void { + this.browserTheme = { ...theme }; + const socket = this.activeSocket; + if (socket) this.sendTheme(socket); + } + start(): { hostname: string; port: number; url: string } { if (this.server) throw new Error("Web TUI server is already running"); const server = Bun.serve({ @@ -280,6 +288,7 @@ export class WebTuiServer { send: (bytes) => this.send(socket, bytes), }; socket.data.client = client; + this.sendTheme(socket); this.host.attach(client, control.cols, control.rows); return; } @@ -327,13 +336,21 @@ export class WebTuiServer { } } + private sendTheme(socket: ServerWebSocket): void { + if (!this.browserTheme) return; + this.send( + socket, + JSON.stringify({ type: "theme", theme: this.browserTheme }), + ); + } + private send( socket: ServerWebSocket, - bytes: Uint8Array, + data: string | Uint8Array, ): void { if (socket.readyState !== WebSocket.OPEN) return; try { - if (socket.send(bytes) > 0) return; + if (socket.send(data) > 0) return; } catch (error) { console.error( `Web TUI send failed: ${error instanceof Error ? error.message : String(error)}`, 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/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/client.ts b/app/src/web-tui/client.ts index fc138740..3bbe8134 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -3,6 +3,7 @@ import { BrowserTerminalInput, TerminalProtocolState, } from "./browser-terminal-input"; +import { applyBrowserTheme, parseBrowserThemeMessage } from "./browser-theme"; const RECONNECT_MIN_MS = 500; const RECONNECT_MAX_MS = 5_000; @@ -78,6 +79,11 @@ class TuiConnection { 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); } }); socket.addEventListener("close", (event) => { diff --git a/app/src/web-tui/tui.css b/app/src/web-tui/tui.css index ead68467..f6c4c193 100644 --- a/app/src/web-tui/tui.css +++ b/app/src/web-tui/tui.css @@ -7,8 +7,15 @@ } :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: #0a0a0a; + background: var(--kit-terminal-bg); } * { @@ -25,8 +32,8 @@ body, } body { - background: #0a0a0a; - color: #fafafa; + background: var(--kit-terminal-bg); + color: var(--kit-terminal-fg); font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; } @@ -42,10 +49,10 @@ body { 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 #404040; + border: 1px solid var(--kit-status-border); border-radius: 0.25rem; - background: #171717; - color: #d4d4d4; + background: var(--kit-status-bg); + color: var(--kit-status-fg); font: 0.75rem / 1.2 system-ui, sans-serif; diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index bad0a9e0..206b1ad4 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -49,7 +49,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 709 passing +- `bun test`: 714 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -77,16 +77,19 @@ A controlled light-theme test verified all three stages: | 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. -Remaining browser-owned colors are not yet synchronized: +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. -- The loading/disconnected page and `color-scheme` CSS are hardcoded dark. -- ghostty-web's selection background and terminal fallback cursor are initialized from a dark palette. -- ghostty-web 0.4.0 warns that changing its renderer theme after `open()` is not fully supported. +Remaining browser-owned colors: -These do not affect connected OpenTUI cells because they are painted with truecolor values, but they can produce mismatched browser selection and disconnected/loading chrome. A small server-to-client theme control message should update CSS variables and browser-owned colors when the resolved Kit theme changes. +- 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 @@ -119,4 +122,4 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l 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, synchronize browser-owned theme colors, retain the Kit-owned input adapter, and address the remaining lifecycle and security gaps above. +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. From 8e1d05475fd4e2dc9c178794bc79c48bd2c54be1 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 20:04:18 -0400 Subject: [PATCH 05/16] fix(web-tui): own orderly signal shutdown --- app/script/smoke-web-tui.ts | 110 +++++++++++++++++------- app/src/app/bootstrap.tsx | 57 +++--------- app/src/app/shutdown-watchdog.ts | 42 +++++++++ app/src/app/web-tui-mode.ts | 56 ++++++++++-- app/src/app/web-tui-server.test.ts | 23 +++++ app/src/app/web-tui-server.ts | 45 +++++++++- docs/experiments/web-tui-ghostty-web.md | 4 +- 7 files changed, 244 insertions(+), 93 deletions(-) create mode 100644 app/src/app/shutdown-watchdog.ts diff --git a/app/script/smoke-web-tui.ts b/app/script/smoke-web-tui.ts index bf9b66ad..14f2ba9c 100644 --- a/app/script/smoke-web-tui.ts +++ b/app/script/smoke-web-tui.ts @@ -24,8 +24,7 @@ const WebSocketWithOptions = WebSocket as unknown as new ( ) => WebSocket; function fail(message: string): never { - console.error(`SMOKE FAIL: ${message}`); - process.exit(1); + throw new Error(`SMOKE FAIL: ${message}`); } type Connection = { @@ -77,38 +76,53 @@ async function connect(): Promise { console.log(`Starting kit --web --experimental-tui on port ${port}...`); const smokeBinary = process.env.KIT_WEB_TUI_SMOKE_BIN; -const server = Bun.spawn({ - cmd: [ - ...(smokeBinary - ? [path.resolve(dir, smokeBinary)] - : ["bun", "--preload=@opentui/solid/preload", "src/app/main.tsx"]), - "--web", - "--experimental-tui", - "--no-session", - "--port", - String(port), - ], - cwd: dir, - stdout: "pipe", - stderr: "pipe", -}); +const spawnServer = () => + Bun.spawn({ + cmd: [ + ...(smokeBinary + ? [path.resolve(dir, smokeBinary)] + : ["bun", "--preload=@opentui/solid/preload", "src/app/main.tsx"]), + "--web", + "--experimental-tui", + "--no-session", + "--port", + String(port), + ], + 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. - { - 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") break; - } catch {} - if (Date.now() > deadline) fail("server did not become healthy"); - await Bun.sleep(100); - } - } + await waitForHealth(); console.log("✓ health endpoint reports web-tui mode"); // Document + assets sanity. @@ -179,18 +193,48 @@ try { console.log( `✓ reconnect replayed terminal setup and repainted (${second.bytesSeen()} bytes)`, ); - second.close(); - // Orderly shutdown. + // 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"); - console.log(`✓ server exited after SIGINT (code ${code})`); + 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"); - process.exit(0); } finally { server.kill(); + terminationServer?.kill(); + await Promise.allSettled([ + server.exited, + ...(terminationServer ? [terminationServer.exited] : []), + ]); } diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index ee702a50..9e596d51 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -22,33 +22,7 @@ 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; @@ -177,22 +151,10 @@ 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 @@ -231,7 +193,11 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { .finally(() => { resolveAlive?.(); resolveAlive = null; - startShutdownWatchdog(); + if (usesProcessStdio) { + startShutdownWatchdog( + typeof process.exitCode === "number" ? process.exitCode : 0, + ); + } }); }, consoleOptions: { @@ -279,9 +245,6 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { renderer.destroy(); } - // With a custom terminal (browser-TUI bridge), the process TTY does not - // own the application lifecycle; the bridge/server does. - const usesProcessStdio = opts?.terminal === undefined; const stdioShutdown = () => quitAndDestroy(); if (usesProcessStdio) { process.stdin.once("end", stdioShutdown); 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-tui-mode.ts b/app/src/app/web-tui-mode.ts index 34633691..db1fe0c3 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -15,6 +15,7 @@ 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, @@ -51,6 +52,7 @@ export async function runWebTuiMode( 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; @@ -105,9 +107,17 @@ export async function runWebTuiMode( ); const handleSignal = (signal: "SIGINT" | "SIGTERM") => { - const exitCode = signal === "SIGINT" ? 130 : 143; - if (signalExitCode !== 0) process.exit(exitCode); - signalExitCode = exitCode; + 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`. @@ -145,11 +155,43 @@ export async function runWebTuiMode( 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); - bridge.shutdown(); - await appPromise; - await server.stop(); + 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)}`, + ); + } + } } - return signalExitCode !== 0 ? signalExitCode : exitCode; + 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 index b499e585..cd5be894 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -262,6 +262,29 @@ describe("WebTuiServer WebSocket", () => { 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(JSON.stringify({ type: "init", cols: 80, rows: 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); diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts index be847d8a..6e259d65 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -326,14 +326,51 @@ export class WebTuiServer { const server = this.server; this.server = null; if (server) { - const stopped = server.stop(true).catch((error) => { + 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: ${error instanceof Error ? error.message : String(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, }); - // Bun may await a browser peer's close handshake despite force=true. - await Promise.race([stopped, Bun.sleep(250)]); } + await probe.stop(true); } private sendTheme(socket: ServerWebSocket): void { diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 206b1ad4..ca8c61fa 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -49,7 +49,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 714 passing +- `bun test`: 716 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -57,7 +57,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - keyboard repaint - resize reflow - suspend/resume reconnect repaint - - clean SIGINT shutdown + - orderly SIGINT/SIGTERM shutdown, exact `130`/`143` exit codes, and immediate server-port release - Real Chromium against the compiled binary: - one Canvas and one focused hidden textarea - WebSocket connected without console errors From 513c7cc2b0932dc33aa5de38f616ddb3106db785 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 20:29:45 -0400 Subject: [PATCH 06/16] feat(web): add canonical public URL policy --- app/script/smoke-web-tui.ts | 2 + app/src/app/main.test.ts | 12 ++ app/src/app/main.tsx | 16 +- app/src/app/web-access-policy.test.ts | 137 +++++++++++++++ app/src/app/web-access-policy.ts | 215 ++++++++++++++++++++++++ app/src/app/web-mode.ts | 9 +- app/src/app/web-rpc-server.test.ts | 29 ++++ app/src/app/web-rpc-server.ts | 180 +++----------------- app/src/app/web-tui-mode.ts | 7 +- app/src/app/web-tui-server.test.ts | 37 ++++ app/src/app/web-tui-server.ts | 151 +++-------------- docs/experiments/web-tui-ghostty-web.md | 12 +- docs/features/rpc-mode.md | 46 ++++- 13 files changed, 565 insertions(+), 288 deletions(-) create mode 100644 app/src/app/web-access-policy.test.ts create mode 100644 app/src/app/web-access-policy.ts diff --git a/app/script/smoke-web-tui.ts b/app/script/smoke-web-tui.ts index 14f2ba9c..d8c21fb1 100644 --- a/app/script/smoke-web-tui.ts +++ b/app/script/smoke-web-tui.ts @@ -87,6 +87,8 @@ const spawnServer = () => "--no-session", "--port", String(port), + "--public-url", + origin, ], cwd: dir, env: { ...process.env, KIT_DEBUG_SHUTDOWN: "1" }, diff --git a/app/src/app/main.test.ts b/app/src/app/main.test.ts index 6c53eeb2..dfe9c175 100644 --- a/app/src/app/main.test.ts +++ b/app/src/app/main.test.ts @@ -111,6 +111,17 @@ 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); @@ -177,6 +188,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 5ac85faf..39c48df3 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({ @@ -15,6 +16,7 @@ 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" }, @@ -33,6 +35,7 @@ const hasWebOnlyOptions = values.auth !== undefined || values.host !== undefined || values.port !== undefined || + values["public-url"] !== undefined || values["allow-host"] !== undefined || values["allow-origin"] !== undefined || values["experimental-tui"] !== undefined; @@ -90,6 +93,10 @@ if (values.mode !== undefined) { 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) @@ -107,6 +114,11 @@ if (values.mode !== undefined) { } else if (values.auth !== undefined && !basicAuth) { console.error("kit --web --auth expects :"); process.exitCode = 1; + } else if (values["public-url"] !== undefined && !publicUrl) { + console.error( + "kit --web --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) @@ -141,6 +153,7 @@ if (values.mode !== undefined) { basicAuth, hostname: typeof values.host === "string" ? values.host : undefined, port, + publicUrl: publicUrl ?? undefined, newSession: selectsNewSession, noSession: values["no-session"] === true, sessionId: @@ -164,6 +177,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, @@ -173,7 +187,7 @@ if (values.mode !== undefined) { } } else if (hasWebOnlyOptions) { console.error( - "--auth, --host, --port, --allow-host, --allow-origin, and --experimental-tui require --web", + "--auth, --host, --port, --public-url, --allow-host, --allow-origin, and --experimental-tui require --web", ); process.exitCode = 1; } else if (values.rpc === true) { 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-mode.ts b/app/src/app/web-tui-mode.ts index db1fe0c3..bb1ade5b 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -40,6 +40,7 @@ export type WebTuiModeOptions = { basicAuth?: WebTuiBasicAuthCredentials; hostname?: string; port?: number; + publicUrl?: string; newSession?: boolean; noSession?: boolean; sessionId?: string; @@ -95,6 +96,7 @@ export async function runWebTuiMode( port: options.port, allowedHosts: options.allowedHosts, allowedOrigins: options.allowedOrigins, + publicUrl: options.publicUrl, basicAuth: options.basicAuth, }, ); @@ -141,8 +143,11 @@ export async function runWebTuiMode( } const started = server.start(); console.error( - `kit web TUI mode (experimental) listening on ${started.url}`, + `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.", ); diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts index cd5be894..7740fb7f 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -117,6 +117,43 @@ describe("WebTuiServer HTTP", () => { }); }); + 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}/`, { diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts index 6e259d65..090ba3c1 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -9,7 +9,6 @@ * `'wasm-unsafe-eval'`, required to instantiate the Ghostty terminal core. */ -import { createHash, timingSafeEqual } from "node:crypto"; import jetbrainsMonoItalic from "@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2" with { type: "file", }; @@ -22,8 +21,14 @@ 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; }; @@ -36,18 +41,14 @@ export type WebTuiHost = { resize(cols: number, rows: number): void; }; -export type WebTuiBasicAuthCredentials = { - username: string; - password: string; -}; - export type WebTuiServerOptions = { hostname?: string; port?: number; + publicUrl?: string; allowedHosts?: string[]; allowedOrigins?: string[]; allowOriginless?: boolean; - basicAuth?: WebTuiBasicAuthCredentials; + basicAuth?: WebBasicAuthCredentials; }; type WebSocketData = { @@ -101,8 +102,11 @@ const TUI_ASSETS = new Map< ], ]); -function tuiDocumentHeaders(url: URL): HeadersInit { - const webSocketOrigins = `ws://${url.host} wss://${url.host}`; +function tuiDocumentHeaders( + url: URL, + accessPolicy: WebAccessPolicy, +): HeadersInit { + const webSocketOrigins = accessPolicy.webSocketConnectSources(url); return { "content-type": "text/html; charset=utf-8", "content-security-policy": [ @@ -123,30 +127,6 @@ function tuiDocumentHeaders(url: URL): HeadersInit { }; } -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\s+([A-Za-z0-9+/=]+)$/i); - if (!match?.[1]) return null; - try { - return Buffer.from(match[1], "base64").toString("utf8"); - } catch { - return null; - } -} - type ControlMessage = { type: "init" | "resize"; cols: number; rows: number }; function parseControlMessage(message: string): ControlMessage | null { @@ -176,18 +156,17 @@ export class WebTuiServer { private server: Server | null = null; private activeSocket: ServerWebSocket | null = null; private readonly clients = new Set>(); - private readonly expectedBasicAuthDigest: Buffer | null; + private readonly accessPolicy: WebAccessPolicy; private browserTheme: BrowserTheme | null = null; constructor( private readonly host: WebTuiHost, private readonly options: WebTuiServerOptions = {}, ) { - this.expectedBasicAuthDigest = options.basicAuth - ? credentialDigest( - `${options.basicAuth.username}:${options.basicAuth.password}`, - ) - : null; + this.accessPolicy = new WebAccessPolicy({ + ...options, + authRealm: "Kit web TUI mode", + }); } get clientCount(): number { @@ -207,18 +186,18 @@ export class WebTuiServer { port: this.options.port ?? 4783, 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 isWebSocketRequest = url.pathname === "/api/tui"; if ( isWebSocketRequest && - !this.isAllowedWebSocketRequest(request, url) + !this.accessPolicy.isAllowedWebSocketRequest(request, url) ) { return new Response("Origin or host not allowed", { status: 403 }); } - if (!this.isAuthorized(request)) { - return this.authenticationRequiredResponse(); + if (!this.accessPolicy.isAuthorized(request)) { + return this.accessPolicy.authenticationRequiredResponse(); } if (url.pathname === "/assets/tui-client.js") { return new Response(await webTuiClientJavaScript(), { @@ -255,7 +234,7 @@ export class WebTuiServer { } if (url.pathname === "/") { return new Response(tuiHtml as unknown as string, { - headers: tuiDocumentHeaders(url), + headers: tuiDocumentHeaders(url, this.accessPolicy), }); } return new Response("Not found", { status: 404 }); @@ -309,9 +288,12 @@ export class WebTuiServer { }, }); 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: server.hostname ?? this.options.hostname ?? "127.0.0.1", - port: server.port ?? this.options.port ?? 4783, + hostname, + port, url: server.url.toString(), }; } @@ -404,81 +386,4 @@ export class WebTuiServer { socket.data.client = null; if (client) this.host.detach(client); } - - 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 TUI 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 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 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 ?? 4783; - 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/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index ca8c61fa..671621f9 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -41,7 +41,8 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 -- Existing Host allowlist, Origin validation, timing-safe Basic auth, and same-origin assets +- 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 @@ -49,7 +50,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 716 passing +- `bun test`: 726 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -64,6 +65,12 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - typing accepted - page reload reconnected and recreated the Canvas without errors +## 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. @@ -112,7 +119,6 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - Canvas is poor for accessibility and browser-native find compared with DOM. It cannot replace the SPA's semantic message and form structure. - Mobile gets a hidden textarea and viewport fitting but no touch shortcut bar, native upload flow, or mobile-specific layout. - Terminal bell and OSC 52 paths that write directly to process stdout do not reach the browser renderer. -- Security policy is mirrored from `WebRpcServer` rather than extracted into one shared implementation. - `--model` is not accepted in this mode; select the model inside the TUI. - Complex IME, selection, links, touch gestures, browser-reserved shortcuts, and long-running reconnects need broader browser testing. - Browser input currently uses deterministic legacy key sequences. Kitty keyboard mode remains disabled until the adapter tracks Kitty protocol flags. 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 From 5b1329a417ced9776ff92c3e7061514dbdeff876 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 21:00:39 -0400 Subject: [PATCH 07/16] test(web-tui): add real browser coverage --- .github/workflows/release.yml | 33 ++- .github/workflows/web-tui-browser.yml | 51 ++++ .gitignore | 2 + app/biome.json | 8 +- app/bun.lock | 9 + app/e2e/web-tui.e2e.ts | 369 ++++++++++++++++++++++++ app/e2e/web-tui.fixture.ts | 249 ++++++++++++++++ app/package.json | 3 + app/playwright.config.ts | 23 ++ app/tsconfig.json | 8 +- docs/experiments/web-tui-ghostty-web.md | 14 +- 11 files changed, 761 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/web-tui-browser.yml create mode 100644 app/e2e/web-tui.e2e.ts create mode 100644 app/e2e/web-tui.fixture.ts create mode 100644 app/playwright.config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6b93a92..127e270c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,40 @@ jobs: path: kit_*.tar.gz if-no-files-found: error + browser-tui: + name: Browser TUI smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + 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 Chromium + working-directory: app + run: bunx playwright install --with-deps chromium + + - name: Build and test browser TUI + working-directory: app + run: bun run test:web-tui-browser + + - name: Upload browser artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-web-tui-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..71fc7b87 --- /dev/null +++ b/.github/workflows/web-tui-browser.yml @@ -0,0 +1,51 @@ +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: + chromium: + runs-on: ubuntu-latest + timeout-minutes: 20 + 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 Chromium + working-directory: app + run: bunx playwright install --with-deps chromium + + - name: Build and test browser TUI + working-directory: app + run: bun run test:web-tui-browser + + - name: Upload browser artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: web-tui-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/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 faec5eca..84747aa6 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -33,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", @@ -231,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=="], @@ -455,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=="], @@ -627,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..3e0bdc2d --- /dev/null +++ b/app/e2e/web-tui.e2e.ts @@ -0,0 +1,369 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "./web-tui.fixture"; + +type WebSocketFrame = { opcode: number; payloadData: string }; +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"); +} + +function binaryText(frames: WebSocketFrame[], offset = 0): string { + return Buffer.concat( + frames + .slice(offset) + .filter((frame) => frame.opcode === 2) + .map((frame) => Buffer.from(frame.payloadData, "base64")), + ).toString("utf8"); +} + +function binaryBytes(frames: WebSocketFrame[], offset = 0): number { + return frames + .slice(offset) + .filter((frame) => frame.opcode === 2) + .reduce( + (total, frame) => + total + Buffer.from(frame.payloadData, "base64").byteLength, + 0, + ); +} + +function terminalSizeControls( + frames: WebSocketFrame[], + offset = 0, +): TerminalSizeControl[] { + return frames + .slice(offset) + .filter((frame) => frame.opcode === 1) + .flatMap((frame) => { + try { + const value = JSON.parse(frame.payloadData) as Record; + if ( + (value.type === "init" || value.type === "resize") && + typeof value.cols === "number" && + typeof value.rows === "number" + ) { + return [value as TerminalSizeControl]; + } + } catch {} + return []; + }); +} + +test("boots the compiled ghostty client and renders the custom theme", async ({ + 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([]); + 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 ({ + webTuiPage, +}) => { + const { diagnostics, page, server, url } = webTuiPage; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Network.enable"); + const sentFrames: WebSocketFrame[] = []; + const receivedFrames: WebSocketFrame[] = []; + cdp.on("Network.webSocketFrameSent", (event: { response: WebSocketFrame }) => + sentFrames.push(event.response), + ); + cdp.on( + "Network.webSocketFrameReceived", + (event: { response: WebSocketFrame }) => + receivedFrames.push(event.response), + ); + await page.goto(url); + await waitForTerminal(page); + + const keyboardOffset = sentFrames.length; + await page.keyboard.press("Escape"); + await page.keyboard.press("ArrowUp"); + await expect + .poll(() => binaryText(sentFrames, keyboardOffset)) + .toContain("\x1b"); + await expect + .poll(() => binaryText(sentFrames, 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 = sentFrames.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(); + // Dispatch a browser WheelEvent directly on the terminal surface; Chromium's + // headless native wheel routing targets the page after ghostty focuses its + // hidden textarea and does not deterministically exercise this adapter. + await canvas.dispatchEvent("wheel", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + deltaY: 100, + }); + const sgrPrefix = `${String.fromCharCode(27)}\\[<`; + await expect + .poll(() => binaryText(sentFrames, mouseOffset)) + .toMatch(new RegExp(`${sgrPrefix}0;\\d+;\\d+M`)); + await expect + .poll(() => binaryText(sentFrames, mouseOffset)) + .toMatch(new RegExp(`${sgrPrefix}0;\\d+;\\d+m`)); + await expect + .poll(() => binaryText(sentFrames, mouseOffset)) + .toMatch(new RegExp(`${sgrPrefix}(?:32|35);\\d+;\\d+M`)); + await expect + .poll(() => binaryText(sentFrames, mouseOffset)) + .toMatch(new RegExp(`${sgrPrefix}65;\\d+;\\d+M`)); + + await expect + .poll(() => terminalSizeControls(sentFrames).length) + .toBeGreaterThan(0); + const initialSize = terminalSizeControls(sentFrames).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 = sentFrames.length; + const resizeOutputOffset = receivedFrames.length; + await page.setViewportSize({ width: 840, height: 560 }); + await expect + .poll(() => + terminalSizeControls(sentFrames, resizeOffset).some( + (control) => + control.cols < initialSize.cols && control.rows < initialSize.rows, + ), + ) + .toBe(true); + await expect + .poll(() => binaryBytes(receivedFrames, 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([]); + + // Ctrl+C is an empty-composer quit shortcut in the real app, so assert it + // last and permit the resulting orderly zero exit for this test only. + server.allowExitCode(0); + const interruptOffset = sentFrames.length; + await page.keyboard.press("Control+C"); + await expect + .poll(() => binaryText(sentFrames, interruptOffset)) + .toContain("\x03"); +}); + +test("reconnects after network loss and reloads without duplicate browser state", async ({ + webTuiPage, +}) => { + const { diagnostics, page, url } = webTuiPage; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Network.enable"); + const receivedFrames: WebSocketFrame[] = []; + cdp.on( + "Network.webSocketFrameReceived", + (event: { response: WebSocketFrame }) => + receivedFrames.push(event.response), + ); + await page.addInitScript(() => { + const sockets: WebSocket[] = []; + const OriginalWebSocket = window.WebSocket; + const TrackingWebSocket = new Proxy(OriginalWebSocket, { + construct(target, args, newTarget) { + const socket = Reflect.construct(target, args, newTarget) as WebSocket; + sockets.push(socket); + return socket; + }, + }); + Object.defineProperty(window, "__kitTestSockets", { value: sockets }); + window.WebSocket = TrackingWebSocket; + }); + await page.goto(url); + await waitForTerminal(page); + + const reconnectOutputOffset = receivedFrames.length; + await page.evaluate(() => { + const sockets = ( + window as typeof window & { __kitTestSockets: WebSocket[] } + ).__kitTestSockets; + const socket = 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(() => + page.evaluate( + () => + (window as typeof window & { __kitTestSockets: WebSocket[] }) + .__kitTestSockets.length, + ), + ) + .toBeGreaterThan(1); + await expect + .poll(() => binaryBytes(receivedFrames, reconnectOutputOffset)) + .toBeGreaterThan(2_000); + await waitForTerminal(page); + await expect(page.locator("#terminal canvas")).toHaveCount(1); + await expect(page.locator("#terminal textarea")).toHaveCount(1); + + const reloadOutputOffset = receivedFrames.length; + await page.reload(); + await expect + .poll(() => binaryBytes(receivedFrames, reloadOutputOffset)) + .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..1a6874a0 --- /dev/null +++ b/app/e2e/web-tui.fixture.ts @@ -0,0 +1,249 @@ +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", + "--experimental-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 50e524ef..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", @@ -67,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..748ebbde --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,23 @@ +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", + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + screenshot: "only-on-failure", + trace: "retain-on-failure", + viewport: { width: 1_000, height: 700 }, + }, + }, + ], +}); 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 index 671621f9..ff75e858 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -59,11 +59,15 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - resize reflow - suspend/resume reconnect repaint - orderly SIGINT/SIGTERM shutdown, exact `130`/`143` exit codes, and immediate server-port release -- Real Chromium against the compiled binary: - - one Canvas and one focused hidden textarea - - WebSocket connected without console errors - - typing accepted - - page reload reconnected and recreated the Canvas without errors +- Automated Playwright Chromium coverage against the compiled binary: + - 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, SGR click/release/move/wheel, and resize WebSocket frames + - forced WebSocket reconnect and page reload without duplicate Canvas or textarea state + - 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 this suite locally; `.github/workflows/web-tui-browser.yml` runs it for relevant pull requests and `main`, and the release workflow gates publication on it. ## Hosting boundary From b6375f2ee17229de50b5bbd3d5746a1188d49823 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 21:40:18 -0400 Subject: [PATCH 08/16] fix(web-tui): complete desktop browser input --- .github/workflows/release.yml | 30 +- .github/workflows/web-tui-browser.yml | 31 +- app/e2e/web-tui.e2e.ts | 507 ++++++++++++++---- app/playwright.config.ts | 17 +- .../web-tui/browser-terminal-input.test.ts | 60 ++- app/src/web-tui/browser-terminal-input.ts | 133 ++++- app/src/web-tui/client.ts | 35 +- app/src/web-tui/index.html | 2 +- app/src/web-tui/terminal-input-frames.test.ts | 32 ++ app/src/web-tui/terminal-input-frames.ts | 18 + docs/experiments/web-tui-ghostty-web.md | 28 +- 11 files changed, 724 insertions(+), 169 deletions(-) create mode 100644 app/src/web-tui/terminal-input-frames.test.ts create mode 100644 app/src/web-tui/terminal-input-frames.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 127e270c..8b0db014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,9 +65,19 @@ jobs: if-no-files-found: error browser-tui: - name: Browser TUI smoke - runs-on: ubuntu-latest - timeout-minutes: 20 + 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-14 + browser: webkit steps: - uses: actions/checkout@v4 @@ -79,19 +89,23 @@ jobs: working-directory: app run: bun install --frozen-lockfile - - name: Install Chromium + - name: Install browser + working-directory: app + run: bunx playwright install --with-deps ${{ matrix.browser }} + + - name: Build binary working-directory: app - run: bunx playwright install --with-deps chromium + run: bun run build - - name: Build and test browser TUI + - name: Test browser TUI working-directory: app - run: bun run test:web-tui-browser + run: bunx playwright test --project=${{ matrix.browser }} - name: Upload browser artifacts if: failure() uses: actions/upload-artifact@v4 with: - name: release-web-tui-browser-results + name: release-web-tui-${{ matrix.os }}-${{ matrix.browser }}-results path: app/test-results/web-tui if-no-files-found: ignore diff --git a/.github/workflows/web-tui-browser.yml b/.github/workflows/web-tui-browser.yml index 71fc7b87..4ebbf469 100644 --- a/.github/workflows/web-tui-browser.yml +++ b/.github/workflows/web-tui-browser.yml @@ -20,9 +20,20 @@ concurrency: cancel-in-progress: true jobs: - chromium: - runs-on: ubuntu-latest - timeout-minutes: 20 + 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-14 + browser: webkit steps: - uses: actions/checkout@v4 @@ -34,18 +45,22 @@ jobs: working-directory: app run: bun install --frozen-lockfile - - name: Install Chromium + - name: Install browser working-directory: app - run: bunx playwright install --with-deps chromium + run: bunx playwright install --with-deps ${{ matrix.browser }} - - name: Build and test browser TUI + - name: Build binary working-directory: app - run: bun run test:web-tui-browser + 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-browser-results + name: web-tui-${{ matrix.os }}-${{ matrix.browser }}-results path: app/test-results/web-tui if-no-files-found: ignore diff --git a/app/e2e/web-tui.e2e.ts b/app/e2e/web-tui.e2e.ts index 3e0bdc2d..b323b127 100644 --- a/app/e2e/web-tui.e2e.ts +++ b/app/e2e/web-tui.e2e.ts @@ -1,7 +1,16 @@ -import type { Page } from "@playwright/test"; +import type { Browser, Page } from "@playwright/test"; import { expect, test } from "./web-tui.fixture"; -type WebSocketFrame = { opcode: number; payloadData: string }; +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; @@ -27,36 +36,107 @@ async function waitForTerminal(page: Page): Promise { .toBe("#fdf6e3"); } -function binaryText(frames: WebSocketFrame[], offset = 0): string { +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.opcode === 2) - .map((frame) => Buffer.from(frame.payloadData, "base64")), + .filter( + (frame): frame is Extract => + frame.kind === "binary", + ) + .map((frame) => Buffer.from(frame.bytes)), ).toString("utf8"); } -function binaryBytes(frames: WebSocketFrame[], offset = 0): number { - return frames - .slice(offset) - .filter((frame) => frame.opcode === 2) - .reduce( - (total, frame) => - total + Buffer.from(frame.payloadData, "base64").byteLength, - 0, - ); -} - function terminalSizeControls( - frames: WebSocketFrame[], + frames: TrackedFrame[], offset = 0, ): TerminalSizeControl[] { return frames .slice(offset) - .filter((frame) => frame.opcode === 1) + .filter( + (frame): frame is Extract => + frame.kind === "text", + ) .flatMap((frame) => { try { - const value = JSON.parse(frame.payloadData) as Record; + const value = JSON.parse(frame.text) as Record; if ( (value.type === "init" || value.type === "resize") && typeof value.cols === "number" && @@ -69,7 +149,54 @@ function terminalSizeControls( }); } +async function clickCellAtDeviceScale( + browser: Browser, + url: string, + deviceScaleFactor: number, +): Promise<{ column: number; row: 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 offset = (await socketTracking(page)).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]), + 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; @@ -166,69 +293,104 @@ test("boots the compiled ghostty client and renders the custom theme", async ({ expect(diagnostics.consoleErrors).toEqual([]); expect(diagnostics.pageErrors).toEqual([]); expect(diagnostics.failedRequests).toEqual([]); - 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); + 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; - const cdp = await page.context().newCDPSession(page); - await cdp.send("Network.enable"); - const sentFrames: WebSocketFrame[] = []; - const receivedFrames: WebSocketFrame[] = []; - cdp.on("Network.webSocketFrameSent", (event: { response: WebSocketFrame }) => - sentFrames.push(event.response), - ); - cdp.on( - "Network.webSocketFrameReceived", - (event: { response: WebSocketFrame }) => - receivedFrames.push(event.response), - ); + 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 keyboardOffset = sentFrames.length; + 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(() => binaryText(sentFrames, keyboardOffset)) + .poll(async () => + binaryText((await socketTracking(page)).sent, keyboardOffset), + ) .toContain("\x1b"); await expect - .poll(() => binaryText(sentFrames, keyboardOffset)) + .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 = sentFrames.length; + 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(); - // Dispatch a browser WheelEvent directly on the terminal surface; Chromium's - // headless native wheel routing targets the page after ghostty focuses its - // hidden textarea and does not deterministically exercise this adapter. await canvas.dispatchEvent("wheel", { bubbles: true, cancelable: true, @@ -237,41 +399,93 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn deltaY: 100, }); const sgrPrefix = `${String.fromCharCode(27)}\\[<`; - await expect - .poll(() => binaryText(sentFrames, mouseOffset)) - .toMatch(new RegExp(`${sgrPrefix}0;\\d+;\\d+M`)); - await expect - .poll(() => binaryText(sentFrames, mouseOffset)) - .toMatch(new RegExp(`${sgrPrefix}0;\\d+;\\d+m`)); - await expect - .poll(() => binaryText(sentFrames, mouseOffset)) - .toMatch(new RegExp(`${sgrPrefix}(?:32|35);\\d+;\\d+M`)); - await expect - .poll(() => binaryText(sentFrames, mouseOffset)) - .toMatch(new RegExp(`${sgrPrefix}65;\\d+;\\d+M`)); + 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 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(() => { + 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 page.keyboard.type("z"); + await expect + .poll(async () => + binaryText((await socketTracking(page)).sent, fallbackOffset), + ) + .toContain("z"); + } await expect - .poll(() => terminalSizeControls(sentFrames).length) + .poll( + async () => + terminalSizeControls((await socketTracking(page)).sent).length, + ) .toBeGreaterThan(0); - const initialSize = terminalSizeControls(sentFrames).at(-1); + 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 = sentFrames.length; - const resizeOutputOffset = receivedFrames.length; + const resizeOffset = initialTracking.sent.length; + const resizeOutputOffset = initialTracking.receivedBytes; await page.setViewportSize({ width: 840, height: 560 }); await expect - .poll(() => - terminalSizeControls(sentFrames, resizeOffset).some( + .poll(async () => + terminalSizeControls( + (await socketTracking(page)).sent, + resizeOffset, + ).some( (control) => control.cols < initialSize.cols && control.rows < initialSize.rows, ), ) .toBe(true); await expect - .poll(() => binaryBytes(receivedFrames, resizeOutputOffset)) + .poll( + async () => + (await socketTracking(page)).receivedBytes - resizeOutputOffset, + ) .toBeGreaterThan(100); await expect .poll(async () => { @@ -290,75 +504,138 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn expect(diagnostics.pageErrors).toEqual([]); expect(diagnostics.failedRequests).toEqual([]); - // Ctrl+C is an empty-composer quit shortcut in the real app, so assert it - // last and permit the resulting orderly zero exit for this test only. server.allowExitCode(0); - const interruptOffset = sentFrames.length; + const interruptOffset = (await socketTracking(page)).sent.length; await page.keyboard.press("Control+C"); await expect - .poll(() => binaryText(sentFrames, interruptOffset)) + .poll(async () => + binaryText((await socketTracking(page)).sent, interruptOffset), + ) .toContain("\x03"); }); -test("reconnects after network loss and reloads without duplicate browser state", async ({ +test("maps the same terminal cell at standard and high DPI", async ({ + browser, + webTuiServer, +}) => { + const standard = await clickCellAtDeviceScale(browser, webTuiServer.url, 1); + const highDpi = await clickCellAtDeviceScale(browser, webTuiServer.url, 2); + expect({ column: highDpi.column, row: highDpi.row }).toEqual({ + column: standard.column, + row: standard.row, + }); + expect(standard.backingScale).toBeCloseTo(1, 1); + expect(highDpi.backingScale).toBeCloseTo(2, 1); +}); + +test("preserves large Unicode input and browser-owned clipboard shortcuts", async ({ webTuiPage, }) => { const { diagnostics, page, url } = webTuiPage; - const cdp = await page.context().newCDPSession(page); - await cdp.send("Network.enable"); - const receivedFrames: WebSocketFrame[] = []; - cdp.on( - "Network.webSocketFrameReceived", - (event: { response: WebSocketFrame }) => - receivedFrames.push(event.response), - ); - await page.addInitScript(() => { - const sockets: WebSocket[] = []; - const OriginalWebSocket = window.WebSocket; - const TrackingWebSocket = new Proxy(OriginalWebSocket, { - construct(target, args, newTarget) { - const socket = Reflect.construct(target, args, newTarget) as WebSocket; - sockets.push(socket); - return socket; - }, + 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 : "") }, }); - Object.defineProperty(window, "__kitTestSockets", { value: sockets }); - window.WebSocket = TrackingWebSocket; + 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 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("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 = receivedFrames.length; + const reconnectOutputOffset = (await socketTracking(page)).receivedBytes; await page.evaluate(() => { - const sockets = ( - window as typeof window & { __kitTestSockets: WebSocket[] } - ).__kitTestSockets; - const socket = sockets.at(-1); + 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(() => - page.evaluate( - () => - (window as typeof window & { __kitTestSockets: WebSocket[] }) - .__kitTestSockets.length, - ), - ) + .poll(async () => (await socketTracking(page)).socketCount) .toBeGreaterThan(1); await expect - .poll(() => binaryBytes(receivedFrames, reconnectOutputOffset)) + .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); - const reloadOutputOffset = receivedFrames.length; await page.reload(); await expect - .poll(() => binaryBytes(receivedFrames, reloadOutputOffset)) + .poll(async () => (await socketTracking(page)).receivedBytes) .toBeGreaterThan(2_000); await waitForTerminal(page); await expect(page.locator("#terminal canvas")).toHaveCount(1); diff --git a/app/playwright.config.ts b/app/playwright.config.ts index 748ebbde..49e0a844 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -9,15 +9,14 @@ export default defineConfig({ 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"], - screenshot: "only-on-failure", - trace: "retain-on-failure", - viewport: { width: 1_000, height: 700 }, - }, - }, + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, ], }); diff --git a/app/src/web-tui/browser-terminal-input.test.ts b/app/src/web-tui/browser-terminal-input.test.ts index 4b4d6f64..a1af4e92 100644 --- a/app/src/web-tui/browser-terminal-input.test.ts +++ b/app/src/web-tui/browser-terminal-input.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { type BrowserKeyLike, + classifyBrowserPlatform, encodeBrowserKey, TerminalProtocolState, } from "./browser-terminal-input"; @@ -20,20 +21,60 @@ function key( } describe("encodeBrowserKey", () => { - test("normalizes Escape and control characters", () => { + test("normalizes the complete control-character matrix", () => { expect(encodeBrowserKey(key("Escape"))).toBe("\x1b"); - expect(encodeBrowserKey(key("c", { ctrlKey: true }))).toBe("\x03"); - expect(encodeBrowserKey(key("[", { ctrlKey: true }))).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 navigation keys and their modifiers", () => { + 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(); @@ -42,6 +83,9 @@ describe("encodeBrowserKey", () => { 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(); }); }); @@ -49,12 +93,16 @@ 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"), + new TextEncoder().encode( + "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?2004h", + ), ); expect(state.mouseTracking).toBe(1003); expect(state.mouseSgr).toBe(true); - state.feed(new TextEncoder().encode("\x1b[?1003l")); + 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", () => { diff --git a/app/src/web-tui/browser-terminal-input.ts b/app/src/web-tui/browser-terminal-input.ts index 930209f8..67272f6c 100644 --- a/app/src/web-tui/browser-terminal-input.ts +++ b/app/src/web-tui/browser-terminal-input.ts @@ -49,19 +49,61 @@ function modifierParameter(key: BrowserKeyLike): number { ); } +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): string | null { +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 ( - (event.metaKey && event.key.toLowerCase() === "c") || - (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "c") || - ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") - ) { - return null; - } + if (isBrowserOwnedKey(event)) return null; if (event.ctrlKey && !event.altKey && !event.metaKey) { if (event.key.length === 1) { @@ -83,6 +125,18 @@ export function encodeBrowserKey(event: BrowserKeyLike): string | 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); @@ -110,6 +164,7 @@ export class TerminalProtocolState { private tail = ""; private readonly decoder = new TextDecoder(); mouseSgr = false; + bracketedPaste = false; get mouseTracking(): MouseTrackingMode { if (this.mouseModes.has(1003)) return 1003; @@ -129,6 +184,8 @@ export class TerminalProtocolState { else this.mouseModes.delete(mode); } else if (mode === 1006) { this.mouseSgr = enabled; + } else if (mode === 2004) { + this.bracketedPaste = enabled; } } } @@ -145,9 +202,12 @@ export type TerminalGeometry = { 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 { @@ -167,22 +227,30 @@ function mouseButton(event: MouseEvent): number | null { 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, @@ -197,11 +265,41 @@ export class BrowserTerminalInput { 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) => { - const sequence = encodeBrowserKey(event); + 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(); @@ -240,6 +338,23 @@ export class BrowserTerminalInput { 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); diff --git a/app/src/web-tui/client.ts b/app/src/web-tui/client.ts index 3bbe8134..a1b60b93 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -4,6 +4,7 @@ import { 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; @@ -29,12 +30,37 @@ function webSocketUrl(): string { return `${scheme}://${location.host}/api/tui`; } +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. + } + } + 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 { + document.execCommand("copy"); + } finally { + textarea.remove(); + if (previouslyFocused instanceof HTMLElement) { + previouslyFocused.focus({ preventScroll: true }); + } + } +} + class TuiConnection { private socket: WebSocket | null = null; private reconnectDelay = RECONNECT_MIN_MS; private reconnectTimer: number | null = null; private closedByPage = false; - private readonly encoder = new TextEncoder(); constructor( private readonly terminal: Terminal, @@ -110,9 +136,8 @@ class TuiConnection { } sendInput(data: string): void { - if (this.socket?.readyState === WebSocket.OPEN) { - this.socket.send(this.encoder.encode(data)); - } + if (this.socket?.readyState !== WebSocket.OPEN) return; + for (const frame of terminalInputFrames(data)) this.socket.send(frame); } private sendControl( @@ -164,6 +189,8 @@ async function main(): Promise { }, send: (data) => connection.sendInput(data), focus: () => terminal.focus(), + copySelection: () => terminal.getSelection(), + writeClipboard: writeBrowserClipboard, }); connection.connect(); } diff --git a/app/src/web-tui/index.html b/app/src/web-tui/index.html index e073abff..c8332f1b 100644 --- a/app/src/web-tui/index.html +++ b/app/src/web-tui/index.html @@ -2,7 +2,7 @@ - + Kit TUI · ghostty-web experiment 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/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index ff75e858..43297e45 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -36,8 +36,9 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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, and function keys -- Kit-owned SGR mouse encoding for click, release, drag, all-motion, and wheel events +- 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, bracketed paste, Unicode preservation, and bounded input frames +- 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 @@ -50,7 +51,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 726 passing +- `bun test`: 730 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -59,15 +60,24 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - resize reflow - suspend/resume reconnect repaint - orderly SIGINT/SIGTERM shutdown, exact `130`/`143` exit codes, and immediate server-port release -- Automated Playwright Chromium coverage against the compiled binary: +- Automated Playwright coverage against the compiled binary: Chromium and Firefox on Linux, plus WebKit on macOS (15 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, SGR click/release/move/wheel, and resize WebSocket frames - - forced WebSocket reconnect and page reload without duplicate Canvas or textarea state + - exact Escape, Ctrl+C, navigation, platform Alt/Option, SGR 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 + - forced WebSocket reconnect and page reload with verified full repaint and no duplicate Canvas or textarea state - 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 this suite locally; `.github/workflows/web-tui-browser.yml` runs it for relevant pull requests and `main`, and the release workflow gates publication on it. +- `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. ## Hosting boundary @@ -121,10 +131,10 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - 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. -- Mobile gets a hidden textarea and viewport fitting but no touch shortcut bar, native upload flow, or mobile-specific layout. +- The browser TUI is desktop-focused; the semantic web app owns mobile/touch, native uploads, and mobile-specific layout. - Terminal bell and OSC 52 paths that write directly to process stdout do not reach the browser renderer. - `--model` is not accepted in this mode; select the model inside the TUI. -- Complex IME, selection, links, touch gestures, browser-reserved shortcuts, and long-running reconnects need broader browser testing. +- 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. From b7c0cd96eca7da22caaa2cead2c2ab43ced86bf7 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 22:40:40 -0400 Subject: [PATCH 09/16] fix(web-tui): copy whole messages in browser --- app/e2e/web-tui.e2e.ts | 40 ++++++-- app/src/app/App.tsx | 2 + app/src/app/bootstrap.tsx | 3 + app/src/app/web-tui-mode.ts | 5 +- app/src/app/web-tui-server.test.ts | 59 ++++++++++++ app/src/app/web-tui-server.ts | 96 ++++++++++++++++++- app/src/shell/AppShell.tsx | 7 +- app/src/shell/SelectionContextMenu.test.tsx | 13 ++- .../shell/transcript/message-context-menu.ts | 12 ++- app/src/web-tui/browser-actions.test.ts | 33 +++++++ app/src/web-tui/browser-actions.ts | 33 +++++++ app/src/web-tui/client.ts | 49 +++++++++- docs/experiments/web-tui-ghostty-web.md | 8 +- 13 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 app/src/web-tui/browser-actions.test.ts create mode 100644 app/src/web-tui/browser-actions.ts diff --git a/app/e2e/web-tui.e2e.ts b/app/e2e/web-tui.e2e.ts index b323b127..131d061f 100644 --- a/app/e2e/web-tui.e2e.ts +++ b/app/e2e/web-tui.e2e.ts @@ -153,7 +153,13 @@ async function clickCellAtDeviceScale( browser: Browser, url: string, deviceScaleFactor: number, -): Promise<{ column: number; row: number; backingScale: number }> { +): Promise<{ + column: number; + row: number; + expectedColumn: number; + expectedRow: number; + backingScale: number; +}> { const context = await browser.newContext({ deviceScaleFactor, viewport: { width: 1_000, height: 700 }, @@ -166,7 +172,10 @@ async function clickCellAtDeviceScale( const canvas = page.locator("#terminal canvas"); const bounds = await canvas.boundingBox(); if (!bounds) throw new Error("Terminal Canvas has no bounds"); - const offset = (await socketTracking(page)).sent.length; + 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, @@ -188,6 +197,8 @@ async function clickCellAtDeviceScale( 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 { @@ -412,6 +423,19 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn .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); @@ -514,16 +538,18 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn .toContain("\x03"); }); -test("maps the same terminal cell at standard and high DPI", async ({ +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); - expect({ column: highDpi.column, row: highDpi.row }).toEqual({ - column: standard.column, - row: standard.row, - }); + 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); }); diff --git a/app/src/app/App.tsx b/app/src/app/App.tsx index 72315da9..459926b3 100644 --- a/app/src/app/App.tsx +++ b/app/src/app/App.tsx @@ -49,6 +49,7 @@ export type AppProps = { updateTerminalTitle: (sessionName: string | undefined, cwd: string) => void; setTerminalTurnActive: (active: boolean) => void; triggerNotification: (message: string, title?: string) => boolean; + copyText: (text: string) => Promise; quitAndDestroy: () => void; registerDispose?: (dispose: () => void | Promise) => void; persistSession: boolean; @@ -453,6 +454,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 9e596d51..7d9219dc 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -13,6 +13,7 @@ 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 { @@ -43,6 +44,7 @@ export type BootstrapTerminal = { onRendererReady?: ( renderer: Awaited>, ) => void; + copyText?: (text: string) => Promise; }; async function loadSession(opts?: BootstrapOpts): Promise { @@ -276,6 +278,7 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { triggerNotification={(message, title) => renderer.triggerNotification(message, title) } + copyText={opts?.terminal?.copyText ?? copyToClipboard} quitAndDestroy={quitAndDestroy} registerDispose={(dispose) => { disposeApp = dispose; diff --git a/app/src/app/web-tui-mode.ts b/app/src/app/web-tui-mode.ts index bb1ade5b..91791c48 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -67,7 +67,10 @@ export async function runWebTuiMode( newSession: options.newSession, noSession: options.noSession, sessionId: options.sessionId, - terminal: bridge.terminal, + terminal: { + ...bridge.terminal, + copyText: (text) => server.copyText(text), + }, }), ) .catch((error) => { diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts index 7740fb7f..c3d592bf 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -255,6 +255,65 @@ describe("WebTuiServer WebSocket", () => { 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(JSON.stringify({ type: "init", cols: 80, rows: 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(JSON.stringify({ type: "init", cols: 80, rows: 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); diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts index 090ba3c1..feb0c3d1 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -17,6 +17,7 @@ import jetbrainsMonoNormal from "@fontsource-variable/jetbrains-mono/files/jetbr }; import type { Server, ServerWebSocket } from "bun"; import ghosttyWasm from "ghostty-web/ghostty-vt.wasm" with { type: "file" }; +import { MAX_BROWSER_CLIPBOARD_BYTES } 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. @@ -127,10 +128,21 @@ function tuiDocumentHeaders( }; } -type ControlMessage = { type: "init" | "resize"; cols: number; rows: number }; +type TerminalControlMessage = { + type: "init" | "resize"; + cols: number; + rows: 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 > 256) return null; + if (message.length > 512) return null; let parsed: unknown; try { parsed = JSON.parse(message); @@ -139,6 +151,18 @@ function parseControlMessage(message: string): ControlMessage | 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" || @@ -158,6 +182,16 @@ export class WebTuiServer { 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, @@ -179,6 +213,28 @@ export class WebTuiServer { if (socket) this.sendTheme(socket); } + 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({ @@ -258,6 +314,10 @@ export class WebTuiServer { if (typeof message === "string") { const control = parseControlMessage(message); if (!control) return; + if (control.type === "clipboard-result") { + this.resolveClipboard(control); + return; + } if (control.type === "init") { if (socket.data.client) { this.host.resize(control.cols, control.rows); @@ -355,6 +415,30 @@ export class WebTuiServer { 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( @@ -369,7 +453,9 @@ export class WebTuiServer { ): void { if (socket.readyState !== WebSocket.OPEN) return; try { - if (socket.send(data) > 0) return; + // 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; } catch (error) { console.error( `Web TUI send failed: ${error instanceof Error ? error.message : String(error)}`, @@ -382,6 +468,10 @@ export class WebTuiServer { } private releaseSocket(socket: ServerWebSocket): void { + 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/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/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..17774e2e --- /dev/null +++ b/app/src/web-tui/browser-actions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_BROWSER_CLIPBOARD_BYTES, + parseBrowserClipboardWrite, +} 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(); + }); +}); diff --git a/app/src/web-tui/browser-actions.ts b/app/src/web-tui/browser-actions.ts new file mode 100644 index 00000000..3c1d3ed3 --- /dev/null +++ b/app/src/web-tui/browser-actions.ts @@ -0,0 +1,33 @@ +export const MAX_BROWSER_CLIPBOARD_BYTES = 1024 * 1024; + +export type BrowserClipboardWrite = { + type: "clipboard-write"; + id: number; + text: string; +}; + +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; +} diff --git a/app/src/web-tui/client.ts b/app/src/web-tui/client.ts index a1b60b93..93c8a349 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -1,4 +1,8 @@ import { FitAddon, Ghostty, Terminal } from "ghostty-web"; +import { + type BrowserClipboardWrite, + parseBrowserClipboardWrite, +} from "./browser-actions"; import { BrowserTerminalInput, TerminalProtocolState, @@ -47,7 +51,9 @@ async function writeBrowserClipboard(text: string): Promise { document.body.append(textarea); textarea.select(); try { - document.execCommand("copy"); + if (!document.execCommand("copy")) { + throw new Error("Browser denied clipboard access"); + } } finally { textarea.remove(); if (previouslyFocused instanceof HTMLElement) { @@ -109,7 +115,12 @@ class TuiConnection { } if (typeof event.data === "string") { const theme = parseBrowserThemeMessage(event.data); - if (theme) applyBrowserTheme(theme); + if (theme) { + applyBrowserTheme(theme); + return; + } + const clipboard = parseBrowserClipboardWrite(event.data); + if (clipboard) void this.writeClipboard(socket, clipboard); } }); socket.addEventListener("close", (event) => { @@ -140,6 +151,40 @@ class TuiConnection { for (const frame of terminalInputFrames(data)) this.socket.send(frame); } + private async writeClipboard( + socket: WebSocket, + action: BrowserClipboardWrite, + ): Promise { + try { + await writeBrowserClipboard(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, diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 43297e45..3f7b3e9e 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -37,7 +37,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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, bracketed paste, Unicode preservation, and bounded input frames +- Browser-owned copy/paste shortcuts with explicit Canvas-selection copying, browser-routed whole-message Markdown copying, bracketed paste, Unicode preservation, and bounded input frames - 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 @@ -51,7 +51,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 730 passing +- `bun test`: 734 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -64,7 +64,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 click/release/move/wheel, and resize WebSocket frames + - 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 - forced WebSocket reconnect and page reload with verified full repaint and no duplicate Canvas or textarea state - no failed requests, browser console errors, or page errors @@ -132,7 +132,7 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - 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. -- Terminal bell and OSC 52 paths that write directly to process stdout do not reach the browser renderer. +- Terminal bell and notification paths still target terminal or host integrations rather than explicit browser behavior. - `--model` is not accepted in this mode; select the model inside the TUI. - 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. From 9978b79f07d4439e3bf8c13b596c95f30482ed6e Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 22:51:24 -0400 Subject: [PATCH 10/16] fix(web-tui): acknowledge context clipboard copies --- app/e2e/web-tui.e2e.ts | 70 ++++++++++++++++++++++++++++++++++++++- app/src/web-tui/client.ts | 36 +++++++++++++------- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/app/e2e/web-tui.e2e.ts b/app/e2e/web-tui.e2e.ts index 131d061f..e6509491 100644 --- a/app/e2e/web-tui.e2e.ts +++ b/app/e2e/web-tui.e2e.ts @@ -124,6 +124,29 @@ function binaryText(frames: TrackedFrame[], offset = 0): string { ).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, @@ -460,7 +483,8 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn .not.toBe(""); expect((await socketTracking(page)).sent).toHaveLength(selectionOffset); - await page.evaluate(() => { + await page.evaluate(async () => { + await navigator.clipboard.writeText(""); Object.defineProperty(navigator.clipboard, "writeText", { configurable: true, value: () => Promise.reject(new Error("forced clipboard fallback")), @@ -470,6 +494,9 @@ test("encodes keyboard, mouse, wheel, and resize through the real browser", asyn 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 () => @@ -555,6 +582,7 @@ test("maps pointer coordinates accurately at standard and high DPI", async ({ }); test("preserves large Unicode input and browser-owned clipboard shortcuts", async ({ + browserName, webTuiPage, }) => { const { diagnostics, page, url } = webTuiPage; @@ -606,6 +634,46 @@ test("preserves large Unicode input and browser-owned clipboard shortcuts", asyn ) .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"); diff --git a/app/src/web-tui/client.ts b/app/src/web-tui/client.ts index 93c8a349..c54f75c2 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -34,15 +34,7 @@ function webSocketUrl(): string { return `${scheme}://${location.host}/api/tui`; } -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. - } - } +function writeBrowserClipboardFallback(text: string): void { const previouslyFocused = document.activeElement; const textarea = document.createElement("textarea"); textarea.value = text; @@ -62,6 +54,26 @@ async function writeBrowserClipboard(text: string): Promise { } } +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; @@ -151,12 +163,12 @@ class TuiConnection { for (const frame of terminalInputFrames(data)) this.socket.send(frame); } - private async writeClipboard( + private writeClipboard( socket: WebSocket, action: BrowserClipboardWrite, - ): Promise { + ): void { try { - await writeBrowserClipboard(action.text); + writeBrowserActionClipboard(action.text); this.sendClipboardResult(socket, action.id, true); } catch (error) { this.sendClipboardResult( From 3b0c2a458b8d3a8dc903d6bcaa6f5f91418fadd4 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 23:09:44 -0400 Subject: [PATCH 11/16] fix(web-tui): reject stale browser protocols --- app/script/smoke-web-tui.ts | 19 +++++- app/src/app/web-tui-server.test.ts | 77 ++++++++++++++++++---- app/src/app/web-tui-server.ts | 86 ++++++++++++++++++++----- app/src/web-tui/browser-actions.ts | 1 + app/src/web-tui/client.ts | 17 ++++- docs/experiments/web-tui-ghostty-web.md | 5 +- 6 files changed, 172 insertions(+), 33 deletions(-) diff --git a/app/script/smoke-web-tui.ts b/app/script/smoke-web-tui.ts index d8c21fb1..3b539e7a 100644 --- a/app/script/smoke-web-tui.ts +++ b/app/script/smoke-web-tui.ts @@ -13,6 +13,7 @@ */ 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); @@ -145,7 +146,14 @@ try { // 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 })); + 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; @@ -183,7 +191,14 @@ try { first.close(); await Bun.sleep(500); const second = await connect(); - second.socket.send(JSON.stringify({ type: "init", cols: 90, rows: 28 })); + 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; diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts index c3d592bf..e76ccbdd 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { WEB_TUI_PROTOCOL_VERSION } from "../web-tui/browser-actions"; import { type WebTuiClient, type WebTuiHost, @@ -62,6 +63,15 @@ function openSocket( }); } +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 }), @@ -196,12 +206,55 @@ describe("WebTuiServer WebSocket", () => { 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(JSON.stringify({ type: "init", cols: 120, rows: 40 })); + socket.send(initControl(120, 40)); await waitFor(() => host.log.attached.length === 1); expect(host.log.attached[0]).toMatchObject({ cols: 120, rows: 40 }); @@ -238,7 +291,7 @@ describe("WebTuiServer WebSocket", () => { socket.addEventListener("message", (event) => { if (typeof event.data === "string") messages.push(event.data); }); - socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + socket.send(initControl(80, 24)); await waitFor(() => messages.length === 1); expect(JSON.parse(messages[0] ?? "null")).toEqual({ type: "theme", @@ -272,7 +325,7 @@ describe("WebTuiServer WebSocket", () => { }; if (message.type === "clipboard-write") messages.push(message); }); - socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + socket.send(initControl(80, 24)); await waitFor(() => host.log.attached.length === 1); const copied = server.copyText("**whole message**"); @@ -305,7 +358,7 @@ describe("WebTuiServer WebSocket", () => { const host = recordingHost(); const { server, wsUrl, origin } = startServer(host); const socket = await openSocket(wsUrl, { headers: { origin } }); - socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + socket.send(initControl(80, 24)); await waitFor(() => host.log.attached.length === 1); const copied = server.copyText("message"); const closed = nextClose(socket); @@ -324,7 +377,7 @@ describe("WebTuiServer WebSocket", () => { frames.push(new Uint8Array(event.data)); } }); - socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + 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); @@ -338,7 +391,7 @@ describe("WebTuiServer WebSocket", () => { 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(JSON.stringify({ type: "init", cols: 100, 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); @@ -349,10 +402,10 @@ describe("WebTuiServer WebSocket", () => { const host = recordingHost(); const { wsUrl, origin } = startServer(host); const socket = await openSocket(wsUrl, { headers: { origin } }); - socket.send(JSON.stringify({ type: "init", cols: 10_000, rows: 1 })); + 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(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + socket.send(initControl(80, 24)); await waitFor(() => host.log.resizes.length === 1); expect(host.log.attached.length).toBe(1); socket.close(); @@ -362,7 +415,7 @@ describe("WebTuiServer WebSocket", () => { const host = recordingHost(); const { server, origin, wsUrl } = startServer(host); const socket = await openSocket(wsUrl, { headers: { origin } }); - socket.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + socket.send(initControl(80, 24)); await waitFor(() => host.log.attached.length === 1); await Promise.race([ @@ -385,16 +438,16 @@ describe("WebTuiServer WebSocket", () => { const host = recordingHost(); const { wsUrl, origin } = startServer(host); const first = await openSocket(wsUrl, { headers: { origin } }); - first.send(JSON.stringify({ type: "init", cols: 80, rows: 24 })); + 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); - - second.send(JSON.stringify({ type: "init", cols: 100, rows: 40 })); 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 index feb0c3d1..eacf058b 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -17,7 +17,10 @@ import jetbrainsMonoNormal from "@fontsource-variable/jetbrains-mono/files/jetbr }; import type { Server, ServerWebSocket } from "bun"; import ghosttyWasm from "ghostty-web/ghostty-vt.wasm" with { type: "file" }; -import { MAX_BROWSER_CLIPBOARD_BYTES } from "../web-tui/browser-actions"; +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. @@ -54,6 +57,7 @@ export type WebTuiServerOptions = { type WebSocketData = { client: WebTuiClient | null; + initTimer: ReturnType | null; }; declare const __KIT_WEB_TUI_CLIENT_JS__: string | undefined; @@ -75,6 +79,9 @@ function webTuiClientJavaScript(): Promise { 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 } @@ -132,6 +139,7 @@ type TerminalControlMessage = { type: "init" | "resize"; cols: number; rows: number; + protocolVersion?: number; }; type ClipboardResultMessage = { type: "clipboard-result"; @@ -172,8 +180,21 @@ function parseControlMessage(message: string): ControlMessage | null { ) { 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 }; + return { + type: record.type, + cols: size.cols, + rows: size.rows, + ...(typeof record.protocolVersion === "number" + ? { protocolVersion: record.protocolVersion } + : {}), + }; } export class WebTuiServer { @@ -283,7 +304,11 @@ export class WebTuiServer { }); } if (isWebSocketRequest) { - if (bunServer.upgrade(request, { data: { client: null } })) { + if ( + bunServer.upgrade(request, { + data: { client: null, initTimer: null }, + }) + ) { return undefined; } return new Response("WebSocket upgrade required", { status: 426 }); @@ -300,29 +325,48 @@ export class WebTuiServer { backpressureLimit: 16 * 1024 * 1024, closeOnBackpressureLimit: true, open: (socket) => { - this.clients.add(socket); - // Single-terminal policy: a new connection replaces the old one. - const previous = this.activeSocket; - this.activeSocket = socket; - if (previous) { - this.releaseSocket(previous); - previous.close(4001, "replaced by a newer client"); + // 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 (this.activeSocket !== socket) return; if (typeof message === "string") { const control = parseControlMessage(message); if (!control) return; - if (control.type === "clipboard-result") { - this.resolveClipboard(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) { - this.host.resize(control.cols, control.rows); + 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), }; @@ -331,11 +375,17 @@ export class WebTuiServer { 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"); } @@ -468,6 +518,10 @@ export class WebTuiServer { } private releaseSocket(socket: ServerWebSocket): void { + if (socket.data.initTimer) { + clearTimeout(socket.data.initTimer); + socket.data.initTimer = null; + } this.rejectClipboardForSocket( socket, "Browser disconnected before copying", diff --git a/app/src/web-tui/browser-actions.ts b/app/src/web-tui/browser-actions.ts index 3c1d3ed3..bdea552c 100644 --- a/app/src/web-tui/browser-actions.ts +++ b/app/src/web-tui/browser-actions.ts @@ -1,3 +1,4 @@ +export const WEB_TUI_PROTOCOL_VERSION = 2; export const MAX_BROWSER_CLIPBOARD_BYTES = 1024 * 1024; export type BrowserClipboardWrite = { diff --git a/app/src/web-tui/client.ts b/app/src/web-tui/client.ts index c54f75c2..c8aa6138 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -2,6 +2,7 @@ import { FitAddon, Ghostty, Terminal } from "ghostty-web"; import { type BrowserClipboardWrite, parseBrowserClipboardWrite, + WEB_TUI_PROTOCOL_VERSION, } from "./browser-actions"; import { BrowserTerminalInput, @@ -143,6 +144,11 @@ class TuiConnection { 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()); @@ -203,7 +209,16 @@ class TuiConnection { rows: number, ): void { if (this.socket?.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify({ type, cols, rows })); + this.socket.send( + JSON.stringify({ + type, + cols, + rows, + ...(type === "init" + ? { protocolVersion: WEB_TUI_PROTOCOL_VERSION } + : {}), + }), + ); } } } diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 3f7b3e9e..ed59c585 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -30,7 +30,7 @@ OpenTUI 0.5.1 accepts custom stdin/stdout streams and explicit dimensions. A non 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 `init`/`resize` controls. A newer browser tab supersedes the old tab with close code 4001. +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 @@ -51,7 +51,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 734 passing +- `bun test`: 736 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -67,6 +67,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 - 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. From 2ec85b98be859bc49d967e355f3ac47292141c9e Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Tue, 18 Aug 2026 23:30:40 -0400 Subject: [PATCH 12/16] feat(web-tui): route notifications to browser --- app/e2e/web-tui.e2e.ts | 134 ++++++++++++++++++ app/src/app/App.tsx | 2 + app/src/app/bootstrap.tsx | 5 + app/src/app/headless-host.ts | 1 + app/src/app/web-tui-mode.ts | 2 + app/src/app/web-tui-server.test.ts | 27 ++++ app/src/app/web-tui-server.ts | 35 ++++- app/src/features/guided-questions/index.tsx | 1 + app/src/features/notifications/index.tsx | 1 + .../notifications/notifications.test.ts | 22 +++ .../features/notifications/notifications.ts | 16 ++- .../features/user-interaction-tools/index.ts | 1 + app/src/plugins/PluginManager.test.ts | 1 + app/src/plugins/api.ts | 2 + app/src/plugins/types.ts | 2 + app/src/web-tui/browser-actions.test.ts | 42 ++++++ app/src/web-tui/browser-actions.ts | 58 +++++++- app/src/web-tui/client.ts | 119 +++++++++++++++- app/src/web-tui/index.html | 2 + app/src/web-tui/tui.css | 33 ++++- docs/experiments/web-tui-ghostty-web.md | 11 +- 21 files changed, 503 insertions(+), 14 deletions(-) create mode 100644 app/src/features/notifications/notifications.test.ts diff --git a/app/e2e/web-tui.e2e.ts b/app/e2e/web-tui.e2e.ts index e6509491..ac68b36a 100644 --- a/app/e2e/web-tui.e2e.ts +++ b/app/e2e/web-tui.e2e.ts @@ -693,6 +693,140 @@ test("preserves large Unicode input and browser-owned clipboard shortcuts", asyn 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, }) => { diff --git a/app/src/app/App.tsx b/app/src/app/App.tsx index 459926b3..9e5d31b1 100644 --- a/app/src/app/App.tsx +++ b/app/src/app/App.tsx @@ -49,6 +49,7 @@ export type AppProps = { 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; @@ -142,6 +143,7 @@ export function App(props: AppProps) { footer, header, triggerNotification: props.triggerNotification, + triggerBell: props.triggerBell, }; let pluginLoadGeneration = 0; let builtInReloadGeneration = 0; diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index 7d9219dc..1f98c90e 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -8,6 +8,7 @@ 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"; @@ -45,6 +46,8 @@ export type BootstrapTerminal = { renderer: Awaited>, ) => void; copyText?: (text: string) => Promise; + notify?: (message: string, title?: string) => boolean; + bell?: (isError: boolean) => void; }; async function loadSession(opts?: BootstrapOpts): Promise { @@ -276,8 +279,10 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { 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) => { 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/web-tui-mode.ts b/app/src/app/web-tui-mode.ts index 91791c48..caec474e 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -70,6 +70,8 @@ export async function runWebTuiMode( terminal: { ...bridge.terminal, copyText: (text) => server.copyText(text), + notify: (message, title) => server.notify(message, title), + bell: (isError) => server.bell(isError), }, }), ) diff --git a/app/src/app/web-tui-server.test.ts b/app/src/app/web-tui-server.test.ts index e76ccbdd..96912858 100644 --- a/app/src/app/web-tui-server.test.ts +++ b/app/src/app/web-tui-server.test.ts @@ -308,6 +308,33 @@ describe("WebTuiServer WebSocket", () => { 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); diff --git a/app/src/app/web-tui-server.ts b/app/src/app/web-tui-server.ts index eacf058b..adb67a13 100644 --- a/app/src/app/web-tui-server.ts +++ b/app/src/app/web-tui-server.ts @@ -234,6 +234,34 @@ export class WebTuiServer { 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) { @@ -500,12 +528,12 @@ export class WebTuiServer { private send( socket: ServerWebSocket, data: string | Uint8Array, - ): void { - if (socket.readyState !== WebSocket.OPEN) return; + ): 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; + if (socket.send(data) !== 0) return true; } catch (error) { console.error( `Web TUI send failed: ${error instanceof Error ? error.message : String(error)}`, @@ -515,6 +543,7 @@ export class WebTuiServer { this.clients.delete(socket); if (this.activeSocket === socket) this.activeSocket = null; socket.terminate(); + return false; } private releaseSocket(socket: ServerWebSocket): void { 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/web-tui/browser-actions.test.ts b/app/src/web-tui/browser-actions.test.ts index 17774e2e..67ac3969 100644 --- a/app/src/web-tui/browser-actions.test.ts +++ b/app/src/web-tui/browser-actions.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; import { MAX_BROWSER_CLIPBOARD_BYTES, + parseBrowserBell, parseBrowserClipboardWrite, + parseBrowserNotification, } from "./browser-actions"; describe("parseBrowserClipboardWrite", () => { @@ -31,3 +33,43 @@ describe("parseBrowserClipboardWrite", () => { ).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 index bdea552c..ced7f753 100644 --- a/app/src/web-tui/browser-actions.ts +++ b/app/src/web-tui/browser-actions.ts @@ -1,4 +1,4 @@ -export const WEB_TUI_PROTOCOL_VERSION = 2; +export const WEB_TUI_PROTOCOL_VERSION = 3; export const MAX_BROWSER_CLIPBOARD_BYTES = 1024 * 1024; export type BrowserClipboardWrite = { @@ -7,6 +7,17 @@ export type BrowserClipboardWrite = { 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 { @@ -32,3 +43,48 @@ export function parseBrowserClipboardWrite( } 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/client.ts b/app/src/web-tui/client.ts index c8aa6138..d116abad 100644 --- a/app/src/web-tui/client.ts +++ b/app/src/web-tui/client.ts @@ -1,7 +1,11 @@ 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 { @@ -30,6 +34,108 @@ function hideStatus(): void { 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`; @@ -133,7 +239,17 @@ class TuiConnection { return; } const clipboard = parseBrowserClipboardWrite(event.data); - if (clipboard) void this.writeClipboard(socket, clipboard); + 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) => { @@ -247,6 +363,7 @@ async function main(): Promise { fit.fit(); const protocol = new TerminalProtocolState(); const connection = new TuiConnection(terminal, fit, protocol); + setupBrowserNotifications(() => terminal.focus()); new BrowserTerminalInput({ root: element, protocol, diff --git a/app/src/web-tui/index.html b/app/src/web-tui/index.html index c8332f1b..3925ea61 100644 --- a/app/src/web-tui/index.html +++ b/app/src/web-tui/index.html @@ -11,5 +11,7 @@
Connecting…
+ + diff --git a/app/src/web-tui/tui.css b/app/src/web-tui/tui.css index f6c4c193..08483fc0 100644 --- a/app/src/web-tui/tui.css +++ b/app/src/web-tui/tui.css @@ -58,6 +58,37 @@ body { sans-serif; } -#status[hidden] { +#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/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index ed59c585..5dbb3b66 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -38,6 +38,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 - 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 @@ -60,12 +61,13 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 (15 tests total): +- 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 @@ -80,6 +82,12 @@ Input uses deterministic legacy terminal sequences. This covers Kit's control-le 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. +## 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. @@ -133,7 +141,6 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - 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. -- Terminal bell and notification paths still target terminal or host integrations rather than explicit browser behavior. - `--model` is not accepted in this mode; select the model inside the TUI. - 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. From 89b4dd1c61d823e79c13ee11e63656bdcd685213 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Wed, 19 Aug 2026 00:02:06 -0400 Subject: [PATCH 13/16] feat(web-tui): support startup model selection --- app/src/app/App.tsx | 22 ++++++++++- app/src/app/bootstrap.tsx | 2 + app/src/app/headless-model.test.ts | 18 +++++++++ app/src/app/headless-model.ts | 20 ++++++++++ app/src/app/main.test.ts | 16 ++++++++ app/src/app/main.tsx | 50 +++++++++++-------------- app/src/app/web-tui-mode.ts | 2 + docs/experiments/web-tui-ghostty-web.md | 9 ++++- 8 files changed, 107 insertions(+), 32 deletions(-) diff --git a/app/src/app/App.tsx b/app/src/app/App.tsx index 9e5d31b1..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,11 +44,16 @@ 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; @@ -216,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; @@ -357,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 { diff --git a/app/src/app/bootstrap.tsx b/app/src/app/bootstrap.tsx index 1f98c90e..1ca26624 100644 --- a/app/src/app/bootstrap.tsx +++ b/app/src/app/bootstrap.tsx @@ -30,6 +30,7 @@ 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). @@ -271,6 +272,7 @@ export async function bootstrap(opts?: BootstrapOpts): Promise { { ); }); + 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 dfe9c175..554cde2d 100644 --- a/app/src/app/main.test.ts +++ b/app/src/app/main.test.ts @@ -128,6 +128,22 @@ describe("web mode CLI", () => { expect(result.stdout).toBe(""); expect(result.stderr).toContain("--model expects /"); }); + + test("accepts a startup model selector for the experimental browser TUI", async () => { + const result = await runMain([ + "--web", + "--experimental-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", () => { diff --git a/app/src/app/main.tsx b/app/src/app/main.tsx index 39c48df3..f64bb1b2 100644 --- a/app/src/app/main.tsx +++ b/app/src/app/main.tsx @@ -132,34 +132,28 @@ if (values.mode !== undefined) { console.error("kit --web --port expects an integer from 1 to 65535"); process.exitCode = 1; } else if (values["experimental-tui"] === true) { - if (typeof values.model === "string") { - console.error( - "kit --web --experimental-tui does not support --model; select the model inside the TUI", - ); - process.exitCode = 1; - } else { - 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, - newSession: selectsNewSession, - noSession: values["no-session"] === true, - sessionId: - typeof values.session === "string" ? values.session : undefined, - }); - } + 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"); diff --git a/app/src/app/web-tui-mode.ts b/app/src/app/web-tui-mode.ts index caec474e..0af7f2a9 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -41,6 +41,7 @@ export type WebTuiModeOptions = { hostname?: string; port?: number; publicUrl?: string; + model?: string; newSession?: boolean; noSession?: boolean; sessionId?: string; @@ -64,6 +65,7 @@ export async function runWebTuiMode( appPromise = import("./bootstrap") .then(({ bootstrap }) => bootstrap({ + startupModel: options.model, newSession: options.newSession, noSession: options.noSession, sessionId: options.sessionId, diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 5dbb3b66..609ee380 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -12,6 +12,7 @@ Run: ```bash kit --web --experimental-tui +kit --web --experimental-tui --model provider/model-id ``` Normal `kit --web` behavior is unchanged. The experimental mode has one runtime owner: @@ -39,6 +40,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - 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 @@ -52,7 +54,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 736 passing +- `bun test`: 742 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets @@ -82,6 +84,10 @@ Input uses deterministic legacy terminal sequences. This covers Kit's control-le 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. @@ -141,7 +147,6 @@ The ghostty-web module contains an embedded base64 WASM fallback even when Kit l - 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. -- `--model` is not accepted in this mode; select the model inside the TUI. - 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. From fc9c90a71fc9ab229198982d5b3ce27eac29e94c Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Wed, 19 Aug 2026 00:16:11 -0400 Subject: [PATCH 14/16] refactor(web-tui): use dedicated CLI flag --- README.md | 4 +- app/e2e/web-tui.fixture.ts | 3 +- app/script/smoke-web-tui.ts | 7 ++-- app/src/app/main.test.ts | 15 ++++++-- app/src/app/main.tsx | 49 ++++++++++++++++--------- app/src/app/web-tui-mode.ts | 4 +- docs/experiments/web-tui-ghostty-web.md | 6 +-- 7 files changed, 56 insertions(+), 32 deletions(-) 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/e2e/web-tui.fixture.ts b/app/e2e/web-tui.fixture.ts index 1a6874a0..398a960e 100644 --- a/app/e2e/web-tui.fixture.ts +++ b/app/e2e/web-tui.fixture.ts @@ -118,8 +118,7 @@ export const test = base.extend({ child = spawn( binary, [ - "--web", - "--experimental-tui", + "--web-tui", "--no-session", "--port", String(port), diff --git a/app/script/smoke-web-tui.ts b/app/script/smoke-web-tui.ts index 3b539e7a..4a3b6117 100644 --- a/app/script/smoke-web-tui.ts +++ b/app/script/smoke-web-tui.ts @@ -2,7 +2,7 @@ /** * Smoke test for the experimental browser TUI mode (ghostty-web experiment). * - * Boots `kit --web --experimental-tui` with an ephemeral session, then acts as + * 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) @@ -75,7 +75,7 @@ async function connect(): Promise { }; } -console.log(`Starting kit --web --experimental-tui on port ${port}...`); +console.log(`Starting kit --web-tui on port ${port}...`); const smokeBinary = process.env.KIT_WEB_TUI_SMOKE_BIN; const spawnServer = () => Bun.spawn({ @@ -83,8 +83,7 @@ const spawnServer = () => ...(smokeBinary ? [path.resolve(dir, smokeBinary)] : ["bun", "--preload=@opentui/solid/preload", "src/app/main.tsx"]), - "--web", - "--experimental-tui", + "--web-tui", "--no-session", "--port", String(port), diff --git a/app/src/app/main.test.ts b/app/src/app/main.test.ts index 554cde2d..0f7c4f6b 100644 --- a/app/src/app/main.test.ts +++ b/app/src/app/main.test.ts @@ -129,10 +129,9 @@ describe("web mode CLI", () => { expect(result.stderr).toContain("--model expects /"); }); - test("accepts a startup model selector for the experimental browser TUI", async () => { + test("accepts a startup model selector for the browser TUI", async () => { const result = await runMain([ - "--web", - "--experimental-tui", + "--web-tui", "--model", "openai/gpt-5.5", "--port", @@ -144,6 +143,14 @@ describe("web mode CLI", () => { ); expect(result.stderr).not.toContain("does not support --model"); }); + + test("directs the old experimental flag to --web-tui", async () => { + const result = await runMain(["--web", "--experimental-tui"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "--experimental-tui is no longer supported; use --web-tui", + ); + }); }); describe("interactive mode CLI", () => { @@ -163,6 +170,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); diff --git a/app/src/app/main.tsx b/app/src/app/main.tsx index f64bb1b2..eb7564ad 100644 --- a/app/src/app/main.tsx +++ b/app/src/app/main.tsx @@ -22,6 +22,7 @@ const { positionals, values } = parseArgs({ session: { type: "string", short: "s" }, version: { type: "boolean", short: "v" }, web: { type: "boolean" }, + "web-tui": { type: "boolean" }, }, strict: false, allowPositionals: true, @@ -37,11 +38,14 @@ const hasWebOnlyOptions = values.port !== undefined || values["public-url"] !== undefined || values["allow-host"] !== undefined || - values["allow-origin"] !== undefined || - values["experimental-tui"] !== undefined; -const selectedModes = [values.print, values.rpc, values.web].filter( - (value) => value === true, -).length; + values["allow-origin"] !== undefined; +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; @@ -81,12 +85,19 @@ 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 (values["experimental-tui"] === true) { + console.error("--experimental-tui is no longer supported; use --web-tui"); 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 = @@ -102,36 +113,40 @@ if (values.mode !== undefined) { (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 --web --public-url expects an HTTP(S) origin without a path, query, credentials, or fragment", + `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["experimental-tui"] === true) { + } else if (values["web-tui"] === true) { const { runWebTuiMode } = await import("./web-tui-mode"); process.exitCode = await runWebTuiMode({ allowedHosts: Array.isArray(values["allow-host"]) @@ -181,7 +196,7 @@ if (values.mode !== undefined) { } } else if (hasWebOnlyOptions) { console.error( - "--auth, --host, --port, --public-url, --allow-host, --allow-origin, and --experimental-tui 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/web-tui-mode.ts b/app/src/app/web-tui-mode.ts index 0af7f2a9..4e726657 100644 --- a/app/src/app/web-tui-mode.ts +++ b/app/src/app/web-tui-mode.ts @@ -1,5 +1,5 @@ /** - * Experimental `kit --web --experimental-tui` mode (ghostty-web experiment). + * 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 @@ -162,7 +162,7 @@ export async function runWebTuiMode( if (appFailed) exitCode = 1; } catch (error) { console.error( - `kit --web --experimental-tui failed: ${error instanceof Error ? error.message : String(error)}`, + `kit --web-tui failed: ${error instanceof Error ? error.message : String(error)}`, ); exitCode = 1; } finally { diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 609ee380..2098b4b9 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -11,8 +11,8 @@ Expose Kit's existing OpenTUI interface in a browser without rebuilding it in th Run: ```bash -kit --web --experimental-tui -kit --web --experimental-tui --model provider/model-id +kit --web-tui +kit --web-tui --model provider/model-id ``` Normal `kit --web` behavior is unchanged. The experimental mode has one runtime owner: @@ -54,7 +54,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 742 passing +- `bun test`: 743 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets From 40a63024eb72b72869d071d71ecfe061a40ee641 Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Wed, 19 Aug 2026 00:18:04 -0400 Subject: [PATCH 15/16] refactor(web-tui): remove obsolete CLI alias --- app/src/app/main.test.ts | 8 -------- app/src/app/main.tsx | 4 ---- docs/experiments/web-tui-ghostty-web.md | 2 +- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/app/src/app/main.test.ts b/app/src/app/main.test.ts index 0f7c4f6b..a4506ee9 100644 --- a/app/src/app/main.test.ts +++ b/app/src/app/main.test.ts @@ -143,14 +143,6 @@ describe("web mode CLI", () => { ); expect(result.stderr).not.toContain("does not support --model"); }); - - test("directs the old experimental flag to --web-tui", async () => { - const result = await runMain(["--web", "--experimental-tui"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "--experimental-tui is no longer supported; use --web-tui", - ); - }); }); describe("interactive mode CLI", () => { diff --git a/app/src/app/main.tsx b/app/src/app/main.tsx index eb7564ad..3f69cc11 100644 --- a/app/src/app/main.tsx +++ b/app/src/app/main.tsx @@ -9,7 +9,6 @@ const { positionals, values } = parseArgs({ options: { "allow-host": { type: "string", multiple: true }, auth: { type: "string" }, - "experimental-tui": { type: "boolean" }, "allow-origin": { type: "string", multiple: true }, host: { type: "string" }, mode: { type: "string" }, @@ -89,9 +88,6 @@ if (values.mode !== undefined) { "--mode is no longer supported; use --web, --web-tui, or --rpc", ); process.exitCode = 1; -} else if (values["experimental-tui"] === true) { - console.error("--experimental-tui is no longer supported; use --web-tui"); - process.exitCode = 1; } else if (selectedModes > 1) { console.error( "kit --print, --rpc, --web, and --web-tui are mutually exclusive", diff --git a/docs/experiments/web-tui-ghostty-web.md b/docs/experiments/web-tui-ghostty-web.md index 2098b4b9..09275d80 100644 --- a/docs/experiments/web-tui-ghostty-web.md +++ b/docs/experiments/web-tui-ghostty-web.md @@ -54,7 +54,7 @@ The WebSocket protocol uses raw binary terminal bytes in both directions and sma - `bun run typecheck` - `bun run check` -- `bun test`: 743 passing +- `bun test`: 742 passing - Production `bun run build` - `script/smoke-web-tui.ts` against both source and compiled modes: - health/document/assets From a14546795c270185b1e53d23b9030eee4408d9ba Mon Sep 17 00:00:00 2001 From: Akonwi Ngoh Date: Wed, 19 Aug 2026 00:29:08 -0400 Subject: [PATCH 16/16] ci(web-tui): run WebKit on macOS 15 --- .github/workflows/release.yml | 2 +- .github/workflows/web-tui-browser.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b0db014..fdeeeea6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: browser: chromium - os: ubuntu-latest browser: firefox - - os: macos-14 + - os: macos-15 browser: webkit steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/web-tui-browser.yml b/.github/workflows/web-tui-browser.yml index 4ebbf469..4fc621af 100644 --- a/.github/workflows/web-tui-browser.yml +++ b/.github/workflows/web-tui-browser.yml @@ -32,7 +32,7 @@ jobs: browser: chromium - os: ubuntu-latest browser: firefox - - os: macos-14 + - os: macos-15 browser: webkit steps: - uses: actions/checkout@v4