Files
stack/packages/cli/src/screens.mjs
T
jason.woltjeandClaude Opus 5.5 79d9fc2d56 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]>
2026-10-11 16:06:11 -05:00

430 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}