// 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 , or type :business .", "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 ; 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 .", "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 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