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
+74 -2
View File
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
start|stop|status` with its Discord notifier (row S4, #1521); the session
launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
#1523). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. Rulings:
lead decisions 70 and 77 in `docs/plans/2026-09-26_lead-decisions.md`.
#1523); and `mosaic` with no argument, the full-screen app (queue row 55,
#1544). Briefs: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6, and
`docs/plans/2026-10-10_design-implementation.md`, "Full-screen terminal
app". Rulings: lead decisions 70 and 77 in
`docs/plans/2026-09-26_lead-decisions.md`.
Every command reads through the broker. None opens the SQLite file.
```sh
scripts/mosaic # the app, on a terminal
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
@@ -70,6 +74,74 @@ mosaic launches list [--json]
`launches list` does not, because the file is readable to the same user
anyway.
## The app
```
mosaic
```
With no argument, on a terminal, `mosaic` opens a full-screen app over the
same broker reads as the verbs: Inbox, Tasks, Agents, Trail and Help.
`src/app.mjs` handles keys and reads; `src/screens.mjs` draws the frames
and nothing else.
- In an agent run it exits 3 with the same refusal as the verbs, before
anything else. Without a terminal on both stdin and stdout it exits 4
and prints the seat usage (`mosaic launch`, `mosaic seat task`) and the
verbs above. A script never gets the app.
- The business is the one of the bus host on this data root. With no
host the app says so and `:business <id>` picks one.
- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
<task|decision>`, `:business <id>`, `:q`.
- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
decide` runs. The box shows the same lines and the same `[y/N]`
question. `y` or `Y` resolves; any other key answers No, and so does
quitting. Keys that arrive within 200 ms of the question being drawn
are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
offers the trail (`v`) or a re-read (`r`). No key sends the choice
again.
- Reads happen on entering a view, after a decision, and every 30 s while
no box is open. A failed read keeps the last list on screen and marks
it stale. A refused read (exit 3 codes) says refused. Broker text goes
through `visible()` from `packages/conversation/src/terminal.mjs`, so
control characters and bidi overrides show as `^[` and `<U+202E>`.
- Talk (`c`) points to `mosaic talk` for now. The conversation pane and
`stop` come in a later row.
- 16 ANSI colours. Under `NO_COLOR` the only escape codes in a frame are
reverse video on the selected row (the design's tokens keep selection
visible without colour). Frames fit 80x24; the layout is designed at
120x36, and under 100 columns the nav shows keys only and the Inbox
drops RAISED BY. Below 40x10 the frame only says the terminal is too
small. If the decide question is open then, the frame says so, and any
key answers it No, `y` included. Widths count terminal cells: East
Asian Width W and F characters and emoji 2, a one-cell emoji base with
U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a
row can come out short, never too wide. Wrapping breaks between
graphemes, so U+FE0F and combining marks stay with their base.
- A decide box taller than the screen cuts the end of the question and
nothing else: the decision line, the options, the recommendation, the
action and target, what the choice authorizes, the task, your choice
and the `[y/N]` question stay. The cut line points to `mosaic inbox
--json`. When even those lines don't fit, the box says only "Too small
to show this decision. Any key answers No." `y` resolves only while the
frame shows the whole box apart from the cut, and the app checks the
size at each key, so a terminal shrunk after the question appeared
answers No. A gated decision with a task needs 20 rows at 80 columns
(21 with a cut question), and 29 rows at 40 columns.
- The transport is the verbs' `spawnSync` child, so the screen does not
redraw while a read is in flight (up to its 30 s timeout).
- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
resize redraw included) the app restores the screen, the cursor and
cooked mode. After anything but an error it then prints
`(app closed with q; N decisions resolved this session)`; an error
goes to stderr instead.
## Bus host
```
+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));
}
+868
View File
@@ -0,0 +1,868 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { App, parseKeys, quitLine, runApp } from "../src/app.mjs";
import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs";
import { broker, humanEnv, io, tmp } from "./helpers.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const REPO = join(here, "..", "..", "..");
const NOW = Date.parse("2026-10-11T18:00:00.000Z");
const SIZES = [
[120, 36],
[80, 24],
[100, 30],
[99, 24],
[40, 10],
];
// Below 40x10 the frame says the terminal is too small.
const TOO_SMALL = [
[39, 10],
[40, 9],
[3, 3],
[1, 1],
];
const exitOf = async (p) => {
try {
await p;
return 0;
} catch (e) {
if (!(e instanceof CliError)) throw e;
return e.exitCode;
}
};
// An app on the in-process broker. `wrap` may replace verbs of the transport.
function app(t, { wrap = (call) => call, business = "demo", ...opts } = {}) {
const bus = broker(t);
const out = { text: "", quit: null };
const a = new App({
business,
transport: (b) => wrap(bus.transport(b)),
write: (s) => (out.text = s),
now: () => NOW,
refreshMs: 0,
askMs: 0,
onQuit: (r) => (out.quit = r),
...opts,
});
return { ...bus, a, out, screen: () => a.frame.map(strip).join("\n") };
}
// Text a hostile or careless agent could put in a decision: control
// characters, a bidi override, a newline, wide characters and a long word.
const HOSTILE = "Push \x1b[31mred\x1b[0m \u202eevil\u202c to 発行 now?\nSecond line " + "x".repeat(150);
// A transport with one of everything, in the broker's shapes, for the
// screens the broker cannot seed (task snapshots need the tracker adapter).
function fake(extra = {}) {
const decision = (n, more = {}) => ({
id: `${String(n).repeat(8)}-aaaa-4bbb-8ccc-${"d".repeat(12)}`,
action: "git.push.protected",
class: "gated",
blocking: n === 1,
at: "2026-10-11T17:00:00.000Z",
raised_by_role: "coder",
raised_by_run: "coder-run-with-a-long-name-0123456789",
question: n === 1 ? HOSTILE : "Push the release?",
options: [
{ key: "yes", text: "Allow" },
{ key: "no", text: "Decline" },
],
recommendation: "no",
authorization: { action: "git.push.protected", target: "refactor-with-a-very-long-branch-name-indeed", approvalChoice: "yes" },
task_ref: "vikunja:1/7",
...more,
});
const data = {
// Emoji and CJK that are two cells wide (P3b in the round 1 review).
inbox: [
decision(1),
decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }),
decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }),
],
tasks: [
{ seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" },
{ seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null },
],
agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }],
trail: [
{ at: "2026-10-11T17:00:00.000Z", table: "decisions", seq: 4, id: "x", task_ref: "vikunja:1/7", question: HOSTILE },
{ at: "2026-10-11T17:05:00.000Z", table: "events", seq: 9, kind: "task.changed.external", body: { changed: ["bucket"] } },
],
...extra,
};
return () => async (verb) => structuredClone(data[verb] ?? []);
}
function fits(frame, cols, rows, label) {
assert.equal(frame.length, rows, `${label}: ${frame.length} rows`);
frame.forEach((l, i) => assert.equal(width(strip(l)), cols, `${label}, row ${i}: ${JSON.stringify(strip(l))}`));
}
// Every view and overlay, drawn at the given size.
async function everyScreen(t, cols, rows, color) {
const shots = [];
const a = new App({ business: "demo", transport: fake(), cols, rows, color, now: () => NOW, refreshMs: 0 });
const shot = (label) => shots.push([label, [...a.render()]]);
await a.start();
shot("inbox");
for (const [key, label] of [["t", "tasks"], ["a", "agents"], ["c", "talk"]]) {
await a.input(key);
shot(label);
}
await a.input("i");
await a.input("v");
shot("trail");
await a.input("\r");
shot("trail detail");
await a.input("\x1b");
for (const [key, label] of [["t", "tasks detail"], ["a", "agents detail"], ["i", "inbox detail"]]) {
await a.input(key);
await a.input("\r");
shot(label);
await a.input("\x1b");
}
await a.input("?");
shot("help");
await a.input("\x1b");
await a.input(":trail vikunja:1/7");
shot("command line");
await a.input("\x1b");
for (const o of [
{ kind: "decide", lines: ["decision 11111111", ` question: ${HOSTILE}`, "your choice: yes (Allow); this authorizes the action"], asking: "resolve 11111111 with yes? [y/N] " },
{ kind: "decide", lines: Array.from({ length: 40 }, (_, i) => `line ${i}`).concat("your choice: yes (Allow)"), asking: "resolve 11111111 with yes? [y/N] " },
{ kind: "unknown", message: "outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail 11111111-aaaa-4bbb-8ccc-dddddddddddd before trying again", id: "x" },
{ kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
]) {
a.state.overlay = o;
shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
}
a.state.overlay = null;
// Empty, failed-with-stale-data, refused and no-business states.
a.state.data.inbox = { list: [], at: new Date(NOW).toISOString() };
shot("inbox empty");
a.state.data.inbox = { list: await fake()()("inbox"), at: "2026-10-11T17:59:00.000Z", error: "invalid-response" };
shot("inbox stale");
a.state.data.inbox = { error: "human-required", refused: true };
shot("inbox refused");
a.state.business = null;
shot("no business");
assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`);
a.quit("q");
return shots;
}
test("every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row", async (t) => {
for (const [cols, rows] of SIZES) {
for (const color of [true, false]) {
for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`);
}
// A decide box too tall to show, with no question to cut, says the
// decision is hidden and that any key answers No (Dewey B1, round 3).
const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
assert.match(long, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`);
assert.doesNotMatch(long, /y resolves/, `${cols}x${rows}`);
}
// Too small: every screen still fits, and says why.
for (const [cols, rows] of TOO_SMALL) {
for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`);
}
assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/);
// Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only.
const [wide] = await everyScreen(t, 120, 36, false);
assert.match(strip(wide[1][0]), /\[i Inbox 3\]/);
assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
const [narrow] = await everyScreen(t, 99, 24, false);
assert.match(strip(narrow[1][0]), /\[i 3\] t a /);
assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
});
test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) {
assert.equal(width(s), cells, JSON.stringify(s));
}
// East Asian Width W and F outside the blocks above: one from each range.
for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
// A line break never splits ❤️ from its U+FE0F; no line is wider than asked.
assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]);
for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l));
// On screen, independent of width(): each wide character takes two cells
// for one UTF-16 unit, so a row of `cols` cells is that much shorter in
// units. 🅰️ is three units for two cells.
const frames = Object.fromEntries(await everyScreen(t, 80, 24, false));
const row = (label, text) => strip(frames[label].find((l) => l.includes(text)));
assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡");
assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️");
assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut");
});
test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => {
for (const [label, frame] of await everyScreen(t, 120, 36, true)) {
for (const l of frame) {
const bare = l.replace(/\x1b\[[0-9;]*m/g, "");
assert.doesNotMatch(bare, /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u, label);
}
}
const shots = await everyScreen(t, 120, 36, true);
assert.match(shots.find(([l]) => l === "inbox detail")[1].join("\n"), /\^\[\[31mred\^\[\[0m <U\+202E>evil/);
});
test("NO_COLOR: no escape codes, except reverse video on the selected row", async (t) => {
for (const [label, frame] of await everyScreen(t, 120, 36, false)) {
for (const l of frame) assert.doesNotMatch(l.replace(/\x1b\[(7|27)m/g, ""), /\x1b/, label);
}
// With nothing to select, nothing at all.
const a = new App({ business: "demo", transport: fake({ inbox: [], tasks: [], agents: [] }), color: false, now: () => NOW, refreshMs: 0 });
await a.start();
for (const key of ["t", "a", "c", "r", "?"]) {
await a.input(key);
assert.doesNotMatch(a.render().join("\n"), /\x1b/, key);
}
a.quit("q");
// The selected row is reversed, with colour and without.
const [[, plain]] = await everyScreen(t, 120, 36, false);
assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
// The colour build does use colour.
const [[, coloured]] = await everyScreen(t, 120, 36, true);
assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/);
});
test("1-9 shows the decide flow's own lines and question; only y resolves", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
await s.a.start();
assert.match(s.screen(), new RegExp(d.id.slice(0, 8)));
assert.match(s.a.state.status, /Press 1-2 to choose/);
await s.a.input("1");
const box = s.screen();
assert.match(box, /your choice: yes \(Allow\); this authorizes the action/);
assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`));
assert.match(box, /y resolves · any other key cancels \(the default is No\)/);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y");
await s.a.input("y");
assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]);
assert.deepEqual(s.read("inbox"), []);
assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
assert.equal(s.a.state.overlay, null);
assert.equal(s.a.resolved, 1);
assert.match(s.screen(), /Nothing is waiting on you\./);
// The second option declines.
const e = s.raise("git.push.protected", { target: "next" });
await s.a.input("i");
await s.a.input("2");
assert.match(s.screen(), /your choice: no \(Decline\); this declines the action/);
await s.a.input("Y");
assert.equal(s.a.state.status, `resolved ${e.id}: no`);
s.a.input("q");
assert.deepEqual(s.out.quit, { how: "q", resolved: 2, error: undefined });
assert.equal(quitLine(s.out.quit), "(app closed with q; 2 decisions resolved this session)");
assert.equal(quitLine({ how: "Ctrl-C", resolved: 1 }), "(app closed with Ctrl-C; 1 decision resolved this session)");
});
test("the app shows the same decide text as mosaic decide", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
// Without --yes and off a terminal, mosaic decide prints the decision and
// the choice, then refuses to ask: the lines before its question.
const x = io();
assert.equal(await exitOf(main(["decide", d.id, "no", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport })), 4);
await s.a.start();
await s.a.input("2");
assert.deepEqual(s.a.state.overlay.lines, x.out.text.trimEnd().split("\n"));
assert.equal(s.a.state.overlay.asking, `resolve ${d.id.slice(0, 8)} with no? [y/N] `);
await s.a.input("n");
});
test("any key but y or Y at the question answers No; nothing is sent", async (t) => {
for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) {
const s = app(t);
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking, JSON.stringify(key));
await s.a.input(key);
assert.equal(s.a.state.overlay, null, JSON.stringify(key));
assert.equal(s.a.state.status, "not resolved");
assert.equal(s.read("inbox").length, 1);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
}
});
test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => {
const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " ");
const notSent = (s, label) => {
assert.equal(s.a.state.overlay, null, label);
assert.equal(s.a.state.status, "not resolved", label);
assert.equal(s.read("inbox").length, 1, label);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
};
// Too small from the start: the frame says a question is open, and y answers No.
for (const [cols, rows] of [[30, 24], [80, 9]]) {
const s = app(t, { cols, rows });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`);
assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`);
assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`);
await s.a.input("y");
notSent(s, `${cols}x${rows}`);
assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone");
}
// Shrunk while the question is open.
const s = app(t);
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.match(s.screen(), /y resolves/);
s.a.resize(30, 24);
assert.match(flat(s), /A decision question is open; any key answers No\./);
await s.a.input("Y");
notSent(s, "shrunk");
// Shrunk and grown back before the key: the question is on screen again, so y resolves.
const g = app(t);
const d = g.raise("git.push.protected", { target: "refactor" });
await g.a.start();
await g.a.input("1");
g.a.resize(30, 24);
g.a.resize(80, 24);
assert.match(g.screen(), /y resolves/);
await g.a.input("y");
assert.equal(g.a.state.status, `resolved ${d.id}: yes`);
});
test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => {
const boom = new Error("the terminal write failed");
let fail = false;
const s = app(t, {
write: () => {
if (fail) throw boom;
},
});
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking);
fail = true;
await s.a.input("y");
assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom });
for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r));
assert.equal(s.a.job, null, "the decide job ended");
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
});
test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
const asking = (s) => Boolean(s.a.state.overlay?.asking);
const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
// P2a: "1 yes" pasted on the Inbox.
const a = app(t);
a.raise("git.push.protected", { target: "refactor" });
await a.a.start();
await a.a.input("1 yes");
assert.ok(asking(a), "P2a");
assert.equal(sent(a), 0, "P2a");
// P2b: "decide 1a2b yes" typed without the colon.
const b = app(t);
b.raise("git.push.protected", { target: "refactor" });
await b.a.start();
await b.a.input("decide 1a2b yes");
assert.ok(asking(b), "P2b");
assert.equal(sent(b), 0, "P2b");
// P2c: 1 and y as separate events, before the question is drawn.
const c = app(t);
c.raise("git.push.protected", { target: "refactor" });
await c.a.start();
await Promise.all([c.a.input("1"), c.a.input("y")]);
assert.ok(asking(c), "P2c");
assert.equal(sent(c), 0, "P2c");
// A y that arrives after the question is drawn but within askMs, as keys
// typed while a read blocked the screen do: one at once, one 10 ms later.
let early;
let d;
d = app(t, {
askMs: 300,
write: (text) => {
if (!early && text.includes("[y/N]")) {
void d.a.input("y");
early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10));
}
},
});
d.raise("git.push.protected", { target: "refactor" });
await d.a.start();
await d.a.input("1");
await early;
assert.ok(asking(d), "within askMs");
assert.equal(sent(d), 0, "within askMs");
while (!d.a.answer) await new Promise((r) => setTimeout(r, 10));
// After that, y resolves.
for (const s of [a, b, c, d]) {
await s.a.input("y");
assert.equal(sent(s), 1);
assert.match(s.a.state.status, /^resolved /);
}
});
test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => {
for (const askMs of [0, 60000]) {
const s = app(t, { askMs });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
const pending = s.a.input("1");
while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r));
s.a.quit("SIGTERM");
await pending;
while (s.a.job) await new Promise((r) => setImmediate(r));
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`);
assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined });
}
});
test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor" });
s.raise("git.push.protected", { target: "next" });
await s.a.start();
const reads = () => s.calls.filter((c) => c.verb === "inbox").length;
const before = reads();
await s.a.input("?");
await s.a.input("\0refresh");
assert.equal(reads(), before, "help open");
await s.a.input("\x1b:");
await s.a.input("\0refresh");
assert.equal(reads(), before, "command line open");
await s.a.input("\x1b");
await s.a.input("\0refresh");
assert.equal(reads(), before + 1, "nothing open");
await s.a.input("1");
const asked = reads();
await s.a.input("\0refresh");
assert.ok(s.a.state.overlay?.asking, "a tick is not an answer");
assert.equal(reads(), asked);
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
await s.a.input("\0refresh");
assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4");
assert.equal(s.a.state.data.inbox.list.length, 1);
});
test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => {
const s = app(t, { cols: 80, rows: 24 });
const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ");
assert.ok(question.length > 530);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
await s.a.start();
await s.a.input("1");
const screen = s.screen();
for (const want of [`decision ${d.id}`, " question: word0 word1", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves", "question cut to fit; the whole text is in mosaic inbox --json"]) {
assert.ok(screen.includes(want), `${want}\n${screen}`);
}
assert.ok(!screen.includes("word89"), "the end of the question is what goes");
fits(s.a.frame, 80, 24, "long question");
});
// A gated, blocking decision with a task, and every line its decide box
// must show before y can resolve it (Dewey B1, round 3).
function gated(t, cols, rows, long) {
const s = app(t, { cols, rows });
const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"];
return { ...s, d, keep };
}
// Box borders and wrapping removed, so a line broken across rows still matches.
const squash = (text) => text.replace(/[\s│]/g, "");
test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => {
const hidden = async (s, key, label) => {
assert.ok(s.a.state.overlay?.asking, label);
assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
assert.doesNotMatch(s.screen(), /y resolves/, label);
await s.a.input(key);
assert.equal(s.a.state.overlay, null, label);
assert.equal(s.a.state.status, "not resolved", label);
assert.equal(s.read("inbox").length, 1, label);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
};
// Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20.
for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) {
const s = gated(t, cols, rows, long);
await s.a.start();
await s.a.input("1");
await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`);
}
// One row more and the whole box shows, so y resolves.
for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) {
const s = gated(t, cols, rows, long);
await s.a.start();
await s.a.input("1");
for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`);
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`);
}
// Shrunk after the question is armed: checked at the key.
const s = gated(t, 80, 24, false);
await s.a.start();
await s.a.input("1");
assert.match(s.screen(), /y resolves/);
s.a.resize(80, 15);
await hidden(s, "Y", "shrunk to 80x15");
});
test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => {
for (const long of [false, true]) {
const s = gated(t, 120, 40, long);
await s.a.start();
await s.a.input("1");
const seen = { shown: 0, hidden: 0 };
for (let cols = 40; cols <= 120; cols += 4) {
for (let rows = 10; rows <= 40; rows++) {
const frame = draw(s.a.state, { cols, rows, color: false, now: NOW });
const label = `${cols}x${rows}${long ? " long" : ""}`;
fits(frame, cols, rows, label);
const text = frame.map(strip).join("\n");
if (decideShown(s.a.state.overlay, cols, rows)) {
seen.shown++;
for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`);
} else {
seen.hidden++;
assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
assert.doesNotMatch(text, /y resolves/, label);
}
}
}
assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen));
s.a.quit("q");
}
});
test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
let resolves = 0;
const s = app(t, {
wrap: (call) => async (verb, args) => {
if (verb !== "decision.resolve") return call(verb, args);
resolves += 1;
const e = new Error("outcome-unknown");
e.code = "outcome-unknown";
throw e;
},
});
const d = s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
await s.a.input("y");
assert.equal(resolves, 1);
assert.equal(s.a.state.overlay.kind, "unknown");
assert.equal(s.a.state.overlay.message, `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`);
assert.match(s.screen(), /Nothing was retried, and the choice was not sent twice\./);
await s.a.input("1y:decide x yes\ry");
assert.equal(resolves, 1, "no key sends the choice again");
await s.a.input("v");
assert.equal(s.a.state.view, "trail");
assert.equal(s.a.state.data.trail.subject, d.id);
assert.ok(s.a.state.data.trail.list.length > 0);
// The transport's own "did not finish" error has no code, only the
// hyphenated message; it opens the same box, and Esc closes it.
const hung = app(t, {
wrap: (call) => async (verb, args) => {
if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1);
return call(verb, args);
},
});
hung.raise("git.push.protected", { target: "refactor" });
await hung.a.start();
await hung.a.input("1");
await hung.a.input("y");
assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]);
await hung.a.input("\x1b");
assert.equal(hung.a.state.overlay, null);
});
test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => {
let fail = null;
const s = app(t, { wrap: (call) => async (verb, args) => {
if (fail && verb === "inbox") {
const e = new Error(fail);
e.code = fail;
throw e;
}
return call(verb, args);
} });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
fail = "invalid-response";
await s.a.input("i");
assert.equal(s.a.state.data.inbox.list.length, 1);
assert.match(s.screen(), /stale: invalid-response/);
assert.equal(s.a.state.status, "read failed: invalid-response");
fail = "human-required";
await s.a.input("i");
assert.equal(s.a.state.status, "refused: human-required");
assert.equal(s.a.state.data.inbox.refused, true);
fail = null;
const decide = app(t, { wrap: (call) => async (verb, args) => {
if (verb !== "decision.resolve") return call(verb, args);
const e = new Error("unauthenticated");
e.code = "unauthenticated";
throw e;
} });
decide.raise("git.push.protected", { target: "refactor" });
await decide.a.start();
await decide.a.input("1");
await decide.a.input("y");
assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
});
test("keys: views, moving, trail of a selection, the command line, help and quit", async (t) => {
const s = app(t);
const a = s.raise("git.push.protected", { target: "one" });
const b = s.raise("git.push.protected", { target: "two" });
await s.a.start();
await s.a.input("j");
assert.equal(s.a.state.sel.inbox, 1);
await s.a.input("\x1b[B\x1b[B");
assert.equal(s.a.state.sel.inbox, 1, "the selection stops at the last row");
await s.a.input("k");
await s.a.input("v");
assert.equal(s.a.state.data.trail.subject, s.read("inbox")[0].id);
await s.a.input("a");
assert.match(s.screen(), /coder-run/);
await s.a.input(":trail " + b.id + "\r");
assert.equal(s.a.state.data.trail.subject, b.id);
await s.a.input(":decide " + a.id.slice(0, 8) + " no\r");
assert.equal(s.a.state.overlay.kind, "decide");
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${a.id}: no`);
await s.a.input(":nope\r");
assert.match(s.a.state.status, /unknown command :nope/);
await s.a.input(":abc\x7f\x7f\x7fq\r");
assert.deepEqual(s.out.quit, { how: "q", resolved: 1, error: undefined });
// ? opens help; Esc closes it; Ctrl-C quits.
const h = app(t);
await h.a.start();
await h.a.input("?");
assert.match(h.screen(), /Decisions resolve only here or with mosaic decide/);
await h.a.input("\x1b");
assert.equal(h.a.state.overlay, null);
await h.a.input("\x03");
assert.equal(h.out.quit.how, "Ctrl-C");
assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]);
// Arrows on the command line move nothing and type nothing.
await h.a.start();
const c = app(t);
await c.a.start();
await c.a.input(":ab\x1b[A\x1b[Bc");
assert.equal(c.a.state.command, "abc");
// v on Tasks opens the trail of the task.
const f = app(t, { transport: fake() });
await f.a.start();
await f.a.input("t");
await f.a.input("v");
assert.equal(f.a.state.data.trail.subject, "vikunja:1/7");
assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/);
// :business switches: the old business's lists and selections go.
await f.a.input("t");
await f.a.input("j");
assert.equal(f.a.state.sel.tasks, 1);
const calls = [];
f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []);
await f.a.input(":business other\r");
assert.equal(f.a.state.business, "other");
assert.equal(f.a.state.sel.tasks, 0);
assert.equal(f.a.state.data.trail, undefined);
assert.deepEqual(f.a.state.data.tasks.list, []);
assert.deepEqual(calls, [["other", "tasks"]]);
});
test("one option: the status line says Press 1, not 1-1", async (t) => {
const base = fake();
const s = app(t, {
transport: () => async (verb) => {
const rows = await base()(verb);
if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1)));
return rows;
},
});
await s.a.start();
assert.match(s.screen(), /Press 1 to choose\./);
const two = app(t);
two.raise("git.push.protected", { target: "refactor" });
await two.a.start();
assert.match(two.screen(), /Press 1-2 to choose\./);
});
test("no business: the app says so and :business picks one", async (t) => {
const s = app(t, { business: null });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
assert.match(s.screen(), /No bus host runs here, so the app knows no business\./);
assert.equal(s.calls.length, 0);
await s.a.input(":business demo\r");
assert.equal(s.a.state.business, "demo");
assert.equal(s.a.state.data.inbox.list.length, 1);
});
test("mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one", async (t) => {
const dataRoot = tmp(t);
const tty = (env = humanEnv()) => {
const x = io(env);
x.stdin.isTTY = true;
x.stdout.isTTY = true;
return x;
};
// An agent run refuses before anything else, terminal or not.
for (const marker of ["CLAUDECODE", "MOSAIC_AGENT_RUN", "PI_AGENT_DIR"]) {
const x = tty({ ...humanEnv(), [marker]: "1" });
const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
assert.equal(err.exitCode, 3);
assert.equal(err.message, `refused: this runs only from a human shell, outside any agent run (found ${marker} in the environment)`);
}
// Not a terminal on stdin, or on stdout: today's usage, exit 4.
for (const [inTty, outTty] of [[false, true], [true, false], [false, false]]) {
const x = io();
x.stdin.isTTY = inTty;
x.stdout.isTTY = outTty;
const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
assert.equal(err.exitCode, 4);
assert.ok(err.message.endsWith(`\n${SEAT_USAGE}\n${USAGE}`), err.message);
}
// On a terminal: the app, then the quit line.
const x = tty(humanEnv({ NO_COLOR: "1" }));
let opened;
await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
assert.equal(opened.business, null, "no host state, no business");
assert.equal(opened.color, false);
assert.equal(x.out.text, "(app closed with q; 0 decisions resolved this session)\n");
});
test("scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted", () => {
const env = humanEnv({ MOSAIC_CONFIG: "/nonexistent/config.json" });
const app = spawnSync(join(REPO, "scripts", "mosaic"), [], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(app.status, 4, app.stderr);
assert.ok(app.stderr.startsWith("mosaic: the app needs a terminal on stdin and stdout"), app.stderr);
assert.ok(app.stderr.endsWith(`${SEAT_USAGE}\n${USAGE}\n`));
const agent = spawnSync(join(REPO, "scripts", "mosaic"), [], { env: { ...env, CLAUDECODE: "1" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(agent.status, 3);
const seat = spawnSync(process.execPath, [join(REPO, "packages", "seat", "src", "cli.mjs")], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(seat.status, 4);
assert.equal(seat.stderr, `refused: ${SEAT_USAGE}\n`);
});
test("strings to keep exactly live once, in the decide flow and the transport; the app does not restate them", () => {
const src = (f) => readFileSync(join(here, "..", "src", f), "utf8");
const cli = src("cli.mjs");
for (const s of [
"`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : \"\"}\\n`",
'"this authorizes the action"',
'"this declines the action"',
"`resolve ${shortId(d.id)} with ${choice}? [y/N] `",
"`resolved ${d.id}: ${choice}\\n`",
"`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`",
])
assert.ok(cli.includes(s), s);
assert.ok(src("transport.mjs").includes("`refused: this runs only from a human shell, outside any agent run (found ${found.join(\", \")} in the environment)`"));
for (const f of ["app.mjs", "screens.mjs"]) {
for (const s of ["authorizes the action", "declines the action", "[y/N]", "may have recorded it", "runs only from a human shell"]) assert.ok(!src(f).includes(s), `${f} restates ${s}`);
}
});
test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => {
const s = broker(t);
const d = s.raise("git.push.protected", { target: "refactor" });
const call = s.transport("demo");
const asked = [];
const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e);
assert.equal(err.message, "not resolved");
assert.equal(asked.length, 1);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
// Off a terminal, --note without --yes refuses before anything is sent.
const x = io(humanEnv());
x.stdin.isTTY = false;
const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e);
assert.equal(off.exitCode, 4);
assert.match(off.message, /stdin is not a terminal; pass --yes/);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
});
test("the app's business comes from a live bus host only", async (t) => {
for (const [startTime, want] of [
["not-this-process", null],
[startTimeOf(process.pid), "demo"],
]) {
const dataRoot = tmp(t);
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
const x = io(humanEnv());
x.stdin.isTTY = true;
x.stdout.isTTY = true;
let opened;
await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
assert.equal(opened.business, want, startTime);
}
});
// A terminal for runApp: raw mode and listeners recorded, writes kept.
function terminal({ throwOnResize = false, throwOnFrame = false } = {}) {
const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
const write = (s) => {
if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed");
stdout.writes.push(s);
};
const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 });
let resized = false;
Object.defineProperty(stdout, "columns", {
get: () => {
if (resized && throwOnResize) throw new Error("columns failed");
return 80;
},
});
const signals = new EventEmitter();
const resize = () => ((resized = true), stdout.emit("resize"));
return { io: { stdin, stdout }, stdin, stdout, signals, resize };
}
test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => {
const cases = [
["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
["end of input", (x) => x.stdin.emit("end")],
["SIGTERM", (x) => x.signals.emit("SIGTERM")],
["SIGINT", (x) => x.signals.emit("SIGINT")],
["SIGHUP", (x) => x.signals.emit("SIGHUP")],
["an error", (x) => x.resize(), { throwOnResize: true }],
["an error", () => {}, { throwOnFrame: true }],
];
for (const [how, act, opts] of cases) {
const x = terminal(opts);
// Handled at once: an error on the first frame rejects before the next tick.
const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then(
(r) => r,
(e) => ({ how: "an error", error: e }),
);
await new Promise((r) => setImmediate(r));
act(x);
const result = await running;
assert.equal(result.how, how);
if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed");
assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
assert.deepEqual(x.stdin.raw, [true, false], how);
for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`);
assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how);
}
});
test("draw is pure: same state, same frame", () => {
const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" };
assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW }));
});
+18 -5
View File
@@ -1,8 +1,9 @@
// `scripts/mosaic queue` (A2). The dispatch must leave every other call to
// scripts/mosaic exactly as it was: the seat CLI sees the same argv, working
// directory, environment and stdin under the new script as under the pre-A2
// copy in fixtures/mosaic-pre-a2.sh. Both CLIs are fakes that record what
// they got; nothing here runs a seat or touches a queue.
// copy in fixtures/mosaic-pre-a2.sh. Row 55 (Sage, 2026-10-11) sends the call
// with no argument to the app in packages/cli instead. The CLIs are fakes that
// record what they got; nothing here runs a seat or touches a queue.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
@@ -23,13 +24,13 @@ process.stderr.write("fake err\\n");
process.exitCode = Number(process.env.EXIT);
`;
// A root holding `script` as scripts/mosaic and both fake CLIs.
// A root holding `script` as scripts/mosaic and the fake CLIs.
function root(t, script) {
const r = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-dispatch-")));
t.after(() => rmSync(r, { recursive: true, force: true }));
mkdirSync(join(r, "scripts"));
cpSync(script, join(r, "scripts/mosaic"));
for (const pkg of ["seat", "queue"]) {
for (const pkg of ["seat", "queue", "cli"]) {
mkdirSync(join(r, `packages/${pkg}/src`), { recursive: true });
writeFileSync(join(r, `packages/${pkg}/src/cli.mjs`), FAKE);
}
@@ -52,7 +53,6 @@ function call(r, args, exit = 7) {
const SEAT_CALLS = [
["launch", "--repo", "/srv/repo", "--harness", "pi", "darkwing"],
["seat", "task", "darkwing", "text with two spaces", ""],
[],
[""],
["launch", "queue"],
["Queue", "list"],
@@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
}
});
test("no argument reaches the app entry in packages/cli, and only that call", (t) => {
const r = root(t, join(SCRIPTS, "mosaic"));
const got = call(r, []);
assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
const c = JSON.parse(got.capture);
assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]);
// Before row 55 the same call went to the seat CLI, with the same
// environment the app now gets.
const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
assert.deepEqual(c.env, JSON.parse(before.capture).env);
});
test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {
const r = root(t, join(SCRIPTS, "mosaic"));
for (const rest of [["list"], [], ["note", "9", "text with spaces", "--op", "note-9-000001"], ["queue"], [""]]) {