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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9472-the-machine-shell-page-is-a-ghostty-term → main 12 commits fork 2 threads · 2 unresolved
28 files
changed +5620
−31
NOTICE +1
| @@ -7,3 +7,4 @@ This repository also contains third-party files under their own licenses: | ||
| 7 | 7 | |
| 8 | 8 | - DejaVu fonts, src/web/og/vendor/DejaVu*.ttf: see src/web/og/vendor/DEJAVU-LICENSE. |
| 9 | 9 | - 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 | ||
| 46 | 46 | GitHub holds a read-only mirror; issues and pulls are on the forge. |
| 47 | 47 | |
| 48 | 48 | Open source under the [](LICENSE). See [](CONTRIBUTING.md). |
| 49 | ||
| 50 | One dependency is vendored for browsers: [](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). |
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: | ||
| 90 | 90 | on its own for a writer who is not one), and in the terminal: the palette's |
| 91 | 91 | **Machines of a repository** (`src/tui/screens/machines.ts`), writers only. |
| 92 | 92 | |
| 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 [](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 | ||
| 93 | 134 | ## Not done yet (split from #9418) |
| 94 | 135 | |
| 95 | 136 | 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 | |
| 98 | 138 | apps even when its machine says `apps`. |
scripts/design-lint.ts +1 −1
| @@ -85,7 +85,7 @@ export type Baseline = { files: Record<string, Counts> }; | ||
| 85 | 85 | export type Finding = { line: number; kind: "colour" | "size"; text: string; use: string }; |
| 86 | 86 | export type Scan = Baseline & { classes: string[]; found?: Record<string, Finding[]> }; |
| 87 | 87 | |
| 88 | const SKIP = /(^|\/)(og\/vendor)\//; | |
| 88 | const SKIP = /(^|\/)(og\/vendor|web\/vendor)\//; | |
| 89 | 89 | |
| 90 | 90 | export function sourceFiles(root: string): string[] { |
| 91 | 91 | const out: string[] = []; |
scripts/unreached.ts +7 −4
| @@ -23,14 +23,17 @@ export const ENTRIES = [ | ||
| 23 | 23 | "src/git/hook-entry.ts", |
| 24 | 24 | ]; |
| 25 | 25 | |
| 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"]; | |
| 28 | 28 | |
| 29 | 29 | /** Not modules: tests, and migrations, which the runner finds by listing the directory. */ |
| 30 | 30 | const NOT_MODULES = /(?:\.test\.ts$|^src\/core\/migrations\/)/; |
| 31 | 31 | |
| 32 | 32 | const SPEC = /(?:\bfrom\s*|\bimport\s*\(?\s*)["'`](\.{1,2}\/[^"'`]*)["'`]/g; |
| 33 | 33 | |
| 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 | ||
| 34 | 37 | /** The src/ file a relative specifier names, among `files`, or undefined. */ |
| 35 | 38 | function resolve(from: string, spec: string, files: Set<string>): string | undefined { |
| 36 | 39 | const base = normalize(join(dirname(from), spec)).replace(/\.(js|ts)$/, ""); |
| @@ -43,10 +46,10 @@ export function unreached(root: string): string[] { | ||
| 43 | 46 | [...new Bun.Glob("src/**/*.ts").scanSync({ cwd: root })].filter((f) => !f.endsWith(".d.ts") && !NOT_MODULES.test(f)), |
| 44 | 47 | ); |
| 45 | 48 | 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 })])]; | |
| 47 | 50 | for (let file = queue.pop(); file; file = queue.pop()) { |
| 48 | 51 | 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)]) { | |
| 50 | 53 | const target = resolve(file, m[1]!, files); |
| 51 | 54 | if (target && !reached.has(target)) { |
| 52 | 55 | reached.add(target); |
src/apps/tty.ts +14 −2
| @@ -246,6 +246,9 @@ export interface TerminalProgram { | ||
| 246 | 246 | readonly hangup: () => void; |
| 247 | 247 | } |
| 248 | 248 | |
| 249 | /** Where `setsid` is, found once; null on a host without util-linux. */ | |
| 250 | const SETSID = Bun.which("setsid"); | |
| 251 | ||
| 249 | 252 | /** |
| 250 | 253 | * Start `command` (through `sh -c`) on `terminal`, held in `cell` when |
| 251 | 254 | * there is one. Without a cell the program leads a process group of its |
| @@ -265,11 +268,20 @@ export function startTerminal( | ||
| 265 | 268 | }, |
| 266 | 269 | ): TerminalProgram { |
| 267 | 270 | 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, { | |
| 269 | 281 | terminal: options.terminal, |
| 270 | 282 | env: cell?.env(options.env) ?? options.env, |
| 271 | 283 | cwd: options.cwd, |
| 272 | detached: cell === undefined, | |
| 284 | detached: !ctty && cell === undefined, | |
| 273 | 285 | }); |
| 274 | 286 | const exited = proc.exited.then((code) => { |
| 275 | 287 | cell?.close(); |
src/http/server.ts +12 −4
| @@ -535,7 +535,8 @@ import { | ||
| 535 | 535 | import { getVerifiedDomain } from "../core/domains.ts"; |
| 536 | 536 | import { previewOwner } from "../spawn/preview-owner.ts"; |
| 537 | 537 | 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"; | |
| 539 | 540 | import { |
| 540 | 541 | defaultCommand, |
| 541 | 542 | mayOpenShell, |
| @@ -544,7 +545,6 @@ import { | ||
| 544 | 545 | shellTarget, |
| 545 | 546 | stopShells, |
| 546 | 547 | withShells, |
| 547 | closeStaleSessions, | |
| 548 | 548 | type ForgeSocket, |
| 549 | 549 | type ShellSocket, |
| 550 | 550 | } from "../machines/shell.ts"; |
| @@ -726,6 +726,10 @@ export function buildRouter(): Router<Handler> { | ||
| 726 | 726 | if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(ctx.request); |
| 727 | 727 | const own = pageScriptNamed(ctx.params.name!, ctx.app.config.dataDir); |
| 728 | 728 | 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 | } | |
| 729 | 733 | const asset = await highlightAsset(); |
| 730 | 734 | if (asset === null || ctx.params.name !== asset.name) return notFound(); |
| 731 | 735 | return assetResponse(ctx.request, asset); |
| @@ -734,6 +738,10 @@ export function buildRouter(): Router<Handler> { | ||
| 734 | 738 | router.add("GET", "/assets/:name.svg", (ctx) => |
| 735 | 739 | ctx.params.name === spriteAsset().name ? fileResponse(ctx.request, spriteAsset()) : notFound(), |
| 736 | 740 | ); |
| 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 | ); | |
| 737 | 745 | router.add("GET", "/assets/:name.json", (ctx) => |
| 738 | 746 | ctx.params.name === speculationAsset().name ? fileResponse(ctx.request, speculationAsset()) : notFound(), |
| 739 | 747 | ); |
| @@ -1371,6 +1379,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1371 | 1379 | ); |
| 1372 | 1380 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1373 | 1381 | 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); | |
| 1374 | 1384 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1375 | 1385 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
| 1376 | 1386 | router.add("POST", "/:owner/:name/settings/app/members", appMembersUpdate); |
| @@ -2161,8 +2171,6 @@ export function forgeHttp(app: App): ForgeHttp { | ||
| 2161 | 2171 | |
| 2162 | 2172 | export async function serve(app: App): Promise<Servers> { |
| 2163 | 2173 | const router = buildRouter(); |
| 2164 | // No shell outlives its forge: a row a stop or crash left open is closed. | |
| 2165 | closeStaleSessions(app.db); | |
| 2166 | 2174 | const apiRouter = buildApiRouter(); |
| 2167 | 2175 | await clearStaleSockets(app); |
| 2168 | 2176 | // 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 | ||
| 127 | 127 | return { cols, rows }; |
| 128 | 128 | } |
| 129 | 129 | |
| 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 | ||
| 130 | 152 | // ---- the records ---- |
| 131 | 153 | |
| 132 | 154 | export type EndReason = "exit" | "idle" | "stopped" | "revoked" | "failed"; |
| @@ -205,6 +227,8 @@ export interface ShellSocket { | ||
| 205 | 227 | readonly shell: ShellGrant & { readonly app: App }; |
| 206 | 228 | joined?: Session; |
| 207 | 229 | release?: () => void; |
| 230 | /** This connection has sent a size; its first one redraws a joined screen (`resizePlan`). */ | |
| 231 | sized?: boolean; | |
| 208 | 232 | } |
| 209 | 233 | |
| 210 | 234 | type Peer = Bun.ServerWebSocket<ShellSocket>; |
| @@ -217,6 +241,8 @@ interface Session { | ||
| 217 | 241 | readonly terminal: Bun.Terminal; |
| 218 | 242 | readonly program: TerminalProgram; |
| 219 | 243 | readonly peers: Set<Peer>; |
| 244 | /** The pty's size, as last set. */ | |
| 245 | size: PtySize; | |
| 220 | 246 | lastInput: number; |
| 221 | 247 | ended: boolean; |
| 222 | 248 | readonly timer: ReturnType<typeof setInterval>; |
| @@ -387,6 +413,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro | ||
| 387 | 413 | terminal: term, |
| 388 | 414 | program, |
| 389 | 415 | peers, |
| 416 | size: { cols, rows }, | |
| 390 | 417 | lastInput: Date.now(), |
| 391 | 418 | ended: false, |
| 392 | 419 | timer: setInterval( |
| @@ -460,7 +487,16 @@ export const shellWebSocket = { | ||
| 460 | 487 | ws.close(1003, "a text frame is a resize"); |
| 461 | 488 | return; |
| 462 | 489 | } |
| 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); | |
| 464 | 500 | return; |
| 465 | 501 | } |
| 466 | 502 | if (message.byteLength > MAX_INPUT_BYTES) { |
src/main.ts +5
| @@ -98,6 +98,7 @@ import { stallLine } from "./perf/loop.ts"; | ||
| 98 | 98 | import { bootStall, gitRecord, loopWatch, perfRecord } from "./perf/live.ts"; |
| 99 | 99 | import { timeStatements } from "./kernel/slow_sql.ts"; |
| 100 | 100 | import { waitForPeer } from "./runs/peer.ts"; |
| 101 | import { closeStaleSessions } from "./machines/shell.ts"; | |
| 101 | 102 | |
| 102 | 103 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| 103 | 104 | // running forge over its socket and exits — it must never boot a server. |
| @@ -425,6 +426,10 @@ const earlySignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; | ||
| 425 | 426 | for (const signal of earlySignals) process.once(signal, leaveEarly); |
| 426 | 427 | await waitForPeer(app.db, app.log); |
| 427 | 428 | 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); | |
| 428 | 433 | // A repository's own check, on every push a pull or the default branch takes. |
| 429 | 434 | const checks = |
| 430 | 435 | process.env.FORGE_CHECKS === "0" |
src/web/asset.ts +3 −3
| @@ -76,7 +76,7 @@ async function build(): Promise<BuiltAsset | null> { | ||
| 76 | 76 | * other response. Only hashed assets come through here, so only they are |
| 77 | 77 | * immutable. |
| 78 | 78 | */ |
| 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 { | |
| 80 | 80 | const headers: Record<string, string> = { |
| 81 | 81 | "content-type": type, |
| 82 | 82 | "cache-control": "public, max-age=31536000, immutable", |
| @@ -87,8 +87,8 @@ export function staticResponse(request: Request, body: string, type: string, eta | ||
| 87 | 87 | if (matches(request.headers.get("if-none-match"), etag)) |
| 88 | 88 | return new Response(null, { status: 304, headers }); |
| 89 | 89 | 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 }); | |
| 92 | 92 | // Hashed, so the encoded bytes are as immutable as the plain ones: made once. |
| 93 | 93 | return new Response(encodeOnce(etag, bytes, encoding), { headers: { ...headers, "content-encoding": encoding } }); |
| 94 | 94 | } |
src/web/layout.ts +8 −1
| @@ -135,6 +135,13 @@ export interface PageOptions { | ||
| 135 | 135 | * origin" — so pages that do not ask for it keep the stricter policy. |
| 136 | 136 | */ |
| 137 | 137 | 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; | |
| 138 | 145 | /** |
| 139 | 146 | * The page draws a PDF from the media route in an `<object>`: the browser's |
| 140 | 147 | * 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 { | ||
| 861 | 868 | // The hashed stylesheet and scripts are 'self'; the two inline <style>s |
| 862 | 869 | // are named by hash. connect-src 'self' lets the live event stream |
| 863 | 870 | // (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'`, | |
| 865 | 872 | "referrer-policy": "same-origin", |
| 866 | 873 | "x-content-type-options": "nosniff", |
| 867 | 874 | // 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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'`). | |
| 6 | 13 | */ |
| 7 | 14 | |
| 8 | 15 | import type { Machine } from "../../crew/machines.ts"; |
| @@ -11,12 +18,37 @@ import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from | ||
| 11 | 18 | import { repoMachines } from "../../crew/machines_read.ts"; |
| 12 | 19 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 20 | 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, | |
| 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. |
||
| 15 | 32 | import { page } from "../layout.ts"; |
| 16 | 33 | 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"; | |
| 18 | 36 | import { settingsPageResponse } from "./settings.ts"; |
| 19 | 37 | |
| 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 | } | |
| 20 | 52 | |
| 21 | 53 | export async function machinesSettingsPage(ctx: Ctx): Promise<Response> { |
| 22 | 54 | nameFrame(ctx.request); |
| @@ -34,17 +66,18 @@ function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readon | ||
| 34 | 66 | html`<p class="muted"> |
| 35 | 67 | A machine is where the crew's shell runs. A writer can open a shell into one; each |
| 36 | 68 | 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. | |
| 38 | 70 | </p> |
| 39 | 71 | <div class="table-scroll"> |
| 40 | 72 | <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> | |
| 42 | 74 | <tbody> |
| 43 | 75 | ${list.map( |
| 44 | 76 | (m) => html`<tr> |
| 45 | 77 | <td><strong>${m.name}</strong></td> |
| 46 | 78 | <td class="muted">${m.tools.length === 0 ? "none" : m.tools.join(", ")}</td> |
| 47 | 79 | <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> | |
| 48 | 81 | </tr>`, |
| 49 | 82 | )} |
| 50 | 83 | </tbody> |
| @@ -87,3 +120,41 @@ function render(ctx: Ctx, repo: Repo, list: readonly Machine[], sessions: readon | ||
| 87 | 120 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })}${body}`, |
| 88 | 121 | }); |
| 89 | 122 | } |
| 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 filetest/cloud-accounts-etag.test.ts +4 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** 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"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| @@ -62,6 +62,9 @@ beforeAll(async () => { | ||
| 62 | 62 | if (!added.ok) throw new Error(String(added.error)); |
| 63 | 63 | }, 30_000); |
| 64 | 64 | 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(); | |
| 65 | 68 | servers.stop(); |
| 66 | 69 | app.close(); |
| 67 | 70 | 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 () => { | ||
| 59 | 59 | if (!shut.ok) throw new Error(String(shut.error)); |
| 60 | 60 | // A row a crashed forge left open, closed when this one boots. |
| 61 | 61 | 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); | |
| 62 | 64 | servers = await serve(app); |
| 63 | 65 | base = servers.url.replace(/^http/, "ws"); |
| 64 | 66 | }); |
| @@ -166,6 +168,36 @@ test("a writer's session echoes input, applies a resize, reattaches, and is reco | ||
| 166 | 168 | expect(liveSessions(app)).toBe(0); |
| 167 | 169 | }); |
| 168 | 170 | |
| 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 | ||
| 169 | 201 | test("a text frame that is not a resize ends the connection", async () => { |
| 170 | 202 | const { ws } = await open("alice"); |
| 171 | 203 | 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"; | ||
| 28 | 28 | quietLogs(); |
| 29 | 29 | |
| 30 | 30 | /** 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"]); | |
| 32 | 32 | |
| 33 | 33 | const ORIGIN = "needs a connected origin account and a mirror job; the seed connects none"; |
| 34 | 34 | const MIGRATE = "needs a migration job, which only an import creates"; |
| @@ -156,7 +156,7 @@ beforeAll(async () => { | ||
| 156 | 156 | if (!inserted.ok) throw new Error(inserted.error); |
| 157 | 157 | Object.assign(fill, { |
| 158 | 158 | 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", | |
| 160 | 160 | issue: String(issue.value.number), pull: String(pull.value.number), run: run.id, |
| 161 | 161 | }); |
| 162 | 162 | 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 | ||
| 40 | 40 | put("src/a.ts", 'export const load = () => import(\n "./b"\n);\n'); |
| 41 | 41 | put("src/b/index.ts", "export const t = () => import(`./tpl`);\n"); |
| 42 | 42 | 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"); | |
| 45 | 47 | put("src/d.ts", 'import "./c.ts";\n'); |
| 46 | 48 | put("src/e.ts"); |
| 47 | 49 | 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 | ||
| 49 | 51 | put("src/core/migrations/001.ts"); |
| 50 | 52 | put("src/f.ts"); |
| 51 | 53 | 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"]); | |
| 53 | 55 | } finally { |
| 54 | 56 | rmSync(dir, { recursive: true, force: true }); |
| 55 | 57 | } |
Second import from ../chrome.ts; merge it with the one above.