cli: terminal app follow-up, narrow-terminal guard test, hidden-box way out and width tests (#1562)

Row 69, built by Darkwing, approved by Dewey (comment 28048) and Filbert
(comment 28055) on manifest c68e6582. Sage gate on c6711453: cli 123/0,
queue 150/0, every scripts suite green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 18:36:52 -05:00
co-authored by Claude Opus 5.5
parent 14c1484da4
commit 90d7ed6628
4 changed files with 103 additions and 8 deletions
+10 -5
View File
@@ -129,11 +129,16 @@ and nothing else.
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`. When even those lines don't fit, the box says only "Too small
to show this decision. Any key answers No." `y` resolves only while the
frame shows the whole box apart from the cut, and the app checks the
size at each key, so a terminal shrunk after the question appeared
answers No. A gated decision with a task needs 20 rows at 80 columns
(21 with a cut question), and 29 rows at 40 columns.
to show this decision. Any key answers No. Resize, or use mosaic
decide.", and the status line says "Too small to confirm here." instead
of promising a `y`. `y` resolves only while the frame shows the whole
box apart from the cut, and the app checks the size at each key, so a
terminal shrunk after the question appeared answers No. Under 40
columns or 10 rows the app shows only the too-small screen, however
tall the terminal is. How many rows a box needs depends on its lines.
For example, a plain gated decision with a task needs 20 rows at 80
columns (21 with a cut question) and 29 rows at 40 columns; one with
longer lines needs more.
- 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, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
+6 -1
View File
@@ -96,7 +96,12 @@ export class App {
render() {
if (this.closed) return this.frame;
this.frame = draw(this.state, { cols: this.cols, rows: this.rows, color: this.color, now: this.now() });
// A hidden decide box can't be confirmed with y, so the status line
// doesn't say it can.
const o = this.state.overlay;
const hidden = o?.kind === "decide" && !decideShown(o, this.cols, this.rows);
const state = hidden ? { ...this.state, status: "Too small to confirm here.", statusStyle: "warning" } : this.state;
this.frame = draw(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;
}
+3 -1
View File
@@ -377,7 +377,9 @@ 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);
// The way out fits the 34 cells a 40-column box has, so the hidden box
// is never cut.
return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"], "Resize, or use mosaic decide."], 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);
+84 -1
View File
@@ -191,7 +191,7 @@ test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell
assert.equal(width(s), cells, JSON.stringify(s));
}
// East Asian Width W and F outside the blocks above: one from each range.
for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x18d08, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
// A line break never splits ❤️ from its U+FE0F; no line is wider than asked.
assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]);
for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l));
@@ -342,6 +342,68 @@ test("below 40x10 the question is hidden, so every key answers it No, y included
assert.equal(g.a.state.status, `resolved ${d.id}: yes`);
});
test("under 40 columns a tall terminal shows the too-small screen even where the decide box would fit; y sends nothing (Filbert N10)", async (t) => {
// A plain decision's box fits at cols - 2 from about 29 rows at 39
// columns and 35 at 30, so each size here would show it if the app
// checked only the box.
for (const [cols, rows] of [[39, 29], [35, 31], [30, 35], [39, 33], [39, 40], [39, 60], [36, 60], [30, 80]]) {
const s = gated(t, cols, rows, false);
await s.a.start();
await s.a.input("1");
const label = `${cols}x${rows}`;
assert.ok(s.a.state.overlay?.asking, label);
const flat = s.a.frame.map(strip).join(" ").replace(/\s+/g, " ");
assert.match(flat, new RegExp(`The terminal is ${cols}x${rows}; the app needs at least 40x10\\.`), label);
assert.match(flat, /A decision question is open; any key answers No\./, label);
assert.doesNotMatch(s.screen(), /y resolves/, label);
await s.a.input("y");
assert.equal(s.a.state.overlay, null, label);
assert.equal(s.a.state.status, "not resolved", label);
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
}
// Every size from 30 to 39 columns and 33 to 60 rows, short question and long.
for (const long of [false, true]) {
const s = gated(t, 120, 40, long);
await s.a.start();
await s.a.input("1");
for (let cols = 30; cols < 40; cols++) {
for (let rows = 33; rows <= 60; rows++) {
const label = `${cols}x${rows}${long ? " long" : ""}`;
assert.equal(decideShown(s.a.state.overlay, cols, rows), false, label);
assert.match(draw(s.a.state, { cols, rows, color: false, now: NOW }).map(strip).join(" ").replace(/\s+/g, " "), /the app needs at least 40x10\./, label);
}
}
s.a.quit("q");
}
});
test("a hidden decide box names the way out, and the status line under it does not promise y (Filbert N12)", async (t) => {
const status = (s) => strip(s.a.frame.at(-2)).trim();
const s = gated(t, 80, 24, false);
await s.a.start();
assert.match(status(s), /confirm with y\.$/, "the Inbox's own status, before a digit");
await s.a.input("1");
assert.match(s.screen(), /y resolves/);
assert.match(status(s), /confirm with y\.$/, "true while the box shows");
s.a.resize(80, 18);
assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\.[\s\S]*Resize, or use mosaic decide\./);
assert.equal(status(s), "Too small to confirm here.");
assert.doesNotMatch(s.screen(), /with y/);
fits(s.a.frame, 80, 18, "hidden at 80x18");
// Grown back: the box and the status return.
s.a.resize(80, 24);
assert.match(s.screen(), /y resolves/);
assert.match(status(s), /confirm with y\.$/);
// At the smallest size the hidden box is whole, its way out included.
s.a.resize(40, 10);
assert.match(s.screen(), /Any key answers No\.[\s\S]*Resize, or use mosaic decide\./);
assert.doesNotMatch(s.screen(), /cut to fit/);
assert.equal(status(s), "Too small to confirm here.");
await s.a.input("y");
assert.equal(s.a.state.status, "not resolved");
assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
});
test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => {
const boom = new Error("the terminal write failed");
let fail = false;
@@ -624,6 +686,27 @@ test("refused and failed reads keep the last list, marked stale; a refused decid
assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
});
test("help or a detail box cut to fit keeps its last line, Esc included (Dewey)", async (t) => {
const s = app(t, { cols: 80, rows: 10 });
s.raise("git.push.protected", { target: "refactor", question: "Push the release?" });
await s.a.start();
// The help's last row at full height; at 80x10 it wraps "Esc closes
// this help." and the cut keeps that row.
const last = (frame) => frame.map(strip).filter((l) => l.includes("│")).at(-1);
const whole = draw({ ...s.a.state, overlay: { kind: "help" } }, { cols: 80, rows: 40, color: false, now: NOW });
assert.match(whole.map(strip).join(" ").replace(/[\s│]+/g, " "), /Esc closes this help\./);
await s.a.input("?");
assert.match(s.screen(), /cut to fit/);
assert.equal(last(s.a.frame), last(whole));
fits(s.a.frame, 80, 10, "help at 80x10");
await s.a.input("\x1b");
await s.a.input("\r");
assert.equal(s.a.state.overlay?.kind, "detail");
assert.match(s.screen(), /cut to fit; the whole text is in mosaic inbox --json/);
assert.match(s.screen(), /│ Esc back +│/);
fits(s.a.frame, 80, 10, "detail at 80x10");
});
test("keys: views, moving, trail of a selection, the command line, help and quit", async (t) => {
const s = app(t);
const a = s.raise("git.push.protected", { target: "one" });