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]>
20 KiB
Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1 and 2
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: <seat usage> 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:
- Agent markers exit 3 through
refuseInsideAgent, with the transport's message unchanged. - stdin or stdout not a TTY exits 4 with the usage.
- 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.mjswith 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:
- 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.
- Broker text is shown, never interpreted. No raw control character or
bidi override reaches the terminal (
visible()frompackages/conversation/src/terminal.mjs). - NO_COLOR: no escape codes except reverse video (7/27) on the selected row. A frame with nothing selected has none.
- 1-9 shows the decide flow's own lines and question. Only y resolves, and it goes through the test broker.
- The app's decide text equals
mosaic decidestdout for the same decision. - n, N, Esc, Enter and q at the question cancel. Other keys wait. Nothing is sent.
- Outcome unknown shows the CLI's sentence and offers no retry. Then v opens the trail, or r reads again.
- Refused and failed reads keep the last list, marked stale. A refused decide says refused.
- Keys: views, moving, the trail of a selection, the command line, help and quit.
- No business: the app says so, and
:business <id>picks one. main([]): exit 3 with a marker set, exit 4 with the usage off a terminal, and the app on one (stubbedrunApp).scripts/mosaicwith no argument reaches the app entry (exit 4 off a terminal, 3 with a marker).SEAT_USAGEstill equals what the seat CLI prints, so the copy can't drift.- Strings to keep exactly appear once, in
cli.mjsandtransport.mjs.app.mjsandscreens.mjsdon't restate "authorizes the action", "declines the action", "[y/N]", "may have recorded it" or "runs only from a human shell". drawis pure: the same state gives the same frame.
Deviations, stated
- NO_COLOR keeps reverse video on the selected row. The brief's test
line says NO_COLOR output has no escape codes.
IMPLEMENTING.mdline 188 says reverse video stays for the selected row, anddocs/design/README.mdline 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[2Jbefore each frame and the alternate screen codes are cursor and screen control, not colour. - 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 thepackages/cliusage, exit 4. Before, it printedrefused: <seat usage>.IMPLEMENTING.mdline 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 asSEAT_USAGE. - The Terminal class is not the base. The brief says the app is built
on the conversation
Terminalclass. Onlyvisible()is imported.Terminalis a line editor for Talk, and Talk is out of scope here. The app needs a whole-screen redraw and single-key input, whichTerminaldoesn't do. When Talk joins, the Talk screen embedsTerminal. - Reads block redraw.
humanTransportusesspawnSync, so the screen doesn't redraw during a read (30 s timeout). That is the existing transport, unchanged. The README says so.
Evidence, round 1
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_HOSTpointed 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.txthas 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.patchapplies to23d566d6in a fresh worktree and the manifest matches.
Follow-ups (not in this row)
- The transport's "did not finish"
CliError(transport.mjsline 60) has no.code, so the outcome-unknown branch inresolveDecisiondoes not see it.mosaic decidethen printsoutcome-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 inFOLLOWUP-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.
Round 2
Round 1 drew changes requested from both reviewers: Filbert in comment
27251 (rev 390) and Dewey in comment 27253 (rev 395), both on candidate
93fa48c1cdfe. This round answers every blocking item and each named
mutant, and most of the non-blocking ones. build.patch, files.txt
and candidate-manifest.sha256 now hold the round 2 candidate. The
round 1 versions are in this packet's history at 4acadaae. The same
nine files: +1741/−16. Base for the apply check: c506c6ec
(out/gate-round2/apply.txt). None of the commits since fe5c68fa touch the nine
files.
Blocking
R1 (Filbert): the question took type-ahead. Now y or Y resolves and
any other key answers No. Quitting at the question (q, Ctrl-C, a signal,
end of input) also answers No and sends nothing. Keys that were queued when
the question opened, or that arrive within ASK_MS (200 ms) of it being
drawn, are dropped. The timer arms the question; arming empties the key
queue. That covers a paste (1 yes), decide 1a2b yes typed without the
colon, and keys typed while a spawnSync read held the screen: those
arrive as data events after the draw, inside the window. A timer refresh
tick is never taken as an answer. The hint now reads "y resolves · any
other key cancels (the default is No)".
200 ms is my choice. It is short enough that a person who reads the
question first never meets it, and long enough to swallow keys the
terminal already holds. A y pressed inside it is dropped silently; the
next one counts. Tests pass askMs: 0, except the window test, which
uses 300 ms and sends one y at once and one 10 ms after the draw.
R2 (Filbert): cells() miscounted emoji. \p{Emoji_Presentation}
counts 2, and U+FE0F after a one-cell \p{Emoji} base adds the second
cell (🅰️, ❤️, 1️⃣). The old range 1F300-1FAFF became Emoji_Presentation
plus 1F200-1F2FF. width, fit and wrap pass the previous code point.
The fake inbox now carries ✅✅, ⚡ and 🅰️; a task title carries 発行 ✅.
The fits test covers them at every size, and a width test checks known
widths plus the UTF-16 length of those rows on screen, which does not
depend on width(). Known limit: ZWJ sequences and flags count more cells
than they draw, so such a row comes out short. It can't overflow.
B1 (Dewey): a tall decide box cut the authorization lines. The box now
cuts inside the question. The decision line, the class line, the options,
the recommendation, action: A on T, the authorizes-it line, the task and
everything from your choice: on always stay. A box still too tall
(a small screen) fall back to the old cut, which keeps the top and the
part from your choice: on, then the bottom. The cut line now says the
whole text is in mosaic inbox --json; formatInbox caps the question at
400 characters, so plain mosaic inbox was wrong. The new test raises a
question of more than 530 characters with a target and a task at 80x24 and
checks that each of those lines, the cut line and [y/N] are on screen,
and that the end of the question is what went.
Filbert's mutants
out/mutants-round2/ has the runner and its output: 30 mutants, each one
edit to src/, with the whole packages/cli suite run against it. All 30
are killed. no-arm-delay (the window set to 0) survived the first run,
because the in-window y in the test arrived synchronously. The test now
also sends one 10 ms later, and the rerun kills it.
| Mutant | Killed by |
|---|---|
resolve-ask-skipped-when-note |
decide with --note still asks |
unknown-space-only |
outcome unknown: the hyphenated transport error with no code |
restore-no-altleave, restore-no-raw-off, no-sigterm |
runApp leaves the terminal as it found it |
paint-no-reverse |
NO_COLOR: exactly one reverse-video row, in colour and NO_COLOR |
cells-no-cjk |
width counts terminal cells (the 発行 ✅ row) |
overlay-cut-loses-prompt |
every screen fits (the long decide shot keeps the prompt) |
app-host-not-live |
the app's business comes from a live bus host only |
quit-no-answer |
quitting at the question answers No, armed or not |
refresh-in-overlay |
a timer tick reads nothing under an overlay |
unknown-esc-stays |
outcome unknown: Esc closes the box |
trail-task-id |
keys: v on Tasks opens the trail of vikunja:1/7 |
business-keeps-data |
keys: :business other drops lists and selections |
nav-no-brackets |
every screen fits: [i Inbox 3] wide, [i 3] narrow |
The runner adds mutants for this round's own changes: no SIGINT, no SIGHUP, an unguarded resize, no Emoji_Presentation, no FE0F rule, the B1 cut removed, type-ahead kept, no arm delay, other keys waiting, no capital Y, a tick taken as an answer, a tick dropping the resolved status, the command line typing key names, "Press 1-1" and no too-small guard.
Non-blocking
- N1 (Filbert; Dewey concurs). The command line takes only one-character keys, so the arrows type nothing. Tested.
- N2.
runApplistens for SIGTERM, SIGINT and SIGHUP, and a resize or a first draw that throws goes tocrash, so the restore runs. Tested with fakes for each, plus q and end of input: the first write enters the alternate screen, the last leaves it, raw mode goes on then off, and every listener is gone. - N3. Below 40x10 the frame says the terminal is too small and how big it must be, and still fits row for row. Tested at 39x10, 40x9, 3x3 and 1x1; 40x10 joins the fits sizes.
- N4. A timer tick keeps a "resolved" status. Tested.
- N5. An inbox read that times out inside the decide flow shows the
outcome-unknown box though nothing was sent. Not changed here. It is
added to
FOLLOWUP-transport-code.md, because the fix belongs with the transport's missing.code. - N6. One option says "Press 1 to choose". Tested.
- N7.
runApphas a test (N2 above). - N8. The dispatch test now also checks that the no-argument call gets
the same environment as it did under the pre-A2 script. The old non-TTY
bytes: deviation 2 stands. If Sage wants them back, it is one line in
cli.mjs, and test 12 already holds the bytes asSEAT_USAGE. - Dewey: Ctrl-C on the command line. It closes the command line; it
does not quit. The
app.mjsheader and the README now say so. - Dewey: REACHES YOU. Listed below as deviation 5.
Deviation 5
- The Inbox has no REACHES YOU column. In the design it says when
the decision reaches you:
now, or a digest time such as08:00 CT. The broker's inbox rows say nothing about delivery: no notifier time, no digest hour. The app could guess from the class, but a wrong time in that column would mislead, so I left it out. When the inbox rows carry delivery, the column is one more entry ininboxBody.
Tests, round 2
packages/cli/tests/app.test.mjs has 23 tests (14 in round 1). New:
width counts terminal cells; type-ahead never answers the question;
quitting at the question; a timer tick under an overlay; the B1 long
question; one option says Press 1; decide with --note still asks; the
business comes from a live host only; runApp restores the terminal.
Changed: test 6 is now "any key but y or Y answers No" (n N Esc Enter q x
space 2 Ctrl-C and the up arrow; none sends, none quits), and test 8
sends 1 and y as separate inputs. Tests 1, 3, 4, 7 and 9 gained the
assertions named above.
Evidence, round 2
All runs in a detached worktree of c506c6ec with the round 2
build.patch applied and node_modules linked. out/gate-round2/ has
the logs, summary.txt and gate.sh, the script that ran them in
sequence.
-
packages/cli: 106/106,out/gate-round2/cli-tests.log. -
Every
scripts/test-*.sh, all exit 0: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; Docker groups skipped, as in round 1 A
gate_defaultDocker network appeared during the run. It had no containers afterwards, and I removed it. -
out/gate-round2/apply.txt:build.patchapplies toc506c6ecand the manifest matches. The nine files are byte-identical atfe5c68faandc506c6ec. -
out/gate-round2/smoke.txt:smoke.shat 80x24 against a fixture config, never the real bus. The alternate screen was entered once and left once, the quit line printed, exit 0. -
out/mutants-round2/:run2.mjsand its output.round2.txtis the run of all 30 mutants: 29 killed,no-arm-delaysurvived. I fixed the type-ahead test (a second y 10 ms after the draw), thenround2-rerun.txtreran the two type-ahead mutants: both killed. The other 28 were not rerun; the fix only adds a send and an assertion to one test. -
Candidate manifest sha256:
3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7.