queue-55: round 2 candidate packet (darkwing)
Answers Filbert's comment 27251 and Dewey's comment 27253: R1 type-ahead
window, R2 terminal cell widths, B1 cut inside the question, 30 mutants
killed, N1-N8. Candidate manifest sha256 3f2d8f8e. Gate on c506c6ec:
cli 106/106 and every scripts/test-*.sh exit 0. The feature stays
uncommitted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
|
||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||
|
||||
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
||||
index 32977afa..3c3255f5 100644
|
||||
index 32977afa..dbb03138 100644
|
||||
--- a/packages/cli/README.md
|
||||
+++ b/packages/cli/README.md
|
||||
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
|
||||
@@ -44,7 +44,7 @@ index 32977afa..3c3255f5 100644
|
||||
scripts/mosaic inbox
|
||||
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
|
||||
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
|
||||
@@ -70,6 +74,52 @@ mosaic launches list [--json]
|
||||
@@ -70,6 +74,65 @@ mosaic launches list [--json]
|
||||
`launches list` does not, because the file is readable to the same user
|
||||
anyway.
|
||||
|
||||
@@ -68,13 +68,16 @@ index 32977afa..3c3255f5 100644
|
||||
+- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
|
||||
+ or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
|
||||
+ opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
|
||||
+ or Ctrl-C quits. Commands: `:decide <decision> <option>`, `:trail
|
||||
+ or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
|
||||
+ arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
|
||||
+ <task|decision>`, `:business <id>`, `:q`.
|
||||
+- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
|
||||
+ option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
|
||||
+ decide` runs. The box shows the same lines and the same `[y/N]`
|
||||
+ question; only `y` resolves. `n`, Esc, Enter and `q` cancel, and other
|
||||
+ keys wait. After `outcome unknown` the app shows the CLI's sentence and
|
||||
+ question. `y` or `Y` resolves; any other key answers No, and so does
|
||||
+ quitting. Keys that arrive within 200 ms of the question being drawn
|
||||
+ are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
|
||||
+ while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
|
||||
+ offers the trail (`v`) or a re-read (`r`). No key sends the choice
|
||||
+ again.
|
||||
+- Reads happen on entering a view, after a decision, and every 30 s while
|
||||
@@ -88,21 +91,31 @@ index 32977afa..3c3255f5 100644
|
||||
+ reverse video on the selected row (the design's tokens keep selection
|
||||
+ visible without colour). Frames fit 80x24; the layout is designed at
|
||||
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
|
||||
+ drops RAISED BY.
|
||||
+ drops RAISED BY. Below 40x10 the frame only says the terminal is too
|
||||
+ small. Widths count terminal cells: CJK and emoji 2, a one-cell emoji
|
||||
+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw,
|
||||
+ so such a row can come out short, never too wide.
|
||||
+- A decide box taller than the screen cuts the question, not the lines
|
||||
+ after it: the action and target, what the choice authorizes, the task,
|
||||
+ your choice and the `[y/N]` question stay. The cut line points to
|
||||
+ `mosaic inbox --json`.
|
||||
+- The transport is the verbs' `spawnSync` child, so the screen does not
|
||||
+ redraw while a read is in flight (up to its 30 s timeout).
|
||||
+- On quit the app restores the screen and the cursor, then prints
|
||||
+ `(app closed with q; N decisions resolved this session)`.
|
||||
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
|
||||
+ resize redraw included) the app restores the screen, the cursor and
|
||||
+ cooked mode. After anything but an error it then prints
|
||||
+ `(app closed with q; N decisions resolved this session)`; an error
|
||||
+ goes to stderr instead.
|
||||
+
|
||||
## Bus host
|
||||
|
||||
```
|
||||
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
|
||||
new file mode 100644
|
||||
index 00000000..208bc41f
|
||||
index 00000000..3cb3ab71
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/src/app.mjs
|
||||
@@ -0,0 +1,447 @@
|
||||
@@ -0,0 +1,480 @@
|
||||
+// 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.
|
||||
@@ -110,13 +123,16 @@ index 00000000..208bc41f
|
||||
+// 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, and only y resolves. After "outcome unknown"
|
||||
+// the app offers the trail and a re-read, never a retry.
|
||||
+// 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; ? help; Esc back; q or Ctrl-C quits. Talk (c) is a pointer
|
||||
+// to `mosaic talk`; the conversation pane is a later row.
|
||||
+// 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";
|
||||
@@ -125,6 +141,8 @@ index 00000000..208bc41f
|
||||
+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
|
||||
@@ -157,7 +175,7 @@ index 00000000..208bc41f
|
||||
+export class App {
|
||||
+ // transport(business) returns call(verb, args). business may be null:
|
||||
+ // the app then says so and waits for :business <id>.
|
||||
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS }) {
|
||||
+ 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;
|
||||
@@ -165,6 +183,7 @@ index 00000000..208bc41f
|
||||
+ 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;
|
||||
@@ -172,6 +191,7 @@ index 00000000..208bc41f
|
||||
+ this.keys = [];
|
||||
+ this.job = null;
|
||||
+ this.answer = null;
|
||||
+ this.cancelAsk = null;
|
||||
+ this.wake = null;
|
||||
+ this.idle = [];
|
||||
+ this.pumping = false;
|
||||
@@ -219,7 +239,14 @@ index 00000000..208bc41f
|
||||
+ for (;;) {
|
||||
+ if (this.closed) break;
|
||||
+ if (this.answer && this.keys.length) {
|
||||
+ this.answer(this.keys.shift());
|
||||
+ // 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) {
|
||||
@@ -251,22 +278,28 @@ index 00000000..208bc41f
|
||||
+ for (const r of this.idle.splice(0)) r();
|
||||
+ }
|
||||
+
|
||||
+ // Asks the decide flow's question; resolves true on y. n, Esc, Enter, q
|
||||
+ // and Ctrl-C say no; other keys are ignored.
|
||||
+ // 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.
|
||||
+ #ask(question) {
|
||||
+ this.state.overlay = { ...this.state.overlay, asking: question };
|
||||
+ this.render();
|
||||
+ return new Promise((resolve) => {
|
||||
+ const take = (k) => {
|
||||
+ if (k === "y" || k === "Y") resolve(true);
|
||||
+ else if (["n", "N", "esc", "enter", "q", "ctrl-c"].includes(k)) resolve(false);
|
||||
+ else return void (this.answer = take);
|
||||
+ this.answer = null;
|
||||
+ const answer = (yes) => {
|
||||
+ clearTimeout(arm);
|
||||
+ this.answer = this.cancelAsk = null;
|
||||
+ this.state.overlay = { ...this.state.overlay, asking: null };
|
||||
+ this.render();
|
||||
+ resolve(yes);
|
||||
+ };
|
||||
+ this.answer = take;
|
||||
+ this.wake?.();
|
||||
+ this.cancelAsk = () => answer(false);
|
||||
+ const arm = setTimeout(() => {
|
||||
+ this.keys = [];
|
||||
+ this.answer = (k) => answer(k === "y" || k === "Y");
|
||||
+ this.wake?.();
|
||||
+ }, this.askMs);
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
@@ -278,7 +311,10 @@ index 00000000..208bc41f
|
||||
+ // Handles one key; returns a promise when it starts a read or a decision.
|
||||
+ #key(k) {
|
||||
+ const s = this.state;
|
||||
+ if (k === "start" || k === "refresh") return k === "refresh" && (s.overlay || s.command !== null) ? null : this.#read(s.view);
|
||||
+ // 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);
|
||||
@@ -437,7 +473,8 @@ index 00000000..208bc41f
|
||||
+ 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() };
|
||||
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press 1-${Math.min(9, this.#selected("inbox")?.options.length ?? 1)} to choose. You will see the decision again and confirm with y.` : "");
|
||||
+ 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.
|
||||
@@ -456,7 +493,7 @@ index 00000000..208bc41f
|
||||
+ const text = s.command.trim();
|
||||
+ s.command = null;
|
||||
+ return this.#run(text);
|
||||
+ } else if (k.length <= 2 && k >= " ") s.command += k;
|
||||
+ } else if ([...k].length === 1 && k >= " ") s.command += k;
|
||||
+ this.render();
|
||||
+ }
|
||||
+
|
||||
@@ -487,7 +524,7 @@ index 00000000..208bc41f
|
||||
+ if (this.closed) return;
|
||||
+ this.closed = true;
|
||||
+ if (this.timer) clearInterval(this.timer);
|
||||
+ this.answer?.("q");
|
||||
+ this.cancelAsk?.();
|
||||
+ this.wake?.();
|
||||
+ this.onQuit({ how, resolved: this.resolved, error: this.error });
|
||||
+ }
|
||||
@@ -507,30 +544,39 @@ index 00000000..208bc41f
|
||||
+}
|
||||
+
|
||||
+// Runs the app on a terminal until it quits: raw mode, the alternate screen,
|
||||
+// no cursor; all three restored on quit, on an error and on SIGTERM.
|
||||
+// Resolves with { how, resolved }.
|
||||
+export function runApp({ io, business, transport, color, refreshMs }) {
|
||||
+// no cursor; all three restored on quit, on an error (a render that throws
|
||||
+// included) and on SIGTERM, SIGINT or SIGHUP. Resolves with { how, resolved }.
|
||||
+export function runApp({ io, business, transport, color, refreshMs, askMs, signals = process }) {
|
||||
+ const { stdin, stdout } = io;
|
||||
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ let app;
|
||||
+ const restore = () => {
|
||||
+ stdin.off("data", onData);
|
||||
+ stdin.off("end", onEnd);
|
||||
+ stdout.off?.("resize", onResize);
|
||||
+ process.off("SIGTERM", onTerm);
|
||||
+ for (const [name, on] of onSignal) signals.off(name, on);
|
||||
+ stdin.setRawMode?.(false);
|
||||
+ stdin.pause?.();
|
||||
+ stdout.write("\x1b[?25h\x1b[?1049l");
|
||||
+ };
|
||||
+ const guard = (f) => {
|
||||
+ try {
|
||||
+ f();
|
||||
+ } catch (e) {
|
||||
+ app.crash(e);
|
||||
+ }
|
||||
+ };
|
||||
+ const onData = (c) => void app.input(c.toString("utf8"));
|
||||
+ const onEnd = () => app.quit("end of input");
|
||||
+ const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);
|
||||
+ const onTerm = () => app.quit("SIGTERM");
|
||||
+ const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));
|
||||
+ const onSignal = SIGNALS.map((name) => [name, () => app.quit(name)]);
|
||||
+ app = new App({
|
||||
+ business,
|
||||
+ transport,
|
||||
+ color,
|
||||
+ refreshMs,
|
||||
+ askMs,
|
||||
+ cols: stdout.columns || 80,
|
||||
+ rows: stdout.rows || 24,
|
||||
+ write: (s) => stdout.write(s),
|
||||
@@ -545,9 +591,9 @@ index 00000000..208bc41f
|
||||
+ stdin.on("data", onData);
|
||||
+ stdin.on("end", onEnd);
|
||||
+ stdout.on?.("resize", onResize);
|
||||
+ process.on("SIGTERM", onTerm);
|
||||
+ for (const [name, on] of onSignal) signals.on(name, on);
|
||||
+ stdin.resume?.();
|
||||
+ void app.start();
|
||||
+ guard(() => void app.start());
|
||||
+ });
|
||||
+}
|
||||
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
||||
@@ -662,10 +708,10 @@ index 381596b4..7bbfe39c 100644
|
||||
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
|
||||
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
|
||||
new file mode 100644
|
||||
index 00000000..d6e09981
|
||||
index 00000000..c0194cb2
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/src/screens.mjs
|
||||
@@ -0,0 +1,367 @@
|
||||
@@ -0,0 +1,405 @@
|
||||
+// 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
|
||||
@@ -685,17 +731,24 @@ index 00000000..d6e09981
|
||||
+
|
||||
+// 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: 0 for combining marks, joiners and
|
||||
+// variation selectors, 2 for East Asian wide characters and emoji, else 1.
|
||||
+function cells(c) {
|
||||
+// 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 (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0f)) return 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 >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
|
||||
+ (n >= 0xac00 && n <= 0xd7a3) ||
|
||||
@@ -703,7 +756,7 @@ index 00000000..d6e09981
|
||||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
|
||||
+ (n >= 0xff00 && n <= 0xff60) ||
|
||||
+ (n >= 0xffe0 && n <= 0xffe6) ||
|
||||
+ (n >= 0x1f300 && n <= 0x1faff) ||
|
||||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
|
||||
+ (n >= 0x20000 && n <= 0x3fffd)
|
||||
+ )
|
||||
+ return 2;
|
||||
@@ -712,7 +765,11 @@ index 00000000..d6e09981
|
||||
+
|
||||
+export function width(s) {
|
||||
+ let w = 0;
|
||||
+ for (const c of s) w += cells(c);
|
||||
+ let prev = "";
|
||||
+ for (const c of s) {
|
||||
+ w += cells(c, prev);
|
||||
+ prev = c;
|
||||
+ }
|
||||
+ return w;
|
||||
+}
|
||||
+
|
||||
@@ -722,11 +779,13 @@ index 00000000..d6e09981
|
||||
+ 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);
|
||||
+ const n = cells(c, prev);
|
||||
+ if (used + n > w - 1) break;
|
||||
+ out += c;
|
||||
+ used += n;
|
||||
+ prev = c;
|
||||
+ }
|
||||
+ return out + "…" + " ".repeat(w - 1 - used);
|
||||
+}
|
||||
@@ -743,7 +802,7 @@ index 00000000..d6e09981
|
||||
+ for (const word of s.split(/(?<= )/)) {
|
||||
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
|
||||
+ for (const c of word) {
|
||||
+ if (width(cur) + cells(c) > w) push();
|
||||
+ if (width(cur + c) > w) push();
|
||||
+ cur += c;
|
||||
+ }
|
||||
+ }
|
||||
@@ -984,18 +1043,32 @@ index 00000000..d6e09981
|
||||
+ 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(w - 2)}┘`, "accent"]));
|
||||
+ out.push(line([`└${"─".repeat(Math.max(0, w - 2))}┘`, "accent"]));
|
||||
+ return out;
|
||||
+}
|
||||
+
|
||||
+function overlayBox(o, cols) {
|
||||
+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.
|
||||
+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") {
|
||||
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
|
||||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · n, Esc or Enter cancels (the default is No)", "muted"]);
|
||||
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
|
||||
+ else lines.push("", ["resolving…", "muted"]);
|
||||
+ return box("decide", lines, w);
|
||||
+ const b = box("decide", lines, w);
|
||||
+ const q = o.lines.findIndex((t) => t.startsWith(" question: "));
|
||||
+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
|
||||
+ if (b.length <= room || q < 0 || end < 0) return b;
|
||||
+ 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] : b;
|
||||
+ }
|
||||
+ 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);
|
||||
@@ -1006,6 +1079,7 @@ index 00000000..d6e09981
|
||||
+
|
||||
+// 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);
|
||||
+ 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()];
|
||||
@@ -1017,13 +1091,15 @@ index 00000000..d6e09981
|
||||
+ body = body.slice(0, room);
|
||||
+ while (body.length < room) body.push(blank());
|
||||
+ if (state.overlay) {
|
||||
+ let b = overlayBox(state.overlay, cols);
|
||||
+ // Too tall: cut the middle, never the choice, the prompt or the keys.
|
||||
+ let b = overlayBox(state.overlay, cols, room);
|
||||
+ // Still too tall (a small screen, or a box with no question): keep the
|
||||
+ // top and, for decide, everything from the choice on; past that, the
|
||||
+ // bottom wins, so the question to answer and its keys stay.
|
||||
+ if (b.length > room) {
|
||||
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
|
||||
+ const tail = b.slice(at > 0 ? at : -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"})`, inner), [" │", "accent"]), ...tail].slice(-room);
|
||||
+ 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].slice(-room);
|
||||
+ }
|
||||
+ 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));
|
||||
@@ -1033,21 +1109,31 @@ index 00000000..d6e09981
|
||||
+ 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.
|
||||
+function tooSmall(cols, rows, color) {
|
||||
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. Esc closes what is open; q quits.`, 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));
|
||||
+}
|
||||
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
|
||||
new file mode 100644
|
||||
index 00000000..b88b2230
|
||||
index 00000000..b01abcb6
|
||||
--- /dev/null
|
||||
+++ b/packages/cli/tests/app.test.mjs
|
||||
@@ -0,0 +1,427 @@
|
||||
@@ -0,0 +1,719 @@
|
||||
+import { test } from "node:test";
|
||||
+import assert from "node:assert/strict";
|
||||
+import { spawnSync } from "node:child_process";
|
||||
+import { readFileSync } from "node:fs";
|
||||
+import { EventEmitter } from "node:events";
|
||||
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
+import { dirname, join } from "node:path";
|
||||
+import { fileURLToPath } from "node:url";
|
||||
+import { App, parseKeys, quitLine } from "../src/app.mjs";
|
||||
+import { main, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
||||
+import { App, parseKeys, quitLine, runApp } from "../src/app.mjs";
|
||||
+import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
|
||||
+import { CliError } from "../src/errors.mjs";
|
||||
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
|
||||
+import { draw, strip, width } from "../src/screens.mjs";
|
||||
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
|
||||
+
|
||||
@@ -1059,6 +1145,14 @@ index 00000000..b88b2230
|
||||
+ [80, 24],
|
||||
+ [100, 30],
|
||||
+ [99, 24],
|
||||
+ [40, 10],
|
||||
+];
|
||||
+// Below 40x10 the frame says the terminal is too small.
|
||||
+const TOO_SMALL = [
|
||||
+ [39, 10],
|
||||
+ [40, 9],
|
||||
+ [3, 3],
|
||||
+ [1, 1],
|
||||
+];
|
||||
+
|
||||
+const exitOf = async (p) => {
|
||||
@@ -1081,6 +1175,7 @@ index 00000000..b88b2230
|
||||
+ write: (s) => (out.text = s),
|
||||
+ now: () => NOW,
|
||||
+ refreshMs: 0,
|
||||
+ askMs: 0,
|
||||
+ onQuit: (r) => (out.quit = r),
|
||||
+ ...opts,
|
||||
+ });
|
||||
@@ -1113,10 +1208,15 @@ index 00000000..b88b2230
|
||||
+ ...more,
|
||||
+ });
|
||||
+ const data = {
|
||||
+ inbox: [1, 2, 3].map((n) => decision(n)),
|
||||
+ // Emoji and CJK that are two cells wide (P3b in the round 1 review).
|
||||
+ inbox: [
|
||||
+ decision(1),
|
||||
+ decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }),
|
||||
+ decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }),
|
||||
+ ],
|
||||
+ tasks: [
|
||||
+ { seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" },
|
||||
+ { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "Short", done: true }, source: "self", via: null, read_at: null },
|
||||
+ { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null },
|
||||
+ ],
|
||||
+ agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }],
|
||||
+ trail: [
|
||||
@@ -1169,7 +1269,7 @@ index 00000000..b88b2230
|
||||
+ { kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
|
||||
+ ]) {
|
||||
+ a.state.overlay = o;
|
||||
+ shot(`overlay ${o.kind}`);
|
||||
+ shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
|
||||
+ }
|
||||
+ a.state.overlay = null;
|
||||
+ // Empty, failed-with-stale-data, refused and no-business states.
|
||||
@@ -1181,6 +1281,7 @@ index 00000000..b88b2230
|
||||
+ shot("inbox refused");
|
||||
+ a.state.business = null;
|
||||
+ shot("no business");
|
||||
+ assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`);
|
||||
+ a.quit("q");
|
||||
+ return shots;
|
||||
+}
|
||||
@@ -1190,15 +1291,40 @@ index 00000000..b88b2230
|
||||
+ for (const color of [true, false]) {
|
||||
+ for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`);
|
||||
+ }
|
||||
+ // A decide box taller than the screen keeps its question and keys.
|
||||
+ const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
|
||||
+ assert.match(long, /resolve 11111111 with yes\?/, `${cols}x${rows}`);
|
||||
+ assert.match(long, /y resolves/, `${cols}x${rows}`);
|
||||
+ if (rows >= 24) assert.match(long, /cut to fit[\s\S]*your choice: yes \(Allow\)/, `${cols}x${rows}`);
|
||||
+ }
|
||||
+ // Too small: every screen still fits, and says why.
|
||||
+ for (const [cols, rows] of TOO_SMALL) {
|
||||
+ for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`);
|
||||
+ }
|
||||
+ assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/);
|
||||
+ // Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only.
|
||||
+ const [wide] = await everyScreen(t, 120, 36, false);
|
||||
+ assert.match(strip(wide[1][0]), /Inbox/);
|
||||
+ assert.match(strip(wide[1][0]), /\[i Inbox 3\]/);
|
||||
+ assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
|
||||
+ const [narrow] = await everyScreen(t, 99, 24, false);
|
||||
+ assert.match(strip(narrow[1][0]), /\[i 3\] t a /);
|
||||
+ assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
|
||||
+});
|
||||
+
|
||||
+test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
|
||||
+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1]]) {
|
||||
+ assert.equal(width(s), cells, JSON.stringify(s));
|
||||
+ }
|
||||
+ // On screen, independent of width(): each wide character takes two cells
|
||||
+ // for one UTF-16 unit, so a row of `cols` cells is that much shorter in
|
||||
+ // units. 🅰️ is three units for two cells.
|
||||
+ const frames = Object.fromEntries(await everyScreen(t, 80, 24, false));
|
||||
+ const row = (label, text) => strip(frames[label].find((l) => l.includes(text)));
|
||||
+ assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡");
|
||||
+ assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️");
|
||||
+ assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut");
|
||||
+});
|
||||
+
|
||||
+test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => {
|
||||
+ for (const [label, frame] of await everyScreen(t, 120, 36, true)) {
|
||||
+ for (const l of frame) {
|
||||
@@ -1222,8 +1348,12 @@ index 00000000..b88b2230
|
||||
+ assert.doesNotMatch(a.render().join("\n"), /\x1b/, key);
|
||||
+ }
|
||||
+ a.quit("q");
|
||||
+ // The selected row is reversed, with colour and without.
|
||||
+ const [[, plain]] = await everyScreen(t, 120, 36, false);
|
||||
+ assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
|
||||
+ // The colour build does use colour.
|
||||
+ const [[, coloured]] = await everyScreen(t, 120, 36, true);
|
||||
+ assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
|
||||
+ assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/);
|
||||
+});
|
||||
+
|
||||
@@ -1237,7 +1367,7 @@ index 00000000..b88b2230
|
||||
+ const box = s.screen();
|
||||
+ assert.match(box, /your choice: yes \(Allow\); this authorizes the action/);
|
||||
+ assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`));
|
||||
+ assert.match(box, /y resolves · n, Esc or Enter cancels \(the default is No\)/);
|
||||
+ assert.match(box, /y resolves · any other key cancels \(the default is No\)/);
|
||||
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y");
|
||||
+ await s.a.input("y");
|
||||
+ assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]);
|
||||
@@ -1273,23 +1403,132 @@ index 00000000..b88b2230
|
||||
+ await s.a.input("n");
|
||||
+});
|
||||
+
|
||||
+test("n, Esc, Enter and q at the question cancel; other keys wait; nothing is sent", async (t) => {
|
||||
+ for (const key of ["n", "N", "\x1b", "\r", "q"]) {
|
||||
+test("any key but y or Y at the question answers No; nothing is sent", async (t) => {
|
||||
+ for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) {
|
||||
+ const s = app(t);
|
||||
+ s.raise("git.push.protected", { target: "refactor" });
|
||||
+ await s.a.start();
|
||||
+ await s.a.input("1");
|
||||
+ await s.a.input("x");
|
||||
+ assert.ok(s.a.state.overlay?.asking, `${JSON.stringify(key)}: still asking after x`);
|
||||
+ assert.ok(s.a.state.overlay?.asking, JSON.stringify(key));
|
||||
+ await s.a.input(key);
|
||||
+ assert.equal(s.a.state.overlay, null, JSON.stringify(key));
|
||||
+ assert.equal(s.a.state.status, "not resolved");
|
||||
+ assert.equal(s.read("inbox").length, 1);
|
||||
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
|
||||
+ assert.equal(s.out.quit, null, "q at the question cancels; it does not quit");
|
||||
+ assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
|
||||
+ const asking = (s) => Boolean(s.a.state.overlay?.asking);
|
||||
+ const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
|
||||
+ // P2a: "1 yes" pasted on the Inbox.
|
||||
+ const a = app(t);
|
||||
+ a.raise("git.push.protected", { target: "refactor" });
|
||||
+ await a.a.start();
|
||||
+ await a.a.input("1 yes");
|
||||
+ assert.ok(asking(a), "P2a");
|
||||
+ assert.equal(sent(a), 0, "P2a");
|
||||
+ // P2b: "decide 1a2b yes" typed without the colon.
|
||||
+ const b = app(t);
|
||||
+ b.raise("git.push.protected", { target: "refactor" });
|
||||
+ await b.a.start();
|
||||
+ await b.a.input("decide 1a2b yes");
|
||||
+ assert.ok(asking(b), "P2b");
|
||||
+ assert.equal(sent(b), 0, "P2b");
|
||||
+ // P2c: 1 and y as separate events, before the question is drawn.
|
||||
+ const c = app(t);
|
||||
+ c.raise("git.push.protected", { target: "refactor" });
|
||||
+ await c.a.start();
|
||||
+ await Promise.all([c.a.input("1"), c.a.input("y")]);
|
||||
+ assert.ok(asking(c), "P2c");
|
||||
+ assert.equal(sent(c), 0, "P2c");
|
||||
+ // A y that arrives after the question is drawn but within askMs, as keys
|
||||
+ // typed while a read blocked the screen do: one at once, one 10 ms later.
|
||||
+ let early;
|
||||
+ let d;
|
||||
+ d = app(t, {
|
||||
+ askMs: 300,
|
||||
+ write: (text) => {
|
||||
+ if (!early && text.includes("[y/N]")) {
|
||||
+ void d.a.input("y");
|
||||
+ early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10));
|
||||
+ }
|
||||
+ },
|
||||
+ });
|
||||
+ d.raise("git.push.protected", { target: "refactor" });
|
||||
+ await d.a.start();
|
||||
+ await d.a.input("1");
|
||||
+ await early;
|
||||
+ assert.ok(asking(d), "within askMs");
|
||||
+ assert.equal(sent(d), 0, "within askMs");
|
||||
+ while (!d.a.answer) await new Promise((r) => setTimeout(r, 10));
|
||||
+ // After that, y resolves.
|
||||
+ for (const s of [a, b, c, d]) {
|
||||
+ await s.a.input("y");
|
||||
+ assert.equal(sent(s), 1);
|
||||
+ assert.match(s.a.state.status, /^resolved /);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("quitting at the question answers No and sends nothing, armed or not", async (t) => {
|
||||
+ for (const askMs of [0, 60000]) {
|
||||
+ const s = app(t, { askMs });
|
||||
+ s.raise("git.push.protected", { target: "refactor" });
|
||||
+ await s.a.start();
|
||||
+ const pending = s.a.input("1");
|
||||
+ while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r));
|
||||
+ s.a.quit("SIGTERM");
|
||||
+ await pending;
|
||||
+ while (s.a.job) await new Promise((r) => setImmediate(r));
|
||||
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`);
|
||||
+ assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined });
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => {
|
||||
+ const s = app(t);
|
||||
+ const d = s.raise("git.push.protected", { target: "refactor" });
|
||||
+ s.raise("git.push.protected", { target: "next" });
|
||||
+ await s.a.start();
|
||||
+ const reads = () => s.calls.filter((c) => c.verb === "inbox").length;
|
||||
+ const before = reads();
|
||||
+ await s.a.input("?");
|
||||
+ await s.a.input("\0refresh");
|
||||
+ assert.equal(reads(), before, "help open");
|
||||
+ await s.a.input("\x1b:");
|
||||
+ await s.a.input("\0refresh");
|
||||
+ assert.equal(reads(), before, "command line open");
|
||||
+ await s.a.input("\x1b");
|
||||
+ await s.a.input("\0refresh");
|
||||
+ assert.equal(reads(), before + 1, "nothing open");
|
||||
+ await s.a.input("1");
|
||||
+ const asked = reads();
|
||||
+ await s.a.input("\0refresh");
|
||||
+ assert.ok(s.a.state.overlay?.asking, "a tick is not an answer");
|
||||
+ assert.equal(reads(), asked);
|
||||
+ await s.a.input("y");
|
||||
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
|
||||
+ await s.a.input("\0refresh");
|
||||
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4");
|
||||
+ assert.equal(s.a.state.data.inbox.list.length, 1);
|
||||
+});
|
||||
+
|
||||
+test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => {
|
||||
+ const s = app(t, { cols: 80, rows: 24 });
|
||||
+ const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ");
|
||||
+ assert.ok(question.length > 530);
|
||||
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
|
||||
+ await s.a.start();
|
||||
+ await s.a.input("1");
|
||||
+ const screen = s.screen();
|
||||
+ for (const want of [`decision ${d.id}`, " question: word0 word1", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves", "question cut to fit; the whole text is in mosaic inbox --json"]) {
|
||||
+ assert.ok(screen.includes(want), `${want}\n${screen}`);
|
||||
+ }
|
||||
+ assert.ok(!screen.includes("word89"), "the end of the question is what goes");
|
||||
+ fits(s.a.frame, 80, 24, "long question");
|
||||
+});
|
||||
+
|
||||
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
|
||||
+ let resolves = 0;
|
||||
+ const s = app(t, {
|
||||
@@ -1315,6 +1554,21 @@ index 00000000..b88b2230
|
||||
+ assert.equal(s.a.state.view, "trail");
|
||||
+ assert.equal(s.a.state.data.trail.subject, d.id);
|
||||
+ assert.ok(s.a.state.data.trail.list.length > 0);
|
||||
+ // The transport's own "did not finish" error has no code, only the
|
||||
+ // hyphenated message; it opens the same box, and Esc closes it.
|
||||
+ const hung = app(t, {
|
||||
+ wrap: (call) => async (verb, args) => {
|
||||
+ if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1);
|
||||
+ return call(verb, args);
|
||||
+ },
|
||||
+ });
|
||||
+ hung.raise("git.push.protected", { target: "refactor" });
|
||||
+ await hung.a.start();
|
||||
+ await hung.a.input("1");
|
||||
+ await hung.a.input("y");
|
||||
+ assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]);
|
||||
+ await hung.a.input("\x1b");
|
||||
+ assert.equal(hung.a.state.overlay, null);
|
||||
+});
|
||||
+
|
||||
+test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => {
|
||||
@@ -1347,7 +1601,8 @@ index 00000000..b88b2230
|
||||
+ } });
|
||||
+ decide.raise("git.push.protected", { target: "refactor" });
|
||||
+ await decide.a.start();
|
||||
+ await decide.a.input("1y");
|
||||
+ await decide.a.input("1");
|
||||
+ await decide.a.input("y");
|
||||
+ assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
|
||||
+});
|
||||
+
|
||||
@@ -1385,6 +1640,48 @@ index 00000000..b88b2230
|
||||
+ await h.a.input("\x03");
|
||||
+ assert.equal(h.out.quit.how, "Ctrl-C");
|
||||
+ assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]);
|
||||
+ // Arrows on the command line move nothing and type nothing.
|
||||
+ await h.a.start();
|
||||
+ const c = app(t);
|
||||
+ await c.a.start();
|
||||
+ await c.a.input(":ab\x1b[A\x1b[Bc");
|
||||
+ assert.equal(c.a.state.command, "abc");
|
||||
+ // v on Tasks opens the trail of the task.
|
||||
+ const f = app(t, { transport: fake() });
|
||||
+ await f.a.start();
|
||||
+ await f.a.input("t");
|
||||
+ await f.a.input("v");
|
||||
+ assert.equal(f.a.state.data.trail.subject, "vikunja:1/7");
|
||||
+ assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/);
|
||||
+ // :business switches: the old business's lists and selections go.
|
||||
+ await f.a.input("t");
|
||||
+ await f.a.input("j");
|
||||
+ assert.equal(f.a.state.sel.tasks, 1);
|
||||
+ const calls = [];
|
||||
+ f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []);
|
||||
+ await f.a.input(":business other\r");
|
||||
+ assert.equal(f.a.state.business, "other");
|
||||
+ assert.equal(f.a.state.sel.tasks, 0);
|
||||
+ assert.equal(f.a.state.data.trail, undefined);
|
||||
+ assert.deepEqual(f.a.state.data.tasks.list, []);
|
||||
+ assert.deepEqual(calls, [["other", "tasks"]]);
|
||||
+});
|
||||
+
|
||||
+test("one option: the status line says Press 1, not 1-1", async (t) => {
|
||||
+ const base = fake();
|
||||
+ const s = app(t, {
|
||||
+ transport: () => async (verb) => {
|
||||
+ const rows = await base()(verb);
|
||||
+ if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1)));
|
||||
+ return rows;
|
||||
+ },
|
||||
+ });
|
||||
+ await s.a.start();
|
||||
+ assert.match(s.screen(), /Press 1 to choose\./);
|
||||
+ const two = app(t);
|
||||
+ two.raise("git.push.protected", { target: "refactor" });
|
||||
+ await two.a.start();
|
||||
+ assert.match(two.screen(), /Press 1-2 to choose\./);
|
||||
+});
|
||||
+
|
||||
+test("no business: the app says so and :business picks one", async (t) => {
|
||||
@@ -1462,12 +1759,91 @@ index 00000000..b88b2230
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("decide with --note still asks; a No sends nothing", async (t) => {
|
||||
+ const s = broker(t);
|
||||
+ const d = s.raise("git.push.protected", { target: "refactor" });
|
||||
+ const call = s.transport("demo");
|
||||
+ const asked = [];
|
||||
+ const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e);
|
||||
+ assert.equal(err.message, "not resolved");
|
||||
+ assert.equal(asked.length, 1);
|
||||
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
|
||||
+ // Off a terminal, --note without --yes refuses before anything is sent.
|
||||
+ const x = io(humanEnv());
|
||||
+ x.stdin.isTTY = false;
|
||||
+ const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e);
|
||||
+ assert.equal(off.exitCode, 4);
|
||||
+ assert.match(off.message, /stdin is not a terminal; pass --yes/);
|
||||
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
|
||||
+});
|
||||
+
|
||||
+test("the app's business comes from a live bus host only", async (t) => {
|
||||
+ for (const [startTime, want] of [
|
||||
+ ["not-this-process", null],
|
||||
+ [startTimeOf(process.pid), "demo"],
|
||||
+ ]) {
|
||||
+ const dataRoot = tmp(t);
|
||||
+ mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
|
||||
+ writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
|
||||
+ const x = io(humanEnv());
|
||||
+ x.stdin.isTTY = true;
|
||||
+ x.stdout.isTTY = true;
|
||||
+ let opened;
|
||||
+ await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
|
||||
+ assert.equal(opened.business, want, startTime);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
|
||||
+function terminal({ throwOnResize = false } = {}) {
|
||||
+ const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
|
||||
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write: (s) => stdout.writes.push(s), rows: 24 });
|
||||
+ let resized = false;
|
||||
+ Object.defineProperty(stdout, "columns", {
|
||||
+ get: () => {
|
||||
+ if (resized && throwOnResize) throw new Error("columns failed");
|
||||
+ return 80;
|
||||
+ },
|
||||
+ });
|
||||
+ const signals = new EventEmitter();
|
||||
+ const resize = () => ((resized = true), stdout.emit("resize"));
|
||||
+ return { io: { stdin, stdout }, stdin, stdout, signals, resize };
|
||||
+}
|
||||
+
|
||||
+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error", async () => {
|
||||
+ const cases = [
|
||||
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
|
||||
+ ["end of input", (x) => x.stdin.emit("end")],
|
||||
+ ["SIGTERM", (x) => x.signals.emit("SIGTERM")],
|
||||
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
|
||||
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
|
||||
+ ["an error", (x) => x.resize(), { throwOnResize: true }],
|
||||
+ ];
|
||||
+ for (const [how, act, opts] of cases) {
|
||||
+ const x = terminal(opts);
|
||||
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals });
|
||||
+ await new Promise((r) => setImmediate(r));
|
||||
+ act(x);
|
||||
+ const result = await running.then(
|
||||
+ (r) => r,
|
||||
+ (e) => ({ how: "an error", error: e }),
|
||||
+ );
|
||||
+ assert.equal(result.how, how);
|
||||
+ if (how === "an error") assert.equal(result.error.message, "columns failed");
|
||||
+ assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
|
||||
+ assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
|
||||
+ assert.deepEqual(x.stdin.raw, [true, false], how);
|
||||
+ for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`);
|
||||
+ assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test("draw is pure: same state, same frame", () => {
|
||||
+ const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" };
|
||||
+ assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW }));
|
||||
+});
|
||||
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
|
||||
index aa425c0b..ddd341b8 100644
|
||||
index aa425c0b..2280cdb2 100644
|
||||
--- a/packages/queue/tests/dispatch.test.mjs
|
||||
+++ b/packages/queue/tests/dispatch.test.mjs
|
||||
@@ -1,8 +1,9 @@
|
||||
@@ -1506,7 +1882,7 @@ index aa425c0b..ddd341b8 100644
|
||||
[""],
|
||||
["launch", "queue"],
|
||||
["Queue", "list"],
|
||||
@@ -73,6 +73,17 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
|
||||
@@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1516,9 +1892,11 @@ index aa425c0b..ddd341b8 100644
|
||||
+ assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
|
||||
+ const c = JSON.parse(got.capture);
|
||||
+ assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]);
|
||||
+ // Before row 55 the same call went to the seat CLI.
|
||||
+ // Before row 55 the same call went to the seat CLI, with the same
|
||||
+ // environment the app now gets.
|
||||
+ const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
|
||||
+ assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
|
||||
+ assert.deepEqual(c.env, JSON.parse(before.capture).env);
|
||||
+});
|
||||
+
|
||||
test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {
|
||||
|
||||
Reference in New Issue
Block a user