diff --git a/apps/pwa/src/migrations/017_session_features.sql b/apps/pwa/src/migrations/017_session_features.sql new file mode 100644 index 0000000..7948ad7 --- /dev/null +++ b/apps/pwa/src/migrations/017_session_features.sql @@ -0,0 +1,8 @@ +-- What the CLI on the other end can do, as a JSON array it declares when it +-- registers. The page needs this to know whether to arm the arrow pad: an older +-- mosh hands anything it is given to readline, so a keypress sent to one would +-- be typed at the prompt of a live machine as text. +-- +-- Declared rather than inferred from `version`, so shipping the next capability +-- costs a string here instead of a release number the app has to know about. +ALTER TABLE cli_sessions ADD COLUMN features TEXT; diff --git a/apps/pwa/src/routes/sessions.mjs b/apps/pwa/src/routes/sessions.mjs index c6e0bff..3983b3c 100644 --- a/apps/pwa/src/routes/sessions.mjs +++ b/apps/pwa/src/routes/sessions.mjs @@ -9,7 +9,7 @@ // GET /sessions human: connected instances // GET /sessions/:id human: the mirror + a send box // GET /sessions/:id/stream human: SSE (scrollback, then live) -// POST /sessions/:id/commands human: queue a command +// POST /sessions/:id/commands human: queue a command, or one key import { Router } from "express"; import { get, all, run } from "../db.mjs"; import { id } from "../lib/crypto.mjs"; @@ -44,6 +44,30 @@ const LONG_POLL_MS = readLongPollMs(); // against a prompt that runs them one at a time. const MAX_PASTED_LINES = 50; +// Arrow keys pressed on the session page. The command queue carries opaque +// strings, so a key rides it as a sentinel the CLI decodes — ESC leads it +// because it is a byte nobody can put in the send box by typing, which is what +// keeps a key from ever colliding with a real command. Kept in step with +// `src/mirror.mjs` (the CLI half) by sessions-keys.test.mjs. +const KEY_PREFIX = "\u001bmoshkey:"; +const KEY_NAMES = new Set(["up", "down", "left", "right", "enter"]); +export const keyCommand = (name) => KEY_PREFIX + name; + +// Capabilities a CLI is allowed to claim when it registers. Anything else is +// dropped, so a session row can never carry whatever a client felt like sending. +const FEATURES = new Set(["keys"]); +export function readFeatures(value) { + const list = Array.isArray(value) ? value : []; + return [...new Set(list.filter((f) => FEATURES.has(f)))]; +} +// Keys are refused unless the CLI said it can press them. An older mosh, which +// says nothing, hands whatever it is given to readline — sending it a key would +// type the sentinel at the prompt of a live machine instead. +export const supportsKeys = (session) => { + try { return JSON.parse(session.features || "[]").includes("keys"); } + catch { return false; } +}; + const isLive = (s) => s.status === "live" && Date.now() - Number(s.last_seen_at) < STALE_MS; // A terminal dimension we're willing to render at. Anything outside this is a @@ -118,11 +142,12 @@ sessionsRouter.post("/api/sessions", cliAuth, async (req, res) => { cwd: req.body?.cwd ? String(req.body.cwd).slice(0, 200) : null, cols: dim(req.body?.cols), rows: dim(req.body?.rows), + features: JSON.stringify(readFeatures(req.body?.features)), }; await run( - `INSERT INTO cli_sessions (id,user_id,name,host,version,cwd,cols,rows,status,created_at,last_seen_at) - VALUES (?,?,?,?,?,?,?,?,'live',?,?)`, - [row.id, row.user_id, row.name, row.host, row.version, row.cwd, row.cols, row.rows, now, now] + `INSERT INTO cli_sessions (id,user_id,name,host,version,cwd,cols,rows,features,status,created_at,last_seen_at) + VALUES (?,?,?,?,?,?,?,?,?,'live',?,?)`, + [row.id, row.user_id, row.name, row.host, row.version, row.cwd, row.cols, row.rows, row.features, now, now] ); res.json({ id: row.id, url: `/sessions/${row.id}` }); }); @@ -289,6 +314,18 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { if (!s) return res.status(404).type("html").send(page({ body: `

No such session

` })); const live = isLive(s); const geo = dim(s.cols) && dim(s.rows) ? `${dim(s.cols)}×${dim(s.rows)}` : ""; + // The pad is only live against a mosh that decodes the keys. Say so on the + // page rather than leaving five buttons that quietly do nothing. + const keys = supportsKeys(s); + const padOn = live && keys; + const padNote = !live + ? "offline" + : keys + ? "navigate the remote screen · ⏎ selects" + : "this mosh is too old for keys — update it"; + const padKey = (name, glyph, label, area) => + ``; res.type("html").send(page({ title: `moshcode ▸ ${s.name}`, head: `${SESSION_CSS}`, @@ -303,6 +340,16 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => {
+
+
+ ${padKey("up", "↑", "Up", "u")} + ${padKey("left", "←", "Left — back out", "l")} + ${padKey("enter", "⏎", "Enter — select", "c")} + ${padKey("right", "→", "Right — drill in", "r")} + ${padKey("down", "↓", "Down", "d")} +
+ ${esc(padNote)} +
${csrfInput(req)} @@ -315,6 +362,8 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => {

Type anywhere on the terminal to reach the prompt. Commands run in the live mosh prompt. + Arrow keys don't queue as text — they're pressed on the far end as they land, from the pad + or from your own arrow keys with the terminal focused. Output from an engine that has taken over the terminal (/agents) stays on that machine — you'll see the hand-off, not the engine's own screen.

@@ -322,7 +371,7 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { - + ${footer}`, })); }); @@ -391,9 +440,28 @@ sessionsRouter.get("/sessions/:id/stream", requireAuth, async (req, res) => { ping = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* gone */ } }, 25000); }); +// Queue one key. Always answers JSON: keys come from the pad, which is script, +// never from a plain form post the way a typed line can be. +async function queueKey(res, s, name) { + if (!KEY_NAMES.has(name)) return res.status(400).json({ error: "unknown key" }); + if (!isLive(s)) return res.status(409).json({ error: "session offline" }); + if (!supportsKeys(s)) return res.status(409).json({ error: "this mosh is too old for keys — update it" }); + const cid = id(); + const body = keyCommand(name); + await run(`INSERT INTO session_commands (id,session_id,body,status,created_at) VALUES (?,?,?,'queued',?)`, + [cid, s.id, body, Date.now()]); + // `key` rides the event so the page can report "▸ ↑" instead of the sentinel. + publish(s.id, { type: "queued", id: cid, body, key: name }); + wake(s.id); + return res.json({ ok: true, id: cid, key: name }); +} + sessionsRouter.post("/sessions/:id/commands", requireAuth, async (req, res) => { const s = await ownedSession(req.params.id, req.user.id); if (!s) return res.status(404).json({ error: "no such session" }); + // A key is one keypress rather than text, so it takes its own path: the + // splitting below is for lines, and a key has no line to split. + if (req.body?.key) return queueKey(res, s, String(req.body.key).toLowerCase()); // A pasted block is queued a line at a time. The CLI hands exactly one line // to the prompt per turn — readline resolves on the first line it sees and // would swallow the rest — so splitting here is what makes paste work, and it @@ -402,6 +470,10 @@ sessionsRouter.post("/sessions/:id/commands", requireAuth, async (req, res) => { .split(/\r\n|\r|\n/) .map((line) => line.trim()) .filter(Boolean) + // The sentinel stays a channel only the key path can open. Nobody can type + // one, but a hand-rolled post could, and it would reach a prompt as a + // keypress that never met the capability check above. + .filter((line) => !line.startsWith(KEY_PREFIX)) .slice(0, MAX_PASTED_LINES) .map((line) => line.slice(0, 500)); if (!lines.length) return wantsJson(req) ? res.status(400).json({ error: "empty command" }) : res.redirect(`/sessions/${s.id}`); @@ -462,6 +534,24 @@ const SESSION_CSS = ``; // The CLI ships raw ANSI, so the browser runs a real terminal emulator over it @@ -476,6 +566,9 @@ function mirror(opts) { var dot = document.getElementById("dot"), geo = document.getElementById("geo"); var form = document.getElementById("send"), input = document.getElementById("body"); var status = document.getElementById("sendstatus"); + var pad = document.getElementById("pad"), padnote = document.querySelector(".padnote"); + var keysOn = !!opts.keys; + var GLYPH = { up: "↑", down: "↓", left: "←", right: "→", enter: "⏎" }; var seq = 0; // Whether the CLI told us its tty size. If it did we run the emulator at // exactly that geometry and size the font to fit; if it didn't (older mosh) @@ -555,10 +648,39 @@ function mirror(opts) { frame.classList.add("off"); if (input) input.disabled = true; if (form) { var b = form.querySelector("button"); if (b) b.disabled = true; } + keysOn = false; + if (pad) { + var pk = pad.querySelectorAll("button"); + for (var i = 0; i < pk.length; i++) pk[i].disabled = true; + } + if (padnote) padnote.textContent = "offline"; } function flash(msg) { if (status) status.textContent = msg; } + // A key is not a command: it goes out on its own and the far end presses it + // straight away, so there is nothing to echo here — what comes back is the + // remote screen redrawing. + function sendKey(name) { + if (!keysOn || !form) return; + fetch(form.action, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ key: name, _csrf: form._csrf.value }), + }).then(function (r) { + if (r.ok) return; + return r.json().catch(function () { return {}; }).then(function (d) { + flash(d && d.error ? "✗ " + d.error : "could not send " + (GLYPH[name] || name)); + }); + }).catch(function () { flash("could not send — network"); }); + } + + if (pad) pad.addEventListener("click", function (ev) { + var btn = ev.target && ev.target.closest ? ev.target.closest("button[data-key]") : null; + if (!btn || btn.disabled) return; + sendKey(btn.getAttribute("data-key")); + }); + function connect() { var es = new EventSource("/sessions/" + sessionId + "/stream?since=" + seq); es.onmessage = function (e) { @@ -568,7 +690,7 @@ function mirror(opts) { // Queued commands are reported beside the terminal, never written into // it: the pit echoes the command itself when it runs, and injecting our // own text would shift whatever the CLI is redrawing out of place. - else if (d.type === "queued") { flash("▸ queued: " + d.body); } + else if (d.type === "queued") { flash(d.key ? "▸ " + (GLYPH[d.key] || d.key) : "▸ queued: " + d.body); } else if (d.type === "command-done") { flash(""); } else if (d.type === "end" || d.type === "offline") { offline(); } }; @@ -578,9 +700,23 @@ function mirror(opts) { // Typing on the terminal reaches the prompt below it. The emulator is a // faithful mirror, not a keyboard: the CLI takes whole command lines, so // keystrokes have nowhere to go until you press enter. + // + // Arrows are the exception: they act on the screen you're looking at rather + // than on the box below it, so they leave as a keypress instead of as text. + // Enter stays with the box — it is how you run what you just typed. + var ARROW = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" }; term.attachCustomKeyEventHandler(function (ev) { - if (ev.type !== "keydown" || !input || input.disabled) return true; + if (ev.type !== "keydown") return true; if (ev.ctrlKey || ev.metaKey || ev.altKey) return true; // leave copy/paste alone + // Auto-repeat is dropped: holding a key down would put thirty presses a + // second on a queue that crosses a network before anything moves, so the + // screen would still be catching up long after you let go. + if (keysOn && ARROW[ev.key]) { + ev.preventDefault(); + if (!ev.repeat) sendKey(ARROW[ev.key]); + return false; + } + if (!input || input.disabled) return true; if (ev.key === "Enter" || ev.key === "Backspace") { input.focus(); return false; } if (ev.key.length === 1) { input.focus(); input.value += ev.key; ev.preventDefault(); return false; } return true; diff --git a/apps/pwa/test/sessions-keys.test.mjs b/apps/pwa/test/sessions-keys.test.mjs new file mode 100644 index 0000000..2d97304 --- /dev/null +++ b/apps/pwa/test/sessions-keys.test.mjs @@ -0,0 +1,179 @@ +// Arrow keys sent from the session page. +// +// A key is not a line: it is queued as a sentinel the CLI decodes and presses +// straight away, and it is refused outright for a mosh that never declared it +// can press keys — because that mosh would hand the sentinel to readline and +// type it at the prompt of a live machine. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-keys-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const SESSION = "keys-session-token"; +const CSRF = "keys-csrf-token"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, all, db } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const routes = await import("../src/routes/sessions.mjs"); + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(routes.sessionsRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`); + await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`, + [SESSION, "u1", Date.now(), Date.now() + 60_000]); + // One session per case: live and able to press keys, live but older than the + // feature (no `features` at all, the way every session before it looks), and + // one that has ended. + const cli = async (id, features, status = "live") => run( + `INSERT INTO cli_sessions (id,user_id,name,features,status,created_at,last_seen_at) VALUES (?,?,?,?,?,?,?)`, + [id, "u1", "local", features, status, Date.now(), Date.now()]); + await cli("new-cli", JSON.stringify(["keys"])); + await cli("old-cli", null); + await cli("dead-cli", JSON.stringify(["keys"]), "ended"); + + const post = (id, body) => fetch(`${base}/sessions/${id}/commands`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + cookie: `mc_sess=${SESSION}; mc_csrf=${CSRF}`, + }, + body: JSON.stringify({ ...body, _csrf: CSRF }), + }).then(async (res) => ({ status: res.status, body: await res.json().catch(() => null) })); + + const queued = (id) => all( + `SELECT body FROM session_commands WHERE session_id = ? ORDER BY created_at ASC, rowid ASC`, [id]); + + const { createApiKey } = await import("../src/lib/apikey.mjs"); + const { plaintext: apiKey } = await createApiKey("u1", "keys-test"); + + return { routes, run, all, db, server, base, apiKey, post, queued }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +test("a key is queued as the sentinel the CLI decodes", skip, async () => { + const { post, queued, routes } = await app(); + + for (const key of ["up", "down", "left", "right", "enter"]) { + const res = await post("new-cli", { key }); + assert.equal(res.status, 200, `${key} should queue`); + assert.equal(res.body.key, key); + } + + assert.deepEqual( + (await queued("new-cli")).map((row) => row.body), + ["up", "down", "left", "right", "enter"].map(routes.keyCommand), + ); +}); + +test("the app and the CLI agree on the sentinel", skip, async () => { + const { routes } = await app(); + // The CLI half is a plain module over node builtins, so it imports here even + // though apps/pwa is not part of the same package. Drifting the two apart + // would leave the arrows silently typing at somebody's prompt. + const { decodeKey, KEY_NAMES } = await import("../../../src/mirror.mjs"); + for (const key of KEY_NAMES) assert.equal(decodeKey(routes.keyCommand(key)), key); + assert.equal(decodeKey("/help"), null, "an ordinary line is not a key"); +}); + +test("an unknown key is refused before anything is queued", skip, async () => { + const { post, queued } = await app(); + const before = (await queued("new-cli")).length; + const res = await post("new-cli", { key: "escape" }); + assert.equal(res.status, 400); + assert.equal((await queued("new-cli")).length, before); +}); + +test("a mosh that never claimed keys is told so, not sent one", skip, async () => { + const { post, queued } = await app(); + const res = await post("old-cli", { key: "up" }); + assert.equal(res.status, 409); + assert.match(res.body.error, /too old/); + assert.deepEqual(await queued("old-cli"), []); +}); + +test("an ended session takes no keys", skip, async () => { + const { post, queued } = await app(); + const res = await post("dead-cli", { key: "up" }); + assert.equal(res.status, 409); + assert.deepEqual(await queued("dead-cli"), []); +}); + +test("the sentinel cannot be smuggled in as a typed line", skip, async () => { + const { post, queued, routes } = await app(); + // Nobody can type an ESC into the send box, but a hand-rolled post could, and + // it would reach the prompt as a keypress that never met the version check. + const res = await post("old-cli", { body: routes.keyCommand("up") }); + assert.equal(res.status, 400); + assert.deepEqual(await queued("old-cli"), []); +}); + +test("a session only carries features we know", skip, async () => { + const { routes } = await app(); + assert.deepEqual(routes.readFeatures(["keys"]), ["keys"]); + assert.deepEqual(routes.readFeatures(["keys", "keys"]), ["keys"], "declared twice is still once"); + assert.deepEqual(routes.readFeatures(["keys", "rm -rf", 7, null]), ["keys"]); + // Every shape an older or hand-rolled client can register with. + for (const value of [undefined, null, "keys", {}, 3]) assert.deepEqual(routes.readFeatures(value), []); +}); + +test("supportsKeys reads what the session declared, and nothing else", skip, async () => { + const { routes } = await app(); + assert.equal(routes.supportsKeys({ features: '["keys"]' }), true); + assert.equal(routes.supportsKeys({ features: "[]" }), false); + assert.equal(routes.supportsKeys({ features: null }), false, "every session before this shipped"); + assert.equal(routes.supportsKeys({ features: "not json" }), false); + assert.equal(routes.supportsKeys({}), false); +}); + +test("a CLI that declares keys can then be sent them end to end", skip, async () => { + const { all, base, apiKey, post } = await app(); + // Register the way the CLI does — through the API — rather than by hand. + const registered = await fetch(`${base}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ name: "declared", version: "0.68.0", features: ["keys", "telepathy"] }), + }).then((r) => r.json()); + + const row = await all(`SELECT features FROM cli_sessions WHERE id = ?`, [registered.id]); + assert.equal(row[0].features, '["keys"]', "telepathy is not a feature we know"); + assert.equal((await post(registered.id, { key: "left" })).status, 200); +}); diff --git a/src/mirror.mjs b/src/mirror.mjs index 1bea962..793808a 100644 --- a/src/mirror.mjs +++ b/src/mirror.mjs @@ -12,6 +12,47 @@ import os from "node:os"; import { loadCreds } from "./auth.mjs"; +// A key pressed on the session page travels through the same queue as a typed +// line, tagged with this sentinel. ESC leads it because it is a byte you cannot +// put in the send box by typing, so a key can never collide with a real command +// — and the app refuses to queue one for a mosh too old to decode it, rather +// than letting the sentinel get typed at somebody's prompt as text. +export const KEY_PREFIX = "\u001bmoshkey:"; +/** Keys the page can send. Anything else is ignored on both ends. */ +export const KEY_NAMES = ["up", "down", "left", "right", "enter"]; + +/** The name of the key this command carries, or null if it is an ordinary line. */ +export function decodeKey(body) { + if (typeof body !== "string" || !body.startsWith(KEY_PREFIX)) return null; + const name = body.slice(KEY_PREFIX.length); + return KEY_NAMES.includes(name) ? name : null; +} + +// What each key looks like to a program reading the tty in raw mode, and the +// keypress readline wants when it is the one holding the line. +const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" }; +const KEY_PRESS = { + up: { name: "up" }, down: { name: "down" }, right: { name: "right" }, + left: { name: "left" }, enter: { name: "return" }, +}; + +/** + * Deliver one key to whatever is reading this terminal. Returns false for a key + * we don't know, so a newer app can never make an older CLI do something odd. + */ +export function pressKey(name, rl = null, stdin = process.stdin) { + const bytes = KEY_BYTES[name]; + if (!bytes) return false; + // At the prompt readline owns the line editor, so hand it a keypress rather + // than bytes: ↑/↓ walk the history, ←/→ move within the line, enter runs it. + if (rl) { + try { rl.write(null, KEY_PRESS[name]); return true; } catch { /* fall through to the tty */ } + } + // Otherwise something has the tty in raw mode — a herd bar, the reader, a + // menu — and it is waiting on the real escape sequence, not on readline. + try { stdin.emit("data", Buffer.from(bytes, "latin1")); return true; } catch { return false; } +} + const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty const MAX_BUFFER = 16000; // flush early once a batch gets big @@ -32,6 +73,7 @@ export function createMirror({ // process alive for up to a full poll window after the pit closes. let poll = null; const onCommand = new Set(); + const onKey = new Set(); const api = (creds?.api || "https://app.moshcode.sh").replace(/\/+$/, ""); const headers = { "content-type": "application/json", authorization: `Bearer ${creds?.token}` }; @@ -114,7 +156,13 @@ export function createMirror({ if (stopped) return; if (!got) { await sleep(5000); continue; } for (const c of got.commands || []) { - for (const fn of onCommand) { try { fn(c.body); } catch { /* handler's problem */ } } + // A key is a navigation action, not a line, so it goes to its own + // handlers: the pit presses it the moment it lands instead of parking + // it behind whatever text is still waiting for the prompt. A "down" + // delivered a command later would land on a different row. + const key = decodeKey(c.body); + if (key) { for (const fn of onKey) { try { fn(key); } catch { /* handler's problem */ } } } + else { for (const fn of onCommand) { try { fn(c.body); } catch { /* handler's problem */ } } } post(`/api/sessions/${sessionId}/commands/${c.id}`, {}); } } @@ -131,6 +179,10 @@ export function createMirror({ host: os.hostname(), version, cwd, + // What this build can be asked to do. The page arms its arrow pad on + // the strength of this: a mosh that never says "keys" is one that would + // type the sentinel at the prompt instead of pressing it. + features: ["keys"], ...size(), }); if (!r?.id) return false; @@ -146,6 +198,8 @@ export function createMirror({ setEngine, /** Subscribe to commands sent from the web. Returns an unsubscribe fn. */ onCommand(fn) { onCommand.add(fn); return () => onCommand.delete(fn); }, + /** Subscribe to keys pressed on the web. Returns an unsubscribe fn. */ + onKey(fn) { onKey.add(fn); return () => onKey.delete(fn); }, async stop() { if (!sessionId || stopped) return; stopped = true; diff --git a/src/tui.mjs b/src/tui.mjs index edbd7d4..16b3877 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -17,7 +17,7 @@ import { locate, tilde } from "./pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; import { loginAuto, whoami, logout } from "./auth.mjs"; import { loadCommand, saveCommand } from "./settings-sync.mjs"; -import { createMirror, teeOutput } from "./mirror.mjs"; +import { createMirror, pressKey, teeOutput } from "./mirror.mjs"; import { fetchMotdAd } from "./ads.mjs"; import { runScript } from "./runtime.mjs"; import { moshVocabulary } from "./commands.mjs"; @@ -1302,6 +1302,13 @@ async function startMirror() { }; mirror.onCommand((body) => { queue.push(body); drainRemote(); }); + // Keys skip the queue: they are pressed the instant they arrive, whether the + // prompt is armed or something else has the tty (a herd bar, the reader, a + // menu). Nothing is echoed for them either — a line gets a `▸ (web)` note + // because it would otherwise appear from nowhere, but a key's effect is the + // redraw it causes, and printing over that would shift it out of place. + mirror.onKey((name) => { pressKey(name, promptRl); }); + console.log(info(`mirroring this session → ${acid(mirror.url)}`)); return { restoreTee, drainRemote, atPrompt: (rl) => { promptRl = rl; } }; } diff --git a/test/mirror-keys.test.mjs b/test/mirror-keys.test.mjs new file mode 100644 index 0000000..70a691c --- /dev/null +++ b/test/mirror-keys.test.mjs @@ -0,0 +1,114 @@ +// Keys pressed on the session page, arriving down the command long-poll. +// +// They travel the same queue as a typed line but must not be treated as one: +// a key is pressed the moment it lands, and it goes to whatever is reading the +// terminal right now — readline at the prompt, a raw-mode UI otherwise. +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { createMirror, decodeKey, pressKey, KEY_PREFIX, KEY_NAMES } from "../src/mirror.mjs"; + +const json = (body) => new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, +}); + +async function waitFor(predicate) { + const deadline = Date.now() + 1000; + while (!predicate() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(predicate(), true, "timed out waiting for the mirror"); +} + +test("decodeKey tells a key from a line", () => { + for (const name of KEY_NAMES) assert.equal(decodeKey(KEY_PREFIX + name), name); + assert.equal(decodeKey("/help"), null); + assert.equal(decodeKey(`${KEY_PREFIX}pgup`), null, "a key we don't know is not a key"); + assert.equal(decodeKey(undefined), null); +}); + +test("a queued key reaches onKey, and never onCommand", async () => { + const acked = []; + let served = false; + const fetchImpl = async (url, options = {}) => { + const pathname = new URL(url).pathname; + if (pathname === "/api/sessions") return json({ id: "session-1" }); + if (pathname === "/api/sessions/session-1/commands") { + if (served) { + // One batch, then park: the pump loops forever otherwise. + return new Promise((resolve, reject) => { + options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }); + }); + } + served = true; + return json({ commands: [ + { id: "c1", body: `${KEY_PREFIX}down` }, + { id: "c2", body: "/ps" }, + ] }); + } + acked.push(pathname); + return json({ ok: true }); + }; + + const mirror = createMirror({ + credentials: { api: "https://app.example.test", token: "mck_test" }, + fetchImpl, + }); + const keys = []; + const lines = []; + mirror.onKey((name) => keys.push(name)); + mirror.onCommand((body) => lines.push(body)); + + assert.equal(await mirror.start(), true); + await waitFor(() => keys.length > 0 && lines.length > 0); + + assert.deepEqual(keys, ["down"]); + assert.deepEqual(lines, ["/ps"], "the sentinel must never be handed to the prompt as text"); + // Both are acked, so neither is claimed again by the next poll. + await waitFor(() => acked.length === 2); + await mirror.stop(); +}); + +test("at the prompt a key is a readline keypress", () => { + const seen = []; + const rl = { write: (data, key) => seen.push({ data, key }) }; + assert.equal(pressKey("up", rl), true); + assert.equal(pressKey("enter", rl), true); + assert.deepEqual(seen, [ + { data: null, key: { name: "up" } }, + // Enter is "return" to readline — the name that runs the line. + { data: null, key: { name: "return" } }, + ]); +}); + +test("with no prompt a key is the escape sequence a raw-mode UI reads", () => { + const stdin = new EventEmitter(); + const chunks = []; + stdin.on("data", (buf) => chunks.push(buf.toString("latin1"))); + + for (const name of ["up", "down", "right", "left", "enter"]) { + assert.equal(pressKey(name, null, stdin), true); + } + assert.deepEqual(chunks, ["\u001b[A", "\u001b[B", "\u001b[C", "\u001b[D", "\r"]); +}); + +test("a key we don't know does nothing at all", () => { + const stdin = new EventEmitter(); + let wrote = false; + stdin.on("data", () => { wrote = true; }); + const rl = { write: () => { wrote = true; } }; + assert.equal(pressKey("pgdn", rl, stdin), false); + assert.equal(pressKey("", null, stdin), false); + assert.equal(wrote, false); +}); + +test("a prompt that refuses the keypress falls back to the tty", () => { + const stdin = new EventEmitter(); + const chunks = []; + stdin.on("data", (buf) => chunks.push(buf.toString("latin1"))); + // readline throws once it has been closed; the key still has somewhere to go. + const rl = { write: () => { throw new Error("readline was closed"); } }; + assert.equal(pressKey("up", rl, stdin), true); + assert.deepEqual(chunks, ["\u001b[A"]); +});