diff --git a/packages/cli/README.md b/packages/cli/README.md index 1abec3d0..3fb537fd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 3f5418b0..e2211224 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -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; } diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs index 38ac5f94..926daad1 100644 --- a/packages/cli/src/screens.mjs +++ b/packages/cli/src/screens.mjs @@ -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); diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs index a679886a..924384ca 100644 --- a/packages/cli/tests/app.test.mjs +++ b/packages/cli/tests/app.test.mjs @@ -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" });