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

Bare `mosaic` on a terminal opens the app. It has Inbox, Tasks, Agents
and Trail screens, help, and a decide overlay, and it uses the alternate
screen, raw mode, resize and SIGTERM, and restores the terminal on exit.
draw(state) in screens.mjs is pure. cli.mjs exports resolveDecision,
which `mosaic decide` and the app share, so both write the same lines
and ask the same question. A decision resolves on `y` only while
decideShown() says the whole decide box is on screen. The app reads that
at the key. A box that doesn't fit is replaced by "Too small to show this
decision", and any key answers No. scripts/mosaic sends no arguments to
packages/cli (a decided scope addition), so the queue dispatch test
follows. The cli README and docs/TOOLS.md describe the app.

Darkwing built it (agents/darkwing/work/queue-55), in three rounds.
Round 3 was approved by Dewey (27308) and Filbert (27309) on manifest
59f1b9b4, 9 paths. Their non-blocking notes go to row 69 (#1562): a
narrow-terminal test for the decideShown MIN_COLS guard, a way out from
the hidden box, a U+18D08 width test, a cut box keeping Esc back, and
the README row count stated as an example.

Sage's gate on 3634d06e, sequential and teed: node suites cli 110 and
queue 150, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed (Docker on).
122b1223..3634d06e touches none of the 9 paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 16:06:11 -05:00
co-authored by Claude Opus 5.5
parent 1e71d74670
commit 79d9fc2d56
9 changed files with 1932 additions and 16 deletions
+868
View File
@@ -0,0 +1,868 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { App, parseKeys, quitLine, runApp } from "../src/app.mjs";
import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs";
import { broker, humanEnv, io, tmp } from "./helpers.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const REPO = join(here, "..", "..", "..");
const NOW = Date.parse("2026-10-11T18:00:00.000Z");
const SIZES = [
[120, 36],
[80, 24],
[100, 30],
[99, 24],
[40, 10],
];
// Below 40x10 the frame says the terminal is too small.
const TOO_SMALL = [
[39, 10],
[40, 9],
[3, 3],
[1, 1],
];
const exitOf = async (p) => {
try {
await p;
return 0;
} catch (e) {
if (!(e instanceof CliError)) throw e;
return e.exitCode;
}
};
// An app on the in-process broker. `wrap` may replace verbs of the transport.
function app(t, { wrap = (call) => call, business = "demo", ...opts } = {}) {
const bus = broker(t);
const out = { text: "", quit: null };
const a = new App({
business,
transport: (b) => wrap(bus.transport(b)),
write: (s) => (out.text = s),
now: () => NOW,
refreshMs: 0,
askMs: 0,
onQuit: (r) => (out.quit = r),
...opts,
});
return { ...bus, a, out, screen: () => a.frame.map(strip).join("\n") };
}
// Text a hostile or careless agent could put in a decision: control
// characters, a bidi override, a newline, wide characters and a long word.
const HOSTILE = "Push \x1b[31mred\x1b[0m \u202eevil\u202c to 発行 now?\nSecond line " + "x".repeat(150);
// A transport with one of everything, in the broker's shapes, for the
// screens the broker cannot seed (task snapshots need the tracker adapter).
function fake(extra = {}) {
const decision = (n, more = {}) => ({
id: `${String(n).repeat(8)}-aaaa-4bbb-8ccc-${"d".repeat(12)}`,
action: "git.push.protected",
class: "gated",
blocking: n === 1,
at: "2026-10-11T17:00:00.000Z",
raised_by_role: "coder",
raised_by_run: "coder-run-with-a-long-name-0123456789",
question: n === 1 ? HOSTILE : "Push the release?",
options: [
{ key: "yes", text: "Allow" },
{ key: "no", text: "Decline" },
],
recommendation: "no",
authorization: { action: "git.push.protected", target: "refactor-with-a-very-long-branch-name-indeed", approvalChoice: "yes" },
task_ref: "vikunja:1/7",
...more,
});
const data = {
// Emoji and CJK that are two cells wide (P3b in the round 1 review).
inbox: [
decision(1),
decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }),
decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }),
],
tasks: [
{ seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" },
{ seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null },
],
agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }],
trail: [
{ at: "2026-10-11T17:00:00.000Z", table: "decisions", seq: 4, id: "x", task_ref: "vikunja:1/7", question: HOSTILE },
{ at: "2026-10-11T17:05:00.000Z", table: "events", seq: 9, kind: "task.changed.external", body: { changed: ["bucket"] } },
],
...extra,
};
return () => async (verb) => structuredClone(data[verb] ?? []);
}
function fits(frame, cols, rows, label) {
assert.equal(frame.length, rows, `${label}: ${frame.length} rows`);
frame.forEach((l, i) => assert.equal(width(strip(l)), cols, `${label}, row ${i}: ${JSON.stringify(strip(l))}`));
}
// Every view and overlay, drawn at the given size.
async function everyScreen(t, cols, rows, color) {
const shots = [];
const a = new App({ business: "demo", transport: fake(), cols, rows, color, now: () => NOW, refreshMs: 0 });
const shot = (label) => shots.push([label, [...a.render()]]);
await a.start();
shot("inbox");
for (const [key, label] of [["t", "tasks"], ["a", "agents"], ["c", "talk"]]) {
await a.input(key);
shot(label);
}
await a.input("i");
await a.input("v");
shot("trail");
await a.input("\r");
shot("trail detail");
await a.input("\x1b");
for (const [key, label] of [["t", "tasks detail"], ["a", "agents detail"], ["i", "inbox detail"]]) {
await a.input(key);
await a.input("\r");
shot(label);
await a.input("\x1b");
}
await a.input("?");
shot("help");
await a.input("\x1b");
await a.input(":trail vikunja:1/7");
shot("command line");
await a.input("\x1b");
for (const o of [
{ kind: "decide", lines: ["decision 11111111", ` question: ${HOSTILE}`, "your choice: yes (Allow); this authorizes the action"], asking: "resolve 11111111 with yes? [y/N] " },
{ kind: "decide", lines: Array.from({ length: 40 }, (_, i) => `line ${i}`).concat("your choice: yes (Allow)"), asking: "resolve 11111111 with yes? [y/N] " },
{ kind: "unknown", message: "outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail 11111111-aaaa-4bbb-8ccc-dddddddddddd before trying again", id: "x" },
{ kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
]) {
a.state.overlay = o;
shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
}
a.state.overlay = null;
// Empty, failed-with-stale-data, refused and no-business states.
a.state.data.inbox = { list: [], at: new Date(NOW).toISOString() };
shot("inbox empty");
a.state.data.inbox = { list: await fake()()("inbox"), at: "2026-10-11T17:59:00.000Z", error: "invalid-response" };
shot("inbox stale");
a.state.data.inbox = { error: "human-required", refused: true };
shot("inbox refused");
a.state.business = null;
shot("no business");
assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`);
a.quit("q");
return shots;
}
test("every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row", async (t) => {
for (const [cols, rows] of SIZES) {
for (const color of [true, false]) {
for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`);
}
// A decide box too tall to show, with no question to cut, says the
// decision is hidden and that any key answers No (Dewey B1, round 3).
const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
assert.match(long, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`);
assert.doesNotMatch(long, /y resolves/, `${cols}x${rows}`);
}
// Too small: every screen still fits, and says why.
for (const [cols, rows] of TOO_SMALL) {
for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`);
}
assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/);
// Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only.
const [wide] = await everyScreen(t, 120, 36, false);
assert.match(strip(wide[1][0]), /\[i Inbox 3\]/);
assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
const [narrow] = await everyScreen(t, 99, 24, false);
assert.match(strip(narrow[1][0]), /\[i 3\] t a /);
assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
});
test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) {
assert.equal(width(s), cells, JSON.stringify(s));
}
// East Asian Width W and F outside the blocks above: one from each range.
for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
// A line break never splits ❤️ from its U+FE0F; no line is wider than asked.
assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]);
for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l));
// On screen, independent of width(): each wide character takes two cells
// for one UTF-16 unit, so a row of `cols` cells is that much shorter in
// units. 🅰️ is three units for two cells.
const frames = Object.fromEntries(await everyScreen(t, 80, 24, false));
const row = (label, text) => strip(frames[label].find((l) => l.includes(text)));
assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡");
assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️");
assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut");
});
test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => {
for (const [label, frame] of await everyScreen(t, 120, 36, true)) {
for (const l of frame) {
const bare = l.replace(/\x1b\[[0-9;]*m/g, "");
assert.doesNotMatch(bare, /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/u, label);
}
}
const shots = await everyScreen(t, 120, 36, true);
assert.match(shots.find(([l]) => l === "inbox detail")[1].join("\n"), /\^\[\[31mred\^\[\[0m <U\+202E>evil/);
});
test("NO_COLOR: no escape codes, except reverse video on the selected row", async (t) => {
for (const [label, frame] of await everyScreen(t, 120, 36, false)) {
for (const l of frame) assert.doesNotMatch(l.replace(/\x1b\[(7|27)m/g, ""), /\x1b/, label);
}
// With nothing to select, nothing at all.
const a = new App({ business: "demo", transport: fake({ inbox: [], tasks: [], agents: [] }), color: false, now: () => NOW, refreshMs: 0 });
await a.start();
for (const key of ["t", "a", "c", "r", "?"]) {
await a.input(key);
assert.doesNotMatch(a.render().join("\n"), /\x1b/, key);
}
a.quit("q");
// The selected row is reversed, with colour and without.
const [[, plain]] = await everyScreen(t, 120, 36, false);
assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
// The colour build does use colour.
const [[, coloured]] = await everyScreen(t, 120, 36, true);
assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/);
});
test("1-9 shows the decide flow's own lines and question; only y resolves", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
await s.a.start();
assert.match(s.screen(), new RegExp(d.id.slice(0, 8)));
assert.match(s.a.state.status, /Press 1-2 to choose/);
await s.a.input("1");
const box = s.screen();
assert.match(box, /your choice: yes \(Allow\); this authorizes the action/);
assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`));
assert.match(box, /y resolves · any other key cancels \(the default is No\)/);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y");
await s.a.input("y");
assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]);
assert.deepEqual(s.read("inbox"), []);
assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
assert.equal(s.a.state.overlay, null);
assert.equal(s.a.resolved, 1);
assert.match(s.screen(), /Nothing is waiting on you\./);
// The second option declines.
const e = s.raise("git.push.protected", { target: "next" });
await s.a.input("i");
await s.a.input("2");
assert.match(s.screen(), /your choice: no \(Decline\); this declines the action/);
await s.a.input("Y");
assert.equal(s.a.state.status, `resolved ${e.id}: no`);
s.a.input("q");
assert.deepEqual(s.out.quit, { how: "q", resolved: 2, error: undefined });
assert.equal(quitLine(s.out.quit), "(app closed with q; 2 decisions resolved this session)");
assert.equal(quitLine({ how: "Ctrl-C", resolved: 1 }), "(app closed with Ctrl-C; 1 decision resolved this session)");
});
test("the app shows the same decide text as mosaic decide", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" });
// Without --yes and off a terminal, mosaic decide prints the decision and
// the choice, then refuses to ask: the lines before its question.
const x = io();
assert.equal(await exitOf(main(["decide", d.id, "no", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport })), 4);
await s.a.start();
await s.a.input("2");
assert.deepEqual(s.a.state.overlay.lines, x.out.text.trimEnd().split("\n"));
assert.equal(s.a.state.overlay.asking, `resolve ${d.id.slice(0, 8)} with no? [y/N] `);
await s.a.input("n");
});
test("any key but y or Y at the question answers No; nothing is sent", async (t) => {
for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) {
const s = app(t);
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking, JSON.stringify(key));
await s.a.input(key);
assert.equal(s.a.state.overlay, null, JSON.stringify(key));
assert.equal(s.a.state.status, "not resolved");
assert.equal(s.read("inbox").length, 1);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
}
});
test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => {
const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " ");
const notSent = (s, label) => {
assert.equal(s.a.state.overlay, null, label);
assert.equal(s.a.state.status, "not resolved", label);
assert.equal(s.read("inbox").length, 1, label);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
};
// Too small from the start: the frame says a question is open, and y answers No.
for (const [cols, rows] of [[30, 24], [80, 9]]) {
const s = app(t, { cols, rows });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`);
assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`);
assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`);
await s.a.input("y");
notSent(s, `${cols}x${rows}`);
assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone");
}
// Shrunk while the question is open.
const s = app(t);
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.match(s.screen(), /y resolves/);
s.a.resize(30, 24);
assert.match(flat(s), /A decision question is open; any key answers No\./);
await s.a.input("Y");
notSent(s, "shrunk");
// Shrunk and grown back before the key: the question is on screen again, so y resolves.
const g = app(t);
const d = g.raise("git.push.protected", { target: "refactor" });
await g.a.start();
await g.a.input("1");
g.a.resize(30, 24);
g.a.resize(80, 24);
assert.match(g.screen(), /y resolves/);
await g.a.input("y");
assert.equal(g.a.state.status, `resolved ${d.id}: yes`);
});
test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => {
const boom = new Error("the terminal write failed");
let fail = false;
const s = app(t, {
write: () => {
if (fail) throw boom;
},
});
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
assert.ok(s.a.state.overlay?.asking);
fail = true;
await s.a.input("y");
assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom });
for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r));
assert.equal(s.a.job, null, "the decide job ended");
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
});
test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
const asking = (s) => Boolean(s.a.state.overlay?.asking);
const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
// P2a: "1 yes" pasted on the Inbox.
const a = app(t);
a.raise("git.push.protected", { target: "refactor" });
await a.a.start();
await a.a.input("1 yes");
assert.ok(asking(a), "P2a");
assert.equal(sent(a), 0, "P2a");
// P2b: "decide 1a2b yes" typed without the colon.
const b = app(t);
b.raise("git.push.protected", { target: "refactor" });
await b.a.start();
await b.a.input("decide 1a2b yes");
assert.ok(asking(b), "P2b");
assert.equal(sent(b), 0, "P2b");
// P2c: 1 and y as separate events, before the question is drawn.
const c = app(t);
c.raise("git.push.protected", { target: "refactor" });
await c.a.start();
await Promise.all([c.a.input("1"), c.a.input("y")]);
assert.ok(asking(c), "P2c");
assert.equal(sent(c), 0, "P2c");
// A y that arrives after the question is drawn but within askMs, as keys
// typed while a read blocked the screen do: one at once, one 10 ms later.
let early;
let d;
d = app(t, {
askMs: 300,
write: (text) => {
if (!early && text.includes("[y/N]")) {
void d.a.input("y");
early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10));
}
},
});
d.raise("git.push.protected", { target: "refactor" });
await d.a.start();
await d.a.input("1");
await early;
assert.ok(asking(d), "within askMs");
assert.equal(sent(d), 0, "within askMs");
while (!d.a.answer) await new Promise((r) => setTimeout(r, 10));
// After that, y resolves.
for (const s of [a, b, c, d]) {
await s.a.input("y");
assert.equal(sent(s), 1);
assert.match(s.a.state.status, /^resolved /);
}
});
test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => {
for (const askMs of [0, 60000]) {
const s = app(t, { askMs });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
const pending = s.a.input("1");
while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r));
s.a.quit("SIGTERM");
await pending;
while (s.a.job) await new Promise((r) => setImmediate(r));
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`);
assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined });
}
});
test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => {
const s = app(t);
const d = s.raise("git.push.protected", { target: "refactor" });
s.raise("git.push.protected", { target: "next" });
await s.a.start();
const reads = () => s.calls.filter((c) => c.verb === "inbox").length;
const before = reads();
await s.a.input("?");
await s.a.input("\0refresh");
assert.equal(reads(), before, "help open");
await s.a.input("\x1b:");
await s.a.input("\0refresh");
assert.equal(reads(), before, "command line open");
await s.a.input("\x1b");
await s.a.input("\0refresh");
assert.equal(reads(), before + 1, "nothing open");
await s.a.input("1");
const asked = reads();
await s.a.input("\0refresh");
assert.ok(s.a.state.overlay?.asking, "a tick is not an answer");
assert.equal(reads(), asked);
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
await s.a.input("\0refresh");
assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4");
assert.equal(s.a.state.data.inbox.list.length, 1);
});
test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => {
const s = app(t, { cols: 80, rows: 24 });
const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ");
assert.ok(question.length > 530);
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
await s.a.start();
await s.a.input("1");
const screen = s.screen();
for (const want of [`decision ${d.id}`, " question: word0 word1", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves", "question cut to fit; the whole text is in mosaic inbox --json"]) {
assert.ok(screen.includes(want), `${want}\n${screen}`);
}
assert.ok(!screen.includes("word89"), "the end of the question is what goes");
fits(s.a.frame, 80, 24, "long question");
});
// A gated, blocking decision with a task, and every line its decide box
// must show before y can resolve it (Dewey B1, round 3).
function gated(t, cols, rows, long) {
const s = app(t, { cols, rows });
const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"];
return { ...s, d, keep };
}
// Box borders and wrapping removed, so a line broken across rows still matches.
const squash = (text) => text.replace(/[\s│]/g, "");
test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => {
const hidden = async (s, key, label) => {
assert.ok(s.a.state.overlay?.asking, label);
assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
assert.doesNotMatch(s.screen(), /y resolves/, label);
await s.a.input(key);
assert.equal(s.a.state.overlay, null, label);
assert.equal(s.a.state.status, "not resolved", label);
assert.equal(s.read("inbox").length, 1, label);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
};
// Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20.
for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) {
const s = gated(t, cols, rows, long);
await s.a.start();
await s.a.input("1");
await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`);
}
// One row more and the whole box shows, so y resolves.
for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) {
const s = gated(t, cols, rows, long);
await s.a.start();
await s.a.input("1");
for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`);
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`);
}
// Shrunk after the question is armed: checked at the key.
const s = gated(t, 80, 24, false);
await s.a.start();
await s.a.input("1");
assert.match(s.screen(), /y resolves/);
s.a.resize(80, 15);
await hidden(s, "Y", "shrunk to 80x15");
});
test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => {
for (const long of [false, true]) {
const s = gated(t, 120, 40, long);
await s.a.start();
await s.a.input("1");
const seen = { shown: 0, hidden: 0 };
for (let cols = 40; cols <= 120; cols += 4) {
for (let rows = 10; rows <= 40; rows++) {
const frame = draw(s.a.state, { cols, rows, color: false, now: NOW });
const label = `${cols}x${rows}${long ? " long" : ""}`;
fits(frame, cols, rows, label);
const text = frame.map(strip).join("\n");
if (decideShown(s.a.state.overlay, cols, rows)) {
seen.shown++;
for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`);
} else {
seen.hidden++;
assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
assert.doesNotMatch(text, /y resolves/, label);
}
}
}
assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen));
s.a.quit("q");
}
});
test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
let resolves = 0;
const s = app(t, {
wrap: (call) => async (verb, args) => {
if (verb !== "decision.resolve") return call(verb, args);
resolves += 1;
const e = new Error("outcome-unknown");
e.code = "outcome-unknown";
throw e;
},
});
const d = s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
await s.a.input("1");
await s.a.input("y");
assert.equal(resolves, 1);
assert.equal(s.a.state.overlay.kind, "unknown");
assert.equal(s.a.state.overlay.message, `outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`);
assert.match(s.screen(), /Nothing was retried, and the choice was not sent twice\./);
await s.a.input("1y:decide x yes\ry");
assert.equal(resolves, 1, "no key sends the choice again");
await s.a.input("v");
assert.equal(s.a.state.view, "trail");
assert.equal(s.a.state.data.trail.subject, d.id);
assert.ok(s.a.state.data.trail.list.length > 0);
// The transport's own "did not finish" error has no code, only the
// hyphenated message; it opens the same box, and Esc closes it.
const hung = app(t, {
wrap: (call) => async (verb, args) => {
if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1);
return call(verb, args);
},
});
hung.raise("git.push.protected", { target: "refactor" });
await hung.a.start();
await hung.a.input("1");
await hung.a.input("y");
assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]);
await hung.a.input("\x1b");
assert.equal(hung.a.state.overlay, null);
});
test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => {
let fail = null;
const s = app(t, { wrap: (call) => async (verb, args) => {
if (fail && verb === "inbox") {
const e = new Error(fail);
e.code = fail;
throw e;
}
return call(verb, args);
} });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
fail = "invalid-response";
await s.a.input("i");
assert.equal(s.a.state.data.inbox.list.length, 1);
assert.match(s.screen(), /stale: invalid-response/);
assert.equal(s.a.state.status, "read failed: invalid-response");
fail = "human-required";
await s.a.input("i");
assert.equal(s.a.state.status, "refused: human-required");
assert.equal(s.a.state.data.inbox.refused, true);
fail = null;
const decide = app(t, { wrap: (call) => async (verb, args) => {
if (verb !== "decision.resolve") return call(verb, args);
const e = new Error("unauthenticated");
e.code = "unauthenticated";
throw e;
} });
decide.raise("git.push.protected", { target: "refactor" });
await decide.a.start();
await decide.a.input("1");
await decide.a.input("y");
assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
});
test("keys: views, moving, trail of a selection, the command line, help and quit", async (t) => {
const s = app(t);
const a = s.raise("git.push.protected", { target: "one" });
const b = s.raise("git.push.protected", { target: "two" });
await s.a.start();
await s.a.input("j");
assert.equal(s.a.state.sel.inbox, 1);
await s.a.input("\x1b[B\x1b[B");
assert.equal(s.a.state.sel.inbox, 1, "the selection stops at the last row");
await s.a.input("k");
await s.a.input("v");
assert.equal(s.a.state.data.trail.subject, s.read("inbox")[0].id);
await s.a.input("a");
assert.match(s.screen(), /coder-run/);
await s.a.input(":trail " + b.id + "\r");
assert.equal(s.a.state.data.trail.subject, b.id);
await s.a.input(":decide " + a.id.slice(0, 8) + " no\r");
assert.equal(s.a.state.overlay.kind, "decide");
await s.a.input("y");
assert.equal(s.a.state.status, `resolved ${a.id}: no`);
await s.a.input(":nope\r");
assert.match(s.a.state.status, /unknown command :nope/);
await s.a.input(":abc\x7f\x7f\x7fq\r");
assert.deepEqual(s.out.quit, { how: "q", resolved: 1, error: undefined });
// ? opens help; Esc closes it; Ctrl-C quits.
const h = app(t);
await h.a.start();
await h.a.input("?");
assert.match(h.screen(), /Decisions resolve only here or with mosaic decide/);
await h.a.input("\x1b");
assert.equal(h.a.state.overlay, null);
await h.a.input("\x03");
assert.equal(h.out.quit.how, "Ctrl-C");
assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]);
// Arrows on the command line move nothing and type nothing.
await h.a.start();
const c = app(t);
await c.a.start();
await c.a.input(":ab\x1b[A\x1b[Bc");
assert.equal(c.a.state.command, "abc");
// v on Tasks opens the trail of the task.
const f = app(t, { transport: fake() });
await f.a.start();
await f.a.input("t");
await f.a.input("v");
assert.equal(f.a.state.data.trail.subject, "vikunja:1/7");
assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/);
// :business switches: the old business's lists and selections go.
await f.a.input("t");
await f.a.input("j");
assert.equal(f.a.state.sel.tasks, 1);
const calls = [];
f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []);
await f.a.input(":business other\r");
assert.equal(f.a.state.business, "other");
assert.equal(f.a.state.sel.tasks, 0);
assert.equal(f.a.state.data.trail, undefined);
assert.deepEqual(f.a.state.data.tasks.list, []);
assert.deepEqual(calls, [["other", "tasks"]]);
});
test("one option: the status line says Press 1, not 1-1", async (t) => {
const base = fake();
const s = app(t, {
transport: () => async (verb) => {
const rows = await base()(verb);
if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1)));
return rows;
},
});
await s.a.start();
assert.match(s.screen(), /Press 1 to choose\./);
const two = app(t);
two.raise("git.push.protected", { target: "refactor" });
await two.a.start();
assert.match(two.screen(), /Press 1-2 to choose\./);
});
test("no business: the app says so and :business picks one", async (t) => {
const s = app(t, { business: null });
s.raise("git.push.protected", { target: "refactor" });
await s.a.start();
assert.match(s.screen(), /No bus host runs here, so the app knows no business\./);
assert.equal(s.calls.length, 0);
await s.a.input(":business demo\r");
assert.equal(s.a.state.business, "demo");
assert.equal(s.a.state.data.inbox.list.length, 1);
});
test("mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one", async (t) => {
const dataRoot = tmp(t);
const tty = (env = humanEnv()) => {
const x = io(env);
x.stdin.isTTY = true;
x.stdout.isTTY = true;
return x;
};
// An agent run refuses before anything else, terminal or not.
for (const marker of ["CLAUDECODE", "MOSAIC_AGENT_RUN", "PI_AGENT_DIR"]) {
const x = tty({ ...humanEnv(), [marker]: "1" });
const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
assert.equal(err.exitCode, 3);
assert.equal(err.message, `refused: this runs only from a human shell, outside any agent run (found ${marker} in the environment)`);
}
// Not a terminal on stdin, or on stdout: today's usage, exit 4.
for (const [inTty, outTty] of [[false, true], [true, false], [false, false]]) {
const x = io();
x.stdin.isTTY = inTty;
x.stdout.isTTY = outTty;
const err = await main([], x, { system: { dataRoot }, runApp: () => assert.fail("opened") }).catch((e) => e);
assert.equal(err.exitCode, 4);
assert.ok(err.message.endsWith(`\n${SEAT_USAGE}\n${USAGE}`), err.message);
}
// On a terminal: the app, then the quit line.
const x = tty(humanEnv({ NO_COLOR: "1" }));
let opened;
await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
assert.equal(opened.business, null, "no host state, no business");
assert.equal(opened.color, false);
assert.equal(x.out.text, "(app closed with q; 0 decisions resolved this session)\n");
});
test("scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted", () => {
const env = humanEnv({ MOSAIC_CONFIG: "/nonexistent/config.json" });
const app = spawnSync(join(REPO, "scripts", "mosaic"), [], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(app.status, 4, app.stderr);
assert.ok(app.stderr.startsWith("mosaic: the app needs a terminal on stdin and stdout"), app.stderr);
assert.ok(app.stderr.endsWith(`${SEAT_USAGE}\n${USAGE}\n`));
const agent = spawnSync(join(REPO, "scripts", "mosaic"), [], { env: { ...env, CLAUDECODE: "1" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(agent.status, 3);
const seat = spawnSync(process.execPath, [join(REPO, "packages", "seat", "src", "cli.mjs")], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.equal(seat.status, 4);
assert.equal(seat.stderr, `refused: ${SEAT_USAGE}\n`);
});
test("strings to keep exactly live once, in the decide flow and the transport; the app does not restate them", () => {
const src = (f) => readFileSync(join(here, "..", "src", f), "utf8");
const cli = src("cli.mjs");
for (const s of [
"`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : \"\"}\\n`",
'"this authorizes the action"',
'"this declines the action"',
"`resolve ${shortId(d.id)} with ${choice}? [y/N] `",
"`resolved ${d.id}: ${choice}\\n`",
"`outcome unknown: the broker may have recorded it. Check mosaic inbox or mosaic trail ${d.id} before trying again`",
])
assert.ok(cli.includes(s), s);
assert.ok(src("transport.mjs").includes("`refused: this runs only from a human shell, outside any agent run (found ${found.join(\", \")} in the environment)`"));
for (const f of ["app.mjs", "screens.mjs"]) {
for (const s of ["authorizes the action", "declines the action", "[y/N]", "may have recorded it", "runs only from a human shell"]) assert.ok(!src(f).includes(s), `${f} restates ${s}`);
}
});
test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => {
const s = broker(t);
const d = s.raise("git.push.protected", { target: "refactor" });
const call = s.transport("demo");
const asked = [];
const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e);
assert.equal(err.message, "not resolved");
assert.equal(asked.length, 1);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
// Off a terminal, --note without --yes refuses before anything is sent.
const x = io(humanEnv());
x.stdin.isTTY = false;
const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e);
assert.equal(off.exitCode, 4);
assert.match(off.message, /stdin is not a terminal; pass --yes/);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
});
test("the app's business comes from a live bus host only", async (t) => {
for (const [startTime, want] of [
["not-this-process", null],
[startTimeOf(process.pid), "demo"],
]) {
const dataRoot = tmp(t);
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
const x = io(humanEnv());
x.stdin.isTTY = true;
x.stdout.isTTY = true;
let opened;
await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
assert.equal(opened.business, want, startTime);
}
});
// A terminal for runApp: raw mode and listeners recorded, writes kept.
function terminal({ throwOnResize = false, throwOnFrame = false } = {}) {
const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
const write = (s) => {
if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed");
stdout.writes.push(s);
};
const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 });
let resized = false;
Object.defineProperty(stdout, "columns", {
get: () => {
if (resized && throwOnResize) throw new Error("columns failed");
return 80;
},
});
const signals = new EventEmitter();
const resize = () => ((resized = true), stdout.emit("resize"));
return { io: { stdin, stdout }, stdin, stdout, signals, resize };
}
test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => {
const cases = [
["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
["end of input", (x) => x.stdin.emit("end")],
["SIGTERM", (x) => x.signals.emit("SIGTERM")],
["SIGINT", (x) => x.signals.emit("SIGINT")],
["SIGHUP", (x) => x.signals.emit("SIGHUP")],
["an error", (x) => x.resize(), { throwOnResize: true }],
["an error", () => {}, { throwOnFrame: true }],
];
for (const [how, act, opts] of cases) {
const x = terminal(opts);
// Handled at once: an error on the first frame rejects before the next tick.
const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then(
(r) => r,
(e) => ({ how: "an error", error: e }),
);
await new Promise((r) => setImmediate(r));
act(x);
const result = await running;
assert.equal(result.how, how);
if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed");
assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
assert.deepEqual(x.stdin.raw, [true, false], how);
for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`);
assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how);
}
});
test("draw is pure: same state, same frame", () => {
const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" };
assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW }));
});