Files
MeshTerm/CLAUDE.md
T
Jean-Pierre MartineauandClaude Opus 5 de0f60cbe0 Rewrite the CLI standard for the two faces it now has
The section described a command line "read by awk", which was the premise that
changed: the plain face is read by a person and --json is read by the program.
It stated the quoting, the absolute times and the comma-joined path line as
rules, and all three now say the opposite.

What replaces it is the seam first — a tool states, a renderer draws, the
boundary picks — then each face with the rules an implementer follows and the
reason behind each one, then the exit statuses, with the two classifications
that were wrong somewhere stated out loud: nothing transmitted is not a device
failure, and a rendering flag never moves the status.

docs/cli.md is untouched and now stale in three places: the --json row still
says only two commands honour it, --absolute is undocumented, and every console
sample carries quoted names and ISO timestamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:02:29 -04:00

527 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MeshTerm — working notes for Claude
MeshTerm is a full-screen terminal companion for MeshCore LoRa mesh devices. The UI is a
prompt_toolkit session rendering Rich content (`meshterm/ui/`), tools register into a
menu (`meshterm/tools/`), services run in the background (`meshterm/services/`), and
everything heard is recorded to SQLite (`meshterm/persistence/`).
Run the tests with `python -m pytest -q`. Screens must stay readable at each platform's
`readable_cols` — 72 regular, 53 PicoCalc (see **Platforms** below); the dual-platform
gallery test (`tests/test_gallery.py`) is the enforcement point, and every picocalc case
is a hard gate.
## Git workflow
Commit directly on `main` — do not create topic branches unless explicitly asked (this
is a solo repo; branches just create divergence to merge back later). Commit freely, but
never `git push` unless explicitly asked in that moment.
## UX standards
These are binding. Every new screen, dialog, row, or hint follows them; when you touch an
old one that doesn't, bring it along. The enforcement points live in code — build through
them instead of hand-rolling:
- `ui/menus.py` — `exit_rows`, `menu_rows`, `lane_row`, `section_heading`,
`confirm_discard`, `fit_cells`.
- `ui/markdown.py` — `render_markdown` (THE prose renderer: a page of writing, drawn in
the language below).
- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash),
`format_ago` (prose ages),
`_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring.
- `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`,
the `you` white.
### Lexicon — one term per concept
| Term | Meaning |
|---|---|
| node | any device on the mesh — a radio broadcasting packets; roles: client, repeater, room server. The umbrella term |
| contact | a node your device knows: **discovered** (heard broadcasting, not yet added) or **added** (in the contact list, messageable). Every contact is a node; not every node is a contact |
| heard | received from ("last heard", "first heard") — never "seen" in UX text |
| key | the full fixed-length value — a node's public key, a channel secret |
| hash | the short derived id — a key's first path-hash-mode bytes (the slice `highlighted_hash` lights), a channel hash, a path hop |
| path | an ordered hop spec you compose or force (`a1,3d,…`) |
| route | the concrete node sequence a trace walked or will walk |
| via | prefix for a packet/message's relay chain |
| Back | leave the current screen/list — Esc's word, and a row's only where leaving is a *choice* (see below) |
| Quit | leave the app (main menu, device splash) — nowhere else |
Node vs contact — the boundary: **node** is the hardware/participant sense — the map,
mesh walk, heard-nodes, relay hops, graph vertices, and node *types* (client/repeater/room/
sensor) all speak "node". **Contact** is the saved-identity sense — the Contacts screen,
the courier recipient, anything you *address*. The reception/persistence layer
(`observations.node`, `HeardNode`, `heard_nodes()`, `trace_hops.node`) stays "node"; the
sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow`/
`ContactsSort`/`contacts_table`). The `contacts` tool lists the device's added contacts.
Setting vs preference — the other boundary, three-way and never blurred: a **setting** is
the *radio's* (`core/device_config.py`, read live from the companion, edited on Device
config); a **preference** is *MeshTerm's own behaviour* (`core/preferences.py`, defaults in
code, overrides in `preferences.yaml`, edited on the Preferences page — `ctx.preferences`,
or `preferences.current()` where there is no context to reach through); and **config** is
machine setup — paths and device profiles (`core/config.py`, `config.toml`, a text editor
only). A behaviour value belongs in the preference registry, not as a module constant and
not in `config.toml`: a code constant a reader can't reach is a preference nobody has.
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
`_format_age` for aligned columns ("now", "5m").
### Navigation — the stack
Navigation is a **strict stack**: entering a screen or a dialog pushes one frame, Esc pops
exactly one, and the frame you land back on is the one you left — same object, so its
cursor, sort, scroll and typed filter are simply still there. Exceptions are added
deliberately, one at a time, and say why in the code.
- A screen that **owns a loop** is a hub, and a hub *stays pushed* for the whole visit:
`async with session.stay(screen) as visit:` / `while True: x = await visit.result()`.
One push, one pop, however many rounds. Never pop-and-re-push a screen to give a dialog
a backdrop — a screen that never left the stack already is one. `run_screen` remains the
one-shot form: push, await, pop, for a dialog or a prompt.
- A **sub-view nests**; nothing flattens the stack to spare the reader a climb. Two peers
may open each other (trace ↔ trophy case) and that cycle is fine — ^W is the climb.
- A list whose **content is data** (an editor's staged values, a queue that lost a row)
refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row
by value and keeps the filter. Rebuilding the screen is for when the rows it was holding
a place in are genuinely gone (a purge, a delete) — and then say so.
- **An entry chain is a stack too.** A flow that asks two or more things in a row runs
through `menus.run_steps`: each step gets the answers so far (to label itself, and to
offer its own previous answer as its default) and returns `None` to step back, so Esc
undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a
decision, not a step — its Cancel abandons.
- **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the
typed query as the most recent thing the reader entered: the first Esc clears it and the
screen stays, the second leaves. One rule on all four (`SelectScreen` and everything
built on it, the path composer, the map, the mesh walk), and the footer says `Esc clear`
for as long as it is true. Chat is the same shape with a different peelable thing — its
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
outcomes behind the same keystroke on the same affordance.
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
screen to belong to, and the F-key lane has only three free slots per screen. They are
stated once, on the About page. `test_navigation` walks every string literal in the
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
navigation frame awaits, so an unwind sent only to the top died there while the hub
below sat on a `visit.result()` nothing would resolve. Such a flow is launched through
`session.run_detached`, which absorbs the duplicate `PopToMenu` its task cannot carry;
the stack is what the unwind actually travels down.
- `modal` is *owning the keyboard* (a prompt, progress, a busy splash); `floating` is only
*drawn as a box*. They are not the same flag: a select list floats and is not modal, the
busy splash is modal and does not float. ^W declines to unwind past anything modal.
- A caller tests one thing for "the user left": `CANCEL` (or the `None` that `ui.select`
folds it to). `POP_ALL` never reaches a caller — the navigation boundary turns it into
`PopToMenu`, which derives from `BaseException` so it crosses the app's `except Exception`
tool guards; only the menu loop catches it.
### Screens and lists
- **No screen carries an exit row.** Esc leaves — it is on both platforms' keyboards and
every footer hint says so — so a row that only repeated it was two lines out of every
screen (a row in thirteen on the PicoCalc's 26) buying nothing. Lists end on their last
content row; a hand-drawn action list ends on its last verb. The gallery enforces it:
no rendered line may be the bare word **Back**.
- The exception is where leaving is a **choice** rather than an exit, which is the same
rule that keeps a dialog's Cancel button. `exit_rows(staged, …)` draws nothing while
clean and, once changes are staged, one blank `Separator(" ")` line then
`✓ Apply n staged changes` over `✗ Back — discard staged changes` — Apply has no key of
its own, so it needs a visible counterpart naming what the other way out costs. Same
shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash):
it *initiates* the app's terminal action behind a confirm, and on the splash it is the
only statement that the app can be left at all.
- **A cursor clamps at both ends — nothing in the app rolls over.** ↓ off the last row
and ↑ off the first stay put, in a select list, a hand-drawn action list, a reorder
list, a dialog's button row, everywhere. The window follows the highlight, so a roll
hauls the whole list back to the other end and flips its edge markers: the one move
that reads as the screen changing under you rather than as one step, and held-down
arrows should settle at an end rather than loop. The exception is a **forward-only key
with no reverse of its own**, which cycles or it dead-ends — Tab on a button row, and
the picocalc F-lane's single-chip advancers (`Sort →`, the node page's tab chip, the
Time Machine's window chip). Those state it in the code.
- Grouped-list section headings use `section_heading("Label")` → `── Label ──` accent.
That is also what makes a heading *sticky* (it pins to the top row while its section
scrolls, and the ^PgUp/^PgDn jumps step by it), so build them through it — a hand-rolled
`Separator` is no landmark. Prose written *directly under* a heading (a description of
what the section holds) is its preamble and pins with it, in order, as each row scrolls
off; prose after the section's first row labels nothing and never pins.
- Label + description command rows go through `menu_rows` (two cell-aligned lanes,
description muted). Editor rows (setting/value/description) go through `lane_row`.
- A row that opens further prompts ends with `…`; a row that acts immediately doesn't.
- Empty states are lowercase muted, optionally `— explanation`, never parenthesized.
- Body section headings inside screens: accent title, optional muted ` · note`.
- A find-as-you-type screen echoes its live query as `/query` in `warn`, on its own body
line **directly above whatever the query narrows** — `render.query_line` is the one
definition (select list, path composer, map, mesh walk). A screen that *also* carries
the query in its footer hint draws the body line only where the footer isn't drawn
(`Platform.footer_fkeys`), never both: the query must be visible on every platform, and
twice on none.
### Written pages
A screen that is *prose* (the three About pages) is **markdown**, not composed rows: the
text lives in `meshterm/assets/pages/*.md` and `ui/markdown.py` draws it in the language
above — `#` the page's own name in brand, `##` a section in the body accent (`## Title ·
note` gives it the muted aside), `###` a sub-heading indented with its prose, paragraphs
and lists hanging as blocks, quotes and fences behind a rail that survives wrapping,
links showing where they go (nothing is clickable on a framebuffer console). Two
paragraphs are page frame rather than body and sit flush and muted: the **standfirst**
under the `#` title and the **colophon**, the last paragraph under a closing `---`. Live
package facts arrive as `{version}` / `{author}` / `{copyright}` placeholders, filled as
the page opens. The `##` headings are the page's landmarks — they pin and the section
jumps step by them — so a written page earns `Sect ↑`/`Sect ↓` on the F-key lane exactly
as a grouped list does. Filling a page in is editing its `.md`; no Python follows.
### Titles
- Sentence case, always ("Trace — Lakeside", "Nodes", "Path width", "Admin login").
- **No emoji in any screen or dialog title** — icons live in menu/list rows. (A terminal
that draws an emoji narrower than Rich measures leaves a content-sized dialog's border
short.)
- `—` (em dash) introduces the subject/qualifier: `Feature — subject`. `·` chains status
atoms: `Map · z12 · 34 nodes · 2.1 km across`.
### Footer hints
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter
is standing the verb becomes `Esc clear`, because that is what the press does then.
- Enter verb by action: `Enter open` when the row pushes a screen or dialog ·
`Enter select` when it picks a value or action row · `Enter set` in a value picker.
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
of the generic three (`pick`, `choose`, `commit`) is not. The filter atom is always
`type to filter`. A **button row** is the one place the specific verb is barred: Enter
commits whichever chip is highlighted, so the hint stays `←→ choose · Enter select ·
Esc cancel` — `Enter quit` on the quit confirm was true only until ←→ moved, and hid
the key that moves it. A lone-button acknowledgement names its verb (`Enter OK`) and
offers no `←→`, having nothing to choose between.
- A hint is drawn **once per frame**, and which surface draws it is the footer's call.
Where the footer is the hint line (regular) it carries the *top* screen's hint, so a
floating dialog's border stays silent — the same sentence in the border and at the
bottom of the terminal was one of them wasted — and its clip arrows fall back to the
base frame's `↑↓ more`. Where the footer is the F-key lane (picocalc) there is no hint
line, so the border is the only place Enter/Esc/the arrows can be named and it keeps
the hint, less every atom whose keys are all chips on the lane one row below
(`fkeys.strip_lane_atoms` via `frame._dialog_hint`, resolved **per paint** — a screen
rewrites its hint as its content changes and reads its lane fresh every frame). The
chromeless splash has no footer row of any kind and keeps its hint in its own border on
both platforms.
- Key notation: `↑↓ move` (include the verb), `^R`/`^End` for Ctrl chords, `⌫` for
backspace, `⇧` for shift.
- An **arrow atom** leads the hint and names only the arrows: what they do (`↑↓ move`,
`←→ choose`, `↑↓ newer/older`) or what they move (`←→ slot`). Several keys share one
atom only when they do the same thing — `↑↓ PgUp/PgDn scroll`, `Home/End ends`; a key
that does something *else* gets its own, which is what `←→/Y/N choose` and `↑↓ Tab
complete` had wrong (Y/N answers, Tab completes, neither is what those arrows do). The
subject must be the right one, too: *cursor* is the row `❯` points at, so the composer's
←→ moves its `slot`. Every button dialog carries the same hint,
`←→ choose · Enter select · Esc cancel` — a yes/no prompt is a button row like any
other, so its y/n accelerators ride its `Yes`/`No` labels rather than the line.
### Dialogs
- Modal popups over the pushed backdrop (never full-screen replacements).
- Buttons are reverse-video chips (` Label `, `selected` style): safe way out on the
left, the committing verb on the right **and default**, so Enter commits and Esc backs
out. Two escalating caution tiers theme the frame: `danger=True` (amber) for a
disruptive choice — discard edits, reboot, show private key — and `destructive=True`
(the reserved red) for irreversible **data loss**, so a delete confirm reads red like
its `typed_confirm` sibling. Bulk or irreversible deletions gate behind `typed_confirm`
(also red); a single-record delete is a red Cancel/Delete confirm.
- Confirms are Cancel/Verb button dialogs, not Yes/No. Prompt lines that ask for input
end with a colon.
- Dialog `title` is short; the question/instruction goes in `prompt`.
### Marks and icons — one glyph per concept
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
- Node types (shared with the map): `★` you (yellow) · `●` node · `▲` repeater ·
`■` room · `◉` sensor · `○` unknown.
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
segment and half is bare page. An ellipsis would say a *word* was shortened; the crack
says the segment continues. Arrow-drawn paths keep the `…` — nothing to shear. Distinct
from `⋯`, which marks whole hops *elided* out of the middle (`PathLine.ellipsized`).
- Path endpoints: a path line runs between the nodes it actually went between — the true
origin and destination, never the first and last *relay*. Our own end is always the
app-wide `★` (`pathline.SELF_GLYPH`, taken straight from `marks.SELF_MARK`), never our
name: the one node the reader never has to be told, and the cells belong to the hops
that differ from row to row. As a chip it is the map's yellow star on neutral dark
grey, padded like every other chip.
- Chip seams are **one** interlocked chevron (previous fill on next). Two chips of the
same fill are the exception the interlock can't draw: the point drops its background so
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
on the page between a closing point and the next chip's notch, no fill, no padding.
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) ·
📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) ·
🏆 trophy case/record · ⌨ command line · 📖 read/about · 💰 support/donate ·
🚪 quit. Packet-class icons (feed/viewer
lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload
classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message
(overheard) · 📥 request · 📮 response · 🎭 anon request · 🧭 path · 🎯 trace ·
🧩 multipart · 🧰 control · ❔ unknown — raw advert/ack reuse 📢/✅.
### Colour
- Node names are always coloured: `name_style(name, key)` palette hue, derived from the
node's key (its first byte — any known prefix agrees) so a rename keeps the colour.
One rule on **every** platform; only the resolution changes (see **Platforms**).
A surface holding only a name resolves it first (`make_name_key_resolver`); a node no
key can place — an unresolved sender, a bare hash standing in as a name, an `○` ring —
takes `node.unknown`, THE light grey for an unidentified node, everywhere and on both
platforms. Colour is reserved for keyed identities, never seeded from a name's
characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a
step darker, while an unidentified node is content you can still act on. Our own node
is the pure-white `you` style; a context colouring (chart quality) may still win.
- **White is what "you picked this" looks like**, and nothing else in the app is allowed
to say it. The cursor row — the row `❯` points at, in a select list or in a screen
drawing its own rows — wears `cursor` (white), never `brand`: the wordmark's teal is the
app's identity, not a selection, and teal/cyan is itself a node hue, so a cyan-keyed
node used to vanish into its own highlight. White is outside the node spectrum, so it
can never collide with an identity. The same white lights the **active sort column's**
heading and triangle (`contactlist._SORT_ACTIVE`, `widgets._sort_header`) — the same
claim one axis over — and the path composer's insertion-slot chip, so the row you pick
and the slot it lands in read as one gesture. It rides *under* the row's spans, so every
lane keeps the colour it set — an age's heat, an SNR reading, a red badge — with the one
exception the highlight exists for: **a node's key-derived hue folds to the cursor white
on the cursor row** (`theme.is_identity_style` → `render._whiten_identities`, applied
once at the render boundary for any Text whose *base* style is `cursor`), so the
highlighted row reads as one thing instead of as a name arguing with its own selection.
Only a keyed hue folds: `node.unknown`'s grey stays grey, because the highlight must not
claim to know a node we can't place. A **path line is spared** wherever it sits on the
row — there a hue is not decoration on a name, it is what tells one hop from the next and
what a route graph's one-byte labels are matched by — so `pathline` stamps its own extent
(`PATH_INK`, a style that draws nothing) and the fold skips what lies inside it. That is
what makes the arrow form say what the chip form always said, its fills having never been
in the fold's vocabulary. Reverse-video chips — a dialog's committing button,
a running action's Abort, the arrow-mode composer slot, the editor cursor — are the
`selected` fill, a **grey** block (`muted`'s slate; light grey slot 7 on the VT, the one
grey a reverse can put behind text there, and the same fill the F-key lane uses). A chip
marks where a press lands; it is chrome, and it does not get a hue.
Recency heat colours heard/first-heard ages, never names — seven steps on plain human
boundaries (`widgets._HEAT_STEPS`), each `heat.*` style named for how old the node it
colours is: white under 5 minutes, then yellow, light red, brown, red, light grey, and
one cold grey shared by "over a year" and "never heard". A key lane (via
`highlighted_hash`) lights its hash in the same key-derived hue; the rest of the key,
and any key standing in as a name, stays muted. UX text says "key" for the lane and
"hash" only for the short derived id — never "hash" for a truncated key.
- SNR always through `snr_style`; timelines oldest→now left-to-right, grey baseline = 0,
drawn via `ui/braillechart` only.
### Layout
- Wrapped labelled rows hang under their value block (two-column grid), never column 0.
- Dialogs anchor slightly above true centre, sized for their populated state.
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
— never bursts.
### The command line — one answer, two faces
Everything above describes the **menu**, which is read by a person sitting in front of it.
The command line has two readers and it no longer pretends they are the same one: the
**plain face** is for somebody at a prompt who typed a command to find something out, and
the **JSON face** (`--json`) is for a program, often on another machine and often later.
The seam that makes two faces possible is the rule everything else here hangs off:
> **A tool states its answer as data. A renderer turns data into bytes. The CLI boundary
> picks the renderer.**
`exit_code` already worked this way — the tool states it, the boundary turns it into a
process status, no tool calls `sys.exit`. The answer travels the same way now. A tool that
*prints* its answer has given it away: it is rendered and gone by the time anything could
offer it in another format, which is why `--json` reached two commands out of twenty and
stopped, each one growing an `if ctx.json_output:` branch above the rendering that restated
the whole answer in a dialect nobody else could reuse.
- `ui/report.py` — `Listing` (records) and `Facts` (one thing, key by key). **A row holds
the typed value** (`6.0`, `None`, a `datetime`), never a formatted cell; each `Column`
carries both projections, a plain `Lane` and a JSON function. A `Facts` block also covers
the one-scalar answer, through `shape=BARE` (`config get` prints its value alone because
the caller named the key) and the acknowledgement, through `shape=SILENT`.
- `ui/fields.py` — **the shared shapes, one constructor per concept**: `node`, `channel`,
`position`, `route`, `spec`, `when`, `instant`, `snr`, `flag`, `free`. A listing that
mentions a node asks for `fields.node`, so every document speaks the same five-key object
and every listing names its lanes the same way — by construction, not by review.
`fields.node` is the one that earns the design: **one machine key expands to as many
plain columns as the surface has room for**, four in `contacts` and one in a hop table.
- `ui/renderers.py` — `PlainRenderer` and `JsonRenderer`, chosen by `ctx.output`.
**A third format is a new renderer and nothing else**: no tool is touched, no report
changes. `ctx.output` is an `OutputFormat`, never a boolean, because the boolean was
already being asked questions it could not answer.
- `ToolResult.report` is `None` for the menu path and for a feature with no scripted face
(the map, the dashboard). It is **not** `summary`: that is the run log's record of what an
invocation did, written to the `runs` table on every execution, and folding a five-hour
`monitor` capture into it would be the price of one field fewer.
- **`ui/surface.py` is untouched by all of this.** `PlainUi`/`TuiUi` is the *menu-vs-terminal*
split and stays exactly what it is; a report is **returned**, not shown, so `ctx.ui` never
grows a `report()` method and `TuiUi` never sees one.
- **Streaming is a second, narrower seam** and deliberately not the general path:
`monitor` and `chat listen` run until a window closes, so their answer cannot be a value
handed back at the end. They open `renderers.stream(...)` and emit one typed row per
record. Two commands use it; anything that can build a whole report builds one.
#### The plain face — for a person at a prompt
It is still a Unix utility's output — one record per line, no colour, no frames, stdout is
the answer, the exit status is half the report — but every rule that existed *only* to make
splitting safe has gone, because splitting is the machine face's job now.
`meshterm/ui/script.py` is the whole vocabulary and carries the reasoning; the rules:
- **Alignment is the delimiter.** A name is bare (`script.name`). The *escaping* under the
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
stranger fills in a message body, and neither may end the record it sits in.
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
it — behaviour belongs in the registry.
- **A route is drawn and a path is typed.** `script.route` joins hops with ` → `; a hop is
`Name (hash)`, or whichever half is known, never an empty `()`. `script.spec` is the
comma-joined hex `--path` takes back, and it is the one line on either face that
round-trips, so nothing creeps into it. **Our own node is a hop like any other** — the
menu's `★` says "you already know who this is", which is true of the reader and false of
whoever opens the file afterwards.
- **`unknown` is a word and `-` is an absence.** `script.NONE` is the one token for absent;
`never` is a *value* (a node not yet heard is a fact). A node type stays the word
`repeater` — a monochrome `▲` would need a legend and the CLI has no legends.
- **A live stream pins its lanes** (`script.stream`), because a capture cannot measure
columns it has not seen. A value wider than its lane overruns and pushes the row right;
nothing is elided, and the one unbounded field goes **last** so it can push nothing.
- **A value that round-trips is untouchable.** `config show` prints what `config set` takes
— an enum's number, `""` for an empty string — and gains nothing cosmetic. `fields.rendered`
carries the typed value beside the text its own spec produced, so neither face parses the
other's output.
- **No wrapping, with one exception**: the four written pages (`about`, `about-author`,
`discord`, `support`) draw on a `script.PAGE_WIDTH` console. The no-wrap rule exists so a
*record* is never split with its fields under the wrong headings; a paragraph has no
fields, and unwrapped it is a 600-cell line no terminal can read. **Never wrap a listing.**
- **stdout is the answer; everything else is stderr** — errors (`meshterm: what went
wrong`), progress bars, log records, `ui.ack`, and a `ToolResult.message`. The last two
used to be dropped; stderr keeps the promise the dropping was made to keep (a redirect
catches only the answer) while giving the person at the prompt back their ✓ and their
count. `script.stderr_console()` stops wrapping off a terminal, because a sentence folded
at 80 columns is a sentence `grep` cannot find.
#### The JSON face — the machine contract
`--json` prints **the answer itself**, and the report a caller needs is still `$?`.
- **No envelope.** An array for a listing, an object for a set of facts, so
`contacts --json | jq '.[].node.name'` reads what it looks like. A multi-block report is
one object: each `Listing` under its own key, each `Facts` merged at the top level.
- **One compact line, `\n`-terminated**, UTF-8 with no ASCII escaping, keys in the order the
report declares them. A stream is one document per record, **all the same shape** — no
discriminator, because every reader would pay for it and only a stream would use it.
- **Absent is `null`, never an omitted key**, and typed values throughout: `9` not `"9"`,
`false` not `"false"`, a body raw and unescaped. `unknown` becomes `null`; a consumer
already has one spelling for "nothing here".
- **Timestamps are UTC, RFC 3339, `Z`, to the second** — twenty characters, so string
comparison is time comparison. `--absolute` does not touch this: a local offset is a fact
about the machine that ran the command, not about the event.
- **A numeric field carries its unit in its key** (`snr_db`, `uptime_s`, `rtt_ms`), never in
its value, and a key/hash is lowercase hex. **A key is never truncated** — a truncated key
cannot go back into `--to` or `--path`, so a short id is a `hash`.
- **`--json` changes the rendering, never the report.** Same exit status, same records. An
empty result prints its empty document (`[]`, or the object with its `null`s) **and still
exits 5**; a failure prints **nothing on stdout** and keeps its sentence on stderr.
- **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is
silent (`config advert`, `channels join`, `courier cancel`) still emits what it did.
- **A command with no data face refuses, loudly.** `--json specimen` is a usage error (exit
2): its output *is* the colour, and a document of it would be a lie or an empty gesture.
So is `--json` with no subcommand. The map, the dashboard, the live feed, the watchtower
and the mesh walk have no subcommand to refuse from, and that stays the honest answer
rather than a degraded one.
#### The exit status is the report
`core/exitcodes.py`, and the `--help` epilog: 0 ok · 1 failure · 2 usage · 3 no device ·
4 device failed · 5 nothing to report. A tool that ran fine and found nothing returns
`NO_RESULT`; a failure is *raised*, never returned. Two classifications worth restating
because both were wrong somewhere: **nothing transmitted is not a device failure** (a bad
argument is exit 2), and **a rendering flag never moves the status**.
`tests/test_report.py` is the enforcement point for the seam, `tests/test_script_output.py`
for the plain vocabulary, and `tests/test_cli_contract.py` for the commands — including a
derived sweep that runs **every** registered command twice, once plain and once under
`--json`, and pins that the two agree about the exit status.
### Platforms
One codebase, two flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji) and
**picocalc** (the PicoCalc's 53×26/53×40 framebuffer console, 16 palette slots, a
512-glyph font, no emoji). A frozen `Platform` spec (`meshterm/platforms.py`) resolves
once at boot; consumers bind at platform-switch time via `platforms.on_platform` — never
branch on the platform per frame, and never `from meshterm.platforms import PLATFORM`.
- The borderless title bar (`frame._title_bar`) is this platform's whole panel border:
`↑↓ ──── Title ─────── Esc back`. The **clip arrows lead the row and are always both
drawn** — colour reads the scroll (the border's `accent` where that direction has more,
`muted` where it doesn't), the same live/dim language the lane draws one row below. A
pair that appeared and vanished, half of it a blank cell, asked the reader to compare
the row against a memory of itself; fixed furniture means the bar never changes width
as the body scrolls either.
- **Never emit a raw emoji or bare hex colour into picocalc output.** Icons go through
`theme.glyph()` (the compact map); everything else is caught by the render-boundary
fold (`theme.fold_text`, applied in `tui/render.render_to_ansi` and
`MapCanvas.to_ansi_lines`) — but the fold is the safety net, not the design.
- The glyph contract is `ui/fontset.py` — the installed console font's exact codepoint
inventory, device-verified. A character outside it is a test failure, not a tofu box
found on-device. The font itself is built by `scripts/calculinux-console-font.sh`;
the two files move in the same commit.
- The 16-slot palette is `theme._VT_SLOTS` (programmed via `/etc/vtrgb`; same script).
`MESH_THEME_16` speaks `color(0..15)` only; backgrounds stop at slot 7. Both themes
define identical style names.
- **Bold is brightness on the VT**: `bold` on a 0–7 foreground *is* slot N+8, so a
dim-slot style must state its intent — `not bold` (keep the declared colour) or `bold`
(the promotion is the point, only `title.muted`), never silent. Rich merges a row's
base style into every span, so a silent one changes colour inside a selected row. Same
trap outside the theme: `MapCanvas` drops emphasis entirely where bold is brightness,
since its colours arrive quantized and it can't know which bank they landed in, and the
fold's quantizer (`theme._nearest_slot_sgr`) states intent for it — a dim slot leaves as
`22;3N`, never a bare `3N`, because `9N` is *how* the console spells bright and adjacent
art spans (the wordmark's bevels, a raster's neighbouring cells) reset nothing between
them, so a bare one inherits the intensity bit and lands a bank too high mid-row.
- On picocalc, the node hue and the heat gradient **quantize** — same rule, coarser
resolution: `node_style` snaps the key's hue to its sixth of the wheel
(`theme._NODE_SLOT_HEXES`, the six chromatic bright slots) and heat to the `heat.*`
steps. Nothing loses its colour for being on the console. Marks whose hue is *fixed*
rather than derived go through a theme name so the slot is chosen deliberately (the
node types' `type.*`; a raster resolves the same entry via `theme.mark_rgb`) — a naive
downsample greys the repeater's violet. Footer hints are replaced by the **F-key lane**
(`ui/tui/fkeys.py`): five `FPair` slots per screen, F1–F5 primary and F6–F10 each
slot's *opposite number* (physical Shift+F1..F5), labels ≤6 cells. `DEFAULT_LANE` claims
only **F4/F5 — the pager**, which has no physical key at all; **the jump to either end
rides the Shift half of the very pager heading for it** (F10 Top behind F5 Page ↑, F9
Bottom behind F4 Page ↓), because Home and End *are* on this keyboard and only ever
wanted a chip for consistency. That leaves **F1–F3 free on every screen** for its own
verbs, and `EMPTY_LANE` for a screen with none at all (every dialog). **A chip names an
action, never a key** — `Page ↑`, not `PgUp`; `Latest` on a transcript; `Region` on the
map, whose Home reframes and whose paging zooms. **A directional pair rises toward its
outer key**: where two adjacent chips are opposite ends of one axis, the *up · in ·
more* end takes the slot nearer the lane's edge — F5 on the right-hand pair (F4/F5's
paging, the map's zoom: `Zoom -` then `Zoom +`, a rocker), F1 on a left-hand pair (a
select list's `Sect ↑` then `Sect ↓`) — and a slot's Shift companion follows its own
slot's direction. The lane *is* the footer here, so it obeys the hint line's rule
— never advertise a key that would do nothing. Empty and dim are different claims: leave
the slot **empty** when the action isn't a thing on this screen (Retry in a channel), and
clear `enabled`/`opp_enabled` to draw it **dim** (label kept, fill dropped) when it's a
thing that just isn't available this paint. Dimming is presentational — `handle` stays
the authority and no-ops. The lane is also the *only* affordance advertisement here, so
anything the desktop reaches by a chord or a bare letter (a list's `^PgUp/^PgDn` section
jumps, the Time Machine's `w`, the map's `^U`, a row's `Del`) earns a slot — otherwise
it is undiscoverable on the device.
- `meshterm specimen` prints the whole visual language through the real funnels — the
acceptance card on-device, a preview under `--platform picocalc` on the desktop.
- Dev loop: `meshterm --mock --platform picocalc` in a 53×40 window; the gallery and
`tests/test_theme16.py` carry the contracts.