Bare `mosaic` on a terminal opens the app. It has Inbox, Tasks, Agents
and Trail screens, help, and a decide overlay, and it uses the alternate
screen, raw mode, resize and SIGTERM, and restores the terminal on exit.
draw(state) in screens.mjs is pure. cli.mjs exports resolveDecision,
which `mosaic decide` and the app share, so both write the same lines
and ask the same question. A decision resolves on `y` only while
decideShown() says the whole decide box is on screen. The app reads that
at the key. A box that doesn't fit is replaced by "Too small to show this
decision", and any key answers No. scripts/mosaic sends no arguments to
packages/cli (a decided scope addition), so the queue dispatch test
follows. The cli README and docs/TOOLS.md describe the app.
Darkwing built it (agents/darkwing/work/queue-55), in three rounds.
Round 3 was approved by Dewey (27308) and Filbert (27309) on manifest
59f1b9b4, 9 paths. Their non-blocking notes go to row 69 (#1562): a
narrow-terminal test for the decideShown MIN_COLS guard, a way out from
the hidden box, a U+18D08 width test, a cut box keeping Esc back, and
the README row count stated as an example.
Sage's gate on 3634d06e, sequential and teed: node suites cli 110 and
queue 150, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed (Docker on).
122b1223..3634d06e touches none of the 9 paths.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
490 lines
18 KiB
JavaScript
490 lines
18 KiB
JavaScript
// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs
|
|
// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
|
|
// the verbs; src/screens.mjs draws the frames.
|
|
//
|
|
// Reads and the decide flow go through the human transport, like the verbs.
|
|
// A decision resolves only through resolveDecision (src/cli.mjs), the code
|
|
// `mosaic decide` runs: a digit picks an option, the app shows the CLI's
|
|
// lines and its yes/no question. y or Y resolves; any other key, or quitting,
|
|
// answers No. Keys that arrive within ASK_MS of the question being drawn are
|
|
// type-ahead and never answer it. After "outcome unknown" the app offers the
|
|
// trail and a re-read, never a retry.
|
|
//
|
|
// Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the
|
|
// selection's detail; v the trail of the selection; 1-9 choose; `:` the
|
|
// command line (Esc or Ctrl-C closes it); ? help; Esc back; q or Ctrl-C
|
|
// quits. Below 40x10 the frame says the terminal is too small. Talk (c) is a
|
|
// pointer to `mosaic talk`; the conversation pane is a later row.
|
|
|
|
import { resolveDecision } from "./cli.mjs";
|
|
import { CliError } from "./errors.mjs";
|
|
import { formatDecision } from "./format.mjs";
|
|
import { decideShown, draw } from "./screens.mjs";
|
|
import { busExit } from "./transport.mjs";
|
|
|
|
export const REFRESH_MS = 30000;
|
|
// How long the decide question is up before a key can answer it.
|
|
export const ASK_MS = 200;
|
|
|
|
// Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc",
|
|
// "tab", "backspace", "ctrl-c", or the character itself. Other escape
|
|
// sequences are dropped. A lone ESC at the end of a chunk is Escape.
|
|
export function parseKeys(data) {
|
|
const out = [];
|
|
let i = 0;
|
|
while (i < data.length) {
|
|
if (data[i] === "\x1b") {
|
|
const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(data.slice(i))[0];
|
|
const name = { "\x1b": "esc", "\x1b[A": "up", "\x1bOA": "up", "\x1b[B": "down", "\x1bOB": "down", "\x1b[5~": "pgup", "\x1b[6~": "pgdn" }[seq];
|
|
if (name) out.push(name);
|
|
i += seq.length;
|
|
continue;
|
|
}
|
|
const c = String.fromCodePoint(data.codePointAt(i));
|
|
i += c.length;
|
|
if (c === "\r" || c === "\n") out.push("enter");
|
|
else if (c === "\t") out.push("tab");
|
|
else if (c === "\x7f" || c === "\b") out.push("backspace");
|
|
else if (c === "\x03" || c === "\x04") out.push("ctrl-c");
|
|
else if (c >= " ") out.push(c);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const VIEW_KEYS = { c: "talk", i: "inbox", t: "tasks", a: "agents", r: "trail" };
|
|
const LISTS = ["inbox", "tasks", "agents", "trail"];
|
|
|
|
export class App {
|
|
// transport(business) returns call(verb, args). business may be null:
|
|
// the app then says so and waits for :business <id>.
|
|
constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS, askMs = ASK_MS }) {
|
|
this.transport = transport;
|
|
this.write = write;
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
this.color = color;
|
|
this.now = now;
|
|
this.onQuit = onQuit;
|
|
this.askMs = askMs;
|
|
this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null };
|
|
this.call = business ? transport(business) : null;
|
|
this.resolved = 0;
|
|
this.frame = [];
|
|
this.keys = [];
|
|
this.job = null;
|
|
this.answer = null;
|
|
this.cancelAsk = null;
|
|
this.wake = null;
|
|
this.idle = [];
|
|
this.pumping = false;
|
|
this.closed = false;
|
|
this.timer = refreshMs > 0 ? setInterval(() => this.input("\0refresh"), refreshMs) : null;
|
|
this.timer?.unref?.();
|
|
}
|
|
|
|
// The first frame and the first read.
|
|
start() {
|
|
this.render();
|
|
return this.input("\0start");
|
|
}
|
|
|
|
resize(cols, rows) {
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
if (this.closed) return this.frame;
|
|
this.frame = draw(this.state, { cols: this.cols, rows: this.rows, color: this.color, now: this.now() });
|
|
this.write(`\x1b[H\x1b[2J${this.frame.join("\r\n")}`);
|
|
return this.frame;
|
|
}
|
|
|
|
// Feeds terminal input. Resolves once every key is handled, or when the
|
|
// app waits for an answer to the decide question.
|
|
input(data) {
|
|
if (this.closed) return Promise.resolve();
|
|
if (data === "\0refresh" || data === "\0start") this.keys.push(data.slice(1));
|
|
else this.keys.push(...parseKeys(data));
|
|
const settled = new Promise((r) => this.idle.push(r));
|
|
if (this.pumping) this.wake?.();
|
|
else this.#pump();
|
|
return settled;
|
|
}
|
|
|
|
// One key at a time, in order. A key that starts a read or a decision
|
|
// runs as a job; later keys wait for it, except the answer to its
|
|
// question, which goes straight to it.
|
|
async #pump() {
|
|
this.pumping = true;
|
|
for (;;) {
|
|
if (this.closed) break;
|
|
if (this.answer && this.keys.length) {
|
|
// A timer tick is not an answer.
|
|
const k = this.keys.shift();
|
|
try {
|
|
if (k !== "refresh") this.answer(k);
|
|
} catch (e) {
|
|
this.crash(e);
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if (this.job) {
|
|
if (this.answer) this.#settle();
|
|
await new Promise((r) => (this.wake = r));
|
|
this.wake = null;
|
|
continue;
|
|
}
|
|
if (!this.keys.length) break;
|
|
let job;
|
|
try {
|
|
job = this.#key(this.keys.shift());
|
|
} catch (e) {
|
|
this.crash(e);
|
|
break;
|
|
}
|
|
if (job instanceof Promise) {
|
|
this.job = job.catch((e) => this.crash(e)).finally(() => {
|
|
this.job = null;
|
|
this.wake?.();
|
|
});
|
|
}
|
|
}
|
|
this.pumping = false;
|
|
this.#settle();
|
|
}
|
|
|
|
#settle() {
|
|
for (const r of this.idle.splice(0)) r();
|
|
}
|
|
|
|
// Asks the decide flow's question; resolves true on y or Y and false on
|
|
// any other key. Only a key typed after the question is up can answer:
|
|
// keys that arrive within askMs of drawing it are type-ahead (a paste, or
|
|
// keys typed while a read blocked the screen) and are dropped. Quitting
|
|
// answers no. y counts only while the frame shows the whole box, the end
|
|
// of a long question aside (decideShown); on a terminal too small for it,
|
|
// every key answers no, y included. The size is read at each key, so a
|
|
// shrink after the question is armed is covered.
|
|
#ask(question) {
|
|
this.state.overlay = { ...this.state.overlay, asking: question };
|
|
this.render();
|
|
return new Promise((resolve) => {
|
|
const answer = (yes) => {
|
|
clearTimeout(arm);
|
|
this.answer = this.cancelAsk = null;
|
|
this.state.overlay = { ...this.state.overlay, asking: null };
|
|
try {
|
|
this.render();
|
|
} catch (e) {
|
|
// The pump sends this to crash; the decide flow ends with No.
|
|
resolve(false);
|
|
throw e;
|
|
}
|
|
resolve(yes);
|
|
};
|
|
this.cancelAsk = () => answer(false);
|
|
const arm = setTimeout(() => {
|
|
this.keys = [];
|
|
this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));
|
|
this.wake?.();
|
|
}, this.askMs);
|
|
});
|
|
}
|
|
|
|
#status(text, style = null) {
|
|
this.state.status = text;
|
|
this.state.statusStyle = style;
|
|
}
|
|
|
|
// Handles one key; returns a promise when it starts a read or a decision.
|
|
#key(k) {
|
|
const s = this.state;
|
|
// A timer tick keeps a "resolved" status; it reads nothing under an
|
|
// overlay or the command line.
|
|
if (k === "start") return this.#read(s.view);
|
|
if (k === "refresh") return s.overlay || s.command !== null ? null : this.#read(s.view, s.statusStyle === "success");
|
|
if (s.command !== null) return this.#commandKey(k);
|
|
if (k === "ctrl-c") return this.quit("Ctrl-C");
|
|
if (s.overlay) return this.#overlayKey(k);
|
|
if (VIEW_KEYS[k]) {
|
|
s.view = VIEW_KEYS[k];
|
|
s.open = false;
|
|
this.#status(k === "c" ? "Talk is a later row; mosaic talk <role> <text> works from a shell" : "");
|
|
return s.view === "talk" ? void this.render() : this.#read(s.view);
|
|
}
|
|
if (k === "q") return this.quit("q");
|
|
if (k === "?") return this.#overlay({ kind: "help" });
|
|
if (k === ":") {
|
|
s.command = "";
|
|
return void this.render();
|
|
}
|
|
if (k === "j" || k === "down") return this.#move(1);
|
|
if (k === "k" || k === "up") return this.#move(-1);
|
|
if (k === "pgdn") return this.#move(Math.max(1, this.rows - 10));
|
|
if (k === "pgup") return this.#move(-Math.max(1, this.rows - 10));
|
|
if (k === "enter") return this.#open();
|
|
if (k === "v") return this.#trailOfSelection();
|
|
if (/^[1-9]$/.test(k)) return this.#choose(Number(k));
|
|
if (k === "tab") this.#status("Tab moves to the composer once Talk is in the app");
|
|
else if (k === "esc") this.#status("");
|
|
return void this.render();
|
|
}
|
|
|
|
#overlay(o) {
|
|
this.state.overlay = o;
|
|
this.render();
|
|
}
|
|
|
|
#overlayKey(k) {
|
|
const o = this.state.overlay;
|
|
if (o.kind === "unknown") {
|
|
if (k === "v") {
|
|
this.state.overlay = null;
|
|
return this.#trail(o.id);
|
|
}
|
|
if (k === "r") {
|
|
this.state.overlay = null;
|
|
this.state.view = "inbox";
|
|
return this.#read("inbox");
|
|
}
|
|
if (k === "esc") this.state.overlay = null;
|
|
return void this.render();
|
|
}
|
|
if (k === "esc" || k === "enter" || (o.kind === "help" && (k === "?" || k === "q"))) this.state.overlay = null;
|
|
this.render();
|
|
}
|
|
|
|
#list(view = this.state.view) {
|
|
return this.state.data[view]?.list ?? [];
|
|
}
|
|
|
|
#selected(view = this.state.view) {
|
|
const list = this.#list(view);
|
|
return list.length ? list[Math.min(this.state.sel[view], list.length - 1)] : null;
|
|
}
|
|
|
|
#move(n) {
|
|
const view = this.state.view;
|
|
if (LISTS.includes(view)) {
|
|
const max = this.#list(view).length - 1;
|
|
this.state.sel[view] = Math.max(0, Math.min(max, this.state.sel[view] + n));
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
// Enter: the selection's detail in a box, whole, at any width.
|
|
#open() {
|
|
const view = this.state.view;
|
|
const item = this.#selected(view);
|
|
if (!item) return void this.render();
|
|
const title = view === "inbox" ? `decision ${item.id.slice(0, 8)}` : view === "tasks" ? item.task_ref : view === "agents" ? item.role : `${item.table}#${item.seq}`;
|
|
const whole = view === "trail" ? `mosaic trail ${this.state.data.trail.subject} --json` : `mosaic ${view} --json`;
|
|
return this.#overlay({ kind: "detail", title, lines: detailLines(view, item), whole });
|
|
}
|
|
|
|
#trailOfSelection() {
|
|
const view = this.state.view;
|
|
const item = this.#selected(view);
|
|
const subject = !item ? null : view === "inbox" ? item.id : view === "tasks" ? item.task_ref : view === "trail" ? item.task_ref ?? null : null;
|
|
if (!subject) {
|
|
this.#status(view === "agents" ? "a role has no trail here; type :trail <task|decision>" : "nothing selected to follow; type :trail <task|decision>");
|
|
return void this.render();
|
|
}
|
|
return this.#trail(subject);
|
|
}
|
|
|
|
#trail(subject) {
|
|
this.state.view = "trail";
|
|
this.state.sel.trail = 0;
|
|
this.state.data.trail = { subject };
|
|
return this.#read("trail");
|
|
}
|
|
|
|
// A digit: option n of the selected decision, through the decide flow.
|
|
#choose(n) {
|
|
if (this.state.view !== "inbox") {
|
|
this.#status("digits choose in the Inbox; press i");
|
|
return void this.render();
|
|
}
|
|
const d = this.#selected("inbox");
|
|
if (!d) return void this.render();
|
|
const option = d.options[n - 1];
|
|
if (!option) {
|
|
this.#status(`decision ${d.id.slice(0, 8)} has ${d.options.length} option(s)`, "warning");
|
|
return void this.render();
|
|
}
|
|
return this.#decide(d.id, option.key);
|
|
}
|
|
|
|
async #decide(ref, choice) {
|
|
if (!this.call) {
|
|
this.#status("no business; type :business <id>", "warning");
|
|
return this.render();
|
|
}
|
|
this.state.view = "inbox";
|
|
this.#overlay({ kind: "decide", lines: [], asking: null });
|
|
let text = "";
|
|
const write = (s) => {
|
|
text += s;
|
|
this.state.overlay = { ...this.state.overlay, lines: text.trimEnd().split("\n") };
|
|
this.render();
|
|
};
|
|
try {
|
|
const d = await resolveDecision({ call: this.call, ref, choice, write, ask: (q) => this.#ask(q) });
|
|
this.resolved += 1;
|
|
this.state.overlay = null;
|
|
this.#status(`resolved ${d.id}: ${choice}`, "success");
|
|
return this.#read("inbox", true);
|
|
} catch (e) {
|
|
if (!(e instanceof CliError) && !e.code) throw e;
|
|
const message = e.message;
|
|
if (message === "not resolved") {
|
|
this.state.overlay = null;
|
|
this.#status("not resolved");
|
|
} else if (/^outcome[ -]unknown/.test(message)) {
|
|
this.state.overlay = { kind: "unknown", message, id: text.match(/^decision (\S+)/)?.[1] ?? ref };
|
|
} else this.state.overlay = { kind: "error", title: "decide", message: e.code && message === e.code ? `${(e.exitCode ?? busExit(e.code)) === 3 ? "refused" : "failed"}: ${e.code}` : message };
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// Reads one view. A failed read keeps the last good list and says it is
|
|
// stale; with no list it shows the failure. `keep` leaves the status line.
|
|
async #read(view, keep = false) {
|
|
if (!LISTS.includes(view)) return this.render();
|
|
if (!this.call) return this.render();
|
|
const before = this.state.data[view] ?? {};
|
|
if (view === "trail" && !before.subject) return this.render();
|
|
this.state.data[view] = { ...before, reading: true };
|
|
if (!keep) this.#status(`reading ${view}…`);
|
|
this.render();
|
|
try {
|
|
const list = await this.call(view, view === "trail" ? { subject: before.subject } : {});
|
|
this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() };
|
|
const n = Math.min(9, this.#selected("inbox")?.options.length ?? 1);
|
|
if (!keep) this.#status(view === "inbox" && list.length ? `Press ${n === 1 ? "1" : `1-${n}`} to choose. You will see the decision again and confirm with y.` : "");
|
|
} catch (e) {
|
|
if (!(e instanceof CliError) && !e.code) throw e;
|
|
// Exit 3 codes are refusals (src/transport.mjs), not failed reads.
|
|
const refused = (e.exitCode ?? busExit(e.code)) === 3;
|
|
this.state.data[view] = { ...before, reading: false, error: e.code ?? e.message, refused };
|
|
this.#status(`${refused ? "refused" : "read failed"}: ${e.code ?? e.message}`, "danger");
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
#commandKey(k) {
|
|
const s = this.state;
|
|
if (k === "esc" || k === "ctrl-c") s.command = null;
|
|
else if (k === "backspace") s.command = Array.from(s.command).slice(0, -1).join("");
|
|
else if (k === "enter") {
|
|
const text = s.command.trim();
|
|
s.command = null;
|
|
return this.#run(text);
|
|
} else if ([...k].length === 1 && k >= " ") s.command += k;
|
|
this.render();
|
|
}
|
|
|
|
// :decide <decision> <option>, :trail <subject>, :business <id>, :q.
|
|
#run(text) {
|
|
const [verb, ...args] = text.split(/\s+/);
|
|
if (verb === "q" || verb === "quit") return this.quit("q");
|
|
if (verb === "decide" && args.length === 2) return this.#decide(args[0], args[1]);
|
|
if (verb === "trail" && args.length === 1) return this.#trail(args[0]);
|
|
if (verb === "business" && args.length === 1) {
|
|
this.state.business = args[0];
|
|
this.call = this.transport(args[0]);
|
|
this.state.data = {};
|
|
for (const v of LISTS) this.state.sel[v] = 0;
|
|
return this.#read(this.state.view === "talk" ? "inbox" : this.state.view);
|
|
}
|
|
this.#status(verb ? `unknown command :${text}; ? lists the commands` : "", "warning");
|
|
this.render();
|
|
}
|
|
|
|
// An error the app has no screen for: quit, and runApp rethrows it.
|
|
crash(error) {
|
|
this.error = error;
|
|
this.quit("an error");
|
|
}
|
|
|
|
quit(how) {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
if (this.timer) clearInterval(this.timer);
|
|
this.cancelAsk?.();
|
|
this.wake?.();
|
|
this.onQuit({ how, resolved: this.resolved, error: this.error });
|
|
}
|
|
}
|
|
|
|
// Every field of the selection, for the Enter box.
|
|
function detailLines(view, item) {
|
|
if (view === "inbox") return formatDecision(item).trimEnd().split("\n");
|
|
if (view === "tasks") return [`snapshot ${item.at ?? "?"}, source ${item.source ?? "?"}${item.read_at ? `, read ${item.read_at}` : ""}`, ...Object.entries(item.fields ?? {}).map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)];
|
|
if (view === "agents") return [`held by ${item.holder_run}`, `harness ${item.harness ?? "-"}`, `since ${item.at}`];
|
|
return Object.entries(item).map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
|
|
}
|
|
|
|
// The quit line printed after the app restores the screen.
|
|
export function quitLine({ how, resolved }) {
|
|
return `(app closed with ${how}; ${resolved} decision${resolved === 1 ? "" : "s"} resolved this session)`;
|
|
}
|
|
|
|
// Runs the app on a terminal until it quits: raw mode, the alternate screen,
|
|
// no cursor; all three restored on quit, on an error (a render that throws
|
|
// included) and on SIGTERM, SIGINT or SIGHUP. Resolves with { how, resolved }.
|
|
export function runApp({ io, business, transport, color, refreshMs, askMs, signals = process }) {
|
|
const { stdin, stdout } = io;
|
|
const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
return new Promise((resolve, reject) => {
|
|
let app;
|
|
const restore = () => {
|
|
stdin.off("data", onData);
|
|
stdin.off("end", onEnd);
|
|
stdout.off?.("resize", onResize);
|
|
for (const [name, on] of onSignal) signals.off(name, on);
|
|
stdin.setRawMode?.(false);
|
|
stdin.pause?.();
|
|
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
};
|
|
const guard = (f) => {
|
|
try {
|
|
f();
|
|
} catch (e) {
|
|
app.crash(e);
|
|
}
|
|
};
|
|
const onData = (c) => void app.input(c.toString("utf8"));
|
|
const onEnd = () => app.quit("end of input");
|
|
const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));
|
|
const onSignal = SIGNALS.map((name) => [name, () => app.quit(name)]);
|
|
app = new App({
|
|
business,
|
|
transport,
|
|
color,
|
|
refreshMs,
|
|
askMs,
|
|
cols: stdout.columns || 80,
|
|
rows: stdout.rows || 24,
|
|
write: (s) => stdout.write(s),
|
|
onQuit: (r) => {
|
|
restore();
|
|
if (r.error) reject(r.error);
|
|
else resolve(r);
|
|
},
|
|
});
|
|
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
stdin.setRawMode?.(true);
|
|
stdin.on("data", onData);
|
|
stdin.on("end", onEnd);
|
|
stdout.on?.("resize", onResize);
|
|
for (const [name, on] of onSignal) signals.on(name, on);
|
|
stdin.resume?.();
|
|
guard(() => void app.start());
|
|
});
|
|
}
|