The full-screen terminal app (#1544): BUILD.md, build.patch overfe5c68fa(9 files), candidate manifest, the gate logs on23d566d6and the pty smoke script. The candidate itself is not committed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1542 lines
69 KiB
Diff
1542 lines
69 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
||
index b96e9012..5a675611 100644
|
||
--- a/docs/TOOLS.md
|
||
+++ b/docs/TOOLS.md
|
||
@@ -193,6 +193,7 @@ config problem · `4` usage or a required file missing. Details:
|
||
## Inbox, decisions and the bus host (`scripts/mosaic`)
|
||
|
||
```bash
|
||
+scripts/mosaic # no argument: the full-screen app
|
||
scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
|
||
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
|
||
scripts/mosaic trail <task|decision> [--json]
|
||
@@ -226,6 +227,10 @@ sends). `stop` sends SIGTERM to a run's runner after checking it in
|
||
the business; running sessions keep running. `launches list` reads
|
||
`sessions.json` and needs no broker. `talk`, `stop` and `launches off|on`
|
||
refuse inside an agent run.
|
||
+With no argument on a terminal, `scripts/mosaic` opens the full-screen app:
|
||
+Inbox, Tasks, Agents, Trail and Help, deciding through the same flow and
|
||
+strings as `decide`. In an agent run it exits 3; without a terminal on
|
||
+stdin and stdout it exits 4 with the usage.
|
||
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||
|
||
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
||
index 32977afa..3c3255f5 100644
|
||
--- a/packages/cli/README.md
|
||
+++ b/packages/cli/README.md
|
||
@@ -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,52 @@ 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. 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; only `y` resolves. `n`, Esc, Enter and `q` cancel, and other
|
||
+ keys wait. 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.
|
||
+- 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 the app restores the screen and the cursor, then prints
|
||
+ `(app closed with q; N decisions resolved this session)`.
|
||
+
|
||
## Bus host
|
||
|
||
```
|
||
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
|
||
new file mode 100644
|
||
index 00000000..208bc41f
|
||
--- /dev/null
|
||
+++ b/packages/cli/src/app.mjs
|
||
@@ -0,0 +1,447 @@
|
||
+// 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, and only y resolves. 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; ? help; Esc back; q or Ctrl-C quits. 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 { draw } from "./screens.mjs";
|
||
+import { busExit } from "./transport.mjs";
|
||
+
|
||
+export const REFRESH_MS = 30000;
|
||
+
|
||
+// 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 }) {
|
||
+ this.transport = transport;
|
||
+ this.write = write;
|
||
+ this.cols = cols;
|
||
+ this.rows = rows;
|
||
+ this.color = color;
|
||
+ this.now = now;
|
||
+ this.onQuit = onQuit;
|
||
+ 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.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) {
|
||
+ this.answer(this.keys.shift());
|
||
+ 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. n, Esc, Enter, q
|
||
+ // and Ctrl-C say no; other keys are ignored.
|
||
+ #ask(question) {
|
||
+ this.state.overlay = { ...this.state.overlay, asking: question };
|
||
+ this.render();
|
||
+ return new Promise((resolve) => {
|
||
+ const take = (k) => {
|
||
+ if (k === "y" || k === "Y") resolve(true);
|
||
+ else if (["n", "N", "esc", "enter", "q", "ctrl-c"].includes(k)) resolve(false);
|
||
+ else return void (this.answer = take);
|
||
+ this.answer = null;
|
||
+ this.state.overlay = { ...this.state.overlay, asking: null };
|
||
+ this.render();
|
||
+ };
|
||
+ this.answer = take;
|
||
+ this.wake?.();
|
||
+ });
|
||
+ }
|
||
+
|
||
+ #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;
|
||
+ if (k === "start" || k === "refresh") return k === "refresh" && (s.overlay || s.command !== null) ? null : this.#read(s.view);
|
||
+ 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() };
|
||
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press 1-${Math.min(9, this.#selected("inbox")?.options.length ?? 1)} 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 <= 2 && 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.answer?.("q");
|
||
+ 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 and on SIGTERM.
|
||
+// Resolves with { how, resolved }.
|
||
+export function runApp({ io, business, transport, color, refreshMs }) {
|
||
+ const { stdin, stdout } = io;
|
||
+ return new Promise((resolve, reject) => {
|
||
+ let app;
|
||
+ const restore = () => {
|
||
+ stdin.off("data", onData);
|
||
+ stdin.off("end", onEnd);
|
||
+ stdout.off?.("resize", onResize);
|
||
+ process.off("SIGTERM", onTerm);
|
||
+ stdin.setRawMode?.(false);
|
||
+ stdin.pause?.();
|
||
+ stdout.write("\x1b[?25h\x1b[?1049l");
|
||
+ };
|
||
+ const onData = (c) => void app.input(c.toString("utf8"));
|
||
+ const onEnd = () => app.quit("end of input");
|
||
+ const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);
|
||
+ const onTerm = () => app.quit("SIGTERM");
|
||
+ app = new App({
|
||
+ business,
|
||
+ transport,
|
||
+ color,
|
||
+ refreshMs,
|
||
+ 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);
|
||
+ process.on("SIGTERM", onTerm);
|
||
+ stdin.resume?.();
|
||
+ void app.start();
|
||
+ });
|
||
+}
|
||
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
||
index 0b48c7a7..8cb07c52 100644
|
||
--- a/packages/cli/src/cli.mjs
|
||
+++ b/packages/cli/src/cli.mjs
|
||
@@ -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);
|
||
diff --git a/packages/cli/src/format.mjs b/packages/cli/src/format.mjs
|
||
index 381596b4..7bbfe39c 100644
|
||
--- a/packages/cli/src/format.mjs
|
||
+++ b/packages/cli/src/format.mjs
|
||
@@ -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(" ");
|
||
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
|
||
new file mode 100644
|
||
index 00000000..d6e09981
|
||
--- /dev/null
|
||
+++ b/packages/cli/src/screens.mjs
|
||
@@ -0,0 +1,367 @@
|
||
+// 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;
|
||
+
|
||
+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: 0 for combining marks, joiners and
|
||
+// variation selectors, 2 for East Asian wide characters and emoji, else 1.
|
||
+function cells(c) {
|
||
+ const n = c.codePointAt(0);
|
||
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0f)) return 0;
|
||
+ if (
|
||
+ (n >= 0x1100 && n <= 0x115f) ||
|
||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
|
||
+ (n >= 0xac00 && n <= 0xd7a3) ||
|
||
+ (n >= 0xf900 && n <= 0xfaff) ||
|
||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
|
||
+ (n >= 0xff00 && n <= 0xff60) ||
|
||
+ (n >= 0xffe0 && n <= 0xffe6) ||
|
||
+ (n >= 0x1f300 && n <= 0x1faff) ||
|
||
+ (n >= 0x20000 && n <= 0x3fffd)
|
||
+ )
|
||
+ return 2;
|
||
+ return 1;
|
||
+}
|
||
+
|
||
+export function width(s) {
|
||
+ let w = 0;
|
||
+ for (const c of s) w += cells(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;
|
||
+ for (const c of s) {
|
||
+ const n = cells(c);
|
||
+ if (used + n > w - 1) break;
|
||
+ out += c;
|
||
+ used += n;
|
||
+ }
|
||
+ return out + "…" + " ".repeat(w - 1 - used);
|
||
+}
|
||
+
|
||
+// Splits `s` into lines of at most `w` cells, at spaces where it can;
|
||
+// later lines start with `indent`.
|
||
+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 c of word) {
|
||
+ if (width(cur) + cells(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(w - 2)}┘`, "accent"]));
|
||
+ return out;
|
||
+}
|
||
+
|
||
+function overlayBox(o, cols) {
|
||
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
|
||
+ const w = Math.min(84, cols - 2);
|
||
+ if (o.kind === "decide") {
|
||
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
|
||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · n, Esc or Enter cancels (the default is No)", "muted"]);
|
||
+ else lines.push("", ["resolving…", "muted"]);
|
||
+ return box("decide", lines, 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() }) {
|
||
+ 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);
|
||
+ // Too tall: cut the middle, never the choice, the prompt or the keys.
|
||
+ if (b.length > room) {
|
||
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
|
||
+ const tail = b.slice(at > 0 ? at : -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"})`, inner), [" │", "accent"]), ...tail].slice(-room);
|
||
+ }
|
||
+ 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));
|
||
+}
|
||
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
|
||
new file mode 100644
|
||
index 00000000..b88b2230
|
||
--- /dev/null
|
||
+++ b/packages/cli/tests/app.test.mjs
|
||
@@ -0,0 +1,427 @@
|
||
+import { test } from "node:test";
|
||
+import assert from "node:assert/strict";
|
||
+import { spawnSync } from "node:child_process";
|
||
+import { readFileSync } from "node:fs";
|
||
+import { dirname, join } from "node:path";
|
||
+import { fileURLToPath } from "node:url";
|
||
+import { App, parseKeys, quitLine } from "../src/app.mjs";
|
||
+import { main, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
||
+import { CliError } from "../src/errors.mjs";
|
||
+import { draw, strip, width } 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],
|
||
+];
|
||
+
|
||
+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,
|
||
+ 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 = {
|
||
+ inbox: [1, 2, 3].map((n) => decision(n)),
|
||
+ 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: "Short", 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}`);
|
||
+ }
|
||
+ 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");
|
||
+ 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}`);
|
||
+ }
|
||
+ }
|
||
+ // 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]), /Inbox/);
|
||
+ assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
|
||
+ const [narrow] = await everyScreen(t, 99, 24, false);
|
||
+ assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
|
||
+});
|
||
+
|
||
+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 colour build does use colour.
|
||
+ const [[, coloured]] = await everyScreen(t, 120, 36, true);
|
||
+ 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 · n, Esc or Enter 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("n, Esc, Enter and q at the question cancel; other keys wait; nothing is sent", async (t) => {
|
||
+ for (const key of ["n", "N", "\x1b", "\r", "q"]) {
|
||
+ const s = app(t);
|
||
+ s.raise("git.push.protected", { target: "refactor" });
|
||
+ await s.a.start();
|
||
+ await s.a.input("1");
|
||
+ await s.a.input("x");
|
||
+ assert.ok(s.a.state.overlay?.asking, `${JSON.stringify(key)}: still asking after x`);
|
||
+ 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 at the question cancels; it does not quit");
|
||
+ }
|
||
+});
|
||
+
|
||
+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);
|
||
+});
|
||
+
|
||
+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("1y");
|
||
+ 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"]);
|
||
+});
|
||
+
|
||
+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("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 }));
|
||
+});
|
||
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
|
||
index aa425c0b..ddd341b8 100644
|
||
--- a/packages/queue/tests/dispatch.test.mjs
|
||
+++ b/packages/queue/tests/dispatch.test.mjs
|
||
@@ -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,17 @@ 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.
|
||
+ const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
|
||
+ assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
|
||
+});
|
||
+
|
||
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"], [""]]) {
|
||
diff --git a/scripts/mosaic b/scripts/mosaic
|
||
index b4e3a614..1f43dcb1 100755
|
||
--- a/scripts/mosaic
|
||
+++ b/scripts/mosaic
|
||
@@ -11,6 +11,10 @@
|
||
# repository-local and only reachable as scripts/mosaic.
|
||
set -euo pipefail
|
||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
+# No argument: the full-screen app on a terminal (packages/cli/README.md).
|
||
+if [ $# -eq 0 ]; then
|
||
+ exec node "$REPO/packages/cli/src/cli.mjs"
|
||
+fi
|
||
if [ "${1:-}" = queue ]; then
|
||
shift
|
||
exec node "$REPO/packages/queue/src/cli.mjs" "$@"
|