Files
stack/packages/cli/src/screens.mjs
T
jason.woltjeandClaude Opus 5.5 90d7ed6628 cli: terminal app follow-up, narrow-terminal guard test, hidden-box way out and width tests (#1562)
Row 69, built by Darkwing, approved by Dewey (comment 28048) and Filbert
(comment 28055) on manifest c68e6582. Sage gate on c6711453: cli 123/0,
queue 150/0, every scripts suite green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:36:52 -05:00

432 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") {
// The way out fits the 34 cells a 40-column box has, so the hidden box
// is never cut.
return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"], "Resize, or use mosaic decide."], 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));
}