# Row 55 (#1544): the full-screen terminal app, candidate packet, round 1 Author: Darkwing. Reviewers: Dewey and Filbert. Brief: `docs/plans/2026-10-10_design-implementation.md`, section "Full-screen terminal app", with `docs/design/IMPLEMENTING.md` "The full-screen terminal app" and "Strings to keep exactly". Base: `fe5c68fa` (row 41, `base.txt`). It applies unchanged to `23d566d6`: the commits between the two touch only BUILD-LOG.md, docs/SESSIONS.md and the queue files, none of them in this candidate. The feature is uncommitted; only this packet is committed. I pushed nothing. ## Files `files.txt` lists the nine repository paths. `candidate-manifest.sha256` has their sha256. `build.patch` is `git diff --cached --binary fe5c68fa` over them: 9 files, +1363/−16. | File | Change | |---|---| | `packages/cli/src/app.mjs` | new: key parsing, the `App` state machine, `runApp` (alternate screen, raw mode, resize, SIGTERM, restore) and the quit line | | `packages/cli/src/screens.mjs` | new: `draw(state)` is pure and returns rows; Inbox, Tasks, Agents, Trail, Help, overlays, the 16-colour palette and NO_COLOR | | `packages/cli/src/cli.mjs` | the decide flow moves into the exported `resolveDecision`, which `decide` and the app share; `SEAT_USAGE`; the no-argument entry `app()` | | `packages/cli/src/format.mjs` | `trailSummary` is exported (the Trail screen uses it); no other change | | `packages/cli/tests/app.test.mjs` | new: 14 tests, listed below | | `packages/cli/README.md` | the intro names row 55; new section "The app" | | `docs/TOOLS.md` | the `mosaic` entry: the no-argument line and a four-line paragraph | | `scripts/mosaic` | a decided scope addition (Sage, 2026-10-11); see below | | `packages/queue/tests/dispatch.test.mjs` | follows from `scripts/mosaic`; in scope (Sage, 2026-10-11); see below | `resolveDecision` writes the same lines in the same order and asks the same question as before. `decide` passes `ask = null` for `--yes` and keeps its own "stdin is not a terminal" refusal. The 83 tests that were in `packages/cli/tests/` at the base still pass unchanged. ### Scope addition: scripts/mosaic `scripts/mosaic` is not in the brief's files. Sage approved it on 2026-10-11 as a decided scope addition. Before, `scripts/mosaic` with no argument fell through to `packages/seat/src/cli.mjs`, which printed `refused: ` and exited 4. The script never reached `packages/cli`, so the brief's acceptance line, bare `mosaic` on a TTY opens the app, could not be met inside `packages/cli` alone. The change is one case that sends no argument to `packages/cli/src/cli.mjs`. Nothing else in the script changed. The entry checks in Sage's order: 1. Agent markers exit 3 through `refuseInsideAgent`, with the transport's message unchanged. 2. stdin or stdout not a TTY exits 4 with the usage. 3. Otherwise the app opens. Test 11 below covers each branch through `main([])`. Test 12 spawns `scripts/mosaic` itself for exits 4 and 3. ### The queue dispatch test My first gate run had test-queue red, 26 passed and 1 failed (`out/round1-first-gate/test-queue.log`). `packages/queue/tests/dispatch.test.mjs` pins A2's rule that every call but `queue` reaches the seat CLI exactly as the pre-A2 script did, and its list of calls included `[]`. Sage's approved change sends exactly that call somewhere else, so the pin can't hold as written. The file is outside the brief. I changed it because the gate needs every suite green and the change follows from the approved one. Sage ruled it in scope on 2026-10-11, after the review request: a decided scope addition, with the candidate unchanged. The change, 16 lines in and 5 out: - `[]` leaves the list of seat calls. `[""]` stays: it is one argument, so it still goes to the seat CLI. - The fake root gains a fake `packages/cli/src/cli.mjs`. - A new test: no argument reaches `packages/cli/src/cli.mjs` with empty argv, the same working directory and stdin, and the fake's exit code and output passed through. The same call on the pre-A2 fixture still reaches the seat CLI. - The header comment names row 55. With `scripts/mosaic` put back to the base, the new test fails and the other three pass. With the candidate, all four pass. The test that A2 only adds lines to the pre-A2 script still passes, because the row 55 case adds four lines and removes none. ## How the brief maps to the candidate | Brief | Where | Test | |---|---|---| | no argument on a TTY opens the app | `cli.mjs` `app()`, `app.mjs` `runApp` | 11, 12; `out/smoke.txt` | | not a TTY: usage, exit 4 | `cli.mjs` `app()` | 11, 12 | | agent run: exit 3 | `refuseInsideAgent` in `transport.mjs` | 11, 12 | | Inbox, Tasks, Agents, Trail, Help; keys c i t a r j k : ? q | `app.mjs` `#key`, `screens.mjs` | 1, 9 | | decide runs the existing flow, strings unchanged | `resolveDecision` | 4, 5, 6, 7, 13 | | 16 colours, NO_COLOR, 80x24, designed at 120x36 | `screens.mjs` | 1, 3 | | no new dependency | `package.json` untouched | | | every screen fits 120 columns and 80x24 | | 1 | | NO_COLOR output has no escape codes | | 3 (see deviation 1) | | the string grep test | | 13 | The tests in `packages/cli/tests/app.test.mjs`: 1. Every screen and overlay fits 120x36, 80x24 and the narrow layout (100x30, 99x24), row for row, in colour and NO_COLOR. That covers every view and overlay, plus the empty, stale, refused and no-business states. 2. Broker text is shown, never interpreted. No raw control character or bidi override reaches the terminal (`visible()` from `packages/conversation/src/terminal.mjs`). 3. NO_COLOR: no escape codes except reverse video (7/27) on the selected row. A frame with nothing selected has none. 4. 1-9 shows the decide flow's own lines and question. Only y resolves, and it goes through the test broker. 5. The app's decide text equals `mosaic decide` stdout for the same decision. 6. n, N, Esc, Enter and q at the question cancel. Other keys wait. Nothing is sent. 7. Outcome unknown shows the CLI's sentence and offers no retry. Then v opens the trail, or r reads again. 8. Refused and failed reads keep the last list, marked stale. A refused decide says refused. 9. Keys: views, moving, the trail of a selection, the command line, help and quit. 10. No business: the app says so, and `:business ` picks one. 11. `main([])`: exit 3 with a marker set, exit 4 with the usage off a terminal, and the app on one (stubbed `runApp`). 12. `scripts/mosaic` with no argument reaches the app entry (exit 4 off a terminal, 3 with a marker). `SEAT_USAGE` still equals what the seat CLI prints, so the copy can't drift. 13. Strings to keep exactly appear once, in `cli.mjs` and `transport.mjs`. `app.mjs` and `screens.mjs` don't restate "authorizes the action", "declines the action", "[y/N]", "may have recorded it" or "runs only from a human shell". 14. `draw` is pure: the same state gives the same frame. ## Deviations, stated 1. **NO_COLOR keeps reverse video on the selected row.** The brief's test line says NO_COLOR output has no escape codes. `IMPLEMENTING.md` line 188 says reverse video stays for the selected row, and `docs/design/README.md` line 148 makes reverse video the selection mark. I followed the design. Test 3 checks that 7 and 27 are the only SGR codes, and that a frame with no selection has none. The writer's `\x1b[H\x1b[2J` before each frame and the alternate screen codes are cursor and screen control, not colour. 2. **The non-TTY message gains one line.** It prints `mosaic: the app needs a terminal on stdin and stdout; from a script, use a verb:` and then the seat usage and the `packages/cli` usage, exit 4. Before, it printed `refused: `. `IMPLEMENTING.md` line 191 says to refuse and point at the verbs, and the old text named only the seat verbs. If a reviewer wants the old bytes, test 12 already holds them as `SEAT_USAGE`. 3. **The Terminal class is not the base.** The brief says the app is built on the conversation `Terminal` class. Only `visible()` is imported. `Terminal` is a line editor for Talk, and Talk is out of scope here. The app needs a whole-screen redraw and single-key input, which `Terminal` doesn't do. When Talk joins, the Talk screen embeds `Terminal`. 4. **Reads block redraw.** `humanTransport` uses `spawnSync`, so the screen doesn't redraw during a read (30 s timeout). That is the existing transport, unchanged. The README says so. ## Evidence All runs in a detached worktree of `23d566d6` with `build.patch` applied and `node_modules` linked (`out/gate/`). - `packages/cli`: 97/97 (83 before, plus the 14 new), `out/gate/cli-tests.log`. - Every `scripts/test-*.sh`, run in sequence on the final candidate, all exit 0 (`out/gate/summary.txt`, logs beside it): | Suite | Result | |---|---| | test-auth | 15 passed, 0 failed | | test-conductor | 17 passed, 0 failed | | test-config | 24 passed, 0 failed | | test-discord | 66 passed, 0 failed | | test-extension-package | 18 passed, 0 failed | | test-foundation | 44 passed, 0 failed | | test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree | | test-release | 14 passed, 0 failed | | test-task | 26 passed, 0 failed; 4 Docker groups skipped | test-task ran with `DOCKER_HOST` pointed at a missing socket, so its adapter, workspace, live task and live recall groups printed skip. The live ones call a real model. Nothing this candidate changes runs in those groups. Sage's gate rerun covers them with Docker. - The first gate run, before the dispatch test change, is kept in `out/round1-first-gate/`. `out/dispatch-mutant.txt` has the dispatch test against the base script and the candidate script. - Smoke, `smoke.sh`: the app in a pty at 80x24 against a fixture config, never the real bus. Keys ? Esc t q. The alternate screen was entered once and left once, then it printed `(app closed with q; 0 decisions resolved this session)` and exit 0 (`out/smoke.txt`). - `out/apply.txt`: `build.patch` applies to `23d566d6` in a fresh worktree and the manifest matches. ## Follow-ups (not in this row) - The transport's "did not finish" `CliError` (`transport.mjs` line 60) has no `.code`, so the outcome-unknown branch in `resolveDecision` does not see it. `mosaic decide` then prints `outcome-unknown: the bus transport did not finish`, exit 1, instead of the "may have recorded it" sentence. The app matches the message text and shows its outcome-unknown overlay, so only the verb has the gap. It predates this row. Written up in `FOLLOWUP-transport-code.md`; Sage queues it with the 2026-10-12 batch. - Talk and stop in the app, Decision Seen, and the web console stay out of scope, as the brief says.