Public: anyone can read this; the crew can change it.

The machine shell page is a Ghostty terminal in the browser that survives a reload and resizes cleanly #9498

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9472-the-machine-shell-page-is-a-ghostty-term → main 12 commits fork 2 threads · 2 unresolved

  • Merged. scout/9472-the-machine-shell-page-is-a-ghostty-term was merged into main as 0041a0c.

Review

Sign in to review this change.
28 files changed +5620 −31
NOTICE +1
@@ -7,3 +7,4 @@ This repository also contains third-party files under their own licenses:
77
88 - DejaVu fonts, src/web/og/vendor/DejaVu*.ttf: see src/web/og/vendor/DEJAVU-LICENSE.
99 - resvg, src/web/og/vendor/resvg.*: Mozilla Public License 2.0, https://github.com/thx/resvg-js and https://github.com/linebender/resvg.
10 - ghostty-web 0.4.0, src/web/vendor/ghostty-web/: MIT License, Copyright (c) 2025 Coder, https://github.com/coder/ghostty-web; see src/web/vendor/ghostty-web/LICENSE.
README.md +5
@@ -46,3 +46,8 @@ It lives at [forge.open-platform.sh/trevato/forge](https://forge.open-platform.s
4646 GitHub holds a read-only mirror; issues and pulls are on the forge.
4747
4848 Open source under the [AGPL-3.0](LICENSE). See [contributing](CONTRIBUTING.md).
49
50 One dependency is vendored for browsers: [ghostty-web](https://github.com/coder/ghostty-web)
51 0.4.0 (MIT, `src/web/vendor/ghostty-web`), the terminal on a machine's shell page —
52 about 75 KB of script and a 423 KB WebAssembly file, both cached immutable and
53 loaded on that page only. See [NOTICE](NOTICE).
docs/design/areas/machines.md added +17
@@ -0,0 +1,17 @@
1 # Machines
2
3 A machine's shell page, `/:owner/:name/machines/:machine/shell`
4 (`src/web/pages/machines.ts`, its rules in `MACHINE_SHELL_CSS`, shipped as the
5 page's own style). The list at `/:owner/:name/settings/machines` uses the
6 table and `btn` it already had; no new class there.
7
8 - `.machine-term` — the box the terminal (ghostty-web's canvas) draws in:
9 its height is fixed to the window below the page head (`100dvh` less
10 `--machine-term-above`, the named sum of what sits over it, never under 320px), so nothing moves when the terminal arrives or the
11 socket opens; a bordered surface on the page background, its overflow
12 clipped. Its `data-wasm` names the emulator's WebAssembly.
13 - `.machine-term-status` — the one-line status above the box (connecting,
14 connected, why it closed): reserves its line (1.5em) so the box never
15 shifts as the words change; muted text, a polite live region. The server
16 renders it empty and the script says "Connecting…"; without JavaScript a
17 `<noscript>` line in the same class says the shell needs it.
docs/features/machines.md +42 −2
@@ -90,9 +90,49 @@ repository's machines:
9090 on its own for a writer who is not one), and in the terminal: the palette's
9191 **Machines of a repository** (`src/tui/screens/machines.ts`), writers only.
9292
93 ## The shell in the browser (#9472)
94
95 `/<owner>/<repo>/settings/machines` lists the machines with **Open shell**,
96 which goes to `/<owner>/<repo>/machines/<machine>/shell`: the same path as the
97 socket, answered as a page when it is not an upgrade. Writers only, like the
98 socket (a reader is refused 403; a private repository or an undeclared
99 machine answers 404). The page renders on the server and draws the terminal
100 with one script:
101
102 - **The emulator** is [ghostty-web](https://github.com/coder/ghostty-web)
103 0.4.0, vendored unmodified in `src/web/vendor/ghostty-web` (MIT; what was copied and
104 its hashes are in its README). `src/web/machine-term-asset.ts` bundles it
105 with the page's client (`src/web/machine-term-client.ts`) into
106 `/assets/machine-term-<hash>.js` (about 75 KB, dropping the module's own
107 inlined copy of the WebAssembly) and serves `ghostty-vt.wasm` at
108 `/assets/ghostty-vt-<hash>.wasm` (423 KB); both are content-addressed and
109 cached immutable.
110 - **The interface** to it is thin: `Term` in `src/web/machine-term-wire.ts`
111 is `write`, `onData`, `resize` and `fit`, and all of the protocol
112 (`wire`) is written against that, so it is tested without a browser and the
113 emulator can be swapped without touching it.
114 - **CSP.** Only this page adds `'wasm-unsafe-eval'` to `script-src` (the
115 page option `wasm` in `src/web/layout.ts`): it admits compiling
116 WebAssembly, not `eval` or inline script. Every other page's policy is
117 unchanged.
118 - **Reload.** A reload opens a new socket, which joins the person's running
119 session (above). On open the page sends its size; a connection's first
120 size equal to the pty's own makes the server pass the pty through one row
121 less for 30 ms (`resizePlan`, `src/machines/shell.ts`), because the kernel
122 signals only a change and tmux redraws the whole screen on the signal. So
123 the reloaded page shows the screen, not a blank one.
124 - **Resize.** The page refits the terminal to its box when the box changes
125 (debounced 100 ms) and sends one resize frame per new size, never the same
126 size twice; tmux redraws the prompt at the new size.
127 - **Closed.** The status line says why the socket closed (the shell exited,
128 idle, the forge cannot hold a shell) and to reload to reattach.
129
130 Try it: on a preview, sign in as a writer of a repository, open
131 `/<owner>/<repo>/settings/machines`, press **Open shell** on `standard`. A
132 forge that cannot hold a cell says so in the status line and closes.
133
93134 ## Not done yet (split from #9418)
94135
95136 Disk ceilings are not declared (#9460); the tools are declared, not yet
96 installed in an image (the brief reports which are on PATH); the browser terminal (ghostty-web) that speaks to the
97 shell socket is not built; the shell's cell does not reach the repository's
137 installed in an image (the brief reports which are on PATH); the shell's cell does not reach the repository's
98138 apps even when its machine says `apps`.
scripts/design-lint.ts +1 −1
@@ -85,7 +85,7 @@ export type Baseline = { files: Record<string, Counts> };
8585 export type Finding = { line: number; kind: "colour" | "size"; text: string; use: string };
8686 export type Scan = Baseline & { classes: string[]; found?: Record<string, Finding[]> };
8787
88 const SKIP = /(^|\/)(og\/vendor)\//;
88 const SKIP = /(^|\/)(og\/vendor|web\/vendor)\//;
8989
9090 export function sourceFiles(root: string): string[] {
9191 const out: string[] = [];
scripts/unreached.ts +7 −4
@@ -23,14 +23,17 @@ export const ENTRIES = [
2323 "src/git/hook-entry.ts",
2424 ];
2525
26 /** Scripts the check and operators run are callers too: their imports count. */
27 const SCRIPTS = "scripts/**/*.ts";
26 /** Scripts the check and operators run, and the benches run at release, are callers too: their imports count. */
27 const SCRIPTS = ["scripts/**/*.ts", "bench/**/*.ts"];
2828
2929 /** Not modules: tests, and migrations, which the runner finds by listing the directory. */
3030 const NOT_MODULES = /(?:\.test\.ts$|^src\/core\/migrations\/)/;
3131
3232 const SPEC = /(?:\bfrom\s*|\bimport\s*\(?\s*)["'`](\.{1,2}\/[^"'`]*)["'`]/g;
3333
34 /** A module named as a URL beside this one, the way a `Bun.build` entry point is: `new URL("./x.ts", import.meta.url)`. */
35 const URL_SPEC = /\bnew\s+URL\(\s*["'`](\.{1,2}\/[^"'`]*)["'`]\s*,\s*import\.meta\.url\s*\)/g;
36
3437 /** The src/ file a relative specifier names, among `files`, or undefined. */
3538 function resolve(from: string, spec: string, files: Set<string>): string | undefined {
3639 const base = normalize(join(dirname(from), spec)).replace(/\.(js|ts)$/, "");
@@ -43,10 +46,10 @@ export function unreached(root: string): string[] {
4346 [...new Bun.Glob("src/**/*.ts").scanSync({ cwd: root })].filter((f) => !f.endsWith(".d.ts") && !NOT_MODULES.test(f)),
4447 );
4548 const reached = new Set<string>(ENTRIES.filter((e) => files.has(e)));
46 const queue = [...reached, ...new Bun.Glob(SCRIPTS).scanSync({ cwd: root })];
49 const queue = [...reached, ...SCRIPTS.flatMap((g) => [...new Bun.Glob(g).scanSync({ cwd: root })])];
4750 for (let file = queue.pop(); file; file = queue.pop()) {
4851 const text = readFileSync(join(root, file), "utf8");
49 for (const m of text.matchAll(SPEC)) {
52 for (const m of [...text.matchAll(SPEC), ...text.matchAll(URL_SPEC)]) {
5053 const target = resolve(file, m[1]!, files);
5154 if (target && !reached.has(target)) {
5255 reached.add(target);
src/apps/tty.ts +14 −2
@@ -246,6 +246,9 @@ export interface TerminalProgram {
246246 readonly hangup: () => void;
247247 }
248248
249 /** Where `setsid` is, found once; null on a host without util-linux. */
250 const SETSID = Bun.which("setsid");
251
249252 /**
250253 * Start `command` (through `sh -c`) on `terminal`, held in `cell` when
251254 * there is one. Without a cell the program leads a process group of its
@@ -265,11 +268,20 @@ export function startTerminal(
265268 },
266269 ): TerminalProgram {
267270 const { cell } = options;
268 const proc = Bun.spawn(cell?.argv(command) ?? ["/bin/sh", "-c", command], {
271 const argv = cell?.argv(command) ?? ["/bin/sh", "-c", command];
272 // Bun's `terminal` gives the child the pty as its stdio but not as its
273 // controlling terminal, so the kernel has no one to tell of a resize: no
274 // SIGWINCH, and tmux never redraws at the new size (#9472). `setsid -c`
275 // (util-linux, Essential on the image's Debian) makes the program lead a
276 // session whose controlling terminal is the pty, in place: it is not a
277 // group leader yet, so setsid execs rather than forks, the pid stays, and
278 // the group is the program's own as `detached` made it.
279 const ctty = SETSID !== null;
280 const proc = Bun.spawn(ctty ? [SETSID, "-c", ...argv] : argv, {
269281 terminal: options.terminal,
270282 env: cell?.env(options.env) ?? options.env,
271283 cwd: options.cwd,
272 detached: cell === undefined,
284 detached: !ctty && cell === undefined,
273285 });
274286 const exited = proc.exited.then((code) => {
275287 cell?.close();
src/http/server.ts +12 −4
@@ -535,7 +535,8 @@ import {
535535 import { getVerifiedDomain } from "../core/domains.ts";
536536 import { previewOwner } from "../spawn/preview-owner.ts";
537537 import { cellsAvailable } from "../apps/contain.ts";
538 import { machinesSettingsPage } from "../web/pages/machines.ts";
538 import { machineShellPage, machinesSettingsPage } from "../web/pages/machines.ts";
539 import { machineTermAsset, wasmResponse, ghosttyWasmAsset } from "../web/machine-term-asset.ts";
539540 import {
540541 defaultCommand,
541542 mayOpenShell,
@@ -544,7 +545,6 @@ import {
544545 shellTarget,
545546 stopShells,
546547 withShells,
547 closeStaleSessions,
548548 type ForgeSocket,
549549 type ShellSocket,
550550 } from "../machines/shell.ts";
@@ -726,6 +726,10 @@ export function buildRouter(): Router<Handler> {
726726 if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(ctx.request);
727727 const own = pageScriptNamed(ctx.params.name!, ctx.app.config.dataDir);
728728 if (own !== undefined) return fileResponse(ctx.request, own);
729 if (ctx.params.name!.startsWith("machine-term-")) {
730 const term = await machineTermAsset();
731 return term !== null && ctx.params.name === term.name ? assetResponse(ctx.request, term) : notFound();
732 }
729733 const asset = await highlightAsset();
730734 if (asset === null || ctx.params.name !== asset.name) return notFound();
731735 return assetResponse(ctx.request, asset);
@@ -734,6 +738,10 @@ export function buildRouter(): Router<Handler> {
734738 router.add("GET", "/assets/:name.svg", (ctx) =>
735739 ctx.params.name === spriteAsset().name ? fileResponse(ctx.request, spriteAsset()) : notFound(),
736740 );
741 // The machine shell's terminal emulator (`web/machine-term-asset.ts`).
742 router.add("GET", "/assets/:name.wasm", (ctx) =>
743 ctx.params.name === ghosttyWasmAsset().name ? wasmResponse(ctx.request) : notFound(),
744 );
737745 router.add("GET", "/assets/:name.json", (ctx) =>
738746 ctx.params.name === speculationAsset().name ? fileResponse(ctx.request, speculationAsset()) : notFound(),
739747 );
@@ -1371,6 +1379,8 @@ export function buildRouter(): Router<Handler> {
13711379 );
13721380 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
13731381 router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage);
1382 // The shell page; an upgrade at the same path is the socket (`machines/shell.ts`).
1383 router.add("GET", "/:owner/:name/machines/:machine/shell", machineShellPage);
13741384 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
13751385 router.add("POST", "/:owner/:name/settings/app/disable", appDisable);
13761386 router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate);
@@ -2161,8 +2171,6 @@ export function forgeHttp(app: App): ForgeHttp {
21612171
21622172 export async function serve(app: App): Promise<Servers> {
21632173 const router = buildRouter();
2164 // No shell outlives its forge: a row a stop or crash left open is closed.
2165 closeStaleSessions(app.db);
21662174 const apiRouter = buildApiRouter();
21672175 await clearStaleSockets(app);
21682176 // One limiter per listener: its state is a minute of timestamps, and two
src/machines/shell.ts +37 −1
@@ -127,6 +127,28 @@ export function parseControl(text: string): { cols: number; rows: number } | und
127127 return { cols, rows };
128128 }
129129
130 export interface PtySize {
131 readonly cols: number;
132 readonly rows: number;
133 }
134
135 /**
136 * The sizes to give the pty for a resize frame, in order. A new size is set
137 * once. A connection's first frame at the size the pty already has (a
138 * reload, a second tab) passes through one row less first: the kernel only
139 * signals a change, and tmux redraws the whole screen on the signal, so the
140 * page that joined sees the screen rather than a blank one. A later frame at
141 * the same size changes nothing.
142 */
143 export function resizePlan(current: PtySize, next: PtySize, firstFromPeer: boolean): PtySize[] {
144 if (current.cols !== next.cols || current.rows !== next.rows) return [next];
145 if (!firstFromPeer) return [];
146 return [{ cols: next.cols, rows: next.rows > RESIZE.rows.min ? next.rows - 1 : next.rows + 1 }, next];
147 }
148
149 /** How long the pty holds the nudged size before the real one, so the program sees two changes. */
150 export const REDRAW_NUDGE_MS = 30;
151
130152 // ---- the records ----
131153
132154 export type EndReason = "exit" | "idle" | "stopped" | "revoked" | "failed";
@@ -205,6 +227,8 @@ export interface ShellSocket {
205227 readonly shell: ShellGrant & { readonly app: App };
206228 joined?: Session;
207229 release?: () => void;
230 /** This connection has sent a size; its first one redraws a joined screen (`resizePlan`). */
231 sized?: boolean;
208232 }
209233
210234 type Peer = Bun.ServerWebSocket<ShellSocket>;
@@ -217,6 +241,8 @@ interface Session {
217241 readonly terminal: Bun.Terminal;
218242 readonly program: TerminalProgram;
219243 readonly peers: Set<Peer>;
244 /** The pty's size, as last set. */
245 size: PtySize;
220246 lastInput: number;
221247 ended: boolean;
222248 readonly timer: ReturnType<typeof setInterval>;
@@ -387,6 +413,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
387413 terminal: term,
388414 program,
389415 peers,
416 size: { cols, rows },
390417 lastInput: Date.now(),
391418 ended: false,
392419 timer: setInterval(
@@ -460,7 +487,16 @@ export const shellWebSocket = {
460487 ws.close(1003, "a text frame is a resize");
461488 return;
462489 }
463 if (!session.terminal.closed) session.terminal.resize(resize.cols, resize.rows);
490 const plan = resizePlan(session.size, resize, ws.data.sized !== true);
491 ws.data.sized = true;
492 session.size = resize;
493 const apply = (size: PtySize): void => {
494 if (!session.ended && !session.terminal.closed) session.terminal.resize(size.cols, size.rows);
495 };
496 const [first, ...rest] = plan;
497 if (first !== undefined) apply(first);
498 // A newer frame in the meantime is the size that stands; this one is dropped.
499 if (rest.length > 0) setTimeout(() => session.size === resize && rest.forEach(apply), REDRAW_NUDGE_MS);
464500 return;
465501 }
466502 if (message.byteLength > MAX_INPUT_BYTES) {
src/main.ts +5
@@ -98,6 +98,7 @@ import { stallLine } from "./perf/loop.ts";
9898 import { bootStall, gitRecord, loopWatch, perfRecord } from "./perf/live.ts";
9999 import { timeStatements } from "./kernel/slow_sql.ts";
100100 import { waitForPeer } from "./runs/peer.ts";
101 import { closeStaleSessions } from "./machines/shell.ts";
101102
102103 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
103104 // running forge over its socket and exits — it must never boot a server.
@@ -425,6 +426,10 @@ const earlySignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
425426 for (const signal of earlySignals) process.once(signal, leaveEarly);
426427 await waitForPeer(app.db, app.log);
427428 for (const signal of earlySignals) process.off(signal, leaveEarly);
429 // No shell outlives its forge: a row a stop or crash left open is closed. Only
430 // once the other forge has let go: its shells are live until then, and a write
431 // at boot would wait on its lock with the event loop held (#9472).
432 closeStaleSessions(app.db);
428433 // A repository's own check, on every push a pull or the default branch takes.
429434 const checks =
430435 process.env.FORGE_CHECKS === "0"
src/web/asset.ts +3 −3
@@ -76,7 +76,7 @@ async function build(): Promise<BuiltAsset | null> {
7676 * other response. Only hashed assets come through here, so only they are
7777 * immutable.
7878 */
79 export function staticResponse(request: Request, body: string, type: string, etag: string): Response {
79 export function staticResponse(request: Request, body: string | Uint8Array<ArrayBuffer>, type: string, etag: string): Response {
8080 const headers: Record<string, string> = {
8181 "content-type": type,
8282 "cache-control": "public, max-age=31536000, immutable",
@@ -87,8 +87,8 @@ export function staticResponse(request: Request, body: string, type: string, eta
8787 if (matches(request.headers.get("if-none-match"), etag))
8888 return new Response(null, { status: 304, headers });
8989 const encoding = negotiate(request.headers.get("accept-encoding"));
90 const bytes = new TextEncoder().encode(body);
91 if (encoding === null || bytes.length < MIN_BYTES || request.method === "HEAD") return new Response(body, { headers });
90 const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
91 if (encoding === null || bytes.length < MIN_BYTES || request.method === "HEAD") return new Response(bytes, { headers });
9292 // Hashed, so the encoded bytes are as immutable as the plain ones: made once.
9393 return new Response(encodeOnce(etag, bytes, encoding), { headers: { ...headers, "content-encoding": encoding } });
9494 }
src/web/layout.ts +8 −1
@@ -135,6 +135,13 @@ export interface PageOptions {
135135 * origin" — so pages that do not ask for it keep the stricter policy.
136136 */
137137 scriptAsset?: string;
138 /**
139 * The page compiles WebAssembly (a machine's shell page runs its terminal
140 * emulator in it): `script-src` gains `'wasm-unsafe-eval'`, which admits
141 * WebAssembly compilation and nothing else — no `eval`, no inline script.
142 * Only that page asks; no other page's policy changes.
143 */
144 wasm?: boolean;
138145 /**
139146 * The page draws a PDF from the media route in an `<object>`: the browser's
140147 * own viewer may load it from this origin (`object-src 'self'`). Only the
@@ -861,7 +868,7 @@ export function page(options: PageOptions, status = 200): Response {
861868 // The hashed stylesheet and scripts are 'self'; the two inline <style>s
862869 // are named by hash. connect-src 'self' lets the live event stream
863870 // (EventSource) and fragment fetches reach same-origin.
864 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
871 "content-security-policy": `default-src 'none'; style-src 'self' ${styleSources}; script-src 'self'${options.wasm ? " 'wasm-unsafe-eval'" : ""}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${frameSources(options)}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
865872 "referrer-policy": "same-origin",
866873 "x-content-type-options": "nosniff",
867874 // Markup is revalidated every time; only the hashed assets it names are
src/web/machine-term-asset.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * The machine shell page's two files, content-addressed and immutable like the
3 * highlighter (`web/asset.ts`):
4 *
5 * - `/assets/machine-term-<hash>.js` — `web/machine-term-client.ts` bundled
6 * with the vendored ghostty-web (`src/web/vendor/ghostty-web`, MIT, 0.4.0) by
7 * `Bun.build` at first use. The vendored module also carries its
8 * WebAssembly as a base64 `data:` URL; the bundle drops that copy (the page
9 * fetches the file below instead), so the script is about 75 KB rather than 680
10 * and the page's CSP needs no `data:` in `connect-src`.
11 * - `/assets/ghostty-vt-<hash>.wasm` — the emulator's WebAssembly, read once.
12 *
13 * A failed build is not fatal: the page says the terminal could not load.
14 */
15
16 import { readFileSync } from "node:fs";
17 import { join } from "node:path";
18 import { sha256 } from "../kernel/hash.ts";
19 import { createLog } from "../kernel/log.ts";
20 import { staticResponse, type BuiltAsset } from "./asset.ts";
21
22 const log = createLog("web");
23
24 export const VENDOR_DIR = join(import.meta.dir, "vendor", "ghostty-web");
25
26 /** The vendored module's inlined WebAssembly: a string literal, base64. */
27 const INLINE_WASM = /(["'`])data:application\/wasm;base64,[A-Za-z0-9+/=]+\1/g;
28
29 /** The vendored source with its inlined WebAssembly replaced by an empty `data:` URL. */
30 export function withoutInlineWasm(source: string): string {
31 return source.replace(INLINE_WASM, '"data:,"');
32 }
33
34 let pending: Promise<BuiltAsset | null> | null = null;
35
36 /** The shell page's script, built once per process. */
37 export function machineTermAsset(): Promise<BuiltAsset | null> {
38 pending ??= build();
39 return pending;
40 }
41
42 async function build(): Promise<BuiltAsset | null> {
43 try {
44 const built = await Bun.build({
45 entrypoints: [Bun.fileURLToPath(new URL("./machine-term-client.ts", import.meta.url))],
46 target: "browser",
47 format: "esm",
48 minify: true,
49 plugins: [
50 {
51 name: "ghostty-web-without-inline-wasm",
52 setup(builder) {
53 builder.onLoad({ filter: /vendor[\\/]ghostty-web[\\/]ghostty-web\.js$/ }, async (args) => ({
54 contents: withoutInlineWasm(await Bun.file(args.path).text()),
55 loader: "js",
56 }));
57 },
58 },
59 ],
60 });
61 const first = built.outputs[0];
62 if (!built.success || first === undefined || built.outputs.length !== 1) {
63 log.warn("web.machine_term_asset_failed", { logs: built.logs.length, outputs: built.outputs.length });
64 return null;
65 }
66 const code = await first.text();
67 const hash = sha256(code);
68 const name = `machine-term-${hash.slice(0, 16)}`;
69 return { path: `/assets/${name}.js`, name, code, etag: `"${hash}"` };
70 } catch (error) {
71 log.warn("web.machine_term_asset_failed", { error: String(error) });
72 return null;
73 }
74 }
75
76 export interface WasmAsset {
77 readonly path: string;
78 readonly name: string;
79 readonly bytes: Uint8Array<ArrayBuffer>;
80 readonly etag: string;
81 }
82
83 let wasm: WasmAsset | null = null;
84
85 /** The emulator's WebAssembly, read and hashed once per process. */
86 export function ghosttyWasmAsset(): WasmAsset {
87 if (wasm === null) {
88 const bytes = new Uint8Array(readFileSync(join(VENDOR_DIR, "ghostty-vt.wasm")));
89 const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
90 const name = `ghostty-vt-${hash.slice(0, 16)}`;
91 wasm = { path: `/assets/${name}.wasm`, name, bytes, etag: `"${hash}"` };
92 }
93 return wasm;
94 }
95
96 /** The response for a hit on the WebAssembly: immutable, because it is. */
97 export function wasmResponse(request: Request): Response {
98 const asset = ghosttyWasmAsset();
99 return staticResponse(request, asset.bytes, "application/wasm", asset.etag);
100 }
src/web/machine-term-client.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * The browser entry point of a machine's shell page
3 * (`/<owner>/<repo>/machines/<machine>/shell`, `web/pages/machines.ts`).
4 * `web/asset.ts` bundles it with the vendored ghostty-web
5 * (`src/web/vendor/ghostty-web`, MIT, pinned at 0.4.0) into one ES module served
6 * from `/assets/machine-term-<hash>.js`; the emulator's WebAssembly is its own
7 * immutable asset, named on the page as `data-wasm`, never inlined.
8 *
9 * Everything the page knows arrives in `data-*` attributes; the socket is the
10 * page's own path, upgraded. All protocol work is in `machine-term-wire.ts`.
11 */
12
13 import { FitAddon, Ghostty, Terminal, type ITheme } from "./vendor/ghostty-web/ghostty-web.js";
14 import { wire, type Term } from "./machine-term-wire.ts";
15
16 // The few browser globals this file touches, named here rather than pulling
17 // the whole DOM library into a server-side type check.
18 interface Box {
19 readonly dataset: Record<string, string | undefined>;
20 textContent: string | null;
21 focus(): void;
22 }
23 declare const document: { getElementById(id: string): Box | null; readonly documentElement: unknown };
24 declare const location: { readonly href: string; readonly pathname: string; readonly protocol: string };
25 declare function getComputedStyle(element: unknown): { getPropertyValue(name: string): string };
26 declare const ResizeObserver: new (callback: () => void) => { observe(element: unknown): void };
27
28 const REFIT_MS = 100;
29
30 const box = document.getElementById("machine-term");
31 const status = document.getElementById("machine-term-status");
32 const say = (words: string): void => {
33 if (status !== null) status.textContent = words;
34 };
35
36 if (box !== null) {
37 // Said here, not by the server: without JavaScript nothing connects (#9472).
38 say("Connecting…");
39 start(box).catch(() => say("The terminal could not start in this browser."));
40 }
41
42 /** The page's own colours and type, read from its tokens: the terminal follows the theme. */
43 function theme(): { theme: ITheme; fontFamily?: string } {
44 const css = getComputedStyle(document.documentElement);
45 const token = (name: string): string | undefined => {
46 const value = css.getPropertyValue(name).trim();
47 return value === "" ? undefined : value;
48 };
49 const colours: ITheme = {};
50 const background = token("--bg");
51 const foreground = token("--fg");
52 const cursor = token("--accent");
53 if (background !== undefined) colours.background = background;
54 if (foreground !== undefined) colours.foreground = foreground;
55 if (cursor !== undefined) colours.cursor = cursor;
56 const font = token("--mono");
57 return font === undefined ? { theme: colours } : { theme: colours, fontFamily: font };
58 }
59
60 async function start(box: Box): Promise<void> {
61 const wasm = box.dataset["wasm"];
62 if (wasm === undefined) throw new Error("no wasm named");
63 const ghostty = await Ghostty.load(wasm);
64 const terminal = new Terminal({ ghostty, fontSize: 14, cursorBlink: true, ...theme() });
65 const fitter = new FitAddon();
66 terminal.loadAddon(fitter);
67 terminal.open(box as never);
68
69 const term: Term = {
70 write: (bytes) => terminal.write(bytes),
71 onData: (listener) => {
72 terminal.onData(listener);
73 },
74 resize: (cols, rows) => terminal.resize(cols, rows),
75 fit: () => {
76 fitter.fit();
77 return terminal.cols > 0 && terminal.rows > 0 ? { cols: terminal.cols, rows: terminal.rows } : undefined;
78 },
79 };
80
81 const url = new URL(location.pathname, location.href);
82 url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
83 const socket = new WebSocket(url);
84 socket.binaryType = "arraybuffer";
85 const wired = wire(term, (frame) => socket.send(frame), say);
86 socket.addEventListener("open", () => wired.opened());
87 socket.addEventListener("message", (event) => {
88 if (event.data instanceof ArrayBuffer) wired.received(new Uint8Array(event.data));
89 });
90 socket.addEventListener("close", (event) => wired.closed(event.code, event.reason));
91
92 // A window dragged wider fires many times; the shell hears the size it settles on.
93 let timer: ReturnType<typeof setTimeout> | undefined;
94 new ResizeObserver(() => {
95 if (timer !== undefined) clearTimeout(timer);
96 timer = setTimeout(() => wired.refit(), REFIT_MS);
97 }).observe(box);
98 terminal.focus();
99 }
src/web/machine-term-wire.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * The browser terminal's wiring to a machine's shell socket
3 * (`machines/shell.ts`), with no DOM and no socket in it: plain functions the
4 * client (`machine-term-client.ts`) connects to a real terminal and a real
5 * WebSocket, and a test connects to fakes.
6 *
7 * The terminal is reached only through `Term` — `write`, `onData`, `resize`,
8 * `fit` — so the emulator behind it (ghostty-web today) can be swapped
9 * without touching the protocol.
10 *
11 * The protocol is the socket's: binary frames are keystrokes in and screen
12 * bytes out; a text frame is `{"type":"resize","cols":n,"rows":n}`. A size is
13 * sent once when the socket opens (the server redraws a reattached session on
14 * it) and then once per size the terminal settles on, never twice in a row.
15 */
16
17 export interface Size {
18 readonly cols: number;
19 readonly rows: number;
20 }
21
22 /** The thin face of a terminal emulator: all the page needs of one. */
23 export interface Term {
24 /** Screen bytes from the shell. */
25 write(bytes: Uint8Array): void;
26 /** Keystrokes the person typed (or pasted). */
27 onData(listener: (text: string) => void): void;
28 resize(cols: number, rows: number): void;
29 /** Fit the terminal to its box; the size it settled on, if it could measure one. */
30 fit(): Size | undefined;
31 }
32
33 /** The text frame that tells the shell its window's size. */
34 export function resizeFrame(size: Size): string {
35 return JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows });
36 }
37
38 /** Why a closed socket closed, in words a person can act on. */
39 export function closedWords(code: number, reason: string): string {
40 const why = reason.trim() !== "" ? reason.trim() : code === 1000 ? "the shell exited" : "the connection dropped";
41 return `Disconnected: ${why}. Reload to reattach.`;
42 }
43
44 export interface Wired {
45 /** The socket opened: send the size, start forwarding keystrokes. */
46 opened(): void;
47 /** Screen bytes arrived. */
48 received(bytes: Uint8Array): void;
49 /** The socket closed. */
50 closed(code: number, reason: string): void;
51 /** The terminal's box may have changed size: fit, and send the size if it is new. */
52 refit(): void;
53 }
54
55 /**
56 * Wire a terminal to a socket's `send`. `say` puts a status line in front of
57 * the person (connecting, connected, why it closed).
58 */
59 export function wire(term: Term, send: (frame: string | Uint8Array) => void, say: (words: string) => void): Wired {
60 const encoder = new TextEncoder();
61 let open = false;
62 let sent: Size | undefined;
63 const sendSize = (size: Size): void => {
64 sent = size;
65 send(resizeFrame(size));
66 };
67 term.onData((text) => {
68 if (open) send(encoder.encode(text));
69 });
70 return {
71 opened() {
72 open = true;
73 say("Connected.");
74 const size = term.fit();
75 if (size !== undefined) sendSize(size);
76 },
77 received(bytes) {
78 term.write(bytes);
79 },
80 closed(code, reason) {
81 open = false;
82 say(closedWords(code, reason));
83 },
84 refit() {
85 const size = term.fit();
86 if (!open || size === undefined) return;
87 if (sent !== undefined && sent.cols === size.cols && sent.rows === size.rows) return;
88 sendSize(size);
89 },
90 };
91 }
src/web/pages/machines.ts +78 −7
@@ -1,8 +1,15 @@
11 /**
22 * GET /:owner/:name/settings/machines — the machines a repository declares
3 * (`crew/machines.ts`) and its last shell sessions (`machines/shell.ts`):
4 * who opened which machine, when, and when and why it ended. Writers only:
5 * a session is a shell on the repository's machine, so a reader sees none.
3 * (`crew/machines.ts`), each with "Open shell", and its last shell sessions
4 * (`machines/shell.ts`): who opened which machine, when, and when and why it
5 * ended. Writers only: a session is a shell on the repository's machine, so a
6 * reader sees none.
7 *
8 * GET /:owner/:name/machines/:machine/shell — the shell itself: a terminal in
9 * the browser (ghostty-web, `web/machine-term-client.ts`) on the WebSocket at
10 * the same path (an upgrade there is answered by `machines/shell.ts` before
11 * any route). Writers only, as the socket is. A reload joins the same tmux
12 * session. The one page whose CSP admits WebAssembly (`'wasm-unsafe-eval'`).
613 */
714
815 import type { Machine } from "../../crew/machines.ts";
@@ -11,12 +18,37 @@ import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from
1118 import { repoMachines } from "../../crew/machines_read.ts";
1219 import type { Ctx } from "../../http/context.ts";
1320 import { ENDED, lastSessions, type SessionRecord } from "../../machines/shell.ts";
14 import { crumbsFor, forbiddenPage, loadRepo, repoHeader, repoNotFound, repoPalette, NOT_YOURS_TO_DO } from "../chrome.ts";
21 import {
22 crumbsFor,
23 forbiddenPage,
24 loadRepo,
25 notFoundPage,
26 pageHead,
outdated

Second import from ../chrome.ts; merge it with the one above.

27 repoHeader,
28 repoNotFound,
29 repoPalette,
30 NOT_YOURS_TO_DO,
31 } from "../chrome.ts";
outdated

Magic 300px and hardcoded 8px padding: use tokens or a named offset for the head height.

1532 import { page } from "../layout.ts";
1633 import { html } from "../html.ts";
17 import { card, icon, time } from "../ui.ts";
34 import { btn, card, icon, time } from "../ui.ts";
35 import { ghosttyWasmAsset, machineTermAsset } from "../machine-term-asset.ts";
1836 import { settingsPageResponse } from "./settings.ts";
1937
38 /** The shell page's own rules: a terminal box that fills the window below the head and never moves. */
39 // --machine-term-above is everything over the box: the site bar (48), the
40 // compact repository header (88), the page head with its sub line (96), the
41 // status line with its margins (40), and the page's top and bottom padding
42 // (28). Padding and margins are the 4px scale's 8 (DESIGN §4).
43 export const MACHINE_SHELL_CSS = /* css */ `
44 .machine-term { --machine-term-above: 300px; height: calc(100dvh - var(--machine-term-above)); min-height: 320px; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); overflow: hidden; }
45 .machine-term-status { min-height: 1.5em; margin: 8px 0; color: var(--muted-fg); }
46 `;
47
48 /** Where a machine's shell page (and its socket) lives. */
49 export function shellPath(repo: Repo, machine: string): string {
50 return `/${repo.ownerName}/${repo.name}/machines/${machine}/shell`;
51 }
2052
2153 export async function machinesSettingsPage(ctx: Ctx): Promise<Response> {
2254 nameFrame(ctx.request);
@@ -34,17 +66,18 @@ function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readon
3466 html`<p class="muted">
3567 A machine is where the crew's shell runs. A writer can open a shell into one; each
3668 writer has a directory and home of their own on each machine, never shared with the
37 next. The browser terminal that opens it is not built yet.
69 next. A shell outlives its tab: reload, or open it again, and you are back in it.
3870 </p>
3971 <div class="table-scroll">
4072 <table class="table">
41 <thead><tr><th>Machine</th><th>Tools</th><th>Reach</th></tr></thead>
73 <thead><tr><th>Machine</th><th>Tools</th><th>Reach</th><th><span class="sr-only">Shell</span></th></tr></thead>
4274 <tbody>
4375 ${list.map(
4476 (m) => html`<tr>
4577 <td><strong>${m.name}</strong></td>
4678 <td class="muted">${m.tools.length === 0 ? "none" : m.tools.join(", ")}</td>
4779 <td class="muted">${m.reach.length === 0 ? "the forge only" : "the forge and its apps"}</td>
80 <td>${btn("Open shell", { href: shellPath(repo, m.name), icon: "terminal", size: "sm" })}</td>
4881 </tr>`,
4982 )}
5083 </tbody>
@@ -87,3 +120,41 @@ function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readon
87120 body: html`${repoHeader(ctx, repo, "settings", { compact: true })}${body}`,
88121 });
89122 }
123
124 export async function machineShellPage(ctx: Ctx): Promise<Response> {
125 nameFrame(ctx.request);
126 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
127 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
128 if (!repo) return repoNotFound(ctx);
129 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return forbiddenPage(ctx, NOT_YOURS_TO_DO);
130 const config = await timePhaseAsync(ctx.request, "machines", () => repoMachines(ctx.app, repo));
131 const machine = config.machines.find((m) => m.name === ctx.params.machine);
132 if (machine === undefined) return notFoundPage(ctx);
133 const script = await timePhaseAsync(ctx.request, "asset", () => machineTermAsset());
134 return timePhase(ctx.request, "render", () => renderShell(ctx, repo, machine, script?.path));
135 }
136
137 function renderShell(ctx: Ctx, repo: Repo, machine: Machine, script: string | undefined): Response {
138 const base = `/${repo.ownerName}/${repo.name}`;
139 const term =
140 script === undefined
141 ? html`<p class="muted" role="status">The terminal could not load on this forge.</p>`
142 : html`<p id="machine-term-status" class="machine-term-status" role="status" aria-live="polite"></p>
143 <noscript><p class="machine-term-status">This shell runs in the browser and needs JavaScript; with it off, nothing connects.</p></noscript>
144 <div id="machine-term" class="machine-term" data-wasm="${ghosttyWasmAsset().path}"></div>`;
145 return page({
146 ctx,
147 title: `Shell · ${machine.name} · ${repo.name}`,
148 crumbs: crumbsFor(repo),
149 palette: repoPalette(ctx, repo),
150 wide: true,
151 styleCss: MACHINE_SHELL_CSS,
152 ...(script === undefined ? {} : { scriptAsset: script, wasm: true }),
153 body: html`${repoHeader(ctx, repo, "settings", { compact: true })}
154 ${pageHead(html`${icon("terminal")}Shell on ${machine.name}`, {
155 sub: machine.tools.length === 0 ? "No tools beyond the shell." : `Tools: ${machine.tools.join(", ")}.`,
156 actions: btn("All machines", { href: `${base}/settings/machines`, size: "sm" }),
157 })}
158 ${term}`,
159 });
160 }
src/web/vendor/ghostty-web/LICENSE added +21
@@ -0,0 +1,21 @@
1 MIT License
2
3 Copyright (c) 2025 Coder
4
5 Permission is hereby granted, free of charge, to any person obtaining a copy
6 of this software and associated documentation files (the "Software"), to deal
7 in the Software without restriction, including without limitation the rights
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 copies of the Software, and to permit persons to whom the Software is
10 furnished to do so, subject to the following conditions:
11
12 The above copyright notice and this permission notice shall be included in all
13 copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 SOFTWARE.
src/web/vendor/ghostty-web/README.md added +24
@@ -0,0 +1,24 @@
1 # ghostty-web 0.4.0, vendored
2
3 The terminal emulator on a machine's shell page
4 (`src/web/machine-term-client.ts`, `docs/features/machines.md`). Copied
5 unmodified from the npm package `[email protected]`
6 (sha512 `0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==`),
7 MIT, Copyright (c) 2025 Coder — see `LICENSE`.
8
9 | file | from the package | bytes | sha256 |
10 | --- | --- | --- | --- |
11 | `ghostty-web.js` | `dist/ghostty-web.js` | 681918 | `078b3fe37e4ef469d3f3d7772ee263070f8613e5a6f5ff305c85778907f45e72` |
12 | `__vite-browser-external-2447137e.js` | `dist/` (imported by the above) | 41 | `f8c456031e5001c0cda4837cd9ee3a33d79beeba120ec633ec9d990632fb2aa6` |
13 | `ghostty-vt.wasm` | `ghostty-vt.wasm` | 423045 | `d6f0326f1874ad2ce9f289e3a4a0c5f3507d4cb38d8747e4b287def470a0c60a` |
14 | `ghostty-web.d.ts` | `dist/index.d.ts` | 55510 | `2848adffa959ead3be5fa7a59a664518b0c679f8174fc4b216a1141bfee46861` |
15 | `LICENSE` | `LICENSE` | 1062 | `5eccd0eeca906db6d661b64dd05e1d4a4b2e49d37d43bbcfd2c8cce4d7832920` |
16
17 Nothing here is edited. `src/web/machine-term-asset.ts` bundles the module
18 with `Bun.build` and, at build time, drops its inlined base64 copy of the
19 WebAssembly (the page fetches `ghostty-vt.wasm` as its own immutable asset),
20 so the browser downloads about 75 KB of script and 423 KB of WebAssembly.
21
22 To move to a new version: replace these files from the new package, update
23 this table, and run `bun test test/machine-shell-page.test.ts` (it fails if
24 the inlined copy is no longer found and dropped).
src/web/vendor/ghostty-web/__vite-browser-external-2447137e.js added +4
@@ -0,0 +1,4 @@
1 const e = {};
2 export {
3 e as default
4 };
src/web/vendor/ghostty-web/ghostty-vt.wasm added
Binary file not shown.
src/web/vendor/ghostty-web/ghostty-web.d.ts added +1842
@@ -0,0 +1,1842 @@
1 export declare class CanvasRenderer {
2 private canvas;
3 private ctx;
4 private fontSize;
5 private fontFamily;
6 private cursorStyle;
7 private cursorBlink;
8 private theme;
9 private devicePixelRatio;
10 private metrics;
11 private palette;
12 private cursorVisible;
13 private cursorBlinkInterval?;
14 private lastCursorPosition;
15 private lastViewportY;
16 private currentBuffer;
17 private selectionManager?;
18 private currentSelectionCoords;
19 private hoveredHyperlinkId;
20 private previousHoveredHyperlinkId;
21 private hoveredLinkRange;
22 private previousHoveredLinkRange;
23 constructor(canvas: HTMLCanvasElement, options?: RendererOptions);
24 private measureFont;
25 /**
26 * Remeasure font metrics (call after font loads or changes)
27 */
28 remeasureFont(): void;
29 private rgbToCSS;
30 /**
31 * Resize canvas to fit terminal dimensions
32 */
33 resize(cols: number, rows: number): void;
34 /**
35 * Render the terminal buffer to canvas
36 */
37 render(buffer: IRenderable, forceAll?: boolean, viewportY?: number, scrollbackProvider?: IScrollbackProvider, scrollbarOpacity?: number): void;
38 /**
39 * Render a single line using two-pass approach:
40 * 1. First pass: Draw all cell backgrounds
41 * 2. Second pass: Draw all cell text and decorations
42 *
43 * This two-pass approach is necessary for proper rendering of complex scripts
44 * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the
45 * base character into the previous cell's visual area. If we draw backgrounds
46 * and text in a single pass (cell by cell), the background of cell N would
47 * cover any left-extending portions of graphemes from cell N-1.
48 */
49 private renderLine;
50 /**
51 * Render a cell's background only (Pass 1 of two-pass rendering)
52 * Selection highlighting is integrated here to avoid z-order issues with
53 * complex glyphs (like Devanagari) that extend outside their cell bounds.
54 */
55 private renderCellBackground;
56 /**
57 * Render a cell's text and decorations (Pass 2 of two-pass rendering)
58 * Selection foreground color is applied here to match the selection background.
59 */
60 private renderCellText;
61 /**
62 * Render cursor
63 */
64 private renderCursor;
65 private startCursorBlink;
66 private stopCursorBlink;
67 /**
68 * Update theme colors
69 */
70 setTheme(theme: ITheme): void;
71 /**
72 * Update font size
73 */
74 setFontSize(size: number): void;
75 /**
76 * Update font family
77 */
78 setFontFamily(family: string): void;
79 /**
80 * Update cursor style
81 */
82 setCursorStyle(style: 'block' | 'underline' | 'bar'): void;
83 /**
84 * Enable/disable cursor blinking
85 */
86 setCursorBlink(enabled: boolean): void;
87 /**
88 * Get current font metrics
89 */
90 /**
91 * Render scrollbar (Phase 2)
92 * Shows scroll position and allows click/drag interaction
93 * @param opacity Opacity level (0-1) for fade in/out effect
94 */
95 private renderScrollbar;
96 getMetrics(): FontMetrics;
97 /**
98 * Get canvas element (needed by SelectionManager)
99 */
100 getCanvas(): HTMLCanvasElement;
101 /**
102 * Set selection manager (for rendering selection)
103 */
104 setSelectionManager(manager: SelectionManager): void;
105 /**
106 * Check if a cell at (x, y) is within the current selection.
107 * Uses cached selection coordinates for performance.
108 */
109 private isInSelection;
110 /**
111 * Set the currently hovered hyperlink ID for rendering underlines
112 */
113 setHoveredHyperlinkId(hyperlinkId: number): void;
114 /**
115 * Set the currently hovered link range for rendering underlines (for regex-detected URLs)
116 * Pass null to clear the hover state
117 */
118 setHoveredLinkRange(range: {
119 startX: number;
120 startY: number;
121 endX: number;
122 endY: number;
123 } | null): void;
124 /**
125 * Get character cell width (for coordinate conversion)
126 */
127 get charWidth(): number;
128 /**
129 * Get character cell height (for coordinate conversion)
130 */
131 get charHeight(): number;
132 /**
133 * Clear entire canvas
134 */
135 clear(): void;
136 /**
137 * Cleanup resources
138 */
139 dispose(): void;
140 }
141
142 /**
143 * Cell style flags (bitfield)
144 */
145 export declare enum CellFlags {
146 BOLD = 1,
147 ITALIC = 2,
148 UNDERLINE = 4,
149 STRIKETHROUGH = 8,
150 INVERSE = 16,
151 INVISIBLE = 32,
152 BLINK = 64,
153 FAINT = 128
154 }
155
156 /**
157 * Cursor position and visibility
158 */
159 export declare interface Cursor {
160 x: number;
161 y: number;
162 visible: boolean;
163 }
164
165 /**
166 * Dirty state from RenderState
167 */
168 declare enum DirtyState {
169 NONE = 0,
170 PARTIAL = 1,
171 FULL = 2
172 }
173
174 export declare class EventEmitter<T> {
175 private listeners;
176 fire(arg: T): void;
177 event: IEvent<T>;
178 dispose(): void;
179 }
180
181 export declare class FitAddon implements ITerminalAddon {
182 private _terminal?;
183 private _resizeObserver?;
184 private _resizeDebounceTimer?;
185 private _lastCols?;
186 private _lastRows?;
187 private _isResizing;
188 /**
189 * Activate the addon (called by Terminal.loadAddon)
190 */
191 activate(terminal: ITerminalCore): void;
192 /**
193 * Dispose the addon and clean up resources
194 */
195 dispose(): void;
196 /**
197 * Fit the terminal to its container
198 *
199 * Calculates optimal dimensions and resizes the terminal.
200 * Does nothing if dimensions cannot be calculated or haven't changed.
201 */
202 fit(): void;
203 /**
204 * Propose dimensions to fit the terminal to its container
205 *
206 * Calculates cols and rows based on:
207 * - Terminal container element dimensions (clientWidth/Height)
208 * - Terminal element padding
209 * - Font metrics (character cell size)
210 * - Scrollbar width reservation
211 *
212 * @returns Proposed dimensions or undefined if cannot calculate
213 */
214 proposeDimensions(): ITerminalDimensions | undefined;
215 /**
216 * Observe the terminal's container for resize events
217 *
218 * Sets up a ResizeObserver to automatically call fit() when the
219 * container size changes. Resize events are debounced to avoid
220 * excessive calls during window drag operations.
221 *
222 * Call dispose() to stop observing.
223 */
224 observeResize(): void;
225 }
226
227 export declare interface FontMetrics {
228 width: number;
229 height: number;
230 baseline: number;
231 }
232
233 /* Excluded from this release type: getGhostty */
234
235 /**
236 * Main Ghostty WASM wrapper class
237 */
238 export declare class Ghostty {
239 private exports;
240 private memory;
241 constructor(wasmInstance: WebAssembly.Instance);
242 createKeyEncoder(): KeyEncoder;
243 createTerminal(cols?: number, rows?: number, config?: GhosttyTerminalConfig): GhosttyTerminal;
244 static load(wasmPath?: string): Promise<Ghostty>;
245 private static loadFromPath;
246 }
247
248 /**
249 * Cell structure matching ghostty_cell_t in C (16 bytes)
250 */
251 export declare interface GhosttyCell {
252 codepoint: number;
253 fg_r: number;
254 fg_g: number;
255 fg_b: number;
256 bg_r: number;
257 bg_g: number;
258 bg_b: number;
259 flags: number;
260 width: number;
261 hyperlink_id: number;
262 grapheme_len: number;
263 }
264
265 /**
266 * GhosttyTerminal - High-performance terminal emulator
267 *
268 * Uses Ghostty's native RenderState for optimal performance:
269 * - ONE call to update all state (renderStateUpdate)
270 * - ONE call to get all cells (getViewport)
271 * - No per-row WASM boundary crossings!
272 */
273 export declare class GhosttyTerminal {
274 private exports;
275 private memory;
276 private handle;
277 private _cols;
278 private _rows;
279 /** Size of GhosttyCell in WASM (16 bytes) */
280 private static readonly CELL_SIZE;
281 /** Reusable buffer for viewport operations */
282 private viewportBufferPtr;
283 private viewportBufferSize;
284 /** Cell pool for zero-allocation rendering */
285 private cellPool;
286 constructor(exports: GhosttyWasmExports, memory: WebAssembly.Memory, cols?: number, rows?: number, config?: GhosttyTerminalConfig);
287 get cols(): number;
288 get rows(): number;
289 write(data: string | Uint8Array): void;
290 resize(cols: number, rows: number): void;
291 free(): void;
292 /**
293 * Update render state from terminal.
294 *
295 * This syncs the RenderState with the current Terminal state.
296 * The dirty state (full/partial/none) is stored in the WASM RenderState
297 * and can be queried via isRowDirty(). When dirty==full, isRowDirty()
298 * returns true for ALL rows.
299 *
300 * The WASM layer automatically detects screen switches (normal <-> alternate)
301 * and returns FULL dirty state when switching screens (e.g., vim exit).
302 *
303 * Safe to call multiple times - dirty state persists until markClean().
304 */
305 update(): DirtyState;
306 /**
307 * Get cursor state from render state.
308 * Ensures render state is fresh by calling update().
309 */
310 getCursor(): RenderStateCursor;
311 /**
312 * Get default colors from render state
313 */
314 getColors(): RenderStateColors;
315 /**
316 * Check if a specific row is dirty
317 */
318 isRowDirty(y: number): boolean;
319 /**
320 * Mark render state as clean (call after rendering)
321 */
322 markClean(): void;
323 /**
324 * Get ALL viewport cells in ONE WASM call - the key performance optimization!
325 * Returns a reusable cell array (zero allocation after warmup).
326 */
327 getViewport(): GhosttyCell[];
328 /**
329 * Get line - for compatibility, extracts from viewport.
330 * Ensures render state is fresh by calling update().
331 * Returns a COPY of the cells to avoid pool reference issues.
332 */
333 getLine(y: number): GhosttyCell[] | null;
334 /** For compatibility with old API */
335 isDirty(): boolean;
336 /**
337 * Check if a full redraw is needed (screen change, resize, etc.)
338 * Note: This calls update() to ensure fresh state. Safe to call multiple times.
339 */
340 needsFullRedraw(): boolean;
341 /** Mark render state as clean after rendering */
342 clearDirty(): void;
343 isAlternateScreen(): boolean;
344 hasBracketedPaste(): boolean;
345 hasFocusEvents(): boolean;
346 hasMouseTracking(): boolean;
347 /** Get dimensions - for compatibility */
348 getDimensions(): {
349 cols: number;
350 rows: number;
351 };
352 /** Get number of scrollback lines (history, not including active screen) */
353 getScrollbackLength(): number;
354 /**
355 * Get a line from the scrollback buffer.
356 * Ensures render state is fresh by calling update().
357 * @param offset 0 = oldest line, (length-1) = most recent scrollback line
358 */
359 getScrollbackLine(offset: number): GhosttyCell[] | null;
360 /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */
361 isRowWrapped(row: number): boolean;
362 /** Hyperlink URI not yet exposed in simplified API */
363 getHyperlinkUri(_id: number): string | null;
364 /**
365 * Check if there are pending responses from the terminal.
366 * Responses are generated by escape sequences like DSR (Device Status Report).
367 */
368 hasResponse(): boolean;
369 /**
370 * Read pending responses from the terminal.
371 * Returns the response string, or null if no responses pending.
372 *
373 * Responses are generated by escape sequences that require replies:
374 * - DSR 6 (cursor position): Returns \x1b[row;colR
375 * - DSR 5 (operating status): Returns \x1b[0n
376 */
377 readResponse(): string | null;
378 /**
379 * Query arbitrary terminal mode by number
380 * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste)
381 * @param isAnsi True for ANSI modes, false for DEC modes (default: false)
382 */
383 getMode(mode: number, isAnsi?: boolean): boolean;
384 private initCellPool;
385 private parseCellsIntoPool;
386 /** Small buffer for grapheme lookups (reused to avoid allocation) */
387 private graphemeBuffer;
388 private graphemeBufferPtr;
389 /**
390 * Get all codepoints for a grapheme cluster at the given position.
391 * For most cells this returns a single codepoint, but for complex scripts
392 * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints.
393 * @returns Array of codepoints, or null on error
394 */
395 getGrapheme(row: number, col: number): number[] | null;
396 /**
397 * Get a string representation of the grapheme at the given position.
398 * This properly handles complex scripts like Hindi, emoji with ZWJ, etc.
399 */
400 getGraphemeString(row: number, col: number): string;
401 /**
402 * Get all codepoints for a grapheme cluster in the scrollback buffer.
403 * @param offset Scrollback line offset (0 = oldest)
404 * @param col Column index
405 * @returns Array of codepoints, or null on error
406 */
407 getScrollbackGrapheme(offset: number, col: number): number[] | null;
408 /**
409 * Get a string representation of a grapheme in the scrollback buffer.
410 */
411 getScrollbackGraphemeString(offset: number, col: number): string;
412 private invalidateBuffers;
413 }
414
415 /**
416 * Terminal configuration (passed to ghostty_terminal_new_with_config)
417 * All color values use 0xRRGGBB format. A value of 0 means "use default".
418 */
419 declare interface GhosttyTerminalConfig {
420 scrollbackLimit?: number;
421 fgColor?: number;
422 bgColor?: number;
423 cursorColor?: number;
424 palette?: number[];
425 }
426
427 /**
428 * Interface for libghostty-vt WASM exports
429 */
430 declare interface GhosttyWasmExports extends WebAssembly.Exports {
431 memory: WebAssembly.Memory;
432 ghostty_wasm_alloc_opaque(): number;
433 ghostty_wasm_free_opaque(ptr: number): void;
434 ghostty_wasm_alloc_u8_array(len: number): number;
435 ghostty_wasm_free_u8_array(ptr: number, len: number): void;
436 ghostty_wasm_alloc_u16_array(len: number): number;
437 ghostty_wasm_free_u16_array(ptr: number, len: number): void;
438 ghostty_wasm_alloc_u8(): number;
439 ghostty_wasm_free_u8(ptr: number): void;
440 ghostty_wasm_alloc_usize(): number;
441 ghostty_wasm_free_usize(ptr: number): void;
442 ghostty_sgr_new(allocator: number, parserPtrPtr: number): number;
443 ghostty_sgr_free(parser: number): void;
444 ghostty_sgr_reset(parser: number): void;
445 ghostty_sgr_set_params(parser: number, paramsPtr: number, subsPtr: number, paramsLen: number): number;
446 ghostty_sgr_next(parser: number, attrPtr: number): boolean;
447 ghostty_sgr_attribute_tag(attrPtr: number): number;
448 ghostty_sgr_attribute_value(attrPtr: number, tagPtr: number): number;
449 ghostty_wasm_alloc_sgr_attribute(): number;
450 ghostty_wasm_free_sgr_attribute(ptr: number): void;
451 ghostty_key_encoder_new(allocator: number, encoderPtrPtr: number): number;
452 ghostty_key_encoder_free(encoder: number): void;
453 ghostty_key_encoder_setopt(encoder: number, option: number, valuePtr: number): number;
454 ghostty_key_encoder_encode(encoder: number, eventPtr: number, bufPtr: number, bufLen: number, writtenPtr: number): number;
455 ghostty_key_event_new(allocator: number, eventPtrPtr: number): number;
456 ghostty_key_event_free(event: number): void;
457 ghostty_key_event_set_action(event: number, action: number): void;
458 ghostty_key_event_set_key(event: number, key: number): void;
459 ghostty_key_event_set_mods(event: number, mods: number): void;
460 ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void;
461 ghostty_terminal_new(cols: number, rows: number): TerminalHandle;
462 ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle;
463 ghostty_terminal_free(terminal: TerminalHandle): void;
464 ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void;
465 ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void;
466 ghostty_render_state_update(terminal: TerminalHandle): number;
467 ghostty_render_state_get_cols(terminal: TerminalHandle): number;
468 ghostty_render_state_get_rows(terminal: TerminalHandle): number;
469 ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number;
470 ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number;
471 ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean;
472 ghostty_render_state_get_bg_color(terminal: TerminalHandle): number;
473 ghostty_render_state_get_fg_color(terminal: TerminalHandle): number;
474 ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean;
475 ghostty_render_state_mark_clean(terminal: TerminalHandle): void;
476 ghostty_render_state_get_viewport(terminal: TerminalHandle, bufPtr: number, bufLen: number): number;
477 ghostty_render_state_get_grapheme(terminal: TerminalHandle, row: number, col: number, bufPtr: number, bufLen: number): number;
478 ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean;
479 ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number;
480 ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number;
481 ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number;
482 ghostty_terminal_get_scrollback_line(terminal: TerminalHandle, offset: number, bufPtr: number, bufLen: number): number;
483 ghostty_terminal_get_scrollback_grapheme(terminal: TerminalHandle, offset: number, col: number, bufPtr: number, bufLen: number): number;
484 ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number;
485 ghostty_terminal_has_response(terminal: TerminalHandle): boolean;
486 ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number;
487 }
488
489 /**
490 * A terminal buffer (normal or alternate screen)
491 */
492 declare interface IBuffer {
493 /** Buffer type: 'normal' or 'alternate' */
494 readonly type: 'normal' | 'alternate';
495 /** Cursor X position (0-indexed) */
496 readonly cursorX: number;
497 /** Cursor Y position (0-indexed, relative to viewport) */
498 readonly cursorY: number;
499 /** Viewport Y position (scroll offset, 0 = bottom of scrollback) */
500 readonly viewportY: number;
501 /** Base Y position (always 0 for normal buffer, may vary for alternate) */
502 readonly baseY: number;
503 /** Total buffer length (rows + scrollback for normal, just rows for alternate) */
504 readonly length: number;
505 /**
506 * Get a line from the buffer
507 * @param y Line index (0 = top of scrollback for normal buffer)
508 * @returns Line object or undefined if out of bounds
509 */
510 getLine(y: number): IBufferLine | undefined;
511 /**
512 * Get the null cell (used for empty/uninitialized cells)
513 */
514 getNullCell(): IBufferCell;
515 }
516
517 /**
518 * A single cell in the buffer
519 */
520 declare interface IBufferCell {
521 /** Character(s) in this cell (may be empty, single char, or emoji) */
522 getChars(): string;
523 /** Unicode codepoint (0 for null cell) */
524 getCode(): number;
525 /** Character width (1 = normal, 2 = wide/emoji, 0 = combining) */
526 getWidth(): number;
527 /** Foreground color index (for palette colors) or -1 for RGB */
528 getFgColorMode(): number;
529 /** Background color index (for palette colors) or -1 for RGB */
530 getBgColorMode(): number;
531 /** Foreground RGB color (or 0 for default) */
532 getFgColor(): number;
533 /** Background RGB color (or 0 for default) */
534 getBgColor(): number;
535 /** Whether cell has bold style */
536 isBold(): number;
537 /** Whether cell has italic style */
538 isItalic(): number;
539 /** Whether cell has underline style */
540 isUnderline(): number;
541 /** Whether cell has strikethrough style */
542 isStrikethrough(): number;
543 /** Whether cell has blink style */
544 isBlink(): number;
545 /** Whether cell has inverse video style */
546 isInverse(): number;
547 /** Whether cell has invisible style */
548 isInvisible(): number;
549 /** Whether cell has faint/dim style */
550 isFaint(): number;
551 /** Get hyperlink ID for this cell (0 = no link) */
552 getHyperlinkId(): number;
553 /** Get the Unicode codepoint for this cell */
554 getCodepoint(): number;
555 /** Whether cell has dim/faint attribute (boolean version) */
556 isDim(): boolean;
557 }
558
559 /**
560 * Represents a coordinate in the terminal buffer
561 */
562 export declare interface IBufferCellPosition {
563 x: number;
564 y: number;
565 }
566
567 /**
568 * A single line in the buffer
569 */
570 declare interface IBufferLine {
571 /** Length of the line (in columns) */
572 readonly length: number;
573 /** Whether this line wraps to the next line */
574 readonly isWrapped: boolean;
575 /**
576 * Get a cell from this line
577 * @param x Column index (0-indexed)
578 * @returns Cell object or undefined if out of bounds
579 */
580 getCell(x: number): IBufferCell | undefined;
581 /**
582 * Translate the line to a string
583 * @param trimRight Whether to trim trailing whitespace (default: false)
584 * @param startColumn Start column (default: 0)
585 * @param endColumn End column (default: length)
586 * @returns String representation of the line
587 */
588 translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string;
589 }
590
591 /**
592 * Minimal buffer line interface for URL detection
593 */
594 declare interface IBufferLineForUrlProvider {
595 length: number;
596 getCell(x: number): {
597 getCodepoint(): number;
598 } | undefined;
599 }
600
601 /**
602 * Top-level buffer API namespace
603 * Provides access to active, normal, and alternate screen buffers
604 */
605 declare interface IBufferNamespace {
606 /** The currently active buffer (normal or alternate) */
607 readonly active: IBuffer;
608 /** The normal buffer (primary screen) */
609 readonly normal: IBuffer;
610 /** The alternate buffer (used by full-screen apps like vim) */
611 readonly alternate: IBuffer;
612 /** Event fired when buffer changes (normal ↔ alternate) */
613 readonly onBufferChange: IEvent<IBuffer>;
614 }
615
616 /**
617 * Buffer range for selection coordinates
618 */
619 export declare interface IBufferRange {
620 start: {
621 x: number;
622 y: number;
623 };
624 end: {
625 x: number;
626 y: number;
627 };
628 }
629
630 /**
631 * Represents a range in the terminal buffer
632 * Can span multiple lines for wrapped links
633 */
634 declare interface IBufferRange_2 {
635 start: IBufferCellPosition;
636 end: IBufferCellPosition;
637 }
638
639 export declare interface IDisposable {
640 dispose(): void;
641 }
642
643 export declare type IEvent<T> = (listener: (arg: T) => void) => IDisposable;
644
645 /**
646 * Keyboard event with key and DOM event
647 */
648 export declare interface IKeyEvent {
649 key: string;
650 domEvent: KeyboardEvent;
651 }
652
653 /**
654 * Represents a detected link in the terminal
655 */
656 export declare interface ILink {
657 /** The URL or text of the link */
658 text: string;
659 /** The range of the link in the buffer (may span multiple lines) */
660 range: IBufferRange_2;
661 /** Called when the link is activated (clicked with modifier) */
662 activate(event: MouseEvent): void;
663 /** Optional: called when mouse enters/leaves the link */
664 hover?(isHovered: boolean): void;
665 /** Optional: called to clean up resources */
666 dispose?(): void;
667 }
668
669 /**
670 * Provides link detection for a specific type of link
671 * Examples: OSC 8 hyperlinks, URL regex detection
672 */
673 export declare interface ILinkProvider {
674 /**
675 * Provide links for a given row
676 * @param y Absolute row in buffer (0-based)
677 * @param callback Called with detected links (or undefined if none)
678 */
679 provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
680 /** Optional: called when terminal is disposed */
681 dispose?(): void;
682 }
683
684 /**
685 * Initialize the ghostty-web library by loading the WASM module.
686 * Must be called before creating any Terminal instances.
687 *
688 * This creates a shared WASM instance that all Terminal instances will use.
689 * For test isolation, pass a Ghostty instance directly to Terminal constructor.
690 *
691 * @example
692 * ```typescript
693 * import { init, Terminal } from 'ghostty-web';
694 *
695 * await init();
696 * const term = new Terminal();
697 * term.open(document.getElementById('terminal'));
698 * ```
699 */
700 export declare function init(): Promise<void>;
701
702 /**
703 * InputHandler class
704 * Attaches keyboard event listeners to a container and converts
705 * keyboard events to terminal input data
706 */
707 export declare class InputHandler {
708 private encoder;
709 private container;
710 private onDataCallback;
711 private onBellCallback;
712 private onKeyCallback?;
713 private customKeyEventHandler?;
714 private getModeCallback?;
715 private keydownListener;
716 private keypressListener;
717 private pasteListener;
718 private compositionStartListener;
719 private compositionUpdateListener;
720 private compositionEndListener;
721 private isComposing;
722 private isDisposed;
723 /**
724 * Create a new InputHandler
725 * @param ghostty - Ghostty instance (for creating KeyEncoder)
726 * @param container - DOM element to attach listeners to
727 * @param onData - Callback for terminal data (escape sequences to send to PTY)
728 * @param onBell - Callback for bell/beep event
729 * @param onKey - Optional callback for raw key events
730 * @param customKeyEventHandler - Optional custom key event handler
731 * @param getMode - Optional callback to query terminal mode state (for application cursor mode)
732 */
733 constructor(ghostty: Ghostty, container: HTMLElement, onData: (data: string) => void, onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, getMode?: (mode: number) => boolean);
734 /**
735 * Set custom key event handler (for runtime updates)
736 */
737 setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void;
738 /**
739 * Attach keyboard event listeners to container
740 */
741 private attach;
742 /**
743 * Map KeyboardEvent.code to USB HID Key enum value
744 * @param code - KeyboardEvent.code value
745 * @returns Key enum value or null if unmapped
746 */
747 private mapKeyCode;
748 /**
749 * Extract modifier flags from KeyboardEvent
750 * @param event - KeyboardEvent
751 * @returns Mods flags
752 */
753 private extractModifiers;
754 /**
755 * Check if this is a printable character with no special modifiers
756 * @param event - KeyboardEvent
757 * @returns true if printable character
758 */
759 private isPrintableCharacter;
760 /**
761 * Handle keydown event
762 * @param event - KeyboardEvent
763 */
764 private handleKeyDown;
765 /**
766 * Handle paste event from clipboard
767 * @param event - ClipboardEvent
768 */
769 private handlePaste;
770 /**
771 * Handle compositionstart event
772 */
773 private handleCompositionStart;
774 /**
775 * Handle compositionupdate event
776 */
777 private handleCompositionUpdate;
778 /**
779 * Handle compositionend event
780 */
781 private handleCompositionEnd;
782 /**
783 * Dispose the InputHandler and remove event listeners
784 */
785 dispose(): void;
786 /**
787 * Check if handler is disposed
788 */
789 isActive(): boolean;
790 }
791
792 export declare interface IRenderable {
793 getLine(y: number): GhosttyCell[] | null;
794 getCursor(): {
795 x: number;
796 y: number;
797 visible: boolean;
798 };
799 getDimensions(): {
800 cols: number;
801 rows: number;
802 };
803 isRowDirty(y: number): boolean;
804 /** Returns true if a full redraw is needed (e.g., screen change) */
805 needsFullRedraw?(): boolean;
806 clearDirty(): void;
807 /**
808 * Get the full grapheme string for a cell at (row, col).
809 * For cells with grapheme_len > 0, this returns all codepoints combined.
810 * For simple cells, returns the single character.
811 */
812 getGraphemeString?(row: number, col: number): string;
813 }
814
815 declare interface IScrollbackProvider {
816 getScrollbackLine(offset: number): GhosttyCell[] | null;
817 getScrollbackLength(): number;
818 }
819
820 export declare interface ITerminalAddon {
821 activate(terminal: ITerminalCore): void;
822 dispose(): void;
823 }
824
825 export declare interface ITerminalCore {
826 cols: number;
827 rows: number;
828 element?: HTMLElement;
829 textarea?: HTMLTextAreaElement;
830 }
831
832 export declare interface ITerminalDimensions {
833 cols: number;
834 rows: number;
835 }
836
837 /**
838 * Minimal terminal interface required by LinkDetector
839 * Keeps coupling low and testing easy
840 */
841 declare interface ITerminalForLinkDetector {
842 buffer: {
843 active: {
844 getLine(y: number): {
845 length: number;
846 getCell(x: number): {
847 getHyperlinkId(): number;
848 } | undefined;
849 } | undefined;
850 };
851 };
852 }
853
854 /**
855 * Minimal terminal interface required by OSC8LinkProvider
856 */
857 declare interface ITerminalForOSC8Provider {
858 buffer: {
859 active: {
860 length: number;
861 getLine(y: number): {
862 length: number;
863 getCell(x: number): {
864 getHyperlinkId(): number;
865 } | undefined;
866 } | undefined;
867 };
868 };
869 wasmTerm?: {
870 getHyperlinkUri(id: number): string | null;
871 };
872 }
873
874 /**
875 * Minimal terminal interface required by UrlRegexProvider
876 */
877 declare interface ITerminalForUrlProvider {
878 buffer: {
879 active: {
880 getLine(y: number): IBufferLineForUrlProvider | undefined;
881 };
882 };
883 }
884
885 export declare interface ITerminalOptions {
886 cols?: number;
887 rows?: number;
888 cursorBlink?: boolean;
889 cursorStyle?: 'block' | 'underline' | 'bar';
890 theme?: ITheme;
891 scrollback?: number;
892 fontSize?: number;
893 fontFamily?: string;
894 allowTransparency?: boolean;
895 convertEol?: boolean;
896 disableStdin?: boolean;
897 smoothScrollDuration?: number;
898 ghostty?: Ghostty;
899 }
900
901 export declare interface ITheme {
902 foreground?: string;
903 background?: string;
904 cursor?: string;
905 cursorAccent?: string;
906 selectionBackground?: string;
907 selectionForeground?: string;
908 black?: string;
909 red?: string;
910 green?: string;
911 yellow?: string;
912 blue?: string;
913 magenta?: string;
914 cyan?: string;
915 white?: string;
916 brightBlack?: string;
917 brightRed?: string;
918 brightGreen?: string;
919 brightYellow?: string;
920 brightBlue?: string;
921 brightMagenta?: string;
922 brightCyan?: string;
923 brightWhite?: string;
924 }
925
926 /**
927 * Unicode version provider (xterm.js compatibility)
928 */
929 export declare interface IUnicodeVersionProvider {
930 readonly activeVersion: string;
931 }
932
933 /**
934 * Physical key codes matching Ghostty's internal Key enum.
935 * These values are used by Ghostty's key encoder to produce correct escape sequences.
936 * Reference: ghostty/src/input/key.zig
937 */
938 export declare enum Key {
939 UNIDENTIFIED = 0,
940 GRAVE = 1,// ` and ~
941 BACKSLASH = 2,// \ and |
942 BRACKET_LEFT = 3,// [ and {
943 BRACKET_RIGHT = 4,// ] and }
944 COMMA = 5,// , and <
945 ZERO = 6,
946 ONE = 7,
947 TWO = 8,
948 THREE = 9,
949 FOUR = 10,
950 FIVE = 11,
951 SIX = 12,
952 SEVEN = 13,
953 EIGHT = 14,
954 NINE = 15,
955 EQUAL = 16,// = and +
956 INTL_BACKSLASH = 17,
957 INTL_RO = 18,
958 INTL_YEN = 19,
959 A = 20,
960 B = 21,
961 C = 22,
962 D = 23,
963 E = 24,
964 F = 25,
965 G = 26,
966 H = 27,
967 I = 28,
968 J = 29,
969 K = 30,
970 L = 31,
971 M = 32,
972 N = 33,
973 O = 34,
974 P = 35,
975 Q = 36,
976 R = 37,
977 S = 38,
978 T = 39,
979 U = 40,
980 V = 41,
981 W = 42,
982 X = 43,
983 Y = 44,
984 Z = 45,
985 MINUS = 46,// - and _
986 PERIOD = 47,// . and >
987 QUOTE = 48,// ' and "
988 SEMICOLON = 49,// ; and :
989 SLASH = 50,// / and ?
990 ALT_LEFT = 51,
991 ALT_RIGHT = 52,
992 BACKSPACE = 53,
993 CAPS_LOCK = 54,
994 CONTEXT_MENU = 55,
995 CONTROL_LEFT = 56,
996 CONTROL_RIGHT = 57,
997 ENTER = 58,
998 META_LEFT = 59,
999 META_RIGHT = 60,
1000 SHIFT_LEFT = 61,
1001 SHIFT_RIGHT = 62,
1002 SPACE = 63,
1003 TAB = 64,
1004 CONVERT = 65,
1005 KANA_MODE = 66,
1006 NON_CONVERT = 67,
1007 DELETE = 68,
1008 END = 69,
1009 HELP = 70,
1010 HOME = 71,
1011 INSERT = 72,
1012 PAGE_DOWN = 73,
1013 PAGE_UP = 74,
1014 DOWN = 75,
1015 LEFT = 76,
1016 RIGHT = 77,
1017 UP = 78,
1018 NUM_LOCK = 79,
1019 KP_0 = 80,
1020 KP_1 = 81,
1021 KP_2 = 82,
1022 KP_3 = 83,
1023 KP_4 = 84,
1024 KP_5 = 85,
1025 KP_6 = 86,
1026 KP_7 = 87,
1027 KP_8 = 88,
1028 KP_9 = 89,
1029 KP_PLUS = 90,// Keypad +
1030 KP_BACKSPACE = 91,
1031 KP_CLEAR = 92,
1032 KP_CLEAR_ENTRY = 93,
1033 KP_COMMA = 94,
1034 KP_PERIOD = 95,// Keypad .
1035 KP_DIVIDE = 96,// Keypad /
1036 KP_ENTER = 97,// Keypad Enter
1037 KP_EQUAL = 98,
1038 KP_MEMORY_ADD = 99,
1039 KP_MEMORY_CLEAR = 100,
1040 KP_MEMORY_RECALL = 101,
1041 KP_MEMORY_STORE = 102,
1042 KP_MEMORY_SUBTRACT = 103,
1043 KP_MULTIPLY = 104,// Keypad *
1044 KP_PAREN_LEFT = 105,
1045 KP_PAREN_RIGHT = 106,
1046 KP_MINUS = 107,// Keypad -
1047 KP_SEPARATOR = 108,
1048 NUMPAD_UP = 109,
1049 NUMPAD_DOWN = 110,
1050 NUMPAD_RIGHT = 111,
1051 NUMPAD_LEFT = 112,
1052 NUMPAD_BEGIN = 113,
1053 NUMPAD_HOME = 114,
1054 NUMPAD_END = 115,
1055 NUMPAD_INSERT = 116,
1056 NUMPAD_DELETE = 117,
1057 NUMPAD_PAGE_UP = 118,
1058 NUMPAD_PAGE_DOWN = 119,
1059 ESCAPE = 120,
1060 F1 = 121,
1061 F2 = 122,
1062 F3 = 123,
1063 F4 = 124,
1064 F5 = 125,
1065 F6 = 126,
1066 F7 = 127,
1067 F8 = 128,
1068 F9 = 129,
1069 F10 = 130,
1070 F11 = 131,
1071 F12 = 132,
1072 F13 = 133,
1073 F14 = 134,
1074 F15 = 135,
1075 F16 = 136,
1076 F17 = 137,
1077 F18 = 138,
1078 F19 = 139,
1079 F20 = 140,
1080 F21 = 141,
1081 F22 = 142,
1082 F23 = 143,
1083 F24 = 144,
1084 F25 = 145,
1085 FN_LOCK = 146,
1086 PRINT_SCREEN = 147,
1087 SCROLL_LOCK = 148,
1088 PAUSE = 149,
1089 BROWSER_BACK = 150,
1090 BROWSER_FAVORITES = 151,
1091 BROWSER_FORWARD = 152,
1092 BROWSER_HOME = 153,
1093 BROWSER_REFRESH = 154,
1094 BROWSER_SEARCH = 155,
1095 BROWSER_STOP = 156,
1096 EJECT = 157,
1097 LAUNCH_APP_1 = 158,
1098 LAUNCH_APP_2 = 159,
1099 LAUNCH_MAIL = 160,
1100 MEDIA_PLAY_PAUSE = 161,
1101 MEDIA_SELECT = 162,
1102 MEDIA_STOP = 163,
1103 MEDIA_TRACK_NEXT = 164,
1104 MEDIA_TRACK_PREVIOUS = 165,
1105 POWER = 166,
1106 SLEEP = 167,
1107 AUDIO_VOLUME_DOWN = 168,
1108 AUDIO_VOLUME_MUTE = 169,
1109 AUDIO_VOLUME_UP = 170,
1110 WAKE_UP = 171,
1111 COPY = 172,
1112 CUT = 173,
1113 PASTE = 174
1114 }
1115
1116 /**
1117 * Key action
1118 */
1119 export declare enum KeyAction {
1120 RELEASE = 0,
1121 PRESS = 1,
1122 REPEAT = 2
1123 }
1124
1125 /**
1126 * Key Encoder - converts keyboard events into terminal escape sequences
1127 */
1128 export declare class KeyEncoder {
1129 private exports;
1130 private encoder;
1131 constructor(exports: GhosttyWasmExports);
1132 setOption(option: KeyEncoderOption, value: boolean | number): void;
1133 setKittyFlags(flags: KittyKeyFlags): void;
1134 encode(event: KeyEvent): Uint8Array;
1135 dispose(): void;
1136 }
1137
1138 /**
1139 * Key encoder options
1140 */
1141 export declare enum KeyEncoderOption {
1142 CURSOR_KEY_APPLICATION = 0,// DEC mode 1
1143 KEYPAD_KEY_APPLICATION = 1,// DEC mode 66
1144 IGNORE_KEYPAD_WITH_NUMLOCK = 2,// DEC mode 1035
1145 ALT_ESC_PREFIX = 3,// DEC mode 1036
1146 MODIFY_OTHER_KEYS_STATE_2 = 4,// xterm modifyOtherKeys
1147 KITTY_KEYBOARD_FLAGS = 5
1148 }
1149
1150 /**
1151 * Key event structure
1152 */
1153 export declare interface KeyEvent {
1154 action: KeyAction;
1155 key: Key;
1156 mods: Mods;
1157 consumedMods?: Mods;
1158 composing?: boolean;
1159 utf8?: string;
1160 unshiftedCodepoint?: number;
1161 }
1162
1163 /**
1164 * Kitty keyboard protocol flags
1165 * From include/ghostty/vt/key/encoder.h
1166 */
1167 declare enum KittyKeyFlags {
1168 DISABLED = 0,
1169 DISAMBIGUATE = 1,// Disambiguate escape codes
1170 REPORT_EVENTS = 2,// Report press and release
1171 REPORT_ALTERNATES = 4,// Report alternate key codes
1172 REPORT_ALL = 8,// Report all events
1173 REPORT_ASSOCIATED = 16,// Report associated text
1174 ALL = 31
1175 }
1176
1177 /**
1178 * Manages link detection across multiple providers with intelligent caching
1179 */
1180 export declare class LinkDetector {
1181 private terminal;
1182 private providers;
1183 private linkCache;
1184 private scannedRows;
1185 constructor(terminal: ITerminalForLinkDetector);
1186 /**
1187 * Register a link provider
1188 */
1189 registerProvider(provider: ILinkProvider): void;
1190 /**
1191 * Get link at the specified buffer position
1192 * @param col Column (0-based)
1193 * @param row Absolute row in buffer (0-based)
1194 * @returns Link at position, or undefined if none
1195 */
1196 getLinkAt(col: number, row: number): Promise<ILink | undefined>;
1197 /**
1198 * Scan a row for links using all registered providers
1199 */
1200 private scanRow;
1201 /**
1202 * Cache a link for fast lookup
1203 */
1204 private cacheLink;
1205 /**
1206 * Check if a position is within a link's range
1207 */
1208 private isPositionInLink;
1209 /**
1210 * Invalidate cache when terminal content changes
1211 * Should be called on terminal write, resize, or clear
1212 */
1213 invalidateCache(): void;
1214 /**
1215 * Invalidate cache for specific rows
1216 * Used when only part of the terminal changed
1217 */
1218 invalidateRows(startRow: number, endRow: number): void;
1219 /**
1220 * Dispose and cleanup
1221 */
1222 dispose(): void;
1223 }
1224
1225 /**
1226 * Modifier keys
1227 */
1228 export declare enum Mods {
1229 NONE = 0,
1230 SHIFT = 1,
1231 CTRL = 2,
1232 ALT = 4,
1233 SUPER = 8,// Windows/Command key
1234 CAPSLOCK = 16,
1235 NUMLOCK = 32
1236 }
1237
1238 /**
1239 * OSC 8 Hyperlink Provider
1240 *
1241 * Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells.
1242 * Automatically handles multi-line links since Ghostty WASM preserves
1243 * hyperlink_id across wrapped lines.
1244 */
1245 export declare class OSC8LinkProvider implements ILinkProvider {
1246 private terminal;
1247 constructor(terminal: ITerminalForOSC8Provider);
1248 /**
1249 * Provide all OSC 8 links on the given row
1250 * Note: This may return links that span multiple rows
1251 */
1252 provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
1253 /**
1254 * Find the full extent of a link by scanning for contiguous cells
1255 * with the same hyperlink_id. Handles multi-line links.
1256 */
1257 private findLinkRange;
1258 dispose(): void;
1259 }
1260
1261 export declare interface RendererOptions {
1262 fontSize?: number;
1263 fontFamily?: string;
1264 cursorStyle?: 'block' | 'underline' | 'bar';
1265 cursorBlink?: boolean;
1266 theme?: ITheme;
1267 devicePixelRatio?: number;
1268 }
1269
1270 /**
1271 * Colors from RenderState (12 bytes packed)
1272 */
1273 declare interface RenderStateColors {
1274 background: RGB;
1275 foreground: RGB;
1276 cursor: RGB | null;
1277 }
1278
1279 /**
1280 * Cursor state from RenderState (8 bytes packed)
1281 * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8)
1282 */
1283 declare interface RenderStateCursor {
1284 x: number;
1285 y: number;
1286 viewportX: number;
1287 viewportY: number;
1288 visible: boolean;
1289 blinking: boolean;
1290 style: 'block' | 'underline' | 'bar';
1291 }
1292
1293 /**
1294 * RGB color
1295 */
1296 export declare interface RGB {
1297 r: number;
1298 g: number;
1299 b: number;
1300 }
1301
1302 export declare interface SelectionCoordinates {
1303 startCol: number;
1304 startRow: number;
1305 endCol: number;
1306 endRow: number;
1307 }
1308
1309 export declare class SelectionManager {
1310 private terminal;
1311 private renderer;
1312 private wasmTerm;
1313 private textarea;
1314 private selectionStart;
1315 private selectionEnd;
1316 private isSelecting;
1317 private mouseDownTarget;
1318 private dirtySelectionRows;
1319 private selectionChangedEmitter;
1320 private boundMouseUpHandler;
1321 private boundContextMenuHandler;
1322 private boundClickHandler;
1323 private boundDocumentMouseMoveHandler;
1324 private autoScrollInterval;
1325 private autoScrollDirection;
1326 private static readonly AUTO_SCROLL_EDGE_SIZE;
1327 /**
1328 * Get current viewport Y position (how many lines scrolled into history)
1329 */
1330 private getViewportY;
1331 /**
1332 * Convert viewport row to absolute buffer row
1333 * Absolute row is an index into combined buffer: scrollback (0 to len-1) + screen (len to len+rows-1)
1334 */
1335 private viewportRowToAbsolute;
1336 /**
1337 * Convert absolute buffer row to viewport row (may be outside visible range)
1338 */
1339 private absoluteRowToViewport;
1340 private static readonly AUTO_SCROLL_SPEED;
1341 private static readonly AUTO_SCROLL_INTERVAL;
1342 constructor(terminal: Terminal, renderer: CanvasRenderer, wasmTerm: GhosttyTerminal, textarea: HTMLTextAreaElement);
1343 /**
1344 * Get the selected text as a string
1345 */
1346 getSelection(): string;
1347 /**
1348 * Check if there's an active selection
1349 */
1350 hasSelection(): boolean;
1351 /**
1352 * Clear the selection
1353 */
1354 clearSelection(): void;
1355 /**
1356 * Select all text in the terminal
1357 */
1358 selectAll(): void;
1359 /**
1360 * Select text at specific column and row with length
1361 * xterm.js compatible API
1362 */
1363 select(column: number, row: number, length: number): void;
1364 /**
1365 * Select entire lines from start to end
1366 * xterm.js compatible API
1367 */
1368 selectLines(start: number, end: number): void;
1369 /**
1370 * Get selection position as buffer range
1371 * xterm.js compatible API
1372 */
1373 getSelectionPosition(): {
1374 start: {
1375 x: number;
1376 y: number;
1377 };
1378 end: {
1379 x: number;
1380 y: number;
1381 };
1382 } | undefined;
1383 /**
1384 * Deselect all text
1385 * xterm.js compatible API
1386 */
1387 deselect(): void;
1388 /**
1389 * Focus the terminal (make it receive keyboard input)
1390 */
1391 focus(): void;
1392 /**
1393 * Get current selection coordinates (for rendering)
1394 */
1395 getSelectionCoords(): SelectionCoordinates | null;
1396 /**
1397 * Get dirty selection rows that need redraw (for clearing old highlight)
1398 */
1399 getDirtySelectionRows(): Set<number>;
1400 /**
1401 * Clear the dirty selection rows tracking (after redraw)
1402 */
1403 clearDirtySelectionRows(): void;
1404 /**
1405 * Get selection change event accessor
1406 */
1407 get onSelectionChange(): IEvent<void>;
1408 /**
1409 * Cleanup resources
1410 */
1411 dispose(): void;
1412 /**
1413 * Attach mouse event listeners to canvas
1414 */
1415 private attachEventListeners;
1416 /**
1417 * Mark current selection rows as dirty for redraw
1418 */
1419 private markCurrentSelectionDirty;
1420 /**
1421 * Update auto-scroll based on mouse Y position within canvas
1422 */
1423 private updateAutoScroll;
1424 /**
1425 * Start auto-scrolling in the given direction
1426 */
1427 private startAutoScroll;
1428 /**
1429 * Stop auto-scrolling
1430 */
1431 private stopAutoScroll;
1432 /**
1433 * Convert pixel coordinates to terminal cell coordinates
1434 */
1435 private pixelToCell;
1436 /**
1437 * Normalize selection coordinates (handle backward selection)
1438 * Returns coordinates in VIEWPORT space for rendering, clamped to visible area
1439 */
1440 private normalizeSelection;
1441 /**
1442 * Get word boundaries at a cell position
1443 */
1444 private getWordAtCell;
1445 /**
1446 * Copy text to clipboard
1447 */
1448 private copyToClipboard;
1449 /**
1450 * Request a render update (triggers selection overlay redraw)
1451 */
1452 private requestRender;
1453 }
1454
1455 export declare class Terminal implements ITerminalCore {
1456 cols: number;
1457 rows: number;
1458 element?: HTMLElement;
1459 textarea?: HTMLTextAreaElement;
1460 readonly buffer: IBufferNamespace;
1461 readonly unicode: IUnicodeVersionProvider;
1462 readonly options: Required<ITerminalOptions>;
1463 private ghostty?;
1464 wasmTerm?: GhosttyTerminal;
1465 renderer?: CanvasRenderer;
1466 private inputHandler?;
1467 private selectionManager?;
1468 private canvas?;
1469 private linkDetector?;
1470 private currentHoveredLink?;
1471 private mouseMoveThrottleTimeout?;
1472 private pendingMouseMove?;
1473 private dataEmitter;
1474 private resizeEmitter;
1475 private bellEmitter;
1476 private selectionChangeEmitter;
1477 private keyEmitter;
1478 private titleChangeEmitter;
1479 private scrollEmitter;
1480 private renderEmitter;
1481 private cursorMoveEmitter;
1482 readonly onData: IEvent<string>;
1483 readonly onResize: IEvent<{
1484 cols: number;
1485 rows: number;
1486 }>;
1487 readonly onBell: IEvent<void>;
1488 readonly onSelectionChange: IEvent<void>;
1489 readonly onKey: IEvent<IKeyEvent>;
1490 readonly onTitleChange: IEvent<string>;
1491 readonly onScroll: IEvent<number>;
1492 readonly onRender: IEvent<{
1493 start: number;
1494 end: number;
1495 }>;
1496 readonly onCursorMove: IEvent<void>;
1497 private isOpen;
1498 private isDisposed;
1499 private animationFrameId?;
1500 private addons;
1501 private customKeyEventHandler?;
1502 private currentTitle;
1503 viewportY: number;
1504 private targetViewportY;
1505 private scrollAnimationStartTime?;
1506 private scrollAnimationStartY?;
1507 private scrollAnimationFrame?;
1508 private customWheelEventHandler?;
1509 private lastCursorY;
1510 private isDraggingScrollbar;
1511 private scrollbarDragStart;
1512 private scrollbarDragStartViewportY;
1513 private scrollbarVisible;
1514 private scrollbarOpacity;
1515 private scrollbarHideTimeout?;
1516 private readonly SCROLLBAR_HIDE_DELAY_MS;
1517 private readonly SCROLLBAR_FADE_DURATION_MS;
1518 constructor(options?: ITerminalOptions);
1519 /**
1520 * Handle runtime option changes (called when options are modified after terminal is open)
1521 * This enables xterm.js compatibility where options can be changed at runtime
1522 */
1523 private handleOptionChange;
1524 /**
1525 * Handle font changes (fontSize or fontFamily)
1526 * Updates canvas size to match new font metrics and forces a full re-render
1527 */
1528 private handleFontChange;
1529 /**
1530 * Parse a CSS color string to 0xRRGGBB format.
1531 * Returns 0 if the color is undefined or invalid.
1532 */
1533 private parseColorToHex;
1534 /**
1535 * Convert terminal options to WASM terminal config.
1536 */
1537 private buildWasmConfig;
1538 /**
1539 * Open terminal in a parent element
1540 *
1541 * Initializes all components and starts rendering.
1542 * Requires a pre-loaded Ghostty instance passed to the constructor.
1543 */
1544 open(parent: HTMLElement): void;
1545 /**
1546 * Write data to terminal
1547 */
1548 write(data: string | Uint8Array, callback?: () => void): void;
1549 /**
1550 * Internal write implementation (extracted from write())
1551 */
1552 private writeInternal;
1553 /**
1554 * Write data with newline
1555 */
1556 writeln(data: string | Uint8Array, callback?: () => void): void;
1557 /**
1558 * Paste text into terminal (triggers bracketed paste if supported)
1559 */
1560 paste(data: string): void;
1561 /**
1562 * Input data into terminal (as if typed by user)
1563 *
1564 * @param data - Data to input
1565 * @param wasUserInput - If true, triggers onData event (default: false for compat with some apps)
1566 */
1567 input(data: string, wasUserInput?: boolean): void;
1568 /**
1569 * Resize terminal
1570 */
1571 resize(cols: number, rows: number): void;
1572 /**
1573 * Clear terminal screen
1574 */
1575 clear(): void;
1576 /**
1577 * Reset terminal state
1578 */
1579 reset(): void;
1580 /**
1581 * Focus terminal input
1582 */
1583 focus(): void;
1584 /**
1585 * Blur terminal (remove focus)
1586 */
1587 blur(): void;
1588 /**
1589 * Load an addon
1590 */
1591 loadAddon(addon: ITerminalAddon): void;
1592 /**
1593 * Get the selected text as a string
1594 */
1595 getSelection(): string;
1596 /**
1597 * Check if there's an active selection
1598 */
1599 hasSelection(): boolean;
1600 /**
1601 * Clear the current selection
1602 */
1603 clearSelection(): void;
1604 /**
1605 * Select all text in the terminal
1606 */
1607 selectAll(): void;
1608 /**
1609 * Select text at specific column and row with length
1610 */
1611 select(column: number, row: number, length: number): void;
1612 /**
1613 * Select entire lines from start to end
1614 */
1615 selectLines(start: number, end: number): void;
1616 /**
1617 * Get selection position as buffer range
1618 */
1619 /**
1620 * Get the current viewport Y position.
1621 *
1622 * This is the number of lines scrolled back from the bottom of the
1623 * scrollback buffer. It may be fractional during smooth scrolling.
1624 */
1625 getViewportY(): number;
1626 getSelectionPosition(): IBufferRange | undefined;
1627 /**
1628 * Attach a custom keyboard event handler
1629 * Returns true to prevent default handling
1630 */
1631 attachCustomKeyEventHandler(customKeyEventHandler: (event: KeyboardEvent) => boolean): void;
1632 /**
1633 * Attach a custom wheel event handler (Phase 2)
1634 * Returns true to prevent default handling
1635 */
1636 attachCustomWheelEventHandler(customWheelEventHandler?: (event: WheelEvent) => boolean): void;
1637 /**
1638 * Register a custom link provider
1639 * Multiple providers can be registered to detect different types of links
1640 *
1641 * @example
1642 * ```typescript
1643 * term.registerLinkProvider({
1644 * provideLinks(y, callback) {
1645 * // Detect URLs, file paths, etc.
1646 * callback(detectedLinks);
1647 * }
1648 * });
1649 * ```
1650 */
1651 registerLinkProvider(provider: ILinkProvider): void;
1652 /**
1653 * Scroll viewport by a number of lines
1654 * @param amount Number of lines to scroll (positive = down, negative = up)
1655 */
1656 scrollLines(amount: number): void;
1657 /**
1658 * Scroll viewport by a number of pages
1659 * @param amount Number of pages to scroll (positive = down, negative = up)
1660 */
1661 scrollPages(amount: number): void;
1662 /**
1663 * Scroll viewport to the top of the scrollback buffer
1664 */
1665 scrollToTop(): void;
1666 /**
1667 * Scroll viewport to the bottom (current output)
1668 */
1669 scrollToBottom(): void;
1670 /**
1671 * Scroll viewport to a specific line in the buffer
1672 * @param line Line number (0 = top of scrollback, scrollbackLength = bottom)
1673 */
1674 scrollToLine(line: number): void;
1675 /**
1676 * Smoothly scroll to a target viewport position
1677 * @param targetY Target viewport Y position (in lines, can be fractional)
1678 */
1679 private smoothScrollTo;
1680 /**
1681 * Animation loop for smooth scrolling
1682 * Uses asymptotic approach - moves a fraction of remaining distance each frame
1683 */
1684 private animateScroll;
1685 /**
1686 * Dispose terminal and clean up resources
1687 */
1688 dispose(): void;
1689 /**
1690 * Start the render loop
1691 */
1692 private startRenderLoop;
1693 /**
1694 * Get a line from native WASM scrollback buffer
1695 * Implements IScrollbackProvider
1696 */
1697 getScrollbackLine(offset: number): GhosttyCell[] | null;
1698 /**
1699 * Get scrollback length from native WASM
1700 * Implements IScrollbackProvider
1701 */
1702 getScrollbackLength(): number;
1703 /**
1704 * Clean up components (called on dispose or error)
1705 */
1706 private cleanupComponents;
1707 /**
1708 * Assert terminal is open (throw if not)
1709 */
1710 private assertOpen;
1711 /**
1712 * Handle mouse move for link hover detection and scrollbar dragging
1713 * Throttled to avoid blocking scroll events (except when dragging scrollbar)
1714 */
1715 private handleMouseMove;
1716 /**
1717 * Process mouse move for link detection (internal, called by throttled handler)
1718 */
1719 private processMouseMove;
1720 /**
1721 * Handle mouse leave to clear link hover
1722 */
1723 private handleMouseLeave;
1724 /**
1725 * Handle mouse click for link activation
1726 */
1727 private handleClick;
1728 /**
1729 * Handle wheel events for scrolling (Phase 2)
1730 */
1731 private handleWheel;
1732 /**
1733 * Handle mouse down for scrollbar interaction
1734 */
1735 private handleMouseDown;
1736 /**
1737 * Handle mouse up for scrollbar drag
1738 */
1739 private handleMouseUp;
1740 /**
1741 * Process scrollbar drag movement
1742 */
1743 private processScrollbarDrag;
1744 /**
1745 * Show scrollbar with fade-in and schedule auto-hide
1746 */
1747 private showScrollbar;
1748 /**
1749 * Hide scrollbar with fade-out
1750 */
1751 private hideScrollbar;
1752 /**
1753 * Fade in scrollbar
1754 */
1755 private fadeInScrollbar;
1756 /**
1757 * Fade out scrollbar
1758 */
1759 private fadeOutScrollbar;
1760 /**
1761 * Process any pending terminal responses and emit them via onData.
1762 *
1763 * This handles escape sequences that require the terminal to send a response
1764 * back to the PTY, such as:
1765 * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR
1766 * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n
1767 *
1768 * Without this, shells like nushell that rely on cursor position queries
1769 * will hang waiting for a response that never comes.
1770 */
1771 private processTerminalResponses;
1772 /**
1773 * Check for title changes in written data (OSC sequences)
1774 * Simplified implementation - looks for OSC 0, 1, 2
1775 */
1776 private checkForTitleChange;
1777 /**
1778 * Query terminal mode state
1779 *
1780 * @param mode Mode number (e.g., 2004 for bracketed paste)
1781 * @param isAnsi True for ANSI modes, false for DEC modes (default: false)
1782 * @returns true if mode is enabled
1783 */
1784 getMode(mode: number, isAnsi?: boolean): boolean;
1785 /**
1786 * Check if bracketed paste mode is enabled
1787 */
1788 hasBracketedPaste(): boolean;
1789 /**
1790 * Check if focus event reporting is enabled
1791 */
1792 hasFocusEvents(): boolean;
1793 /**
1794 * Check if mouse tracking is enabled
1795 */
1796 hasMouseTracking(): boolean;
1797 }
1798
1799 /**
1800 * Opaque terminal pointer (WASM memory address)
1801 */
1802 export declare type TerminalHandle = number;
1803
1804 /**
1805 * URL Regex Provider
1806 *
1807 * Detects plain text URLs on a single line using regex.
1808 * Does not support multi-line URLs or file paths.
1809 *
1810 * Supported protocols:
1811 * - https://, http://
1812 * - mailto:
1813 * - ftp://, ssh://, git://
1814 * - tel:, magnet:
1815 * - gemini://, gopher://, news:
1816 */
1817 export declare class UrlRegexProvider implements ILinkProvider {
1818 private terminal;
1819 /**
1820 * URL regex pattern
1821 * Matches common protocols followed by valid URL characters
1822 * Excludes file paths (no ./ or ../ or bare /)
1823 */
1824 private static readonly URL_REGEX;
1825 /**
1826 * Characters to strip from end of URLs
1827 * Common punctuation that's unlikely to be part of the URL
1828 */
1829 private static readonly TRAILING_PUNCTUATION;
1830 constructor(terminal: ITerminalForUrlProvider);
1831 /**
1832 * Provide all regex-detected URLs on the given row
1833 */
1834 provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
1835 /**
1836 * Convert a buffer line to plain text string
1837 */
1838 private lineToText;
1839 dispose(): void;
1840 }
1841
1842 export { }
src/web/vendor/ghostty-web/ghostty-web.js added +2963

Diff not shown — 678,947 bytes of diff (limit 100,000). +2963

View file
test/cloud-accounts-etag.test.ts +4 −1
@@ -1,5 +1,5 @@
11 /** GET /api/v1/cloud/accounts carries a weak ETag and answers a repeat with 304 until an account or its use changes (#9206). */
2 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
2 import { afterAll, beforeAll, expect, mock, spyOn, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
@@ -62,6 +62,9 @@ beforeAll(async () => {
6262 if (!added.ok) throw new Error(String(added.error));
6363 }, 30_000);
6464 afterAll(async () => {
65 // The spy outlives this file otherwise: every later file in the same `bun test`
66 // run would read a frozen clock, and setSystemTime would move nothing.
67 mock.restore();
6568 servers.stop();
6669 app.close();
6770 await rm(dataDir, { recursive: true, force: true });
test/global-spies-restored.test.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * A spy on a global (`Date`, `globalThis`, `Math`, `performance`, `process`)
3 * outlives its file: `bun test` runs a shard's files in one process, so every
4 * later file reads the spy — a `Date.now` frozen by one file made
5 * `setSystemTime` move nothing in nine tests elsewhere in its shard (#9498).
6 * A file that spies on one restores it (`mock.restore()` or `.mockRestore()`).
7 */
8 import { expect, test } from "bun:test";
9 import { readdirSync, readFileSync } from "node:fs";
10 import { join } from "node:path";
11
12 const dir = new URL(".", import.meta.url).pathname;
13 const SPY = /spyOn\(\s*(Date|globalThis|Math|performance|process)\b/;
14 const RESTORE = /mock\.restore\(\)|\.mockRestore\(\)/;
15
16 /** The test files that spy on a global and never restore it. */
17 export function unrestored(files: ReadonlyArray<{ name: string; text: string }>): string[] {
18 return files.filter((f) => SPY.test(f.text) && !RESTORE.test(f.text)).map((f) => f.name);
19 }
20
21 test("a spy on a global without a restore is named; one with a restore is not", () => {
22 const spied = 'beforeAll(() => { spyOn(Date, "now").mockImplementation(() => 1); });';
23 expect(unrestored([{ name: "a.test.ts", text: spied }])).toEqual(["a.test.ts"]);
24 expect(unrestored([{ name: "b.test.ts", text: `${spied} afterAll(() => mock.restore());` }])).toEqual([]);
25 expect(unrestored([{ name: "c.test.ts", text: 'spyOn(app, "send")' }])).toEqual([]);
26 });
27
28 test("every test file that spies on a global restores it", () => {
29 const files = readdirSync(dir, { recursive: true })
30 .map(String)
31 .filter((name) => name.endsWith(".ts") && name !== "global-spies-restored.test.ts")
32 .map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }));
33 expect(unrestored(files)).toEqual([]);
34 });
test/machine-shell-page.test.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * The machine shell page (#9472): a writer gets a terminal page at the
3 * socket's own path, a reader is refused it; the machines list offers "Open
4 * shell"; the page alone admits WebAssembly in its CSP and carries the
5 * security floor; the terminal's script and WebAssembly are served immutable,
6 * the script without the vendored module's inlined copy of the WebAssembly.
7 * The wiring sends one resize frame per settled size, and a reattached
8 * connection's first size redraws the screen (`resizePlan`).
9 */
10 import { afterAll, beforeAll, expect, test } from "bun:test";
11 import { mkdtempSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { createRepo } from "../src/core/repos.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { resizePlan, RESIZE } from "../src/machines/shell.ts";
20 import { closedWords, resizeFrame, wire, type Size, type Term } from "../src/web/machine-term-wire.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 let app: App;
26 let servers: Servers;
27 const cookies: Record<string, string> = {};
28
29 async function person(name: string): Promise<string> {
30 const made = await createUser(app.db, { username: name, email: `${name}@x.test`, password: "correct horse battery", origin: "seed", trusted: true });
31 if (!made.ok) throw new Error(String(made.error));
32 cookies[name] = `forge_session=${createSession(app.db, made.value.id).token}`;
33 return made.value.id;
34 }
35
36 beforeAll(async () => {
37 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "machine-shell-page-")), port: 0 });
38 const alice = await person("alice");
39 await person("bob");
40 for (const [name, visibility] of [["lab", "public"], ["shut", "private"]] as const) {
41 const made = await createRepo(app.db, app.reposRoot, { ownerId: alice, name, visibility });
42 if (!made.ok) throw new Error(String(made.error));
43 }
44 servers = await serve(app);
45 });
46
47 afterAll(() => {
48 servers.stop();
49 app.close();
50 });
51
52 const get = (path: string, who?: string) =>
53 fetch(`${servers.url}${path}`, { headers: who === undefined ? {} : { cookie: cookies[who]! } });
54
55 test("a writer gets the terminal page, its CSP admitting WebAssembly and nothing looser", async () => {
56 const res = await get("/alice/lab/machines/standard/shell", "alice");
57 expect(res.status).toBe(200);
58 const body = await res.text();
59 expect(body).toContain('id="machine-term"');
60 expect(body).toMatch(/data-wasm="\/assets\/ghostty-vt-[0-9a-f]{16}\.wasm"/);
61 // Without JavaScript nothing connects: the server never claims to be
62 // connecting, and a noscript line says what the shell needs.
63 expect(body).not.toContain("Connecting");
64 expect(body).toMatch(/<noscript>[^]*needs JavaScript[^]*<\/noscript>/);
65 expect(body).toMatch(/<script type="module" src="\/assets\/machine-term-[0-9a-f]{16}\.js"><\/script>/);
66 const csp = res.headers.get("content-security-policy") ?? "";
67 expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval';");
68 expect(csp).not.toContain("unsafe-inline");
69 expect(csp).not.toContain("'unsafe-eval'");
70 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
71 expect(res.headers.get("referrer-policy")).toBe("same-origin");
72 });
73
74 test("a reader is refused the page; a stranger to a private repository and an undeclared machine get 404", async () => {
75 expect((await get("/alice/lab/machines/standard/shell", "bob")).status).toBe(403);
76 expect((await get("/alice/lab/machines/standard/shell")).status).toBe(403);
77 expect((await get("/alice/shut/machines/standard/shell", "bob")).status).toBe(404);
78 expect((await get("/alice/lab/machines/nope/shell", "alice")).status).toBe(404);
79 });
80
81 test("the machines list offers Open shell, and its own CSP admits no WebAssembly", async () => {
82 const res = await get("/alice/lab/settings/machines", "alice");
83 expect(res.status).toBe(200);
84 const body = await res.text();
85 expect(body).toContain('href="/alice/lab/machines/standard/shell"');
86 expect(body).toContain("Open shell");
87 expect(res.headers.get("content-security-policy")).not.toContain("wasm-unsafe-eval");
88 });
89
90 test("the terminal's script and WebAssembly are immutable, and the script carries no inlined WebAssembly", async () => {
91 const page = await (await get("/alice/lab/machines/standard/shell", "alice")).text();
92 const script = page.match(/\/assets\/machine-term-[0-9a-f]{16}\.js/)![0];
93 const wasm = page.match(/\/assets\/ghostty-vt-[0-9a-f]{16}\.wasm/)![0];
94 const js = await get(script);
95 expect(js.status).toBe(200);
96 expect(js.headers.get("cache-control")).toContain("immutable");
97 const code = await js.text();
98 expect(code).not.toContain("application/wasm;base64");
99 expect(code.length).toBeLessThan(200_000);
100 const bin = await get(wasm);
101 expect(bin.status).toBe(200);
102 expect(bin.headers.get("content-type")).toBe("application/wasm");
103 expect(bin.headers.get("cache-control")).toContain("immutable");
104 expect([...new Uint8Array(await bin.arrayBuffer()).slice(0, 4)]).toEqual([0, 0x61, 0x73, 0x6d]);
105 expect((await get("/assets/ghostty-vt-0000000000000000.wasm")).status).toBe(404);
106 expect((await get("/assets/machine-term-0000000000000000.js")).status).toBe(404);
107 });
108
109 /** A terminal whose box is whatever size the test says. */
110 function fakeTerm(): { term: Term; box: { size: Size }; typed: (text: string) => void; written: Uint8Array[] } {
111 const box = { size: { cols: 80, rows: 24 } as Size };
112 const written: Uint8Array[] = [];
113 let listener: (text: string) => void = () => {};
114 return {
115 box,
116 written,
117 typed: (text) => listener(text),
118 term: {
119 write: (bytes) => written.push(bytes),
120 onData: (l) => {
121 listener = l;
122 },
123 resize: () => {},
124 fit: () => box.size,
125 },
126 };
127 }
128
129 test("the wiring sends the size on open, one frame per new size, and keystrokes only while open", () => {
130 const { term, box, typed, written } = fakeTerm();
131 const frames: (string | Uint8Array)[] = [];
132 const said: string[] = [];
133 const wired = wire(term, (f) => frames.push(f), (w) => said.push(w));
134 typed("early");
135 expect(frames).toEqual([]);
136 wired.opened();
137 expect(frames).toEqual([resizeFrame({ cols: 80, rows: 24 })]);
138 // A window dragged: many refits, one settled size, one frame.
139 box.size = { cols: 120, rows: 40 };
140 wired.refit();
141 wired.refit();
142 wired.refit();
143 expect(frames.filter((f) => typeof f === "string")).toEqual([
144 '{"type":"resize","cols":80,"rows":24}',
145 '{"type":"resize","cols":120,"rows":40}',
146 ]);
147 typed("ls\r");
148 expect(new TextDecoder().decode(frames.at(-1) as Uint8Array)).toBe("ls\r");
149 wired.received(new Uint8Array([104, 105]));
150 expect(written).toHaveLength(1);
151 wired.closed(1011, "this forge cannot hold a machine's shell");
152 expect(said.at(-1)).toBe("Disconnected: this forge cannot hold a machine's shell. Reload to reattach.");
153 const count = frames.length;
154 typed("late");
155 box.size = { cols: 100, rows: 30 };
156 wired.refit();
157 expect(frames).toHaveLength(count);
158 expect(closedWords(1000, "")).toBe("Disconnected: the shell exited. Reload to reattach.");
159 });
160
161 test("a joining connection's first size redraws the screen; a new size is set once; a repeat changes nothing", () => {
162 const at = { cols: 80, rows: 24 };
163 expect(resizePlan(at, { cols: 100, rows: 30 }, true)).toEqual([{ cols: 100, rows: 30 }]);
164 expect(resizePlan(at, { cols: 100, rows: 30 }, false)).toEqual([{ cols: 100, rows: 30 }]);
165 expect(resizePlan(at, { cols: 80, rows: 24 }, true)).toEqual([{ cols: 80, rows: 23 }, { cols: 80, rows: 24 }]);
166 expect(resizePlan(at, { cols: 80, rows: 24 }, false)).toEqual([]);
167 const one = { cols: 80, rows: RESIZE.rows.min };
168 expect(resizePlan(one, one, true)).toEqual([{ cols: 80, rows: 2 }, one]);
169 });
test/machine-shell.test.ts +32
@@ -59,6 +59,8 @@ beforeAll(async () => {
5959 if (!shut.ok) throw new Error(String(shut.error));
6060 // A row a crashed forge left open, closed when this one boots.
6161 staleRow = recordStart(app.db, repo.id, "standard", alice.id);
62 // What main.ts does once no other forge holds the database.
63 closeStaleSessions(app.db);
6264 servers = await serve(app);
6365 base = servers.url.replace(/^http/, "ws");
6466 });
@@ -166,6 +168,36 @@ test("a writer's session echoes input, applies a resize, reattaches, and is reco
166168 expect(liveSessions(app)).toBe(0);
167169 });
168170
171 test("a reload's first size redraws the screen, though the size is the same; a repeat does not", async () => {
172 const first = await open("alice");
173 first.ws.send(JSON.stringify({ type: "resize", cols: 100, rows: 30 }));
174 first.ws.send(new TextEncoder().encode("stty size\n"));
175 await until(() => first.seen().includes("30 100"));
176 // A program in the foreground redraws (here: prints its size) on every
177 // change it is signalled, as tmux does; its output alone reaches a page.
178 first.ws.send(new TextEncoder().encode("sh -c 'trap \"stty size\" WINCH; while :; do sleep 0.05; done'\n"));
179 await Bun.sleep(200);
180 first.ws.close(); // the reload
181 await until(() => liveSessions(app) === 1 && first.ws.readyState === WebSocket.CLOSED);
182
183 const second = await open("alice");
184 expect(liveSessions(app)).toBe(1);
185 second.ws.send(JSON.stringify({ type: "resize", cols: 100, rows: 30 }));
186 await until(() => second.seen().includes("30 100"));
187 const redraws = () => second.seen().split("30 100").length - 1;
188 const after = redraws();
189 // The same size again from the same page changes nothing.
190 second.ws.send(JSON.stringify({ type: "resize", cols: 100, rows: 30 }));
191 await Bun.sleep(300);
192 expect(redraws()).toBe(after);
193
194 const closed = new Promise((r) => second.ws.addEventListener("close", r));
195 second.ws.send(new TextEncoder().encode("\x03"));
196 second.ws.send(new TextEncoder().encode("exit\n"));
197 await closed;
198 await until(() => liveSessions(app) === 0);
199 }, 15_000);
200
169201 test("a text frame that is not a resize ends the connection", async () => {
170202 const { ws } = await open("alice");
171203 const closed = new Promise<number>((r) => ws.addEventListener("close", (e) => r(e.code)));
test/security-floor-routes.test.ts +2 −2
@@ -28,7 +28,7 @@ import { quietLogs } from "./helpers/globals.ts";
2828 quietLogs();
2929
3030 /** Parameters the seed supplies; any other parameter makes a pattern unseeded. */
31 const SEEDED = new Set(["owner", "name", "username", "number", "ref", "oid", "path", "env", "slug", "spec"]);
31 const SEEDED = new Set(["owner", "name", "username", "number", "ref", "oid", "path", "env", "slug", "spec", "machine"]);
3232
3333 const ORIGIN = "needs a connected origin account and a mirror job; the seed connects none";
3434 const MIGRATE = "needs a migration job, which only an import creates";
@@ -156,7 +156,7 @@ beforeAll(async () => {
156156 if (!inserted.ok) throw new Error(inserted.error);
157157 Object.assign(fill, {
158158 owner: "ada", name: "notes", username: "ada", ref: "main", oid: pull.value.headOid,
159 path: "a.txt", env: "prod", slug: "notes", spec: "192",
159 path: "a.txt", env: "prod", slug: "notes", spec: "192", machine: "standard",
160160 issue: String(issue.value.number), pull: String(pull.value.number), run: run.id,
161161 });
162162 cookie = `forge_session=${createSession(app.db, ada.id).token}`;
test/unreached.test.ts +5 −3
@@ -40,8 +40,10 @@ test("unreached() follows imports from the entries and scripts, and nothing else
4040 put("src/a.ts", 'export const load = () => import(\n "./b"\n);\n');
4141 put("src/b/index.ts", "export const t = () => import(`./tpl`);\n");
4242 put("src/b/tpl.ts");
43 put("src/side.ts");
44 put("src/c.ts");
43 put("src/side.ts", 'Bun.build({ entrypoints: [Bun.fileURLToPath(new URL("./entry.ts", import.meta.url))] });\n');
44 put("src/entry.ts");
45 put("src/c.ts", 'const notAnEntry = new URL("./g.ts", "https://x");\n');
46 put("src/g.ts");
4547 put("src/d.ts", 'import "./c.ts";\n');
4648 put("src/e.ts");
4749 put("src/a.test.ts", 'import "./e.ts";\n');
@@ -49,7 +51,7 @@ test("unreached() follows imports from the entries and scripts, and nothing else
4951 put("src/core/migrations/001.ts");
5052 put("src/f.ts");
5153 put("scripts/tool.ts", 'const { f } = await import("../src/f.ts");\n');
52 expect(unreached(dir)).toEqual(["src/c.ts", "src/d.ts", "src/e.ts"]);
54 expect(unreached(dir)).toEqual(["src/c.ts", "src/d.ts", "src/e.ts", "src/g.ts"]);
5355 } finally {
5456 rmSync(dir, { recursive: true, force: true });
5557 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs