| 1 | # @clo/terminal |
| 2 | |
| 3 | Browser-pure terminal emulation on the vendored libghostty-vt WebAssembly core. The package includes a headless terminal, a native-selection DOM renderer, thin React and Solid adapters, explicit CSS, pinned fonts, and the wasm artifact. Transport remains caller-owned: pty bytes go in, terminal-bound bytes come out. |
| 4 | |
| 5 | ## Install |
| 6 | |
| 7 | ```sh |
| 8 | pnpm add @clo/terminal |
| 9 | ``` |
| 10 | |
| 11 | Add `react` or `solid-js` only when using that adapter. Import the renderer stylesheet once: |
| 12 | |
| 13 | ```ts |
| 14 | import "@clo/terminal/terminal.css"; |
| 15 | ``` |
| 16 | |
| 17 | Vite and compatible bundlers can resolve the exported wasm as a URL: |
| 18 | |
| 19 | ```ts |
| 20 | import { GhosttyModule } from "@clo/terminal"; |
| 21 | import wasmUrl from "@clo/terminal/ghostty-vt.wasm?url"; |
| 22 | |
| 23 | const module = await GhosttyModule.fetch(wasmUrl); |
| 24 | ``` |
| 25 | |
| 26 | `GhosttyModule.load(bytes)` remains available when the host owns artifact loading. One module can host many terminals. |
| 27 | |
| 28 | ## Vanilla DOM |
| 29 | |
| 30 | ```ts |
| 31 | import { mountTerminal } from "@clo/terminal"; |
| 32 | |
| 33 | const terminal = mountTerminal(document.querySelector("#terminal")!, module, { |
| 34 | onData: (bytes) => socket.send(bytes), |
| 35 | onResize: ({ cols, rows }) => resizePty(cols, rows), |
| 36 | }); |
| 37 | |
| 38 | socket.binaryType = "arraybuffer"; |
| 39 | const receive = ({ data }: MessageEvent<string | ArrayBuffer>) => |
| 40 | terminal.write(typeof data === "string" ? data : new Uint8Array(data)); |
| 41 | socket.addEventListener("message", receive); |
| 42 | await terminal.ready; |
| 43 | |
| 44 | // Later: |
| 45 | socket.removeEventListener("message", receive); |
| 46 | terminal.dispose(); |
| 47 | ``` |
| 48 | |
| 49 | `write` parses pty output and schedules a DOM frame. `onData` combines keystrokes, paste, mouse reports, and terminal query responses so all terminal-bound bytes use one transport path. |
| 50 | |
| 51 | ## React |
| 52 | |
| 53 | ```tsx |
| 54 | import { TerminalPane } from "@clo/terminal/react"; |
| 55 | |
| 56 | <TerminalPane |
| 57 | module={module} |
| 58 | onData={(bytes) => socket.send(bytes)} |
| 59 | onResize={({ cols, rows }) => resizePty(cols, rows)} |
| 60 | onReady={(handle) => { |
| 61 | socket.binaryType = "arraybuffer"; |
| 62 | const receive = ({ data }: MessageEvent<string | ArrayBuffer>) => |
| 63 | handle.write(typeof data === "string" ? data : new Uint8Array(data)); |
| 64 | socket.addEventListener("message", receive); |
| 65 | return () => socket.removeEventListener("message", receive); |
| 66 | }} |
| 67 | />; |
| 68 | ``` |
| 69 | |
| 70 | ## Solid |
| 71 | |
| 72 | ```tsx |
| 73 | import { GhosttyModule } from "@clo/terminal"; |
| 74 | import { Show, createSignal } from "solid-js"; |
| 75 | import { TerminalPane } from "@clo/terminal/solid"; |
| 76 | |
| 77 | const [module, setModule] = createSignal<GhosttyModule>(); |
| 78 | void GhosttyModule.fetch(wasmUrl).then(setModule); |
| 79 | |
| 80 | <Show when={module()}> |
| 81 | {(loaded) => ( |
| 82 | <TerminalPane |
| 83 | module={loaded()} |
| 84 | onData={(bytes) => socket.send(bytes)} |
| 85 | onResize={({ cols, rows }) => resizePty(cols, rows)} |
| 86 | onReady={(handle) => { |
| 87 | socket.binaryType = "arraybuffer"; |
| 88 | const receive = ({ data }: MessageEvent<string | ArrayBuffer>) => |
| 89 | handle.write(typeof data === "string" ? data : new Uint8Array(data)); |
| 90 | socket.addEventListener("message", receive); |
| 91 | return () => socket.removeEventListener("message", receive); |
| 92 | }} |
| 93 | /> |
| 94 | )} |
| 95 | </Show>; |
| 96 | ``` |
| 97 | |
| 98 | Both framework components own disposal and remount only when `module` changes. Geometry, parser, and renderer options are mount-time values; callback props remain live. `AnsiText` in each adapter renders a static escaped ANSI snapshot without creating an emulator. |
| 99 | |
| 100 | ## Public API |
| 101 | |
| 102 | | Entry | Surface | |
| 103 | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 104 | | `@clo/terminal` | `GhosttyModule`, `mountTerminal`, `Terminal`, `TerminalView`, frame/cell types, ANSI conversion, link detection, key mapping, run helpers, predictive-echo engine, and shell-mark/command utilities | |
| 105 | | `@clo/terminal/react` | `TerminalPane`, `AnsiText`, and their props | |
| 106 | | `@clo/terminal/solid` | `TerminalPane`, `AnsiText`, and their props | |
| 107 | | `@clo/terminal/terminal.css` | DOM renderer structure, pinned font faces, cursor/blink/scrollbar behavior, and CSS theme seams | |
| 108 | | `@clo/terminal/ghostty-vt.wasm` | pinned libghostty-vt artifact for caller-controlled loading | |
| 109 | |
| 110 | ### Mounted handle |
| 111 | |
| 112 | `mountTerminal` and adapter `onReady` callbacks expose: |
| 113 | |
| 114 | - `write(data)` feeds pty output and schedules paint. |
| 115 | - `ready` resolves after initial fonts, measurement, fit, and resize reporting settle. |
| 116 | - `terminal` exposes headless state, scrollback, input encoders, snapshots, title/pwd, and shell command history. |
| 117 | - `view` exposes scheduling, fit, resize observation, metric refresh, inspection, and direct paint controls. |
| 118 | - `dispose()` releases the renderer lease and wasm resources; framework adapters call it automatically. |
| 119 | |
| 120 | ### Mount options |
| 121 | |
| 122 | The high-level mount and both framework adapters share the same options: initial `cols`/`rows`, byte-bounded scrollback, shell integration and command retention, scrollbar/scrollback/mouse/sticky behavior, links, predictive echo, resize, title/pwd/bell/shell-command events, clipboard policy, and the unified `onData` transport callback. |
| 123 | |
| 124 | ### Headless and advanced APIs |
| 125 | |
| 126 | `Terminal` can run without the DOM. `snapshot()` is a destructive dirty-state read; use `acquireFrameReader()` when attaching a renderer and release it before another consumer. `TerminalView` is exported for custom lifecycle ownership. Pure utilities cover ANSI-to-HTML, canonical cells/runs, URL/path/OSC 8 links, DOM key translation, OSC scanning, shell-mark parsing, command tracking, and sticky-command selection. |
| 127 | |
| 128 | ## Predictive echo |
| 129 | |
| 130 | Over a remote pty, every keystroke waits a round trip before a glyph appears. `predict` paints typed characters speculatively, in the manner of [mosh](https://mosh.org), and reconciles them against real output as it arrives. |
| 131 | |
| 132 | ```ts |
| 133 | mountTerminal(element, module, { |
| 134 | onData: (bytes) => socket.send(bytes), |
| 135 | predict: { mode: "prompt", minLatencyMs: 25 }, |
| 136 | }); |
| 137 | ``` |
| 138 | |
| 139 | `predict: true` is shorthand for the defaults. It is **off** unless you ask for it. |
| 140 | |
| 141 | Unlike mosh, this does not guess whether the far end echoes — it requires proof. Predictions run only inside a shell-integration readline region (OSC 133/633 `B` seen, `C` not yet), so an uninstrumented shell, a `sh` session, and every full-screen application predict nothing at all. Within that region only single-column printable ASCII is predicted, and backspace only un-types the engine's own predictions. Wide characters, combining marks, line wraps, paste, mouse input, control chords, and resize all drop the outstanding speculation rather than extend it, and any contradiction drops all of it. |
| 142 | |
| 143 | Display is adaptive. Predictions are recorded from the first keystroke but painted only once the smoothed round trip reaches `minLatencyMs` (default 25ms), so a local pty never shows one. Set `minLatencyMs: 0` to predict immediately. |
| 144 | |
| 145 | | Option | Default | Meaning | |
| 146 | | ---------------- | ------- | ---------------------------------------------------------- | |
| 147 | | `mode` | `off` | `off`, or `prompt` for the readline-region gate | |
| 148 | | `minLatencyMs` | `25` | Round trip below which predictions are recorded, not shown | |
| 149 | | `timeoutMs` | `1000` | Unconfirmed prediction lifetime | |
| 150 | | `underline` | `true` | Mark unconfirmed predictions as speculative | |
| 151 | | `maxPredictions` | `128` | Cap on outstanding predictions | |
| 152 | |
| 153 | Speculative glyphs render as `.clo-term-run.predicted` and are themed with `--clo-term-predicted-opacity` and `--clo-term-predicted-underline`. They never enter terminal state: `Terminal.text()`, snapshots, and the command model only ever describe what the pty actually sent. `view.predictions` exposes the engine for diagnostics, including the measured `latencyMs`. |
| 154 | |
| 155 | `PredictionEngine` is also exported standalone. It is pure — plain screen data in, an overlay out, time supplied as an argument — so a different renderer can reuse it. |
| 156 | |
| 157 | ## Styling |
| 158 | |
| 159 | Set theme values on the mounted element or an ancestor: |
| 160 | |
| 161 | ```css |
| 162 | .my-terminal { |
| 163 | --clo-term-bg: #11111b; |
| 164 | --clo-term-fg: #cdd6f4; |
| 165 | --clo-term-cursor: #f5e0dc; |
| 166 | --clo-term-font-size: 14px; |
| 167 | --clo-term-line-height: 1.35; |
| 168 | --clo-term-scrollbar-thumb: rgb(205 214 244 / 28%); |
| 169 | } |
| 170 | ``` |
| 171 | |
| 172 | Call `handle.view.refreshMetrics()` after changing a loaded font family or metric-affecting CSS variable. |
| 173 | |
| 174 | ## Security |
| 175 | |
| 176 | Links are inert unless `links.onActivate` is supplied. Clipboard writes are denied unless `onClipboardWrite` synchronously returns `true`; the package never reads the clipboard. The terminal performs no navigation, socket, pty, filesystem, Node, Electron, or VS Code work. |