feat(cli): full-screen terminal app (#1544, row 55)
Bare `mosaic` on a terminal opens the app. It has Inbox, Tasks, Agents
and Trail screens, help, and a decide overlay, and it uses the alternate
screen, raw mode, resize and SIGTERM, and restores the terminal on exit.
draw(state) in screens.mjs is pure. cli.mjs exports resolveDecision,
which `mosaic decide` and the app share, so both write the same lines
and ask the same question. A decision resolves on `y` only while
decideShown() says the whole decide box is on screen. The app reads that
at the key. A box that doesn't fit is replaced by "Too small to show this
decision", and any key answers No. scripts/mosaic sends no arguments to
packages/cli (a decided scope addition), so the queue dispatch test
follows. The cli README and docs/TOOLS.md describe the app.
Darkwing built it (agents/darkwing/work/queue-55), in three rounds.
Round 3 was approved by Dewey (27308) and Filbert (27309) on manifest
59f1b9b4, 9 paths. Their non-blocking notes go to row 69 (#1562): a
narrow-terminal test for the decideShown MIN_COLS guard, a way out from
the hidden box, a U+18D08 width test, a cut box keeping Esc back, and
the README row count stated as an example.
Sage's gate on 3634d06e, sequential and teed: node suites cli 110 and
queue 150, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed (Docker on).
122b1223..3634d06e touches none of the 9 paths.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -19,6 +19,7 @@ import { humanTransport, refuseInsideAgent } from "./transport.mjs";
|
||||
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
|
||||
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
|
||||
import { createLauncher } from "./launcher.mjs";
|
||||
import { quitLine, runApp } from "./app.mjs";
|
||||
import { readSessions, stopSession } from "../../seat/src/session.mjs";
|
||||
import { SeatError } from "../../seat/src/seat.mjs";
|
||||
|
||||
@@ -36,6 +37,13 @@ export const USAGE = `usage:
|
||||
mosaic bus stop
|
||||
mosaic bus status [--json]`;
|
||||
|
||||
// packages/seat/src/cli.mjs USAGE, which that module does not export (it runs
|
||||
// its main on import). tests/app.test.mjs fails if the two drift.
|
||||
export const SEAT_USAGE = [
|
||||
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
||||
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
|
||||
].join("\n");
|
||||
|
||||
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
|
||||
|
||||
function parse(args, { flags = [], values = [] }) {
|
||||
@@ -110,18 +118,29 @@ async function confirm(io, question) {
|
||||
async function decide(parsed, call, io) {
|
||||
if (parsed.positional.length !== 2) throw usage("decide takes a decision and an option");
|
||||
const [ref, choice] = parsed.positional;
|
||||
const ask = parsed.flags.has("--yes")
|
||||
? null
|
||||
: (question) => {
|
||||
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
|
||||
return confirm(io, question);
|
||||
};
|
||||
await resolveDecision({ call, ref, choice, note: parsed.values["--note"], write: (s) => io.stdout.write(s), ask });
|
||||
}
|
||||
|
||||
// The decide flow, shared by `mosaic decide` and the app's Inbox (src/app.mjs):
|
||||
// re-read the inbox, find the decision and the option, write the decision
|
||||
// and what the choice means, ask (null skips the question, as --yes does),
|
||||
// then resolve. Returns the decision.
|
||||
export async function resolveDecision({ call, ref, choice, note, write, ask }) {
|
||||
const d = findDecision(await call("inbox"), ref);
|
||||
const option = d.options.find((o) => o.key === choice);
|
||||
if (!option) throw new CliError(`decision ${shortId(d.id)} has no option ${JSON.stringify(choice)}; its options are ${d.options.map((o) => o.key).join(", ")}`, 2);
|
||||
io.stdout.write(formatDecision(d));
|
||||
write(formatDecision(d));
|
||||
const effect = d.authorization ? (choice === d.authorization.approvalChoice ? "this authorizes the action" : "this declines the action") : null;
|
||||
io.stdout.write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
|
||||
if (!parsed.flags.has("--yes")) {
|
||||
if (!io.stdin.isTTY) throw usage("stdin is not a terminal; pass --yes to resolve without the prompt");
|
||||
if (!(await confirm(io, `resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
|
||||
}
|
||||
write(`your choice: ${choice} (${option.text})${effect ? `; ${effect}` : ""}\n`);
|
||||
if (ask && !(await ask(`resolve ${shortId(d.id)} with ${choice}? [y/N] `))) throw new CliError("not resolved", 1);
|
||||
const args = { id: d.id, choice };
|
||||
if (parsed.values["--note"] !== undefined) args.note = parsed.values["--note"];
|
||||
if (note !== undefined) args.note = note;
|
||||
try {
|
||||
await call("decision.resolve", args);
|
||||
} catch (e) {
|
||||
@@ -131,7 +150,8 @@ async function decide(parsed, call, io) {
|
||||
if (e.code === "decision-closed") throw new CliError(`decision ${shortId(d.id)} was closed before your answer arrived; see mosaic trail ${d.id}`, 2);
|
||||
throw e;
|
||||
}
|
||||
io.stdout.write(`resolved ${d.id}: ${choice}\n`);
|
||||
write(`resolved ${d.id}: ${choice}\n`);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function human(verb, rest, io, deps) {
|
||||
@@ -296,7 +316,23 @@ async function bus(rest, io, deps) {
|
||||
throw usage();
|
||||
}
|
||||
|
||||
// `mosaic` with no argument. In an agent run it refuses (exit 3); without a
|
||||
// terminal on both stdin and stdout it prints the usage (exit 4); on a
|
||||
// terminal it opens the app (src/app.mjs) and prints one line once it quits.
|
||||
async function app(io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
if (!io.stdin.isTTY || !io.stdout.isTTY) throw new CliError(`the app needs a terminal on stdin and stdout; from a script, use a verb:\n${SEAT_USAGE}\n${USAGE}`, 4);
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
const state = readHostState(system.dataRoot);
|
||||
const business = state?.live ? state.business : null;
|
||||
const transport = deps.transport ?? ((b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }));
|
||||
const run = deps.runApp ?? runApp;
|
||||
const result = await run({ io, business, transport, color: !io.env.NO_COLOR });
|
||||
io.stdout.write(`${quitLine(result)}\n`);
|
||||
}
|
||||
|
||||
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
|
||||
if (argv.length === 0) return app(io, deps);
|
||||
const [verb, ...rest] = argv;
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
|
||||
Reference in New Issue
Block a user