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:
+74
-2
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(" ");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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"], [""]]) {
|
||||
|
||||
Reference in New Issue
Block a user