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]>
430 lines
21 KiB
JavaScript
430 lines
21 KiB
JavaScript
// 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));
|
||
}
|