/** * The composed view the recovery companion pane renders (#994). * * Two defects produced this module, and they were one decision: * * - The pane re-forked `watch -c -n 60 ''`, and every 60 seconds the * foreground-process transition made herdr repaint it — a visible flash. The * board pane, one long-lived process holding its terminal, never flashed. So * the fix is a process that persists between renders, which is what * {@link companionFrame} exists to feed. * - It rendered `decision list` alone, which on this fleet meant a whole pane * refreshing forever to display `no open decisions` while nine watches, four * of them `condition:met`, were invisible in it. * * The split is deliberate and this is the only place it is composed: operator * decisions are rare and already interrupt through Telegram when they matter, * while watches are the live state a glanceable pane is *for* — and a met * condition is the one row in either list that wants eyes immediately. * * An empty section is omitted entirely rather than printed with a placeholder. * A pane showing nothing is honest; a pane showing `no open decisions` forever * is the noise the operator objected to. * * `decision list` and `watch list` keep their own contracts untouched — this is * the pane's composition, not those commands' output. */ import type { DecisionRecord } from "./types.ts"; /** The marker that makes an actionable watch findable at a glance. Uppercase * rather than coloured: the pane inherits whatever palette the terminal has, * and the flash this module replaced was chased through an ANSI hypothesis. */ export const MET_MARKER = "[ACT NOW]"; /** The single line shown when neither list has anything. Quiet, and still * proof the pane is alive rather than blank because something died. */ export const IDLE_LINE = "nothing open"; /** One row, reduced to what the pane shows. */ function row(record: DecisionRecord, now: number): string { const ageHours = Math.max(0, Math.round((now - record.askedAt) / 3_600_000)); const condition = record.condition === undefined ? "-" : record.conditionMetAt === undefined ? "pending" : "met"; const met = condition === "met" ? `${MET_MARKER} ` : ""; return ` ${met}${record.id} ${String(ageHours)}h blocks:${record.blocks ?? "-"} condition:${condition} ${record.question}`; } /** * The frame to draw, as lines. * * Decisions above watches, because only a decision wants an answer. Met * conditions are hoisted to the top of the watch section: the whole reason to * render watches at all is that one of them may be actionable, and a reader who * has to scan nine rows for the one that matters is reading a log, not a board. */ export function companionFrame( open: readonly DecisionRecord[], now: number = Date.now(), ): string[] { const decisions = open.filter((d) => d.kind !== "watch"); const watches = open.filter((d) => d.kind === "watch"); const lines: string[] = []; if (decisions.length > 0) { lines.push(`operator decisions (${String(decisions.length)}) — these want an answer`); for (const decision of decisions) lines.push(row(decision, now)); } if (watches.length > 0) { const met = watches.filter((w) => w.conditionMetAt !== undefined); const pending = watches.filter((w) => w.conditionMetAt === undefined); if (lines.length > 0) lines.push(""); lines.push( `watches (${String(watches.length)}) — no answer needed` + (met.length === 0 ? "" : `, ${String(met.length)} to act on now`), ); for (const watch of [...met, ...pending]) lines.push(row(watch, now)); } return lines.length === 0 ? [IDLE_LINE] : lines; }