feat(cli): full-screen terminal app (#1544, row 55)

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]>
This commit is contained in:
2026-10-11 16:06:11 -05:00
co-authored by Claude Opus 5.5
parent 1e71d74670
commit 79d9fc2d56
9 changed files with 1932 additions and 16 deletions
+489
View File
@@ -0,0 +1,489 @@
// 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());
});
}
+44 -8
View File
@@ -19,6 +19,7 @@ import { humanTransport, refuseInsideAgent } from "./transport.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
@@ -36,6 +37,13 @@ export const USAGE = `usage:
mosaic bus stop
mosaic bus status [--json]`;
// packages/seat/src/cli.mjs USAGE, which that module does not export (it runs
// its main on import). tests/app.test.mjs fails if the two drift.
export const SEAT_USAGE = [
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
].join("\n");
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
function parse(args, { flags = [], values = [] }) {
@@ -110,18 +118,29 @@ async function confirm(io, question) {
async function decide(parsed, call, io) {
if (parsed.positional.length !== 2) throw usage("decide takes a decision and an option");
const [ref, choice] = parsed.positional;
const ask = parsed.flags.has("--yes")
? null
: (question) => {
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
return confirm(io, question);
};
await resolveDecision({ call, ref, choice, note: parsed.values["--note"], write: (s) => io.stdout.write(s), ask });
}
// The decide flow, shared by `mosaic decide` and the app's Inbox (src/app.mjs):
// re-read the inbox, find the decision and the option, write the decision
// and what the choice means, ask (null skips the question, as --yes does),
// then resolve. Returns the decision.
export async function resolveDecision({ call, ref, choice, note, write, ask }) {
const d = findDecision(await call("inbox"), ref);
const option = d.options.find((o) => o.key === choice);
if (!option) throw new CliError(`decision ${shortId(d.id)} has no option ${JSON.stringify(choice)}; its options are ${d.options.map((o) => o.key).join(", ")}`, 2);
io.stdout.write(formatDecision(d));
write(formatDecision(d));
const effect = d.authorization ? (choice === d.authorization.approvalChoice ? "this authorizes the action" : "this declines the action") : null;
io.stdout.write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
if (!parsed.flags.has("--yes")) {
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
if (!(await confirm(io, `resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
}
write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
if (ask && !(await ask(`resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
const args = { id: d.id, choice };
if (parsed.values["--note"] !== undefined) args.note = parsed.values["--note"];
if (note !== undefined) args.note = note;
try {
await call("decision.resolve", args);
} catch (e) {
@@ -131,7 +150,8 @@ async function decide(parsed, call, io) {
if (e.code === "decision-closed") throw new CliError(`decision ${shortId(d.id)} was closed before your answer arrived; see mosaic trail ${d.id}`, 2);
throw e;
}
io.stdout.write(`resolved ${d.id}: ${choice}\n`);
write(`resolved ${d.id}: ${choice}\n`);
return d;
}
async function human(verb, rest, io, deps) {
@@ -296,7 +316,23 @@ async function bus(rest, io, deps) {
throw usage();
}
// `mosaic` with no argument. In an agent run it refuses (exit 3); without a
// terminal on both stdin and stdout it prints the usage (exit 4); on a
// terminal it opens the app (src/app.mjs) and prints one line once it quits.
async function app(io, deps) {
refuseInsideAgent(io.env);
if (!io.stdin.isTTY || !io.stdout.isTTY) throw new CliError(`the app needs a terminal on stdin and stdout; from a script, use a verb:\n${SEAT_USAGE}\n${USAGE}`, 4);
const system = deps.system ?? loadSystem({ env: io.env });
const state = readHostState(system.dataRoot);
const business = state?.live ? state.business : null;
const transport = deps.transport ?? ((b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }));
const run = deps.runApp ?? runApp;
const result = await run({ io, business, transport, color: !io.env.NO_COLOR });
io.stdout.write(`${quitLine(result)}\n`);
}
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "talk") return talk(rest, io, deps);
+1 -1
View File
@@ -66,7 +66,7 @@ export function formatTasks(list) {
.join("\n")}\n`;
}
function trailSummary(r) {
export function trailSummary(r) {
switch (r.table) {
case "events":
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
+429
View File
@@ -0,0 +1,429 @@
// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes
// the app state and returns exactly `rows` strings, each `cols` cells wide
// once its escape codes are dropped. Nothing here reads the bus or the
// terminal.
//
// Colour is the terminal's own 16 (docs/design/tokens.json `terminal.ansi`).
// With colour off (NO_COLOR) a frame carries no SGR code except reverse video
// on the selected row; every state still reads from its word or mark.
//
// Every text from the broker goes through the conversation terminal's
// visible() (control, bidi and invisible characters shown as text), and a
// line break shows as ^J, so no broker text can move the cursor or split a
// row.
import { visible } from "../../conversation/src/terminal.mjs";
import { formatDecision, shortId, trailSummary } from "./format.mjs";
// Below this width the nav shows keys only and Inbox drops RAISED BY.
export const NARROW_BELOW = 100;
// Below this size the frame only says the terminal is too small.
export const MIN_COLS = 40;
export const MIN_ROWS = 10;
const SGR = { action: ["34", "39"], accent: ["33", "39"], success: ["32", "39"], warning: ["93", "39"], danger: ["31", "39"], muted: ["90", "39"], bold: ["1", "22"] };
const oneLine = (s) => visible(s ?? "").replace(/\r?\n/g, "^J");
// Terminal cells for one code point after `prev`: 0 for combining marks,
// joiners and variation selectors, 2 for East Asian wide characters and
// emoji shown as emoji, else 1. U+FE0F after a one-cell emoji base (🅰️, ❤️)
// asks for emoji presentation and adds the second cell.
function cells(c, prev = "") {
const n = c.codePointAt(0);
if (n === 0xfe0f) return prev && /\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;
if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0e)) return 0;
if (
/\p{Emoji_Presentation}/u.test(c) ||
(n >= 0x1100 && n <= 0x115f) ||
n === 0x2329 ||
n === 0x232a ||
(n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
(n >= 0xa960 && n <= 0xa97c) ||
(n >= 0xac00 && n <= 0xd7a3) ||
(n >= 0xf900 && n <= 0xfaff) ||
(n >= 0xfe10 && n <= 0xfe19) ||
(n >= 0xfe30 && n <= 0xfe4f) ||
(n >= 0xfe50 && n <= 0xfe6b) ||
(n >= 0xff00 && n <= 0xff60) ||
(n >= 0xffe0 && n <= 0xffe6) ||
(n >= 0x16fe0 && n <= 0x16ff1) ||
(n >= 0x17000 && n <= 0x18d08) ||
(n >= 0x1aff0 && n <= 0x1b2fb) ||
(n >= 0x1f200 && n <= 0x1f2ff) ||
(n >= 0x20000 && n <= 0x3fffd)
)
return 2;
return 1;
}
export function width(s) {
let w = 0;
let prev = "";
for (const c of s) {
w += cells(c, prev);
prev = c;
}
return w;
}
// The first `w` cells of `s`, with … for what was cut; then spaces to `w`.
export function fit(s, w) {
if (w <= 0) return "";
if (width(s) <= w) return s + " ".repeat(w - width(s));
let out = "";
let used = 0;
let prev = "";
for (const c of s) {
const n = cells(c, prev);
if (used + n > w - 1) break;
out += c;
used += n;
prev = c;
}
return out + "…" + " ".repeat(w - 1 - used);
}
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
// Splits `s` into lines of at most `w` cells, at spaces where it can;
// later lines start with `indent`. A long word breaks between graphemes,
// so a mark or U+FE0F stays with its base.
export function wrap(s, w, indent = "") {
const out = [];
let cur = "";
const push = () => {
out.push(cur.trimEnd());
cur = indent;
};
for (const word of s.split(/(?<= )/)) {
if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
for (const { segment: c } of graphemes.segment(word)) {
if (width(cur + c) > w) push();
cur += c;
}
}
if (cur.trim() !== "" || out.length === 0) out.push(cur.trimEnd());
return out;
}
// A line is a list of [text, style] pieces; `sel` reverses the whole row.
const piece = (text, style = null) => [oneLine(text), style];
const line = (...pieces) => ({ pieces: pieces.map((p) => (Array.isArray(p) ? piece(...p) : piece(p))) });
const blank = () => line("");
const sel = (l) => ({ ...l, sel: true });
// Fits a line to `cols` cells and paints it.
function paint(l, cols, color) {
const out = [];
let left = cols;
for (const [text, style] of l.pieces) {
if (left <= 0) break;
let t = text;
if (width(t) > left) t = fit(t, left);
left -= width(t);
out.push(color && style && SGR[style] ? `\x1b[${SGR[style][0]}m${t}\x1b[${SGR[style][1]}m` : t);
}
const body = out.join("") + " ".repeat(Math.max(0, left));
return l.sel ? `\x1b[7m${body}\x1b[27m` : body;
}
export function strip(s) {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
const rule = (cols) => line(["─".repeat(cols), "muted"]);
function age(at, now) {
const t = Date.parse(at);
if (!Number.isFinite(t)) return "?";
const s = Math.max(0, Math.round((now - t) / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h${String(m % 60).padStart(2, "0")}m`;
return `${Math.floor(h / 24)}d`;
}
// Fixed-width columns; the last one takes what is left.
function columns(cells_, widths) {
return cells_.map((c, i) => (i === cells_.length - 1 ? c : fit(oneLine(c), widths[i] - 1) + " "));
}
const VIEWS = [
["c", "Talk", "talk"],
["i", "Inbox", "inbox"],
["t", "Tasks", "tasks"],
["a", "Agents", "agents"],
["r", "Trail", "trail"],
];
// The current view shows in brackets as well as bold, so it reads without
// colour.
function nav(state, cols, now) {
const narrow = cols < NARROW_BELOW;
const l = line([" mosaic", "bold"], " ", [state.business ?? "no business", state.business ? "action" : "warning"], " ");
for (const [key, name, view] of VIEWS) {
const count = view === "inbox" && state.data.inbox?.list ? ` ${state.data.inbox.list.length}` : "";
const label = narrow ? `${key}${count}` : `${key} ${name}${count}`;
l.pieces.push(state.view === view ? piece(`[${label}]`, "bold") : piece(` ${label} `), piece(" "));
}
const d = state.data[state.view];
const right = d?.at ? (narrow ? age(d.at, now) : `read ${age(d.at, now)} ago`) : "";
const used = l.pieces.reduce((n, [t]) => n + width(t), 0);
if (right && used + width(right) + 2 <= cols) l.pieces.push(piece(" ".repeat(cols - used - width(right) - 1)), piece(right, "muted"));
return l;
}
const HINT_WIDE = " c i t a r views ↑↓ or j k move Enter open v trail 1-9 choose : command ? help q quit";
const HINT_NARROW = " c i t a r views 1-9 choose : command ? help q quit";
// The heading of a view: its name, a count, and what is wrong with the read.
function heading(name, d, extra = "") {
const l = line([` ${name}`, "bold"], extra ? ` ${extra}` : "");
if (d?.error && d.list) l.pieces.push(piece(` stale: ${d.error}; showing the read from ${d.at}`, "warning"));
return l;
}
// The body when a view has nothing to list: the reason, then where it would
// come from.
function emptyBody(d, what, from, cols) {
const say = (text, style) => wrap(text, cols - 2).map((t) => line([` ${t}`, style]));
if (d?.error) return [...say(`${d.refused ? "refused" : "read failed"}: ${d.error}`, "danger"), ...say("Nothing was written. Press the view's key to read again.", "muted")];
if (!d || d.reading) return [line([" Reading from the bus…", "muted"])];
return [...say(what, "bold"), ...say(from, "muted")];
}
function noBusiness() {
return [
line([" No bus host runs here, so the app knows no business.", "warning"]),
line([" Start one with mosaic bus start <business>, or type :business <id>.", "muted"]),
];
}
// A list over a detail block: `head` lines, the column header, the rows,
// a rule, then `detail`. The detail gets at least four lines when it has
// them; the list scrolls to keep the selection in view.
function listOver(head, header, rows, selected, detail, room, cols) {
const fixed = head.length + 1;
const detailRoom = Math.min(detail.length + 1, Math.max(5, room - fixed - rows.length));
const listRoom = Math.max(1, room - fixed - detailRoom);
const top = Math.max(0, selected - listRoom + 1);
return [...head, header, ...rows.slice(top, top + listRoom), line([` ${"─".repeat(Math.max(0, cols - 2))}`, "muted"]), ...detail];
}
const marker = (i, selected) => (i === selected ? " ›" : "");
function wrapped(text, cols, style = null, indent = "") {
return wrap(oneLine(text), cols - 1, indent).map((t) => line([` ${t}`, style]));
}
function inboxBody(state, room, cols, now) {
const d = state.data.inbox;
if (!d?.list?.length) return [heading("Inbox", d), blank(), ...emptyBody(d, "Nothing is waiting on you.", "A decision shows here when an agent raises one routed to you through the bus.", cols)];
const narrow = cols < NARROW_BELOW;
const list = d.list;
const selected = Math.min(state.sel.inbox, list.length - 1);
const blocking = list.filter((x) => x.blocking).length;
const widths = narrow ? [3, 10, 8, 10, 22, 20, 6] : [3, 10, 8, 10, 26, 24, 22, 6];
const names = narrow ? ["", "ID", "CLASS", "FLAGS", "ACTION", "TARGET", "AGE"] : ["", "ID", "CLASS", "FLAGS", "ACTION", "TARGET", "RAISED BY", "AGE"];
const header = line([columns(["", ...names.slice(1)], widths).join(""), "muted"]);
const rows = list.map((x, i) => {
const target = x.authorization?.target ?? x.task_ref ?? "-";
const cellsOut = narrow
? [marker(i, selected), shortId(x.id), x.class, x.blocking ? "BLOCKING" : "", x.action, target, age(x.at, now)]
: [marker(i, selected), shortId(x.id), x.class, x.blocking ? "BLOCKING" : "", x.action, target, x.raised_by_role, age(x.at, now)];
const text = columns(cellsOut, widths);
const l = line(text.slice(0, 3).join(""), [text[3], x.blocking ? "danger" : null], text.slice(4).join(""));
return i === selected ? sel(l) : l;
});
const detail = [];
for (const t of formatDecision(list[selected]).trimEnd().split("\n")) detail.push(...wrapped(t, cols, null, " "));
const head = [heading("Inbox", d, `${list.length} for you, ${blocking} blocking`), blank()];
return listOver(head, header, rows, selected, detail, room, cols);
}
function taskState(f) {
return f.status ?? f.state ?? (f.done === true || f.done === 1 ? "done" : f.done === false || f.done === 0 ? "open" : "");
}
function tasksBody(state, room, cols) {
const d = state.data.tasks;
if (!d?.list?.length) return [heading("Tasks", d), blank(), ...emptyBody(d, "No tasks in this business yet.", "Tasks appear after the PM creates them or the poller reads them from the tracker.", cols)];
const list = d.list;
const selected = Math.min(state.sel.tasks, list.length - 1);
const widths = [3, 22, 12, 0];
const header = line([columns(["", "REF", "STATE", "TITLE"], widths).join(""), "muted"]);
const rows = list.map((t, i) => {
const f = t.fields ?? {};
const l = line(columns([marker(i, selected), t.task_ref, taskState(f) || "-", f.title ?? f.name ?? ""], widths).join(""));
return i === selected ? sel(l) : l;
});
const t = list[selected];
const f = t.fields ?? {};
const detail = [line([` ${t.task_ref}`, "bold"], ` ${f.title ?? f.name ?? ""}`)];
detail.push(line([" ", null], [`snapshot ${t.at ?? "?"} · source ${t.source ?? "?"}${t.read_at ? ` · read ${t.read_at}` : ""}`, "muted"]));
for (const [k, v] of Object.entries(f)) if (k !== "title" && k !== "name") detail.push(...wrapped(` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`, cols, null, " "));
detail.push(line([" read-only here; the tracker holds the task", "muted"]));
return listOver([heading("Tasks", d, `${list.length} task(s)`), blank()], header, rows, selected, detail, room, cols);
}
function agentsBody(state, room, cols, now) {
const d = state.data.agents;
if (!d?.list?.length) return [heading("Agents", d), blank(), ...emptyBody(d, "No role is held right now.", "A role shows here while a run holds its claim on the bus.", cols)];
const list = d.list;
const selected = Math.min(state.sel.agents, list.length - 1);
const widths = [3, 14, 30, 12, 0];
const header = line([columns(["", "ROLE", "HOLDER", "HARNESS", "HELD FOR"], widths).join(""), "muted"]);
const rows = list.map((c, i) => {
const l = line(columns([marker(i, selected), c.role, c.holder_run, c.harness ?? "-", age(c.at, now)], widths).join(""));
return i === selected ? sel(l) : l;
});
const c = list[selected];
const detail = [line([` ${c.role}`, "bold"], ` held by ${c.holder_run} since ${c.at}`), line([" stop a run with mosaic stop <run>; see mosaic launches list", "muted"])];
return listOver([heading("Agents", d, `${list.length} role(s) held`), blank()], header, rows, selected, detail, room, cols);
}
function trailBody(state, room, cols) {
const d = state.data.trail;
if (!d?.subject) return [line([" Trail", "bold"]), blank(), line([" No subject yet.", "bold"]), line([" Press v on a decision or a task, or type :trail <task|decision>.", "muted"])];
if (!d.list?.length) return [heading("Trail", d, d.subject), blank(), ...emptyBody(d, "Nothing in the trail for this subject.", "Every step the stack takes on a subject writes a trail row.", cols)];
const narrow = cols < NARROW_BELOW;
const list = d.list;
const selected = Math.min(state.sel.trail, list.length - 1);
const header = line([` ${narrow ? "TIME " : "AT "} ROW SUMMARY`, "muted"]);
const rows = list.map((r, i) => {
const at = String(r.at ?? "");
const when = narrow ? at.slice(11, 19) : at.slice(0, 19).replace("T", " ");
const l = line(fit(marker(i, selected), 2) + " ", [fit(oneLine(when), narrow ? 8 : 19), "muted"], " ", fit(oneLine(`${r.table}#${r.seq}`), 20), " ", trailSummary(r));
return i === selected ? sel(l) : l;
});
const r = list[selected];
const detail = [line([` ${r.table}#${r.seq}`, "bold"], ` ${r.at}`), ...wrapped(` ${trailSummary(r)}`, cols, null, " ")];
const follow = r.task_ref && r.task_ref !== d.subject ? r.task_ref : null;
if (follow) detail.push(line([" v follows to ", "muted"], follow));
return listOver([heading("Trail", d, `${d.subject} · ${list.length} row(s), oldest first`), blank()], header, rows, selected, detail, room, cols);
}
function talkBody() {
return [
line([" Talk", "bold"]),
blank(),
line([" Talk is not in this version of the app.", "bold"]),
line([" From a shell, mosaic talk <role> <text> sends a request and waits for the reply.", "muted"]),
];
}
// Help: what every key does, and what the app never does.
export const HELP = [
"Views Anywhere Inbox",
"c Talk (not yet) ↑↓ or j k move 1-9 choose an option,",
"i Inbox Enter open then y resolves",
"t Tasks v trail v trail of the decision",
"a Agents : command",
"r Trail ? this help Command line",
" q quit :decide <decision> <option>",
" :trail <task|decision>",
" :business <id>",
" :q quit",
"",
"Decisions resolve only here or with mosaic decide, from a shell you started",
"yourself. Inside an agent run, mosaic refuses with exit 3. Esc closes this help.",
];
// A box over the body: `lines` are [text, style] or a string.
function box(title, lines, w) {
const inner = w - 4;
const out = [line([`┌─ ${title} ${"─".repeat(Math.max(0, w - 5 - width(title)))}┐`, "accent"])];
for (const l of lines) {
const [text, style] = Array.isArray(l) ? l : [l, null];
for (const t of wrap(oneLine(text), inner, " ")) out.push(line(["│ ", "accent"], [fit(t, inner), style], [" │", "accent"]));
}
out.push(line([`└${"─".repeat(Math.max(0, w - 2))}┘`, "accent"]));
return out;
}
const cutLine = (what, inner) => line(["│ ", "accent"], fit(`… (${what} cut to fit; the whole text is in mosaic inbox --json)`, inner), [" │", "accent"]);
// `room` is the body's height. A decide box taller than that loses the end
// of the question, never the decision line, the options, the
// recommendation, the authorization lines, the task, the choice, the
// question to answer or its keys. If those don't fit either, the box only
// says the decision is hidden, and App#ask answers every key No.
function decideBox(o, w, room) {
const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
else lines.push("", ["resolving…", "muted"]);
const b = box("decide", lines, w);
if (b.length <= room) return b;
const q = o.lines.findIndex((t) => t.startsWith(" question: "));
const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
if (q < 0 || end < 0) return null;
const head = box("decide", lines.slice(0, q), w).slice(0, -1);
const question = box("decide", lines.slice(q, end), w).slice(1, -1);
const tail = box("decide", lines.slice(end), w).slice(1);
const keep = room - head.length - tail.length - 1;
return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : null;
}
// Whether a frame of cols x rows shows the whole decide box, the end of a
// long question aside. y resolves only then (App#ask).
export function decideShown(o, cols, rows) {
return cols >= MIN_COLS && rows >= MIN_ROWS && o?.kind === "decide" && decideBox(o, Math.min(84, cols - 2), rows - 5) !== null;
}
function overlayBox(o, cols, room) {
if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
const w = Math.min(84, cols - 2);
if (o.kind === "decide") {
return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"]], w);
}
if (o.kind === "unknown") {
return box("outcome unknown", [[o.message, "warning"], "", "Nothing was retried, and the choice was not sent twice.", "", "v open the trail r re-read the inbox Esc back"], w);
}
if (o.kind === "detail") return box(o.title, [...o.lines, "", ["Esc back", "muted"]], w);
return box(o.title ?? "error", [[o.message, "danger"], "", "Esc back"], w);
}
// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
export function draw(state, { cols, rows, color = true, now = Date.now() }) {
if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking));
const room = rows - 5;
let body;
if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
else if (state.view === "inbox") body = inboxBody(state, room, cols, now);
else if (state.view === "tasks") body = tasksBody(state, room, cols);
else if (state.view === "agents") body = agentsBody(state, room, cols, now);
else if (state.view === "trail") body = trailBody(state, room, cols);
else body = talkBody();
body = body.slice(0, room);
while (body.length < room) body.push(blank());
if (state.overlay) {
let b = overlayBox(state.overlay, cols, room);
// Still too tall (help, or a long detail on a small screen; a decide
// box always fits): the top, a cut line and the last two lines. room is
// at least 5, so that is exactly room lines.
if (b.length > room) {
const tail = b.slice(-2);
const inner = Math.min(84, cols - 2) - 4;
b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail];
}
const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2));
const top = Math.max(0, Math.min(2, room - b.length));
b.forEach((l, j) => (body[top + j] = line(" ".repeat(left), ...l.pieces)));
}
const status = state.command !== null && state.command !== undefined ? line(":", state.command, ["█", "muted"]) : line([` ${state.status ?? ""}`, state.statusStyle ?? "muted"]);
const frame = [nav(state, cols, now), rule(cols), ...body, rule(cols), status, line([cols < NARROW_BELOW ? HINT_NARROW : HINT_WIDE, "muted"])];
return frame.map((l) => paint(l, cols, color));
}
// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
// With the decide question open, any key answers it No (App#ask).
function tooSmall(cols, rows, color, asking) {
const keys = asking ? "A decision question is open; any key answers No." : "Esc closes what is open; q quits.";
const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. ${keys}`, Math.max(1, cols));
const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
while (body.length < rows) body.push(blank());
return body.map((l) => paint(l, Math.max(0, cols), color));
}