The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.
meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.
The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.
Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.
ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last 122 `D205` sites, which takes `ruff check .` to zero.
Same shape as the package pass and just as additive: these were a paragraph
with no summary, so each one gains a plain sentence saying what is being
asserted, and every word that was there moves down into the description. The
dense ones benefit most — `test_pathline`'s 23, where "A seam is one cell, not
two" now stands alone and the chevron mechanics explain it underneath, and
`test_tui`'s wide-glyph repaint test, whose seven lines on prompt_toolkit's
relative cursor now sit under the one line that says what the test pins.
Four module docstrings (`test_advert`, `test_gallery`, `test_platforms`,
`test_repeater_admin`) opened with "X tests: a, b, and c" and now name the
subject on the first line with the list below it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 46 missing docstrings, written rather than generated.
Most of the test ones already carried the explanation — as a comment sitting on
the first line of the body. That prose is the docstring; it was only in the
wrong place, so the config editor's PIN and lane tests keep their own words and
lose the duplicate comment. The rest are new: the two `repo` fixtures, the
counting `FakeDevice` in `test_device_state` (each method now says what it
answers *and* that it counts the call), the PicoCalc chrome and 16-slot theme
tests, and three one-offs.
In the package: `PathLine` and `FastRenderer`'s constructors point at the class
docstring that already documents their arguments rather than repeating it,
`SelectItem.__post_init__` and `courier_screen`'s opener say what they cache and
why, and `emoji_width.create_content` explains the memo it wraps the line lookup
in. `flash.py` and `uart1-mux.py` get theirs too, and `tests/__init__.py`
finally says what the package is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`E402` read as seventeen deliberate late imports and was in fact two accidents.
A module constant had settled into the middle of `packet_viewer`'s import block
(`_BODY_CACHE_MAX`, now below it), and ten test modules put their `_FIXTURE`
path or a small helper above the last import — the `tests.conftest` screen
reader, which now joins the block it belonged to.
`B008` is the opposite case: all six are Typer declaring a CLI parameter by
calling `Argument`/`Option` in the default, which is the framework's API and
cannot be written another way in this style. The objects it returns are inert
sentinels no command mutates — exactly the thing `extend-immutable-calls` is
for, so the rule now knows about them instead of being silenced per line.
`.claude` leaves the lint's reach: those are helper scripts for the skills, not
package code, and CI has no reason to gate on them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Down off the last row wrapped to the first on a good third of the app's
lists, and the select screen shipped that as its default -- twenty-one
call sites had already opted out one at a time, each writing wrap=False
for its own reason. The reasons were all the same one, so the flag is
gone and clamping is simply what a cursor does.
The window follows the highlight, so a roll doesn't move a pointer: it
hauls the whole list back to the other end and flips its edge markers,
which reads as the screen changing under you rather than as one step.
Held-down arrows now settle at an end instead of looping past what you
were trying to reach.
The hand-rolled cursors came along: the trophy case's, the trace and TX
optimize action rows, the reorder list -- where the ungrabbed cursor
wrapped while the grabbed one clamped, so the same two keys meant
opposite things in the two modes -- a prompt's completion list, and a
dialog's buttons.
Tab is the exception, and it stays a ring: it has no reverse of its own,
so on the last chip it comes round or it dead-ends. Same for the three
picocalc F-lane chips that are single-key advancers -- Sort, the node
page's tab strip, the Time Machine's window -- where the lane offers no
way back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device info stopped printing the pairing PIN; the Device config editor one menu
over still listed it in plain digits, which meant the concealment held only until
the reader walked one screen sideways. It masks there now on the same terms —
and a *staged* PIN masks as well: one typed a moment ago is still a PIN, and a
row that uncovered itself the instant it was edited would leave the secret up for
the rest of the staging session.
The reveal moves to `^S`, on both pages. It was a bare `p` on the info page,
which a read-only body can afford; the editor cannot, its letters being
find-as-you-type, and one value that comes out from under a different key
depending on which screen you are standing on is a second thing to learn for
nothing. The chord also has to be meant — no PIN appears because a hand brushed a
letter. `^S` is XOFF's key and safe for the same reason `^Q` is not XON here: raw
mode clears IXON/IXOFF, which the chord table already relied on. Both pages say
`^S show PIN` / `^S hide PIN` in the footer and carry the same F3 `Reveal`/`Hide`
chip — F3 being the slot a select list spends on its own verb, free here because
this list hands out no deletable rows.
The editor's list becomes `_ConfigMenu`, which owns the toggle and the one row
builder both it and the staging loop call: a toggle is a refresh like any other
(`replace_items` follows the highlighted row by value and keeps the typed
filter), so uncovering the PIN while three letters deep in a filter leaves the
reader exactly where they were. `_menu_items` grew the flag and hands it to the
value lane; nothing else in the row shape moved.
Both pages join the gallery, each in both states — the config editor had no
specimen at all before this, and the revealed state is the widest its PIN row
gets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device info is the page you open to read your radio out to somebody, the one that
gets screenshotted into a forum thread and left up while a colleague leans over
your shoulder. Every value on it is yours to give away except one: the BLE
pairing PIN is what lets another phone onto the radio, and it was sitting in the
Identity section in plain digits alongside the node name and the coordinates.
It now draws as the field rather than the value — six mask bullets, the same
glyph a password prompt types in, promoted to marks.MASK_MARK so the two can
never drift apart. Six is the width of the *field* (the setting's own bound is
999999), not of what is in it: masking per character is right while someone is
typing and counting their own digits, and wrong for a stored secret, where it
would say how many to guess. A device that reports no PIN keeps its "?" — an
absence is not a secret, and a "?" behind bullets would promise something to
uncover.
`p` uncovers it, and `p` puts it back. A bare letter is a read-only page's idiom
for its own verb (the Time Machine's `w`) and this page has no find-as-you-type
to compete for it; the footer names what the press would do *now* — `p show PIN`,
then `p hide PIN` — and on the console the same toggle is F3, `Reveal` then
`Hide`, since the lane is the only place that platform can learn the key exists.
A device with no PIN advertises neither: the atom drops and the slot stays empty,
because the action isn't a thing on that page at all.
The page is therefore its own screen now (ui/device_info_screen.py) rather than a
generic result window, and ScrollScreen grows `replace_content` — replace_items
for a read-only body — so the toggle swaps the page under a reader who keeps
their scroll position; nothing reflows, six cells become six. The CLI has no key
to press, so `meshterm info` and `config show` print it masked and
`config get device_pin` is the way to name it deliberately: one rule, which is
that the whole-device dump never carries the PIN. The editor one menu over still
lists it plainly — you go there to change it, a row at a time, and its letters
already belong to find-as-you-type.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The device config table let its two narrow lanes size themselves to their
content first: SETTING stretched to the longest label it happened to hold and
CURRENT to the longest value, and DESCRIPTION — the one lane whose content is
systematically the widest — lived on what was left. That was 19 cells of 72, so
nearly every explanation wrapped two or three lines deep and the reader scrolled
a 67-line table to read twenty settings.
The narrow lanes are capped instead, at 24 cells for the setting and 15 for the
value, and their cells are pre-folded to fit so `no_wrap` still holds each lane
to exactly that width. DESCRIPTION keeps the whole remainder: 27 cells, and the
table falls to 52 lines. Four rows fold — three telemetry labels and the
path-hash value — and every one of them sat on a wrapping description already,
so the fold costs nothing the reallocation didn't already buy back. A folded
label hangs two further in, so a continuation can never be read as a section
heading at column 0, and a value folds before its parenthesised label rather
than through it: `1` over `(2-byte hashes)`, not a torn `1 (2-byte`. A long node
name — the field is 31 bytes — now folds too, where before it dragged the whole
lane across the screen.
The cap only exists to buy the prose room, so where that lane isn't drawn it
lifts. Two places don't draw it: the PicoCalc, whose 53 columns are spent by the
setting and its value, and the staged view, whose third narrow lane leaves
nothing for prose. Rich was already dropping the column outright at both widths
— now the table says so itself, and shreds nothing on the way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six lists rebuilt themselves as a whole new screen every round, because their
content is data: an editor's rows carry a staged `current → new` value, its
title counts what is staged, the outbox loses the message it just sent, the
watchtower's log rewrites the row you acknowledged. Each approximated the cost
with `default=cursor`, which recovers only the cursor, and only while the row it
names still exists — the typed filter went every time.
SelectScreen.replace_items swaps the rows (and optionally the title) in place
instead. The place is kept by value, so the highlight lands back on its row
wherever that row moved to, and falls back to the same position in the list when
the row is gone entirely — which is what you want after deleting the row you
were sitting on. The filter rides along.
So the config editor, device actions, the channel manager, the courier outbox,
the watchtower and the repeater-admin editor all hold one screen for the whole
visit now, refreshed after each action. The courier's ticker is started once for
the visit rather than per round, and folds a flow's effect in immediately rather
than waiting up to a second for the next tick.
The admin node picker gains a `stay` form. Repeater admin used to draw a second,
identical SelectScreen as a static backdrop so the password prompt had something
behind it, then drop the list before the session; the real list stays up through
both now — so cancelling the password lands back on the node list rather than
bowing out of the flow, and leaving a node's admin session lands there too.
Administering two repeaters in a row is an Esc and a pick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.
back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.
Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.
With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.
Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tile fetch that gets no answer was being remembered as one that did.
BasemapSource is careful about the difference — a timeout is never written
to disk, precisely so the next pan can ask again — and both callers then
threw it away, filing every None as "fetched, empty/absent" and never
asking again for the life of the screen. The same latch sat one level up:
_resolve() marked itself done whether or not it had resolved anything, so
one failed TileJSON round-trip made `available` False, and _ensure_tiles
skipped every fetch, for the whole session.
That is a desktop no-op and a PicoCalc bug. The device brings its Wi-Fi up
some forty seconds into the boot, well after an app started alongside it
reaches the menu and warms the tile source — so the map answers "offline"
before the answer can be true and draws the entire session from whatever
the cache already held. JP, 2026-08-18: black tiles at the two highest
zooms, where one z14 tile is the whole screen and a hole is the picture.
VE2RAD_FH_50m's tile (14/4842/5861) was simply absent from the device
cache; nothing there had been written in ten days of using the map, and it
fetched in 1.4s the moment it was asked for.
So: `available` reports what is known and never itself goes to the network
(it is read on the paint path), a failed resolve stands for 30s rather than
for ever, `answered_empty` lets a caller tell the two Nones apart, and a
tile nobody answered about waits out a 20s cooldown instead of a restart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The picker's crosshair is a per-frame marker: render_body appends it to
_markers, calls up, and takes it straight back out in a finally. But the
real ground raster runs off the paint path, and _draw_ground read
self._markers when the *thread* ran — after that restore. So every
finished basemap frame was drawn without the crosshair, and landing it
erased the pick: pan and the crosshair rides the ghost, stop and the
streets arrive over it.
Snapshot the whole scene — viewport, markers, find query — when the
raster is requested, and hand it to the draw. A frame filed under a key
has to be the scene that key stands for.
Two things fall out of it. The "same view, only tiles differ" shortcut
now compares everything but the tiles, since a frame drawn for a
different marker set is a picture missing a node, not one missing
streets. And _ensure_viewport splits out of render_body, so the picker
settles its view without rendering a whole crosshair-less frame first —
one less full raster on open, and the crosshair rides a resize too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven files defined the same _plain (plus test_chat's _strip_ansi and
test_timemachine's renderable flavour), and two of the copies had
already diverged into join-without-strip. tests/conftest.plain is now
the single strip-and-join reader — a list of lines or a bare string —
and every file imports it; the local variants keep their names so call
sites stay put (housekeeping H-003).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.
The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.
The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).
A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.
The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Braille (one dot per module) would not scan, and the quadrant mosaic
reads worse on screen than the plain half-block form. The original
qr_text render scans and stays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One braille dot per module drew smallest but starves the scanner of
ink (each dot carries its white surround), and phones would not read
it. Quadrant mosaics keep every dark module a solid quarter-cell —
the same ink geometry that made the half-block form scannable — at
half its width: 27x27 cells for a contact URL instead of 53x27.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A braille cell packs a 2x4 block of modules, so the code draws at half
the width and a quarter of the height of the half-block form (27x14 vs
53x27 for a contact URL) — small enough for the 40-column PicoCalc
panel. Simulated-camera decode tests pass with dot fills down to 50%,
since a phone's blur merges the dots into blobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The advert menu's own-node contact card generalises into
show_contact_card (QR code over the raw meshcore://contact/add link,
in one floating view); the Info tab gains a 📱 Share contact row that
pops it for the page's node. Offered only when the full 32-byte key is
known — a prefix-only heard node has nothing scannable to encode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every screen and dialog title now follows the same three rules. Titles
are sentence-cased ("Trace — YUL-Poly", "Dashboard — mesh overview",
"Nodes"), never bare lowercase ("trace · x", "nodes", "command line ·
x", "path width", "admin login" and a dozen siblings). An em dash
introduces a title's subject or qualifier while middle dots stay for
chained status atoms ("Map · z12 · 34 nodes · 2 km across"), so the two
separators mean different things instead of being used at random. And
no title carries an emoji — the icon belongs to the menu row; a
content-sized floating dialog whose title emoji the terminal draws a
cell narrower than Rich measures leaves the border short (the bug the
Send advert popup already documented), so the Watchtower, Courier,
Time machine, identity-key, reboot, sync, restore, location, factory
reset, and advert dialogs all drop theirs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panning is the arrow keys (Shift for fine), zoom is PgUp/PgDn alone, and
reset moves to Home — freeing every letter and +/- so typing can drive a
live name filter instead: matches keep bright white labels and always win
the label contest, everything else dims to bare context glyphs, Enter
frames the matches, Backspace edits, and Esc peels the filter before the
map. The footer swaps to the query while a find is active, the title
counts matches, and the location picker follows the same movement keys
with find disabled (typing there has no job). Hints fit 72 columns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main-menu advert entry ran the same flow as Device actions but
replaced the screen instead of floating over it. Tools can now declare
popup = True to run while the menu stays pushed — their prompts and
result draw as modal dialogs over it, the quit-dialog pattern — and
the advert tool does, so both doors into the flow looked and felt the
same. With the popup one keystroke away there was no reason to keep
the second door: the Device actions row is gone, leaving that screen
to the box-level operations (clock, backup, key, reboot, reset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MeshTerm now announces the connected node by itself: a zero-hop advert
every 1/2/3/4 hours (default hourly) and a flood advert every
3/6/12/24/48/168 hours (default daily), each switchable off. The
cadences are staged and applied from a new Background adverts section
in the device-configuration editor, and persist per device (keyed by
public key) in adverts.json alongside the last-sent mark for each type
— so the countdown survives restarts, and a manual send from any
advert flow resets the same clock the scheduler reads.
The scheduler stays transmission-shy: at most one advert per 30 s pass
(a flood due together with a direct waits a pass), and a never-sent
device is armed to count from now rather than advertised immediately.
It holds no device subscription — each pass just skips when nothing is
connected — so reconnects need no special handling, and scripted CLI
runs never start it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Announcing the node is the everyday operation in the Device actions
bucket, but it sat two screens deep and out of reach of the main menu's
type-to-filter. Give it a top-level entry (order 10, right after Device
actions; Device info shifts to 11) via a new menu-only tool that reuses
the existing advert flow through a standalone send_advert() entry point.
Only SELF_INFO is read on open — the advert op and contact card never
need the full snapshot — so it stays snappy over Bluetooth. The row
inside Device actions stays, and scripting remains under `config
advert` / `config share`, so no new CLI subcommand.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New staged settings: contact auto-add bitmask, default flood scope
(hashtag-normalized, 31-byte slot), and the two TX delay factors the
library always zeroed (write-only on hardware, so unknown values fall
back to 0 exactly as before). TX power is now capped by the board's
reported max_tx_power instead of a hardcoded 22, with the prompt hint
and validation sharing one effective_maximum.
Device actions gain Sync clock (drift shown before writing host time;
also a config sync-clock CLI command), and Device info now leads with
a live Status panel: role, model/firmware, battery, storage, clock
drift, uptime, and radio/packet statistics — every read best-effort so
older firmware still renders.
The simulator models all of it, including a seeded clock drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Dialog footer hints take the border's hue one shade darker (hint.*
theme styles), mirroring the brighter title tinting.
- Dialogs get a blank breathing row above and below the body whenever
it doesn't cost visible content.
- The chat conversation picker gains column headers over its lanes.
- The map screen titles itself "Map", matching its menu item.
- The config editor lays settings out in aligned SETTING / VALUE /
DESCRIPTION columns under a header line, and with changes staged the
Apply and Back rows sit together below the blank separator.
- Device actions move out of the config editor into their own screen
behind a new menu-only device-actions tool (CLI equivalents stay
under the config group).
- Type-to-filter keeps section headers (and column headers) in place,
and section jumps now work over the filtered rows too.
- The main menu aligns tool names and muted descriptions into two
columns, headerless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rework the device-configuration screen from a flat list with an
advert-laden "danger zone" into one grouped menu whose every prompt is
a real floating modal popup over a persistent backdrop, matching the
quit dialog. The main menu now stays pushed on the session stack for
the whole editor session, so sub-prompts float over it rather than
replacing the screen.
Settings are staged and applied together (with a dirty-cancel
confirmation); device actions — adverts, reboot, backup/restore,
identity key, factory reset — run immediately behind their own
dialogs. Adverts get a dedicated menu (zero-hop, flood, or share this
node as a QR/URI contact card). Destructive actions gate behind a new
TypedConfirmDialog that requires typing RESET / IMPORT. Reboot hands
off to the reconnect dialog and waits for the companion to return.
Location can be set by pointing at the full-screen map (new
LocationPickScreen) or by typing coordinates.
Make every dialog consistent: a short title in the border, the
instruction as an in-body prompt, and a width sized to its content
instead of a fixed wide cap. TextScreen/SelectScreen/AutocompleteScreen
gain an optional in-body prompt, and those plus ConfirmScreen gain a
dialog_width; the prompt is threaded through the surface and session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>