From c3a87cf70787c999ad549bb27fff2e8db741508a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 21:03:37 +0000 Subject: [PATCH 1/4] feat(wasm): expose Terminal in @everruns/bashkit-wasm Interactive terminal session (send / runUntilIdle / takeOutput / screenText / resize / fs) for xterm.js renderers, behind a new bashkit-wasm `terminal` cargo feature (off by default). The npm build enables it (~60 KB gzipped). Claude-Session: https://claude.ai/code/session_01SkbgQjVzwWzswNhV5YP8XL --- crates/bashkit-wasm/Cargo.toml | 7 + crates/bashkit-wasm/README.md | 40 +++++ .../bashkit-wasm/__test__/terminal.test.mjs | 64 +++++++ crates/bashkit-wasm/js/index.d.ts | 39 +++++ crates/bashkit-wasm/js/index.js | 10 +- crates/bashkit-wasm/scripts/build.sh | 5 +- crates/bashkit-wasm/src/lib.rs | 22 ++- crates/bashkit-wasm/src/terminal.rs | 160 ++++++++++++++++++ docs/terminal.md | 10 ++ knowledge/integrations/in-process-terminal.md | 9 +- knowledge/log.md | 1 + knowledge/runtimes/browser-package.md | 17 ++ 12 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 crates/bashkit-wasm/__test__/terminal.test.mjs create mode 100644 crates/bashkit-wasm/src/terminal.rs diff --git a/crates/bashkit-wasm/Cargo.toml b/crates/bashkit-wasm/Cargo.toml index 08b65b06f..954318c53 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..ae881ae7f --- /dev/null +++ b/site/src/playground/client.ts @@ -0,0 +1,300 @@ +// 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 = ""; + 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); + } + + function replaceLine(next: string) { + xterm.write("\b \b".repeat(line.length)); + line = next; + xterm.write(line); + } + + 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`); + } catch (err) { + xterm.write(`\x1b[31m${crlf(String(err))}\x1b[0m\r\n`); + } + busy = false; + } + historyIndex = history.length; + line = ""; + await prompt(); + } + + xterm.onData((data) => { + if (busy) return; + // Escape sequences (arrow keys etc.) arrive as one chunk. + if (data.startsWith("\x1b")) { + if (data === "\x1b[A" && historyIndex > 0) { + historyIndex -= 1; + replaceLine(history[historyIndex]); + } else if (data === "\x1b[B" && historyIndex < history.length) { + historyIndex += 1; + replaceLine(history[historyIndex] ?? ""); + } + return; + } + for (const ch of data) { + if (ch === "\r") { + void submit(line); + return; + } + if (ch === "\x7f" || ch === "\b") { + if (line.length > 0) { + line = line.slice(0, -1); + xterm.write("\b \b"); + } + } else if (ch === "\x03") { + xterm.write("^C\r\n"); + line = ""; + void prompt(); + } else if (ch === "\x15") { + replaceLine(""); + } else if (ch === "\x0c") { + xterm.clear(); + } else if (ch >= " ") { + line += ch; + xterm.write(ch); + } + } + }); + + void prompt(); + + return { + mode: "line", + run(command) { + if (busy) return; + replaceLine(command); + void submit(command); + }, + focus: () => xterm.focus(), + }; +} From c23e13351e8bf38c4917a3f99f5478dd3bb18d9a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 23:52:53 +0000 Subject: [PATCH 3/4] fix(site): playground line editor cursor keys and prompt placement Left/Right, Home/End, Delete, Ctrl-A/E/K/U now edit at the cursor, and the prompt starts on a new line when output lacks a trailing newline. Claude-Session: https://claude.ai/code/session_01SkbgQjVzwWzswNhV5YP8XL --- site/src/playground/client.ts | 90 +++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/site/src/playground/client.ts b/site/src/playground/client.ts index ae881ae7f..e12412b36 100644 --- a/site/src/playground/client.ts +++ b/site/src/playground/client.ts @@ -207,6 +207,8 @@ 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"); @@ -223,12 +225,21 @@ function attachLineEditor(xterm: XTerm, bash: WasmBash): Playground { xterm.write(ps1); } - function replaceLine(next: string) { - xterm.write("\b \b".repeat(line.length)); + // 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; - xterm.write(line); + 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() !== "") { @@ -238,6 +249,10 @@ function attachLineEditor(xterm: XTerm, bash: WasmBash): Playground { 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`); } @@ -245,45 +260,82 @@ function attachLineEditor(xterm: XTerm, bash: WasmBash): Playground { } 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; - // Escape sequences (arrow keys etc.) arrive as one chunk. if (data.startsWith("\x1b")) { - if (data === "\x1b[A" && historyIndex > 0) { - historyIndex -= 1; - replaceLine(history[historyIndex]); - } else if (data === "\x1b[B" && historyIndex < history.length) { - historyIndex += 1; - replaceLine(history[historyIndex] ?? ""); - } + 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 (line.length > 0) { - line = line.slice(0, -1); - xterm.write("\b \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") { - replaceLine(""); + render(line.slice(cursor), 0); // Ctrl-U } else if (ch === "\x0c") { xterm.clear(); - } else if (ch >= " ") { - line += ch; - xterm.write(ch); } } + flushText(); }); void prompt(); From 1ce9c01516c6c40b0c6764f3e7fb0e491a015a13 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 00:01:54 +0000 Subject: [PATCH 4/4] feat(terminal): cursor keys and history in the prompt line editor Left/Right, Home/End, ^A/^E, Delete, ^K and Up/Down history at the Terminal prompt. Also fix the playground's last row being clipped once the terminal scrolls (xterm now mounts in an unpadded box). Claude-Session: https://claude.ai/code/session_01SkbgQjVzwWzswNhV5YP8XL --- crates/bashkit/src/terminal/mod.rs | 291 +++++++++++++++--- docs/terminal.md | 6 +- knowledge/integrations/in-process-terminal.md | 8 +- site/src/pages/playground.astro | 12 +- 4 files changed, 267 insertions(+), 50 deletions(-) diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index 5b8487e0e..fb0a27b38 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -43,8 +43,8 @@ //! Zig/C toolchain, builds for every bashkit target. //! - Time blocked on terminal input is excluded from the execution timeout //! (TM-DOS-057): typing speed is not sandbox work. All other limits apply. -//! - Cooked mode is a small built-in line discipline (echo, backspace, ^U, ^W, -//! ^C, ^D). Command stdin is still a value fixed before the command starts +//! - Cooked mode is a small built-in line editor (echo, cursor keys, Up/Down +//! history, backspace/delete, ^U, ^K, ^W, ^C, ^D). Command stdin is still a value fixed before the command starts //! (L-CLI-002), so `read` does not block on the terminal; programs that need //! keystrokes (`vi`) read the device directly in raw mode. @@ -258,9 +258,10 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 if !bash.shell_state_view().env.contains_key("TERM") { bash.set_env("TERM", "xterm-256color"); } + let mut history = History::default(); loop { tty.write_cooked(prompt(&bash, "PS1", DEFAULT_PS1).as_bytes()); - let mut input = match read_line(&tty).await { + let mut input = match read_line(&tty, &history).await { LineRead::Line(line) => line, LineRead::Interrupt => continue, LineRead::Eof => { @@ -271,6 +272,7 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 if input.trim().is_empty() { continue; } + history.push(&input); last_exit = loop { let size = tty.size(); @@ -302,7 +304,7 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc) -> i3 break 2; } tty.write_cooked(prompt(&bash, "PS2", DEFAULT_PS2).as_bytes()); - match read_line(&tty).await { + match read_line(&tty, &history).await { LineRead::Line(next) => { input.push('\n'); input.push_str(&next); @@ -388,9 +390,142 @@ enum LineRead { Eof, } -/// Cooked-mode line editor: echo, backspace, ^U, ^W, ^C, ^D. -async fn read_line(tty: &Tty) -> LineRead { - let mut line = String::new(); +/// Most recent command lines kept for Up/Down recall. +const MAX_HISTORY: usize = 500; + +/// Command-line history for the cooked-mode editor (oldest first). +#[derive(Default)] +struct History(std::collections::VecDeque); + +impl History { + fn push(&mut self, line: &str) { + if self.0.back().is_some_and(|last| last == line) { + return; + } + if self.0.len() == MAX_HISTORY { + self.0.pop_front(); + } + self.0.push_back(line.to_string()); + } +} + +/// Keys the editor understands from CSI/SS3 escape sequences. +enum EscKey { + Up, + Down, + Left, + Right, + Home, + End, + Delete, + Other, +} + +/// Line being edited plus the cursor, as a char index. +struct LineEditor<'a> { + tty: &'a Tty, + chars: Vec, + cursor: usize, + bytes: usize, +} + +impl<'a> LineEditor<'a> { + fn new(tty: &'a Tty) -> Self { + Self { + tty, + chars: Vec::new(), + cursor: 0, + bytes: 0, + } + } + + fn text(&self) -> String { + self.chars.iter().collect() + } + + /// Redraw only what changed: step back to where the old and new text + /// first differ, write the new tail, erase leftovers, then step back to + /// the new cursor. Assumes one cell per char. + fn set(&mut self, chars: Vec, cursor: usize) { + let same = self + .chars + .iter() + .zip(&chars) + .take_while(|(a, b)| a == b) + .count() + .min(self.cursor); + let mut out = String::new(); + if self.cursor > same { + out.push_str(&format!("\x1b[{}D", self.cursor - same)); + } + out.extend(chars[same..].iter()); + out.push_str("\x1b[J"); + let back = chars.len().saturating_sub(cursor); + if back > 0 { + out.push_str(&format!("\x1b[{back}D")); + } + self.tty.write(out.as_bytes()); + self.bytes = chars.iter().map(|c| c.len_utf8()).sum(); + self.chars = chars; + self.cursor = cursor; + } + + fn move_to(&mut self, cursor: usize) { + let cursor = cursor.min(self.chars.len()); + if cursor < self.cursor { + self.tty + .write(format!("\x1b[{}D", self.cursor - cursor).as_bytes()); + } else if cursor > self.cursor { + self.tty + .write(format!("\x1b[{}C", cursor - self.cursor).as_bytes()); + } + self.cursor = cursor; + } + + fn insert(&mut self, s: &str) { + if self.bytes + s.len() > MAX_LINE_BYTES { + return; + } + if self.cursor == self.chars.len() { + // Fast path: typing at the end just echoes. + self.chars.extend(s.chars()); + self.cursor = self.chars.len(); + self.bytes += s.len(); + self.tty.write(s.as_bytes()); + return; + } + let mut chars = self.chars.clone(); + let added: Vec = s.chars().collect(); + let cursor = self.cursor + added.len(); + chars.splice(self.cursor..self.cursor, added); + self.set(chars, cursor); + } + + /// Delete `range` (char indices) and leave the cursor at its start. + fn delete(&mut self, range: std::ops::Range) { + if range.is_empty() { + return; + } + let mut chars = self.chars.clone(); + let start = range.start; + chars.drain(range); + self.set(chars, start); + } + + fn replace(&mut self, line: &str) { + let chars: Vec = line.chars().collect(); + let len = chars.len(); + self.set(chars, len); + } +} + +/// Cooked-mode line editor: echo, cursor movement (Left/Right, Home/End, +/// ^A/^E), Up/Down history, Backspace/Delete, ^U, ^K, ^W, ^C, ^D. +async fn read_line(tty: &Tty, history: &History) -> LineRead { + let mut ed = LineEditor::new(tty); + // Index into `history` while browsing; `history.0.len()` is the draft. + let mut hist_pos = history.0.len(); + let mut draft = String::new(); let mut pending: Vec = Vec::new(); loop { let b = match tty.read_event().await { @@ -400,50 +535,63 @@ async fn read_line(tty: &Tty) -> LineRead { }; match b { b'\r' | b'\n' => { + ed.move_to(ed.chars.len()); tty.write(b"\r\n"); - return LineRead::Line(line); + return LineRead::Line(ed.text()); } 0x03 => { + ed.move_to(ed.chars.len()); tty.write(b"^C\r\n"); return LineRead::Interrupt; } - 0x04 if line.is_empty() => return LineRead::Eof, - 0x7f | 0x08 => { - if line.pop().is_some() { - tty.write(b"\x08 \x08"); - } - } - 0x15 => { - for _ in line.drain(..) { - tty.write(b"\x08 \x08"); - } - } + 0x04 if ed.chars.is_empty() => return LineRead::Eof, + 0x04 => ed.delete(ed.cursor..(ed.cursor + 1).min(ed.chars.len())), + 0x7f | 0x08 if ed.cursor > 0 => ed.delete(ed.cursor - 1..ed.cursor), + 0x01 => ed.move_to(0), + 0x05 => ed.move_to(ed.chars.len()), + 0x02 => ed.move_to(ed.cursor.saturating_sub(1)), + 0x06 => ed.move_to(ed.cursor + 1), + 0x0b => ed.delete(ed.cursor..ed.chars.len()), + 0x15 => ed.delete(0..ed.cursor), 0x17 => { - while line.ends_with(' ') { - line.pop(); - tty.write(b"\x08 \x08"); + let mut start = ed.cursor; + while start > 0 && ed.chars[start - 1] == ' ' { + start -= 1; } - while !line.is_empty() && !line.ends_with(' ') { - line.pop(); - tty.write(b"\x08 \x08"); + while start > 0 && ed.chars[start - 1] != ' ' { + start -= 1; } + ed.delete(start..ed.cursor); } - 0x1b => skip_escape_sequence(tty), - b'\t' => { - if line.len() < MAX_LINE_BYTES { - line.push('\t'); - tty.write(b"\t"); + 0x1b => match read_escape(tty) { + EscKey::Left => ed.move_to(ed.cursor.saturating_sub(1)), + EscKey::Right => ed.move_to(ed.cursor + 1), + EscKey::Home => ed.move_to(0), + EscKey::End => ed.move_to(ed.chars.len()), + EscKey::Delete => ed.delete(ed.cursor..(ed.cursor + 1).min(ed.chars.len())), + EscKey::Up if hist_pos > 0 => { + if hist_pos == history.0.len() { + draft = ed.text(); + } + hist_pos -= 1; + ed.replace(&history.0[hist_pos]); } - } - b if b < 0x20 => {} + EscKey::Down if hist_pos < history.0.len() => { + hist_pos += 1; + match history.0.get(hist_pos) { + Some(line) => ed.replace(line), + None => ed.replace(&draft), + } + } + _ => {} + }, + b'\t' => ed.insert("\t"), + b if b < 0x20 || b == 0x7f => {} b => { pending.push(b); match std::str::from_utf8(&pending) { Ok(s) => { - if line.len() + s.len() <= MAX_LINE_BYTES { - line.push_str(s); - tty.write(s.as_bytes()); - } + ed.insert(s); pending.clear(); } Err(e) if e.error_len().is_some() || pending.len() >= 4 => pending.clear(), @@ -454,19 +602,34 @@ async fn read_line(tty: &Tty) -> LineRead { } } -/// Discard a CSI/SS3 sequence (arrow keys etc.) the line editor ignores. -fn skip_escape_sequence(tty: &Tty) { - if !matches!(tty.peek_byte(), Some(b'[') | Some(b'O')) { - return; - } +/// Read the rest of a CSI/SS3 sequence after ESC and classify it. Unknown +/// sequences are consumed and ignored. +fn read_escape(tty: &Tty) -> EscKey { + let intro = match tty.peek_byte() { + Some(b @ (b'[' | b'O')) => b, + _ => return EscKey::Other, + }; tty.try_read_byte(); + let mut params = Vec::new(); for _ in 0..16 { match tty.try_read_byte() { - Some(b) if (0x40..=0x7e).contains(&b) => return, - Some(_) => {} - None => return, + Some(b) if (0x40..=0x7e).contains(&b) => { + return match (intro, params.as_slice(), b) { + (_, [], b'A') => EscKey::Up, + (_, [], b'B') => EscKey::Down, + (_, [], b'C') => EscKey::Right, + (_, [], b'D') => EscKey::Left, + (_, [], b'H') | (b'[', b"1" | b"7", b'~') => EscKey::Home, + (_, [], b'F') | (b'[', b"4" | b"8", b'~') => EscKey::End, + (b'[', b"3", b'~') => EscKey::Delete, + _ => EscKey::Other, + }; + } + Some(b) => params.push(b), + None => return EscKey::Other, } } + EscKey::Other } #[cfg(test)] @@ -524,6 +687,46 @@ mod tests { assert!(!text.contains("garbage"), "{text}"); } + #[tokio::test] + async fn arrow_keys_edit_mid_line() { + let mut term = Terminal::new(Bash::builder()); + // Left x5, insert; Home + Delete x2 + insert; Ctrl-E, Ctrl-A, Ctrl-K. + run( + &mut term, + "echo world\x1b[D\x1b[D\x1b[D\x1b[D\x1b[Dhello \r", + ) + .await; + run(&mut term, "XXcho hi\x1b[H\x1b[3~\x1b[3~e\x1b[F!\r").await; + run( + &mut term, + "echo keep junk\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x0b\x01\x05\r", + ) + .await; + let text = term.screen_text(); + assert!(text.contains("$ echo hello world\nhello world\n"), "{text}"); + assert!(text.contains("$ echo hi!\nhi!\n"), "{text}"); + assert!(text.contains("\nkeep\n$"), "{text}"); + } + + #[tokio::test] + async fn up_down_recall_history() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "echo one\r").await; + run(&mut term, "echo two\r").await; + // Up twice reaches "echo one"; Down returns to "echo two"; edit it. + run(&mut term, "\x1b[A\x1b[A\x1b[B\x7fZ\r").await; + // Up past the oldest entry stays there; Down past the newest restores + // the draft. + run( + &mut term, + "draft\x1b[A\x1b[A\x1b[A\x1b[A\x1b[B\x1b[B\x1b[B\x1b[B\x15echo end\r", + ) + .await; + let text = term.screen_text(); + assert!(text.contains("$ echo twZ\ntwZ\n"), "{text}"); + assert!(text.contains("$ echo end\nend\n$"), "{text}"); + } + #[tokio::test] async fn ctrl_c_discards_line() { let mut term = Terminal::new(Bash::builder()); diff --git a/docs/terminal.md b/docs/terminal.md index 44b47f41e..aabf3f31d 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -114,8 +114,10 @@ shows what happened, and the filesystem holds what was saved. | Arrow up/down/right/left | `\x1b[A` `\x1b[B` `\x1b[C` `\x1b[D` | At the prompt the terminal behaves like a normal line-mode terminal: typed -characters echo, Backspace, Ctrl-U (kill line) and Ctrl-W (kill word) edit the -line, Ctrl-C discards it, and an incomplete command (`for i in 1 2; do`) shows +characters echo, Left/Right, Home/End (or Ctrl-A/Ctrl-E) move the cursor, +Backspace, Delete, Ctrl-U (kill to start), Ctrl-K (kill to end) and Ctrl-W +(kill word) edit the line, Up/Down recall earlier commands from this session, +Ctrl-C discards the line, and an incomplete command (`for i in 1 2; do`) shows the `PS2` prompt and waits for more lines. Shell state persists between lines, as in any `Bash` session. `PS1` and `PS2` are honoured (`\u \h \w \W \$`); the default prompt is `$ `. diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 9e4360183..529c828d0 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -74,8 +74,12 @@ now. Exposing it to custom builtins is a deliberate later step. ## Decision: line discipline split -- Cooked mode (prompt): the shell loop's own small line editor (echo, Backspace, - ^U, ^W, ^C discards line, ^D on empty line exits). Command output is +- Cooked mode (prompt): the shell loop's own small line editor (echo, cursor + movement with Left/Right/Home/End/^A/^E, Backspace/Delete, ^U, ^K, ^W, Up/Down + history of the last 500 lines, ^C discards line, ^D on empty line exits). + Redraws assume one cell per char and step back with CSI D, which does not + cross a soft-wrapped row; editing a line longer than the terminal width can + misplace the cursor. Command output is post-processed `\n` to `\r\n` (ONLCR). Multiline detection reuses the CLI REPL's parse-error heuristics. - Raw mode: set by `vi` through a drop guard (restored on cancel too); bytes go diff --git a/site/src/pages/playground.astro b/site/src/pages/playground.astro index eddf30776..dd8d34123 100644 --- a/site/src/pages/playground.astro +++ b/site/src/pages/playground.astro @@ -35,8 +35,12 @@ const examples = [ user@bashkit: ~ -
-

Loading WebAssembly…

+
+ +
+

Loading WebAssembly…

+
@@ -142,6 +146,10 @@ const examples = [ height: min(32rem, 65vh); padding: 0.6rem 0.4rem 0.6rem 0.8rem; } + .pg__xterm { + height: 100%; + overflow: hidden; + } .pg__status { margin: 0.4rem; color: #8b949e;