🗑 is drawn in one cell where 💾, 🌍, ⏳ and 📱 are drawn in two, and a command
row that wrote `icon + " "` started its label wherever its own icon ended. So
Delete contact… sat a column left of Archive contact above it (JP). Ten of the
app's lexicon icons are like this — 🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ # — so any list mixing
them with the other twenty-seven was out of line; the Contacts tail's 🗑 Purge
against 📂 View archived was the same fault one screen over.
Not an emoji-width entry. The classifier is clear that both authorities and the
terminal agree 🗑 is one cell: it sits outside Emoji_Presentation and carries no
VS16, so the font draws a one-cell text glyph and everyone is right. Forcing it
into the wide set would reserve a cell nothing draws and pull the border in —
the mistake 🛣 already cost the Trophy case. The measurement was never wrong;
the layout assumed it away.
The trace and TX sweep screens had solved this properly and privately, each
with its own copy: declare the marks a list can draw, measure the widest, pad
every mark out to it. That is now menus.icon_lane/icon_mark, THE icon column,
and those two delegate rather than duplicate. marked_label takes the lane so a
mixed list can pass it, defaulting to measuring the single icon — which stays
right for a list whose rows all lead with the same mark, and is precisely what
misaligns one that doesn't.
The node page measures its column once over every action it can offer, so the
group ends aligned however many of archive/restore/delete a given contact
earns. PicoCalc is unaffected and stays that way by construction: it draws no
icon lane at all, so the column measures zero, the separator goes with it, and
the tint lands on the label as before.
The tests assert in display cells rather than characters. That distinction is
the bug — 🗑 plus two spaces and 📂 plus one are three cells and different
string lengths, and an index-based assertion would have called the fix broken
and the break fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing it in the packet viewer closed the viewer and stopped there, then fired
at whatever the reader pressed next. A screen opened from a key handler cannot
be awaited by its opener — a handler is sync, so the live feed floats the viewer
with a detached task — and the sentinel went only to the top screen, so the
unwind it raised had nowhere to propagate and died in that task. The live feed
below was meanwhile parked on a visit.result() that no press would resolve, and
the flag stayed armed to unwind at the next navigation boundary the reader
happened to cross. The chat's delivery paths and the trace screen's path flows
open the same way and had the same fault.
The unwind now arms every frame on the stack, top first, down to but never
including the root, stopping at anything modal for the reason the top-of-stack
refusal already gives. Every frame is on the stack whether or not something
awaits it, so the stack is the reliable path down; resolving a frame nothing is
waiting on is inert.
The detached launches go through session.run_detached, which absorbs the
PopToMenu its task cannot carry — a duplicate of an unwind already travelling
out through the screen that opened it — so a press no longer leaves an
unretrieved task error behind. Flows keep their own finally blocks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node detail, the contacts list and the trophy case were each rebuilt every time
something they opened closed. Node detail was the sharpest: its constructor sets
the tab to Info, so Tab to Routes, pick a route, trace it, come back — and you
were on Info at the top again, two keypresses from the route you were working
through, every single time.
All three now stay pushed for the whole visit. The screen object is what carries
the tab, the cursor, the sort and the filter, so returning is a pop rather than
a rebuild. It also retires the pop-then-re-push-as-a-backdrop dance in three
places: a screen that never left the stack is already the backdrop its dialogs
draw over. Contacts still rebuilds after a purge or a removal, and the trophy
case after the record count moves — the rows they were holding a place in are
gone, which is the one thing worth losing the cursor over.
The two peer hand-offs stop flattening. Trace → trophy case used to resolve the
whole trace screen with a sentinel so it unwound before the trophy case opened;
trophy case → trace ran the trace only after the browser came down and then
returned outright. Both landed the reader on the main menu, both said in their
comments that this was to avoid a stack the user would have to Esc through.
That was a hand-built escape from a stack with no other way out, and ^W is the
real one, so both nest now: Esc goes back one, to the screen that opened it.
The trace screen takes an `open_trophy_case` callback rather than resolving a
sentinel the opener has to interpret, which is also what lets the two screens
open each other — a cycle a strict stack allows on purpose.
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>
Administering a repeater that happened to be down made MeshTerm forget its admin
password. The node had said nothing at all, and we took the silence as a denial.
`admin_login` returned a bool, and all five callers read `False` as *wrong password* and
dropped the credential. Underneath, they could not have done better: meshcore's
`send_login_sync` waits for LOGIN_SUCCESS and only LOGIN_SUCCESS, so a refusal — which
the firmware does send, as a LOGIN_FAILED frame the library parses and dispatches like
any other event — times out exactly like an unreachable node and comes back as the same
`None`. Our own `etype in (ERROR, LOGIN_FAILED)` branch could never fire.
So the device now watches for that frame itself, subscribed before the request goes out
because the refusal can land the moment the request does, and answers with a three-way
`LoginResult`: ACCEPTED, REFUSED (the node said no), NO_REPLY (nothing came back — which
includes a companion-side error, where the request never even left the radio). It is
truthy only when logged in, so `if not await device.admin_login(...)` still reads "we are
not in"; a caller that acts on the failure has to name which failure.
What that means for the stored password now lives in one place, `AdminStore.record`:
remember on accepted, forget on refused, leave it exactly as it is on silence. It was
five copies of the policy, and every copy was wrong the same way. Each surface still
phrases its own message — the sweep says "sweep again", the CLI says "re-run" — and a
no-reply now says so plainly ("No reply from X — it may be out of reach, asleep, or busy.
The saved password was kept") instead of accusing the operator of a typo. The trace
screen's forget-on-DeviceAuthenticationError goes with it: the credential is settled
where the outcome is known, and an exception there is only a message to show.
A source-level test walks the package's AST and holds the line: anything that calls
`admin_login` records the outcome through the store, and nothing reaches for `forget()`
to decide this on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The row `❯` points at borrowed `brand` — so the app's identity ink and
"the row you're on" were the same hue, and worse, teal/cyan is itself a
node hue: a cyan-keyed node's name vanished into its own highlight.
Give the highlight its own name. `cursor` is white on both platforms
(slot 15 on the VT, the top of the bright bank, so the row's bold cannot
promote it further and every dim-slot `not bold` pin inside a highlighted
row goes on meaning what it meant). White sits outside the per-node
spectrum, so it can never collide with an identity; it does share ink
with `you`, which is the accepted cost — spans that set their own colour
keep it over the base, so only unstyled text goes white, and only on the
row already under the reader's eye.
Every surface that draws its own cursor row follows: the select list and
its reorder mode, the feed, chat, routes and actions on the node page,
the composer, records, trace, tx, the walk, the paths screen, the prompt's
suggestions. The composer's insertion-slot chip takes the same white — its
docstring already tied it to "the colour the list cursor below it wears",
and the chip ink was already picked to stay readable on white.
Reverse-video chips keep `selected`: on the VT a reversed white lands on
the light grey the F-key lane already owns, so a white default button
would stop reading as the committing verb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A trace subscribed to its tag only after send_trace returned. That send
waits on the companion's MSG_SENT, which the meshcore library correlates
by event type alone — so any other command in flight (a scheduled advert,
a telemetry poll, the courier) can consume ours and leave the send
blocked on its own 15-second default. Every reply that landed inside that
window was dispatched to no listener and dropped, and the walk recorded
as "no reply" though the mesh had answered it. Because the traffic that
steals the acknowledgement is periodic, the condition outlives the trace
that hit it: traces keep failing for as long as it lasts. Subscribe
first, stamp the reply's own arrival so a stalled send inflates no round
trip, and log every trace and every reply heard so a silent walk can be
told apart from a deaf one.
The other half is pacing. run_traces sleeps between its own samples and
never after the last one, so at one trace per candidate — the path
probe's default — it paced nothing at all, and the sweep walked every
candidate back to back. Same hole across the TX optimizer's level
boundaries, and on the trace screen, where aborting a walk and hitting
Enter again transmitted immediately. Repeaters penalize a node that
bursts, and a penalized node's every later trace comes home empty. Pace
between probe candidates and TX levels, and measure the screen's cooldown
from its last transmission rather than only from the previous sample.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every icon on the console is a one-glyph stand-in for an emoji, and the
menu is where that pinches hardest: the main menu drew @ for both Map and
Mesh walk, … for both Live feed and Time machine, and ?? for Trace path,
in a lane costing three of fifty-three columns on every row (JP,
on-device).
So drop the lane there, mark and emoji alike. A command row's icon is
decoration — it names the action's family while the label names the
action — so where it can only be approximated it is worth less than the
cells it takes, and the label and its description get them back. This is
a platform choice (Platform.menu_icons), not a per-emoji one: a glyph
that carries *data* is not an icon in this sense and never goes, which is
why the Channels page keeps its #/🌐/🔒 openness mark, the feed keeps the
class icon it shows in place of the class word at 53 columns, and node
types, status marks and the mute mark in the channel lane all stay put.
Three funnels do the work, so a screen never asks the platform itself:
command_label for a label carrying its own icon, command_icon for a row
that builds its own lane, and marked_label where the icon carried a tint
— a delete row's red moves onto its words rather than leaving with the 🗑.
Section headings go through the same cut (a stand-in beside the ── rule
is pure noise), which also retires the ?? that No revisits was drawing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every action row led with its mark and a hard space, which only aligns
while every mark is one cell wide. ⚡ is an emoji — two — so Explore's
label sat a column right of Compose's, Trace's, and the rest.
The marks now go through a measured icon column: each pads out to the
widest of them, so the labels share one column whatever they lead with.
Measured rather than fixed at two because the marks change width with the
platform — on PicoCalc every icon funnels through theme.glyph to a single
console-font character, so the column tightens to one cell and the labels
move left with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the path rows owed their readers.
The opens-further `…` was being reserved out of the lane, so a route that
fitted exactly cracked on its own last chip — the row claiming the walk ran
on when it had arrived, the crack being the closing cap with half of it taken
away. It now rides outside the budget (`with_action_mark`, and `cut_to(...,
action=True)`): the path is fitted to the whole lane and the mark takes the
leftovers, or goes unshown. That is the widget's answer everywhere a path row
carries it, not the node page's local trade.
Every stats line hanging under a path now leads with the hop count
(`hops_atom`) — the figure the picture above encodes but never states, and
the first thing two routes are compared on. `direct` where there are none,
which is the app's own word for it; the Explore list's direct-shot provenance
tag was that same word for the same reason, so the atom absorbs it.
And Explore paths cuts its unselected candidates instead of middle-eliding
them, so walking the cursor no longer rewrites the row under it. The `⋯`
rescue saves a route's two endpoints; here they are the same two on every row
by construction, so it spent cells on what the reader knows and took them off
the candidates' front. The probe-results rows pin their ranking lanes out of
the ←→ scroll while they are at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.
That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:
select lists Sect down/up, the section jumps. Ctrl+PgUp on a
keyboard with no PgUp is the one binding in the app
that was physically unreachable. Plus Delete, lit on
exactly the rows the key acts on.
map Region (Home, renamed -- it frames the ground the mesh
covers, it does not "reset" anything), You (new: Ctrl+L,
recentre on our own node at the zoom you chose), and
Frame for the find matches.
node page the tab it would switch you to.
time machine the window span it would take you to -- w was
undiscoverable on the device.
The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.
Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).
The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:
Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).
Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).
Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.
Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.
Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).
The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.
Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.
Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.
That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.
The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.
tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Choice.title accepts a width-taking callable (arity read once from the
signature; the zero-argument live form is untouched), resolved at the
row's content area each repaint while filtering, h-scroll measurement,
and dialog_width keep reading the natural unbounded form. The two
route-bearing lists use it: trophy rows and Explore-paths candidates
middle-elide through PathLine.ellipsized so both endpoints survive a
narrow terminal — a right truncation amputated the return leg — while
the highlighted row of an hscroll list keeps its natural length so ←→
still slide the full line (housekeeping H-007).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five words had grown for one key — select, pick, open, choose, commit —
so CLAUDE.md now fixes the Enter verb by action (open pushes a screen,
select picks a value or action row, set commits a value picker) and the
outliers come along: the purge and discipline pickers say select, the
record card's action rows say select, "Esc keep current" and the three
"type filter"s were typos against existing rules (housekeeping H-008).
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>
The trace surfaces' route lanes were spending their width on hex nobody
reads there: every name carried the hash it is addressed by, and the wire
spec one lane below already spells that out verbatim, at the width it goes
on the air. The route lane, the planned-route preview and the composer's
live preview now name nodes and nothing else; an unnamed hop still reads as
its hash, since that is the only identity it has.
The composer's preview becomes that same lane rather than its own dialect —
pathline chips, hop-boundary wrapping, our two ends the bare star.
Both stars fade. Setting out from us and landing back on us are fixtures of
every such route, no more composed (or removable) than a mirrored return
leg, so they wear the automatic grey — dark slate as a chip, never the loud
you white. Finding the turn a wrapped route folds at now reads the faded
*tail* rather than the first faded hop, which the leading star had become.
Chips are separated, not interlocked: every seam draws its point with no
background, so the page shows through the taper and a route reads as a row
of blocks the way oh-my-posh spaces its segments. Two neighbours landing on
one fill can no longer fuse, without that being a special case.
Finally, two places that had the right information and didn't use it: the
composer's suggestion list fell back to bare hex for a short hop matching
several contacts (the topology won't guess, so it names nothing), and the
not-a-trail confirm printed whole 12-hex ids. Both now go through the
owning screen's own resolver and its chosen path-hash width, so a node
reads the same in the dialog as in the window behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wrapped line was opening on U+E0B2, which points backwards — it read as
an arrow going the wrong way, not as a carry-on. It now reopens the seam
the break interrupted: the same right-facing point, arriving out of the
previous line's last fill into this line's first chip. U+E0B0 is once
again the only glyph the widget draws; what varies is whether the point
lands on a field (a seam) or on the page (a closing edge), which is also
the same-fill gap rule, now factored into _seam().
A bare hop keeps the one-cell padding every chip wears — it has no words
between them, not no chip — so our end of a route reads as a stub of the
you white rather than as nothing at all.
Explore's candidate paths go bare too: every candidate starts from us, so
the word was the same on every row and the cells are the candidates'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hop with no label now draws as its seam alone — the arrow, no chip, no
word. The trace screens' route lane uses it (bare_self) for both ends of
every walk: a trace leaves us and comes back to us, so naming ourselves
twice only pushed the hops that *are* news out of the lane. Arrow mode
drops the padding the vacated label sat in ("→ 3d → f2 →"); chip mode
lets the seam point into the page.
Wrapped chip lines now open on the female point (U+E0B2, core set) from
the second line down, so a continuation reads as one — only the very
first chip of a path keeps its square left edge. The packer pays for that
cell, so lines still fit their column.
Also carries the round already sitting in the tree: the wrap evens hops
across the fewest lines it can take and folds at the route's turn, the
trace/node-detail/select surfaces route their rows through the widget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explore-paths candidate rows render through the path widget — every hop in
its own key hue, unnamed hops prefix-lit — instead of one brand-coloured
join; both the explore and probe-results pickers scroll their highlighted
row under arrows; and the path lane hangs under its value column like the
route above it instead of folding a long spec back to column zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.
Four fixes fell out of driving it on real hardware:
Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.
Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.
Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.
Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
←/→ slide a reverse-video → along the editable leg; Enter splices the
chosen hop in at the cursor and ⌫ removes the hop to its left, so a route
can be edited anywhere, not just at its end. It opens on the last arrow,
keeping the classic append flow unchanged. Suggestions, the fetch row,
and the "Next hop from" heading all follow the node left of the cursor,
and the fetch-neighbours round trip now resumes at the same position.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A record walk must now be a trail — never the same link twice in the
same direction (the reverse crossing stays legal, so target boomerangs
still qualify). Repeating a stretch would let its score be farmed, so
walk_scores disqualifies such walks from every board at once; the
composer shows a yellow "not a trail" note while one is being built,
and Trace gates it behind an amber Cancel/Trace confirm. Matching
non-trail records were purged from the local database (one-off; the
rule never shipped, so no migration).
The inline "new record" note becomes a floating dialog once a run
settles (one per run, Close default, Trophy case on the left). Choosing
Trophy case resolves the whole trace session first, and the trophy
case's own Trace this path no longer reopens the browser behind the
trace screen — both directions unwind to the main menu instead of
piling up states to Esc through.
Committing the path composer also parks the action cursor on Trace, so
compose-then-Enter walks the new route; Esc leaves the cursor put.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both trace tools now feed the boards: on a successful trace the session
derives the walk's spec, route, and stats from the reply and offers it to
every discipline, and a walk that sets a record surfaces a ★ note under the
run's aggregates (the walk still records nothing extra — it's the same one
trace per transmission). Scoring is defensive: a hiccup never breaks a
trace.
Trace path can open armed on a given spec, so the trophy case's Trace this
path reopens a record's exact route ready to walk again. The shared route
graph is unchanged; its docstring just drops the pathfinder example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The path-hash mode is a four-value enum (1/2/3/4-byte routing hashes), so a
device's width never resolves above 4 and 8 was only ever a value you could pick
by hand. Eight bytes puts 64 bits of every hop's key on the air per hop — airtime
we would never spend for collision resistance a LoRa mesh cannot need. Offer the
range that is actually useful (1/2/4); the device default still lands inside it.
The wire decoder keeps its 1/2/4/8 flag mapping, so received traces addressed at
any width still read back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Radio links rarely read the same both ways — a route that carries cleanly
outbound can be marginal on the return — but Trace path only ever walked a
route in the direction it was built. Add a Reverse path action that flips the
walk's hop order (us → a → b → c becomes us → c → b → a), so one composed
route can be measured in both directions.
It lives only in path mode: a target-mode route is a symmetric boomerang, so
reversing it changes nothing. Reverse sits in the build-path group beside
Compose and adopts the flipped spec exactly like any other path change — the
forward run's aggregates clear (they described the other direction) and Trace
stays idle, so you flip, then trace; reverse again to go back. Both readings
persist to history for side-by-side comparison. Disabled, with the same
"compose a path first" note as Trace, until there's a multi-hop path to flip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The composer opened seeded from the stored spec, which is empty until a path
is first composed — so the very first Compose opened blank even though the
screen was already showing the auto-resolved route (the last stored walk, or
the device/history route in target mode). Seed the composer from the visible
route instead (the effective spec: the composed one if it stands, else the
auto plan), so opening Compose always resumes from what the user sees.
The adopt test still compares against the stored spec, so re-confirming an
auto plan verbatim just pins it — the same outcome as adopting it by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Target mode groups the build-path actions (Compose, Explore) and sets them
off from the width/samples settings with a blank line. Path mode has no
Explore, so Compose sat flush against Path width with no such break. Give
path mode the same grouping — a blank line under Compose — so both modes
read with the same rhythm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.
DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The atlas's list-in-a-fixed-screen pattern graduates to a shared
ListWindow (tui.screen): fit() settles the window, the marker rows, and
the highlight clamp together, and remembers the PgUp/PgDn stride; the
frame now records the viewport (or a dialog's budget) before render_body
runs so screens can size their chrome to it. Adopters: the dashboard
pins its charts and windows the feed; the trace screen pins route,
aggregates, and actions over a windowed results block; the path
composer pins the route preview over windowed suggestions (and gains
PgUp/PgDn); the nodes table pins its title, column header, and legend
around windowed rows. The composer's route preview and node rows also
join THE path widget, dropping its hand-rolled Name (hash) renderer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hash annotations (Alice (3d63)), None hops for our own device at a
route's endpoints, and dim_from to fade a boomerang's mirrored return
leg — so the trace screens' walked/planned routes, the per-hop link
tables, and the CLI trace output all render through THE path widget.
Trace routes thereby join the app-wide colour rules (per-name hues,
pure-white you) instead of their old flat brand/accent, and the live
route line wraps under a hanging indent instead of folding to column 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three button/status stragglers join the house style. The tracing
dialog's "❯ [ Abort ]" — the one control drawn with a pointer and
brackets — becomes the centered reverse-video chip every other popup
button already is (ButtonDialog, the reconnect dialog's Abort). Chat's
✅/❌ delivery emoji become the app-wide ✓/✗ status marks in their
ok/err styles: the emoji belong to the packet-class icon lane, and the
single-width marks keep transcript rows evenly spaced. And the channel
manager's Clear — the last bare Yes/No confirm in the app — becomes a
standard destructive dialog (Cancel left, Clear right and default,
danger theming) like every other consequence gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The key hints now follow one sentence shape across every screen:
navigation keys first, action keys next, Esc last — the quit dialog and
TX apply dialog had Esc leading. The Esc verb is now systematic: "Esc
back" leaves a screen, "Esc close" dismisses a read-only floating view
(result windows and the share/key popups switch over), "Esc cancel"
abandons a prompt, "Esc keep" leaves a value picker unchanged. Bare
hints fill in their missing verbs ("↑↓ move", the map's and composer's
naked "Esc"), the confirm prompt says "Enter select" like its button
siblings, and key notation unifies on the compact forms the app already
half-used: ^R/^End for Ctrl chords (chat's "Ctrl-R" included) and ⌫ for
backspace (the map/atlas "Bksp").
Co-Authored-By: Claude Opus 4.8 <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>
Every select list now builds its closing furniture through one module,
so the conventions hold by construction instead of by copy-paste:
* Back is THE exit word — bare, no arrow, no icon — with exactly one
blank separator above it (back_rows). Courier and Watchtower lose
their "← Back"; the rules popover's "← Close" becomes Back too.
* The staged-changes exit pair (✓ Apply … / ✗ Back — discard) moves to
exit_rows, shared by the config editor and repeater admin, along with
changes_phrase and one confirm_discard dialog (apply/send verb
parametrised).
* Label + description rows share menu_rows (cell-padded lanes): Device
actions, channel detail, repeater actions, and — converted from the
inline "label — description" style — Send advert, Identity key, and
the channel manager's Add-a-channel section, whose rows now read as
two aligned lanes with the trailing … that marks "opens a prompt".
* Grouped-list headings share section_heading (── Label ── in accent):
Watchtower, Courier, the Time Machine picker, and the admin picker
(which also drops the stray 🔑 inside its heading).
* Status marks normalize to ✓/✗ (U+2713/17): Watchtower's ✔ and the
✖ on Cancel-this-message / Stop-watching become err-tinted ✗ rows.
* fit_cells replaces the four private _fit copies (channels, dashboard,
time machine, chat picker) and measures display cells, so a wide
glyph in a name can't skew a lane; empty-state notes drop their
parentheses ("no channels configured yet", "no matches").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
highlighted_hash grows a width budget - shorter than the key shows an
ellipsis, shorter keys pad so lanes align - making it THE hash widget.
The chat sender palette moves to theme.name_style as the app-wide node
name colour rule; our own node is always the pure-white "you" style
(the nodes table's self row now follows it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
meter() is the one horizontal proportion bar (two fill steps per cell,
full-height tally or slim-on-a-track gauge); the SNR bar now draws
through it. axis_caption() fills a timeline's caption with as many
intermediate marks as the width fits, instead of only its two ends.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path screen opened showing the previous walk's route yet refused to
trace it ("compose a path first"): the display seeded from history but
the spec never did. The last successful stored walk now feeds path
mode's auto route — verbatim, with "last walk" provenance — so the
route on screen is the route Enter walks.
Adopting a different spec (composed, explored, or re-rendered at a new
width) also clears the aggregates, per-hop medians, and trace log: they
described the old route. The screen-lifetime total the owner reports
survives the reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SNR quality bars' unlit remainder now reuses the same full braille
cell as a lit step, dimmed to a new dark-gray track style, instead of
a plain "." placeholder — the meter reads as one glyph system, filled
in from a background rather than dots giving way to blocks. The
boundary half-step still carries the reading's own colour, since a
single character can't be split between it and the track.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-hop meters (shared by the trace and TX-optimize screens) drew
16 solid block characters. They now pack two fill steps into each of
8 braille characters, using only the middle two dot rows (2, 3, 5, 6)
so the glyph sits mid-cell instead of hugging the top or bottom edge
— half the width at the same 16-step resolution.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An auto (device-routed) Trace target went on the air as a bare
destination hash — an observer confirmed no repeater hops — so any
target past a direct neighbour could never answer. Dumping the
radio's contact table showed why: every contact reports
out_path_len -1 (flood). MeshCore firmware only learns a contact's
out_path from two-way addressed traffic, and tracing repeaters
teaches it nothing, so "device-routed" has nothing to route with.
Auto now resolves a route the session can prove, in order: the
device's learned route when one genuinely exists, else the outbound
leg of the last successful stored walk (_previous_outbound extracts
it from the recorded boomerang), else a bare direct attempt. One
resolver feeds both the wire and the screen, so the planned route
line renders exactly what Trace will transmit, with its provenance
under it — (auto · device route), (auto · last trace · stamp), or
(auto · direct — no known route) — and the summary's path row says
the same instead of claiming a device route that never existed.
The connection layer's path-less fallback keeps the mirrored return
leg for contacts that do carry a learned route (the trace protocol
reflects nothing home), and its docstring now says the firmware
never routes a trace itself. Resolution verified against the real
database and radio: a multi-hop target resolves to its full stored
boomerang, the direct gateway stays a single-hop spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Esc (or Back) from the Trace target screen now returns to the main
menu instead of looping through the target picker. The action list
breathes: a blank line after Explore paths and after Sample count
groups the rows, and the previous-route stamp sits flush left under
the route instead of indented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A trace is one walked path and "target" is pure UX (hardware-verified),
so the feature now splits along that line: Trace target keeps the
symmetric mirror-and-return flow (picker, Explore paths), while the new
Trace path skips the picker entirely and walks a hand-composed route —
no Auto, no Explore, Trace gated until a path exists, history recorded
under the "(path)" sentinel so composed walks never pollute the target
picker. The composer loses its mode toggle: a pinned target means
mirrored, no target means verbatim, and its preview opens as us → us.
Sampling returns, deliberately: a Sample count action (1/2/3/5/8) runs
that many traces per commit, paced by trace_cooldown_s between sends and
counted off on the in-flight dialog — never a burst. Actions reorder to
compose/explore/width/samples/trace with a Back row; the w/p/x hotkeys
retire.
Display: route nodes drop the underscore (prompt_toolkit draws U+00A0 as
"_", so name (hash) now joins with plain spaces), the composer renders
nodes exactly like the route line, the (previous · …) stamp gets its own
line, the path row ends with the displayed hop count, Explore rows lead
with the route instead of an "observed path" label, and the empty Traces
placeholder is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four trace-feature upgrades, all UX-preserving:
- Stored traces recover their path-hash width from the (uniform) hop
hashes at rehydration, so a target's previous route renders short
hashes instead of the device's full 64-char public key.
- The trace screen's verbs are selectable action rows now — Trace,
Path width, Compose path, Explore paths. The cursor opens on Trace so
plain Enter still just traces; p/x/w hotkeys stay on their rows, and
the cursor only pins the view while it's actually being moved, so
PgUp/PgDn still read a long trace log freely.
- A floating path-width dialog picks the 1/2/4/8-byte hop addressing
(each row previews the target key with the addressed slice lit, the
device's routing width marked). Changing it re-renders a standing
forced path — hops widened back through their canonical hashes — and
shapes every spec composed or explored afterwards.
- The composer's new ⇄ row toggles symmetric (return mirrors the
outbound, the current behaviour) against asymmetric, where the whole
walk is composed by hand — target routed *through*, outbound
repeaters reusable, only the landing back on us implicit. Toggling
materializes the mirror into editable hops (or regenerates it from
what precedes the target), so the wire route never changes under the
toggle; the planned-route preview dims only palindromic return legs.
Also: the target now carries its hash in the composer preview like
every other node, and the composer's footer hint reads briefer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trace protocol has no separate return-path field, so a forced
path (from the composer or an adopted scenario) has to spell out the
whole boomerang itself: outbound hops, the target, then those hops
mirrored back. We were only sending the outbound half — repeaters
never saw the return leg, so a forced multi-hop trace couldn't rely
on the destination reflecting it.
render_forced_spec() in topology.py is now the one place that builds
a forced-path spec; PathScenario.spec() and the composer's _spec()
both call it. The composer's live preview already showed the mirrored
return dimmed alongside the outbound hops - that display code stays,
but the trace screen's planned-route preview now splits the *actual*
wire spec at its midpoint instead of assuming it was outbound-only,
and the composer's reopen-with-current-path reseeding drops the whole
return leg instead of just the spec's last token.
Also normalized node-hash display in the composer to the spec's
path-hash width (matching the planned/completed route views) instead
of a hardcoded 6-hex-digit slice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Repeaters penalize (and can blacklist) nodes that burst traffic, so
every trace-transmitting surface now sends exactly one transmission
per human action: Enter on the live screen runs a single trace (the
screen still aggregates the session's medians), the scenario probe
measures each candidate path once, and the CLI drops --samples to
become a true one-shot. trace_runner.measure and the s-key sample
cycle go with it.
Also de-pollute the target picker's "Recently traced" section: the
repository now returns a short recency-ordered list instead of the
ever-growing all-time tally, and stored hex prefixes fold back to
their contact names so a node shows once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The topology graph gains its fourth evidence source — the only one not
derived from our own reception. A repeater we hold admin rights on
reports its neighbour table (who it hears directly, at what SNR) when
asked over the mesh, revealing links and whole nodes our radio has
never received anything from: exactly the second vantage point the
composer runs dry without, two hops out.
Probed live against YUL-Cartierville (v1.15.0) through the Waymarker
before building: firmware answers the binary NEIGHBOURS request but
silently ignores guests, so the flow is login-first, tx-optimize style
(stored password, one-time prompt, forget on rejection). An empty table
is a normal answer — the gateway's own table was empty during the probe
(confirmed via its text CLI: '-none-') — so the UI says "ask again
later" instead of erroring.
Standing on a fetchable repeater, the composer shows a "fetch
neighbours" row with the suggestions it extends; the fetch runs under
the abortable spinner dialog, persists as a runs-row-owned snapshot
(schema v8, neighbour_reports), and the composer reopens mid-thought
with the same hops and refreshed suggestions tagged N. Refetching
supersedes the old snapshot per (repeater, neighbour) pair rather than
inflating the link's sample count: a table is current state, not an
event stream.
Verified end-to-end on hardware via MeshCoreDevice over BLE (login +
fetch + clean empty-table return) and in the mock (guest refusal,
discovery of a never-heard node, composer render + hand-off). 441
tests pass; the real database migrated v7 -> v8 cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything we receive carries a fragment of the mesh's shape: successful
traces crossed every link in their path (out and back — the reply retraces
the reverse path, so links read both ways), each contact's out_path is a
route the firmware distilled from received floods, and RX-logged packets
report the relay chain they rode in on. A new topology service folds those
into one undirected evidence graph (schema v7 persists packet paths).
The trace screen stops auto-starting on target pick. Enter runs a burst
under a floating tracing dialog (spinner chip, live progress, Abort); "p"
opens a hop-by-hop path composer whose suggestions are the tail's observed
neighbours, strongest link first, with raw hex entry for unseen nodes; "x"
browses ranked path scenarios (device route, direct, best observed
alternatives) and can probe them all — a measured burst per candidate,
recorded as path_candidates rows, reliability-first ranking, winner offered
for adoption.
Composed specs are outbound-only, always ending at the target's hash: the
stored data shows the firmware auto-replies along the reversed path, and
user-composed "there and back" loops (3d,f2,3d) never succeeded. Specs
collapse to a uniform per-hop width the narrowest known hash can honour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The menu's trace flow joins the living-screen pattern (chat, nodes,
map). One recency-ordered picker — recently traced targets first, then
contacts by freshness — replaces the target/samples/path prompt chain,
and the screen opens tracing immediately: each reply lands in a
newest-first log as it arrives, the route line and per-hop medians
(with SNR quality bars) update live, and Enter / s / p run another
burst, cycle the burst size, and edit the forced path without leaving
the screen. Esc steps back to the picker, chat-style. The route line
opens seeded from the target's last stored trace, so the path history
last saw shows while the first reply is still in flight.
Every burst persists exactly like a scripted run — one runs row, each
trace recorded under it — and the scripted CLI keeps its one-shot
table output unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>