diff --git a/crates/bashkit-wasm/Cargo.toml b/crates/bashkit-wasm/Cargo.toml index 792c77fea..7c2cddda9 100644 --- a/crates/bashkit-wasm/Cargo.toml +++ b/crates/bashkit-wasm/Cargo.toml @@ -26,6 +26,13 @@ authors.workspace = true repository.workspace = true publish = false +[features] +# Interactive `Terminal` class (keystrokes in, escape-sequence bytes out) for +# xterm.js-style renderers. Off by default for crate users, like +# `bashkit/terminal`; `scripts/build.sh` turns it on for the npm package because +# it costs ~60 KB gzipped and stays inert until a page constructs `Terminal`. +terminal = ["bashkit/terminal"] + [lib] crate-type = ["cdylib", "rlib"] diff --git a/crates/bashkit-wasm/README.md b/crates/bashkit-wasm/README.md index b3c241041..9a16cba49 100644 --- a/crates/bashkit-wasm/README.md +++ b/crates/bashkit-wasm/README.md @@ -113,6 +113,46 @@ as the `Bash` VFS helpers below. - `executeWithOutput(cmd, callback)`, async execution plus incremental `(stdout, stderr)` chunks. The returned `ExecResult` remains authoritative. +## Interactive terminal + +`Terminal` runs an interactive shell session on an in-memory terminal: you +forward keystrokes, it returns escape-sequence bytes for a renderer such as +[xterm.js](https://xtermjs.org). `vi`, `less` and `more` run full-screen. Try it +on the [bashkit.sh playground](https://bashkit.sh/playground). + +```js +import { Terminal as XTerm } from "@xterm/xterm"; +import { initBashkit, Terminal } from "@everruns/bashkit-wasm"; + +await initBashkit(); +const xterm = new XTerm(); +xterm.open(document.getElementById("term")); + +const term = new Terminal({ rows: xterm.rows, cols: xterm.cols, cwd: "/home/user" }); +let running = false; +let again = false; +async function pump() { + if (running) return void (again = true); + running = true; + do { + again = false; + await term.runUntilIdle(); // { status: "idle" } or { status: "exited", exitCode } + xterm.write(term.takeOutput()); + } while (again); + running = false; +} +xterm.onData((keys) => { term.send(keys); pump(); }); +xterm.onResize(({ rows, cols }) => term.resize(rows, cols)); +pump(); +``` + +`runUntilIdle()` resolves once the session waits for input; keystrokes sent +while a command runs are queued. `screenText()` returns the visible screen as +plain text and `fs()` reads what `vi` saved. `Terminal` takes every `Bash` +option plus `rows` and `cols`. It is `undefined` in custom builds made with +`BASHKIT_WASM_FEATURES=""`. See the [terminal guide](https://bashkit.sh/docs/terminal/) +for the line editor, `vi` keys and limits. + ## Options ```ts diff --git a/crates/bashkit-wasm/__test__/terminal.test.mjs b/crates/bashkit-wasm/__test__/terminal.test.mjs new file mode 100644 index 000000000..7fd60cb12 --- /dev/null +++ b/crates/bashkit-wasm/__test__/terminal.test.mjs @@ -0,0 +1,64 @@ +// Terminal (xterm.js-style) session tests. The bundle exposes `Terminal` only +// when built with the `terminal` cargo feature, which scripts/build.sh enables +// by default. With BASHKIT_WASM_FEATURES="" `Terminal` is undefined and these +// tests skip. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { initBashkit, Terminal } from "../pkg/index.js"; + +await initBashkit( + readFileSync(fileURLToPath(new URL("../pkg/bashkit_wasm_bg.wasm", import.meta.url))), +); + +const skip = Terminal === undefined && "built without the terminal feature"; +const decoder = new TextDecoder(); + +test("terminal: prompt, echo and output bytes", { skip }, async () => { + const term = new Terminal({ rows: 10, cols: 40 }); + assert.deepEqual(await term.runUntilIdle(), { status: "idle" }); + term.send("echo hi | tr a-z A-Z\r"); + assert.deepEqual(await term.runUntilIdle(), { status: "idle" }); + assert.equal(term.screenText(), "$ echo hi | tr a-z A-Z\nHI\n$"); + const out = decoder.decode(term.takeOutput()); + assert.match(out, /HI\r\n/); + assert.equal(term.takeOutput().length, 0, "output is drained"); +}); + +test("terminal: vi edits a VFS file", { skip }, async () => { + const term = new Terminal(); + term.send("vi /tmp/n.txt\r"); + await term.runUntilIdle(); + assert.equal(term.isAlternateScreen(), true); + term.send("ihello\x1b:wq\r"); + await term.runUntilIdle(); + assert.equal(term.isAlternateScreen(), false); + assert.equal(term.fs().readFile("/tmp/n.txt"), "hello\n"); +}); + +test("terminal: options, resize and exit", { skip }, async () => { + const term = new Terminal({ cols: 50, files: { "/data/a.txt": "seeded\n" } }); + term.resize(12, 60); + assert.equal(term.rows, 12); + assert.equal(term.cols, 60); + term.send("cat /data/a.txt; echo $COLUMNS\r"); + await term.runUntilIdle(); + assert.match(term.screenText(), /seeded\n60/); + term.send("exit 4\r"); + assert.deepEqual(await term.runUntilIdle(), { status: "exited", exitCode: 4 }); + assert.equal(term.exitCode, 4); +}); + +test("terminal: keystrokes sent mid-command are queued", { skip }, async () => { + const term = new Terminal(); + await term.runUntilIdle(); + term.send("sleep 0.05; echo done\r"); + const running = term.runUntilIdle(); + term.send("echo next\r"); + await running; + await term.runUntilIdle(); + assert.match(term.screenText(), /done\n\$ echo next\nnext\n\$$/); +}); diff --git a/crates/bashkit-wasm/js/index.d.ts b/crates/bashkit-wasm/js/index.d.ts index 63595ca8c..e306fece0 100644 --- a/crates/bashkit-wasm/js/index.d.ts +++ b/crates/bashkit-wasm/js/index.d.ts @@ -249,6 +249,45 @@ export class Bash { ls(path: string): string[]; } +/** Options for constructing a {@link Terminal}. */ +export interface TerminalOptions extends BashOptions { + /** Rows (default 24, min 2). */ + rows?: number; + /** Columns (default 80, min 2). */ + cols?: number; +} + +/** Result of {@link Terminal.runUntilIdle}. */ +export type TerminalStatus = + | { status: "idle" } + | { status: "exited"; exitCode: number }; + +/** + * Interactive bash session on an in-memory terminal: keystrokes in, + * escape-sequence bytes out, for renderers such as xterm.js. Runs `vi`, + * `less` and `more` full-screen. + */ +export class Terminal { + constructor(options?: TerminalOptions); + /** Queue keystrokes (`\r` Enter, `\x03` Ctrl-C). Returns bytes accepted. */ + send(input: string): number; + sendBytes(input: Uint8Array): number; + /** Run until the session waits for input or the shell exits. */ + runUntilIdle(): Promise; + /** Drain output bytes produced since the last call. */ + takeOutput(): Uint8Array; + /** Visible screen as plain text. */ + screenText(): string; + resize(rows: number, cols: number): void; + readonly rows: number; + readonly cols: number; + isAlternateScreen(): boolean; + /** Exit code once the shell has exited. */ + readonly exitCode: number | undefined; + /** Live handle to the session's virtual filesystem. */ + fs(): FileSystem; +} + /** * Initialize the WebAssembly module. Must resolve before constructing `Bash`. * Idempotent. diff --git a/crates/bashkit-wasm/js/index.js b/crates/bashkit-wasm/js/index.js index 2537f7745..40064244d 100644 --- a/crates/bashkit-wasm/js/index.js +++ b/crates/bashkit-wasm/js/index.js @@ -5,7 +5,15 @@ // so it loads correctly whether served from a CDN, a bundler, or a plain // + + diff --git a/site/src/playground/client.ts b/site/src/playground/client.ts new file mode 100644 index 000000000..e12412b36 --- /dev/null +++ b/site/src/playground/client.ts @@ -0,0 +1,352 @@ +// Playground runtime: xterm.js wired to @everruns/bashkit-wasm. +// +// Decisions: +// - Loaded only by /playground through a dynamic import, so xterm.js and the +// WebAssembly module never touch any other page's load. +// - Two modes. When the installed package exports `Terminal` (bundle built +// with the `terminal` cargo feature), keystrokes go straight to an in-memory +// PTY and full-screen programs (vi, less, more) work. Older packages lack it, +// so we fall back to a small line editor over `Bash.execute`. The fallback +// goes away once the site pins a release that ships `Terminal`. +// - The session is pull-driven: after each keystroke batch we await +// `runUntilIdle()` and flush `takeOutput()` into xterm. While a command +// runs, output is flushed every animation frame so `sleep` loops stream. + +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import * as bashkit from "@everruns/bashkit-wasm"; + +export interface Playground { + /** "terminal" (full PTY) or "line" (fallback line editor). */ + mode: "terminal" | "line"; + /** Type a command as if the user entered it. */ + run(command: string): void; + focus(): void; +} + +const THEME = { + background: "#0d1117", + foreground: "#d1d5db", + cursor: "#3fb950", + selectionBackground: "#264f78", + black: "#484f58", + red: "#ff7b72", + green: "#3fb950", + yellow: "#d29922", + blue: "#58a6ff", + magenta: "#bc8cff", + cyan: "#39c5cf", + white: "#b1bac4", + brightBlack: "#6e7681", + brightRed: "#ffa198", + brightGreen: "#56d364", + brightYellow: "#e3b341", + brightBlue: "#79c0ff", + brightMagenta: "#d2a8ff", + brightCyan: "#56d4dd", + brightWhite: "#f0f6fc", +}; + +const SEED_FILES: Record = { + "/home/user/README.md": [ + "# Welcome to the Bashkit playground", + "", + "Everything here runs in your browser: a sandboxed bash", + "interpreter compiled to WebAssembly, with a virtual filesystem.", + "Nothing you type leaves this page.", + "", + "Try:", + " ls -la", + " cat data.json | jq '.tools[] | select(.stars > 2) | .name'", + " vi notes.txt # :wq to save, :q! to quit", + " seq 1 200 | less # q to quit", + "", + ].join("\n"), + "/home/user/data.json": + JSON.stringify( + { + tools: [ + { name: "grep", stars: 5 }, + { name: "awk", stars: 4 }, + { name: "sed", stars: 3 }, + { name: "jq", stars: 5 }, + { name: "ed", stars: 1 }, + ], + }, + null, + 2, + ) + "\n", +}; + +const BASH_OPTIONS = { + username: "user", + hostname: "bashkit", + cwd: "/home/user", + env: { HOME: "/home/user", PS1: "\\u@\\h:\\w\\$ " }, + files: SEED_FILES, +}; + +const BANNER = + "\x1b[1;32mbashkit\x1b[0m playground\r\n" + + "\x1b[2mSandboxed bash in WebAssembly.\x1b[0m\r\n" + + "Type \x1b[1mcat README.md\x1b[0m to get started.\r\n\r\n"; + +export async function startPlayground(mount: HTMLElement): Promise { + await bashkit.initBashkit(); + + const xterm = new XTerm({ + theme: THEME, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 14, + cursorBlink: true, + convertEol: false, + scrollback: 2000, + }); + const fit = new FitAddon(); + xterm.loadAddon(fit); + mount.replaceChildren(); + xterm.open(mount); + fit.fit(); + xterm.write(BANNER); + + const TerminalCtor = (bashkit as Record).Terminal as + | (new (options: object) => WasmTerminal) + | undefined; + const playground = TerminalCtor + ? attachTerminal(xterm, new TerminalCtor({ ...BASH_OPTIONS, rows: xterm.rows, cols: xterm.cols })) + : attachLineEditor(xterm, new bashkit.Bash(BASH_OPTIONS)); + + const observer = new ResizeObserver(() => fit.fit()); + observer.observe(mount); + xterm.focus(); + return playground; +} + +// --- Full PTY mode --------------------------------------------------------- + +interface WasmTerminal { + send(input: string): number; + runUntilIdle(): Promise<{ status: "idle" } | { status: "exited"; exitCode: number }>; + takeOutput(): Uint8Array; + resize(rows: number, cols: number): void; +} + +function attachTerminal(xterm: XTerm, term: WasmTerminal): Playground { + let running = false; + let again = false; + let exited = false; + + const flush = () => { + const bytes = term.takeOutput(); + if (bytes.length > 0) xterm.write(bytes); + }; + + async function pump() { + if (running) { + again = true; + return; + } + running = true; + let frame = 0; + const tick = () => { + flush(); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + try { + do { + again = false; + const status = await term.runUntilIdle(); + if (status.status === "exited") { + exited = true; + flush(); + xterm.write( + `\r\n\x1b[2m[session exited with code ${status.exitCode}; reload the page to start again]\x1b[0m\r\n`, + ); + break; + } + } while (again); + } finally { + cancelAnimationFrame(frame); + flush(); + running = false; + } + } + + xterm.onData((data) => { + if (exited) return; + term.send(data); + void pump(); + }); + xterm.onResize(({ rows, cols }) => { + term.resize(rows, cols); + void pump(); + }); + void pump(); + + return { + mode: "terminal", + run(command) { + if (exited) return; + // Ctrl-U clears whatever is half-typed on the prompt first. + term.send(`\x15${command}\r`); + void pump(); + }, + focus: () => xterm.focus(), + }; +} + +// --- Fallback line-editor mode --------------------------------------------- + +interface WasmBash { + execute(commands: string): Promise<{ stdout: string; stderr: string }>; +} + +function attachLineEditor(xterm: XTerm, bash: WasmBash): Playground { + const history: string[] = []; + let historyIndex = 0; + let line = ""; + // Cursor position within `line`, in characters. + let cursor = 0; + let busy = false; + + const crlf = (s: string) => s.replace(/\r?\n/g, "\r\n"); + + async function prompt() { + let ps1 = "$ "; + try { + const r = await bash.execute("pwd"); + const cwd = r.stdout.trim().replace(/^\/home\/user(?=\/|$)/, "~"); + ps1 = `\x1b[1;32muser@bashkit\x1b[0m:\x1b[1;34m${cwd}\x1b[0m$ `; + } catch { + // keep the plain prompt + } + xterm.write(ps1); + } + + // Redraw the edited line in place: back to its start, rewrite it, clear + // what's left of the old text, then put the cursor where it belongs. + function render(next: string, nextCursor: number) { + let out = "\b".repeat(cursor) + next + "\x1b[K"; + const back = next.length - nextCursor; + if (back > 0) out += `\x1b[${back}D`; + xterm.write(out); + line = next; + cursor = nextCursor; + } + + const replaceLine = (next: string) => render(next, next.length); + const insert = (text: string) => + render(line.slice(0, cursor) + text + line.slice(cursor), cursor + text.length); + + async function submit(command: string) { + xterm.write("\r\n"); + if (command.trim() !== "") { + history.push(command); + busy = true; + try { + const r = await bash.execute(command); + xterm.write(crlf(r.stdout)); + if (r.stderr) xterm.write(`\x1b[31m${crlf(r.stderr)}\x1b[0m`); + // Keep the next prompt on its own line when output lacks a final + // newline (`printf abc`, or stderr from older packages). + const tail = r.stderr || r.stdout; + if (tail && !tail.endsWith("\n")) xterm.write("\r\n"); + } catch (err) { + xterm.write(`\x1b[31m${crlf(String(err))}\x1b[0m\r\n`); + } + busy = false; + } + historyIndex = history.length; + line = ""; + cursor = 0; + await prompt(); + } + + // Escape sequences (arrows, Home/End, Delete) arrive as one chunk. + function handleEscape(seq: string) { + switch (seq) { + case "\x1b[A": // Up: older history + if (historyIndex > 0) replaceLine(history[--historyIndex]); + break; + case "\x1b[B": // Down: newer history + if (historyIndex < history.length) replaceLine(history[++historyIndex] ?? ""); + break; + case "\x1b[D": // Left + if (cursor > 0) render(line, cursor - 1); + break; + case "\x1b[C": // Right + if (cursor < line.length) render(line, cursor + 1); + break; + case "\x1b[H": + case "\x1bOH": + case "\x1b[1~": // Home + render(line, 0); + break; + case "\x1b[F": + case "\x1bOF": + case "\x1b[4~": // End + render(line, line.length); + break; + case "\x1b[3~": // Delete + if (cursor < line.length) render(line.slice(0, cursor) + line.slice(cursor + 1), cursor); + break; + } + } + + xterm.onData((data) => { + if (busy) return; + if (data.startsWith("\x1b")) { + handleEscape(data); + return; + } + let text = ""; + const flushText = () => { + if (text) insert(text); + text = ""; + }; + for (const ch of data) { + if (ch >= " " && ch !== "\x7f") { + text += ch; + continue; + } + flushText(); + if (ch === "\r") { + void submit(line); + return; + } + if (ch === "\x7f" || ch === "\b") { + if (cursor > 0) render(line.slice(0, cursor - 1) + line.slice(cursor), cursor - 1); + } else if (ch === "\x03") { + xterm.write("^C\r\n"); + line = ""; + cursor = 0; + void prompt(); + } else if (ch === "\x01") { + render(line, 0); // Ctrl-A + } else if (ch === "\x05") { + render(line, line.length); // Ctrl-E + } else if (ch === "\x0b") { + render(line.slice(0, cursor), cursor); // Ctrl-K + } else if (ch === "\x15") { + render(line.slice(cursor), 0); // Ctrl-U + } else if (ch === "\x0c") { + xterm.clear(); + } + } + flushText(); + }); + + void prompt(); + + return { + mode: "line", + run(command) { + if (busy) return; + replaceLine(command); + void submit(command); + }, + focus: () => xterm.focus(), + }; +}