// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs // main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as // the verbs; src/screens.mjs draws the frames. // // Reads and the decide flow go through the human transport, like the verbs. // A decision resolves only through resolveDecision (src/cli.mjs), the code // `mosaic decide` runs: a digit picks an option, the app shows the CLI's // lines and its yes/no question. y or Y resolves; any other key, or quitting, // answers No. Keys that arrive within ASK_MS of the question being drawn are // type-ahead and never answer it. After "outcome unknown" the app offers the // trail and a re-read, never a retry. // // Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the // selection's detail; v the trail of the selection; 1-9 choose; `:` the // command line (Esc or Ctrl-C closes it); ? help; Esc back; q or Ctrl-C // quits. Below 40x10 the frame says the terminal is too small. Talk (c) is a // pointer to `mosaic talk`; the conversation pane is a later row. import { resolveDecision } from "./cli.mjs"; import { CliError } from "./errors.mjs"; import { formatDecision } from "./format.mjs"; import { decideShown, draw } from "./screens.mjs"; import { busExit } from "./transport.mjs"; export const REFRESH_MS = 30000; // How long the decide question is up before a key can answer it. export const ASK_MS = 200; // Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc", // "tab", "backspace", "ctrl-c", or the character itself. Other escape // sequences are dropped. A lone ESC at the end of a chunk is Escape. export function parseKeys(data) { const out = []; let i = 0; while (i < data.length) { if (data[i] === "\x1b") { const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(data.slice(i))[0]; const name = { "\x1b": "esc", "\x1b[A": "up", "\x1bOA": "up", "\x1b[B": "down", "\x1bOB": "down", "\x1b[5~": "pgup", "\x1b[6~": "pgdn" }[seq]; if (name) out.push(name); i += seq.length; continue; } const c = String.fromCodePoint(data.codePointAt(i)); i += c.length; if (c === "\r" || c === "\n") out.push("enter"); else if (c === "\t") out.push("tab"); else if (c === "\x7f" || c === "\b") out.push("backspace"); else if (c === "\x03" || c === "\x04") out.push("ctrl-c"); else if (c >= " ") out.push(c); } return out; } const VIEW_KEYS = { c: "talk", i: "inbox", t: "tasks", a: "agents", r: "trail" }; const LISTS = ["inbox", "tasks", "agents", "trail"]; export class App { // transport(business) returns call(verb, args). business may be null: // the app then says so and waits for :business . constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS, askMs = ASK_MS }) { this.transport = transport; this.write = write; this.cols = cols; this.rows = rows; this.color = color; this.now = now; this.onQuit = onQuit; this.askMs = askMs; this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null }; this.call = business ? transport(business) : null; this.resolved = 0; this.frame = []; this.keys = []; this.job = null; this.answer = null; this.cancelAsk = null; this.wake = null; this.idle = []; this.pumping = false; this.closed = false; this.timer = refreshMs > 0 ? setInterval(() => this.input("\0refresh"), refreshMs) : null; this.timer?.unref?.(); } // The first frame and the first read. start() { this.render(); return this.input("\0start"); } resize(cols, rows) { this.cols = cols; this.rows = rows; this.render(); } render() { if (this.closed) return this.frame; this.frame = draw(this.state, { cols: this.cols, rows: this.rows, color: this.color, now: this.now() }); this.write(`\x1b[H\x1b[2J${this.frame.join("\r\n")}`); return this.frame; } // Feeds terminal input. Resolves once every key is handled, or when the // app waits for an answer to the decide question. input(data) { if (this.closed) return Promise.resolve(); if (data === "\0refresh" || data === "\0start") this.keys.push(data.slice(1)); else this.keys.push(...parseKeys(data)); const settled = new Promise((r) => this.idle.push(r)); if (this.pumping) this.wake?.(); else this.#pump(); return settled; } // One key at a time, in order. A key that starts a read or a decision // runs as a job; later keys wait for it, except the answer to its // question, which goes straight to it. async #pump() { this.pumping = true; for (;;) { if (this.closed) break; if (this.answer && this.keys.length) { // A timer tick is not an answer. const k = this.keys.shift(); try { if (k !== "refresh") this.answer(k); } catch (e) { this.crash(e); break; } continue; } if (this.job) { if (this.answer) this.#settle(); await new Promise((r) => (this.wake = r)); this.wake = null; continue; } if (!this.keys.length) break; let job; try { job = this.#key(this.keys.shift()); } catch (e) { this.crash(e); break; } if (job instanceof Promise) { this.job = job.catch((e) => this.crash(e)).finally(() => { this.job = null; this.wake?.(); }); } } this.pumping = false; this.#settle(); } #settle() { for (const r of this.idle.splice(0)) r(); } // Asks the decide flow's question; resolves true on y or Y and false on // any other key. Only a key typed after the question is up can answer: // keys that arrive within askMs of drawing it are type-ahead (a paste, or // keys typed while a read blocked the screen) and are dropped. Quitting // answers no. y counts only while the frame shows the whole box, the end // of a long question aside (decideShown); on a terminal too small for it, // every key answers no, y included. The size is read at each key, so a // shrink after the question is armed is covered. #ask(question) { this.state.overlay = { ...this.state.overlay, asking: question }; this.render(); return new Promise((resolve) => { const answer = (yes) => { clearTimeout(arm); this.answer = this.cancelAsk = null; this.state.overlay = { ...this.state.overlay, asking: null }; try { this.render(); } catch (e) { // The pump sends this to crash; the decide flow ends with No. resolve(false); throw e; } resolve(yes); }; this.cancelAsk = () => answer(false); const arm = setTimeout(() => { this.keys = []; this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows)); this.wake?.(); }, this.askMs); }); } #status(text, style = null) { this.state.status = text; this.state.statusStyle = style; } // Handles one key; returns a promise when it starts a read or a decision. #key(k) { const s = this.state; // A timer tick keeps a "resolved" status; it reads nothing under an // overlay or the command line. if (k === "start") return this.#read(s.view); if (k === "refresh") return s.overlay || s.command !== null ? null : this.#read(s.view, s.statusStyle === "success"); if (s.command !== null) return this.#commandKey(k); if (k === "ctrl-c") return this.quit("Ctrl-C"); if (s.overlay) return this.#overlayKey(k); if (VIEW_KEYS[k]) { s.view = VIEW_KEYS[k]; s.open = false; this.#status(k === "c" ? "Talk is a later row; mosaic talk works from a shell" : ""); return s.view === "talk" ? void this.render() : this.#read(s.view); } if (k === "q") return this.quit("q"); if (k === "?") return this.#overlay({ kind: "help" }); if (k === ":") { s.command = ""; return void this.render(); } if (k === "j" || k === "down") return this.#move(1); if (k === "k" || k === "up") return this.#move(-1); if (k === "pgdn") return this.#move(Math.max(1, this.rows - 10)); if (k === "pgup") return this.#move(-Math.max(1, this.rows - 10)); if (k === "enter") return this.#open(); if (k === "v") return this.#trailOfSelection(); if (/^[1-9]$/.test(k)) return this.#choose(Number(k)); if (k === "tab") this.#status("Tab moves to the composer once Talk is in the app"); else if (k === "esc") this.#status(""); return void this.render(); } #overlay(o) { this.state.overlay = o; this.render(); } #overlayKey(k) { const o = this.state.overlay; if (o.kind === "unknown") { if (k === "v") { this.state.overlay = null; return this.#trail(o.id); } if (k === "r") { this.state.overlay = null; this.state.view = "inbox"; return this.#read("inbox"); } if (k === "esc") this.state.overlay = null; return void this.render(); } if (k === "esc" || k === "enter" || (o.kind === "help" && (k === "?" || k === "q"))) this.state.overlay = null; this.render(); } #list(view = this.state.view) { return this.state.data[view]?.list ?? []; } #selected(view = this.state.view) { const list = this.#list(view); return list.length ? list[Math.min(this.state.sel[view], list.length - 1)] : null; } #move(n) { const view = this.state.view; if (LISTS.includes(view)) { const max = this.#list(view).length - 1; this.state.sel[view] = Math.max(0, Math.min(max, this.state.sel[view] + n)); } this.render(); } // Enter: the selection's detail in a box, whole, at any width. #open() { const view = this.state.view; const item = this.#selected(view); if (!item) return void this.render(); const title = view === "inbox" ? `decision ${item.id.slice(0, 8)}` : view === "tasks" ? item.task_ref : view === "agents" ? item.role : `${item.table}#${item.seq}`; const whole = view === "trail" ? `mosaic trail ${this.state.data.trail.subject} --json` : `mosaic ${view} --json`; return this.#overlay({ kind: "detail", title, lines: detailLines(view, item), whole }); } #trailOfSelection() { const view = this.state.view; const item = this.#selected(view); const subject = !item ? null : view === "inbox" ? item.id : view === "tasks" ? item.task_ref : view === "trail" ? item.task_ref ?? null : null; if (!subject) { this.#status(view === "agents" ? "a role has no trail here; type :trail " : "nothing selected to follow; type :trail "); return void this.render(); } return this.#trail(subject); } #trail(subject) { this.state.view = "trail"; this.state.sel.trail = 0; this.state.data.trail = { subject }; return this.#read("trail"); } // A digit: option n of the selected decision, through the decide flow. #choose(n) { if (this.state.view !== "inbox") { this.#status("digits choose in the Inbox; press i"); return void this.render(); } const d = this.#selected("inbox"); if (!d) return void this.render(); const option = d.options[n - 1]; if (!option) { this.#status(`decision ${d.id.slice(0, 8)} has ${d.options.length} option(s)`, "warning"); return void this.render(); } return this.#decide(d.id, option.key); } async #decide(ref, choice) { if (!this.call) { this.#status("no business; type :business ", "warning"); return this.render(); } this.state.view = "inbox"; this.#overlay({ kind: "decide", lines: [], asking: null }); let text = ""; const write = (s) => { text += s; this.state.overlay = { ...this.state.overlay, lines: text.trimEnd().split("\n") }; this.render(); }; try { const d = await resolveDecision({ call: this.call, ref, choice, write, ask: (q) => this.#ask(q) }); this.resolved += 1; this.state.overlay = null; this.#status(`resolved ${d.id}: ${choice}`, "success"); return this.#read("inbox", true); } catch (e) { if (!(e instanceof CliError) && !e.code) throw e; const message = e.message; if (message === "not resolved") { this.state.overlay = null; this.#status("not resolved"); } else if (/^outcome[ -]unknown/.test(message)) { this.state.overlay = { kind: "unknown", message, id: text.match(/^decision (\S+)/)?.[1] ?? ref }; } else this.state.overlay = { kind: "error", title: "decide", message: e.code && message === e.code ? `${(e.exitCode ?? busExit(e.code)) === 3 ? "refused" : "failed"}: ${e.code}` : message }; this.render(); } } // Reads one view. A failed read keeps the last good list and says it is // stale; with no list it shows the failure. `keep` leaves the status line. async #read(view, keep = false) { if (!LISTS.includes(view)) return this.render(); if (!this.call) return this.render(); const before = this.state.data[view] ?? {}; if (view === "trail" && !before.subject) return this.render(); this.state.data[view] = { ...before, reading: true }; if (!keep) this.#status(`reading ${view}…`); this.render(); try { const list = await this.call(view, view === "trail" ? { subject: before.subject } : {}); this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() }; const n = Math.min(9, this.#selected("inbox")?.options.length ?? 1); if (!keep) this.#status(view === "inbox" && list.length ? `Press ${n === 1 ? "1" : `1-${n}`} to choose. You will see the decision again and confirm with y.` : ""); } catch (e) { if (!(e instanceof CliError) && !e.code) throw e; // Exit 3 codes are refusals (src/transport.mjs), not failed reads. const refused = (e.exitCode ?? busExit(e.code)) === 3; this.state.data[view] = { ...before, reading: false, error: e.code ?? e.message, refused }; this.#status(`${refused ? "refused" : "read failed"}: ${e.code ?? e.message}`, "danger"); } this.render(); } #commandKey(k) { const s = this.state; if (k === "esc" || k === "ctrl-c") s.command = null; else if (k === "backspace") s.command = Array.from(s.command).slice(0, -1).join(""); else if (k === "enter") { const text = s.command.trim(); s.command = null; return this.#run(text); } else if ([...k].length === 1 && k >= " ") s.command += k; this.render(); } // :decide