// The mediated terminal (#1507, CHAT-03 §1, §4, §7, §9). // // node packages/conversation/src/terminal.mjs --socket [--grant ] // // A thin view over the client library. It renders the same Transcript the // library offers (E7), so it shows what any other client shows. It holds no // engine-side state. // // The composer is a local buffer (§4). It is empty at start, cleared after // each submit and whenever the controller changes, and it submits only while // this connection is the controller. An observer's submit is refused here, // "not admitted: controller", and sends nothing (S5). A bracketed paste is // inserted literally, newlines included; it never submits by itself. // // Keys: Enter submits; Ctrl-J or Alt-Enter adds a newline; Ctrl-T takes // control; Ctrl-G interrupts; Ctrl-O reconnects if needed and re-reads the // page; PageUp and PageDown scroll; Ctrl-C or Ctrl-D quits. Input after a // Ctrl-T or Ctrl-O waits until that finishes, in the same chunk or a later // one, so it is judged as if typed one key at a time. // // Engine text is shown with control characters made visible, so transcript // content can't drive the operator's terminal. import { pathToFileURL } from "node:url"; import { ConversationClient, OUTCOME_UNKNOWN } from "./client.mjs"; import { Transcript } from "./transcript.mjs"; export const NOT_CONTROLLER = "not admitted: controller"; const PASTE_START = "\x1b[200~"; const PASTE_END = "\x1b[201~"; // Keys whose action can change who holds control. Input after one waits until // it finishes, so an Enter in the same chunk is judged as the keys would be // one at a time (Filbert F2 on #1507). const HOLDS = new Set(["takeover", "reload"]); const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" }); // Control characters, line and paragraph separators, bidi controls, invisible // characters that can hide or spoof text (zero-width space, word joiner and // invisible operators, BOM, tag characters) shown as text. ZWJ and ZWNJ pass: // emoji sequences and joining scripts need them. export function visible(s) { return String(s).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]|\t/gu, (c) => { if (c === "\t") return " "; const n = c.codePointAt(0); if (n < 0x20) return "^" + String.fromCharCode(n + 64); if (n === 0x7f) return "^?"; return ``; }); } // For lines that must stay one line (head, status, notices): LF shown too. const oneLine = (s) => visible(s).replace(/\n/g, "^J"); export class Terminal { constructor({ client, write = () => {}, rows = 24, onQuit = () => {} }) { this.client = client; this.write = write; this.rows = rows; this.onQuit = onQuit; this.transcript = new Transcript({ client }); this.composer = ""; this.inPaste = false; this.carry = ""; this.scroll = 0; this.status = ""; this.unknownCount = 0; this.dialogs = []; this.notices = []; this.lastReceipt = null; this.controller = client.binding?.controllerConnection ?? null; this.frame = []; this.sent = 0; this.queue = Promise.resolve(); this.held = null; client.on((m) => this.#onClient(m)); this.transcript.on(() => this.render()); } #onClient(m) { if (m.type === "welcome" || (m.type === "push" && m.kind === "binding")) { const next = this.client.binding?.controllerConnection ?? null; // §4: the composer clears on every control transfer (mutant 10). if (next !== this.controller) this.composer = ""; this.controller = next; } else if (m.type === "push" && m.kind === "unknown") { this.unknownCount = m.count; } else if (m.type === "push" && m.kind === "dialog") { this.dialogs.push(m.dialog); } else if (m.type === "push" && m.kind === "receipt") { if (m.outcomeUnknown) this.notices.push(`prompt ${m.receipt.id}: ${OUTCOME_UNKNOWN}`); if (m.receipt.id === this.lastReceipt?.id) this.lastReceipt = m.receipt; } else if (m.type === "outcome-unknown") { this.notices.push(`${m.operation}: ${OUTCOME_UNKNOWN}`); } else if (m.type === "status" && m.status === "disconnected") { this.status = "disconnected; Ctrl-O reconnects"; } this.render(); } // Feeds raw terminal input. Resolves when the actions it started finish. // While a takeover or reload is pending, input is held, not parsed. key(data) { return this.#feed(data, null); } // Feeds terminal input like key(), but an action's error goes to the // status line when it happens instead of rejecting, so an unhandled // rejection can't end the process (Ctrl-T before the handshake throws "not // connected"), and a later status, such as the held Enter's "prompt: // admitted", is never overwritten by an earlier error (Filbert N4 on #1522). input(data) { return this.#feed(data, (err) => { this.status = `failed: ${err.refusal ?? err.message}`; this.render(); }); } // Held input joins the run of the takeover or reload that holds it: its // promise settles when that run finishes, and an error there belongs to // the call that started the run, not to this one. #feed(data, report) { if (this.held !== null) { this.held += data; return this.queue.catch(() => {}); } const actions = this.#parse(data); const holds = this.held !== null; // A chunk runs after the one before it whether that one finished or // threw; the throw belongs to the call that started it, and later input // still runs (Filbert N1 on #1522). this.queue = this.queue.catch(() => {}).then(() => this.#run(actions, holds, report)); return report ? this.queue.catch(() => {}) : this.queue; } // Runs one chunk's actions in order. If that chunk's parse set `held` // (`holds`), it then parses and runs what was held behind its takeover or // reload. Only that run drains it: an earlier chunk's run that finishes // first leaves it, so the held Enter is judged after the takeover (Darkwing // T1 on #1522). An action that throws doesn't stop the ones after it or // the held input, as if each key came on its own. With `report` an error // is reported when it happens; without, the first one is rethrown at the // end. async #run(actions, holds, report) { let failure = null; while (actions) { for (const run of actions) { try { await run(); } catch (err) { if (report) report(err); else failure ??= err; } } if (!holds) break; const rest = this.held; this.held = null; actions = this.#parse(rest); holds = this.held !== null; } if (failure) throw failure; } // Applies a chunk to the composer and returns the actions it starts. After // a HOLDS action the rest of the chunk goes to `held`. #parse(data) { const actions = []; let s = this.carry + data; this.carry = ""; let i = 0; while (i < s.length) { if (this.inPaste) { const end = s.indexOf(PASTE_END, i); if (end === -1) { const keep = partialSuffix(s.slice(i), PASTE_END); this.composer += s.slice(i, s.length - keep); this.carry = s.slice(s.length - keep); break; } this.composer += s.slice(i, end); this.inPaste = false; i = end + PASTE_END.length; continue; } if (s.startsWith(PASTE_START, i)) { this.inPaste = true; i += PASTE_START.length; continue; } if (s[i] === "\x1b") { // A trailing ESC, alone or with more of the paste-start marker, waits // for the next chunk. A lone Escape has no action here, so holding it // costs nothing. if (s.length - i < PASTE_START.length && PASTE_START.startsWith(s.slice(i))) { this.carry = s.slice(i); break; } const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(s.slice(i))[0]; if (seq === "\x1b[5~") this.scroll += Math.max(1, this.rows - 4); else if (seq === "\x1b[6~") this.scroll = Math.max(0, this.scroll - Math.max(1, this.rows - 4)); else if (seq === "\x1b\r") this.composer += "\n"; i += seq.length; continue; } const action = KEYS[s[i]]; if (action === "newline") this.composer += "\n"; else if (action === "backspace") this.composer = Array.from(this.composer).slice(0, -1).join(""); else if (action === "submit") { // The composer is taken at the Enter, so text after it in the same // chunk starts the next message instead of joining this one. const text = this.#take(); if (text !== null) actions.push(() => this.#send(text)); } else if (action) { actions.push(() => this.#act(action)); if (HOLDS.has(action)) { this.held = s.slice(i + 1); break; } } else if (s[i] >= " ") this.composer += s[i]; i += 1; } this.render(); return actions; } async #act(action) { if (action === "takeover") return this.#show("takeover", await this.client.takeover()); if (action === "interrupt") return this.#show("interrupt", await this.client.interrupt()); if (action === "reload") { if (this.client.closed) { try { await this.client.connect(); } catch (err) { this.status = `connect: ${err.refusal ?? err.message}`; } } else await this.transcript.reload("manual"); return this.render(); } if (action === "quit") return this.onQuit(); } // Sends the composer as one prompt, only as the controller. A submit clears // the composer whatever its outcome; an observer's Enter is refused before // that and leaves the buffer for the operator. async submit() { const text = this.#take(); if (text === null) { this.render(); return { sent: false, refusal: "controller" }; } return this.#send(text); } // Empties the composer and returns its text, or refuses an observer and // returns null, leaving the buffer. #take() { if (!this.client.isController) { this.status = NOT_CONTROLLER; return null; } const text = this.composer; this.composer = ""; return text; } async #send(text) { if (!text) return { sent: false, refusal: null }; this.sent += 1; const r = await this.client.prompt(text); if (r.receipt) this.lastReceipt = r.receipt; this.#show("prompt", r); return { sent: true, reply: r }; } #show(op, r) { if (r.outcome === "outcome-unknown") this.status = `${op}: ${OUTCOME_UNKNOWN}`; else if (r.refusal) this.status = `not admitted: ${r.refusal}`; else this.status = `${op}: ${r.outcome}`; this.render(); } // The frame: a header, the transcript window, dialogs, notices, the status // line and the composer. render() { const b = this.client.binding; const head = `${oneLine(b?.state ?? "disconnected")} | ${this.client.isController ? "controller" : "observer"} | unknown events: ${this.unknownCount}`; const body = []; for (const line of this.transcript.lines()) body.push(...visible(line.replace(/\r\n/g, "\n")).split("\n")); for (const d of this.dialogs) body.push(oneLine(`[dialog ${d.method} disabled: ${d.reason}]`)); for (const n of this.notices) body.push(oneLine(n)); const receipt = this.lastReceipt ? `last prompt: ${this.lastReceipt.state}${this.lastReceipt.reasonCode ? ` (${this.lastReceipt.reasonCode})` : ""}` : ""; const status = oneLine([this.status, receipt].filter(Boolean).join(" | ")); const composer = (this.composer ? this.composer.split("\n") : [""]).map((l, i) => (i ? " " : "> ") + visible(l)); const room = Math.max(1, this.rows - 2 - composer.length); const end = Math.max(0, body.length - this.scroll); this.frame = [head, ...body.slice(Math.max(0, end - room), end), status, ...composer]; this.write("\x1b[H\x1b[2J" + this.frame.join("\r\n")); return this.frame; } } // How many trailing characters of `s` begin `marker`. function partialSuffix(s, marker) { for (let n = Math.min(s.length, marker.length - 1); n > 0; n--) if (marker.startsWith(s.slice(-n))) return n; return 0; } function parseArgs(argv) { const out = { socket: null, grant: "grant-local" }; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--socket") out.socket = argv[++i]; else if (argv[i] === "--grant") out.grant = argv[++i]; else throw new Error(`unknown argument: ${argv[i]}`); } if (!out.socket) throw new Error("usage: terminal.mjs --socket [--grant ]"); return out; } async function main() { let args; try { args = parseArgs(process.argv.slice(2)); } catch (err) { process.stderr.write(err.message + "\n"); process.exit(2); } const { stdin, stdout } = process; const client = new ConversationClient({ socketPath: args.socket, grant: args.grant }); const restore = () => { stdout.write("\x1b[?2004l\r\n"); if (stdin.isTTY) stdin.setRawMode(false); }; const quit = () => { restore(); client.close(); process.exit(0); }; const term = new Terminal({ client, write: (s) => stdout.write(s), rows: stdout.rows || 24, onQuit: quit }); try { await client.connect(); } catch (err) { process.stderr.write(`could not connect: ${err.refusal ?? err.message}\n`); process.exit(1); } if (stdin.isTTY) stdin.setRawMode(true); stdout.write("\x1b[?2004h"); stdout.on("resize", () => { term.rows = stdout.rows || 24; term.render(); }); stdin.on("data", (c) => void term.input(c.toString("utf8"))); stdin.on("end", quit); term.render(); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();