Message paths laid its whole body out as one strip — quote, fan, captions,
then every arrival — and let the frame scroll it under the selection. Walk
far enough down a busy message and the picture the routes are being compared
against slides off the top of the dialog, which is the one thing the reader
was looking at (JP, 2026-09-04).
The head is pinned now and only the list moves, the app-wide windowed-list
pattern the node detail's Routes tab already runs: the quote and the fan take
their lines, the arrivals window into what is left with faint `↑ n more` /
`↓ n more` edge markers, and the fan compresses its own lane pitch before the
list would lose its window — floored at two arrivals and a marker, because
comparing one arrival against another is what this dialog is for. PgUp/PgDn
now page the selection by a windowful of arrivals rather than by body lines,
and both they and the F-lane's chips say so only while there is more than one
arrival to move between.
The variable-height fit both screens need moves to `ListWindow.fit_blocks`,
beside the single-line `fit` it is the sibling of; node detail drops its own
copy and its hand-kept top/page state for the shared window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fourth About page, `Join Discord`, sitting between the author and the ask:
what is this, who made it, where is everyone, how do I help. Like its three
siblings it is written markdown under assets/pages, reads nothing and
transmits nothing, and keeps a CLI face — `meshterm discord`.
It is short on purpose. The invite link, and the code for it in a `qr` fence,
because on a PicoCalc there is no clipboard and the code is how a link leaves
the screen at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three codes on the Support page were half-block art pasted into
support.md, and markdown had been quietly shearing them. A module row whose
first module is light opens on a *space*, and every CommonMark parser eats
leading whitespace on a continuation line — so four of the fifteen rows in the
Buy-me-a-coffee code, and rows in the other two, were rendered one module to
the left of where they belong. A sheared row is not a QR; a phone gets
nothing.
Markdown grows one construct instead: a fence tagged `qr`, whose body is a URL
and whose rendering is the code for it, through the same `qr.qr_text` widget
the channel and contact share popups draw. So the link is written once, the
code can never drift from it, and a QR looks the same everywhere in MeshTerm
— black on white, which is what makes it read under any terminal palette
rather than inverted on a dark one.
The three fences replace 45 lines of art.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No, it isn't the ack — ACK is its own payload class and was never in the match.
Both symptoms come from the same place: a packet header's route type, which we
never recorded, decides what its `path` field *is*.
A FLOOD packet accumulates: every relay appends its hash, so the path is the
route it travelled to reach us. A DIRECT packet is the opposite — the path is a
routing instruction the sender wrote and the relays consume — so it says where
the packet is going, and an empty one means the route was used up, not that the
packet arrived in a single leap. Reading every path the first way is what drew a
message from five hops out as having appeared from nowhere.
The evidence is in your own log. The four-hop route on those sends is identical
across four consecutive messages, where a flood-accumulated path varies every
time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*;
and a PATH frame carrying exactly those four hops crosses the log seconds before
the first message that uses it. That is a learned route to hairu being announced
and then used — not a route anything took to us.
So route_typename is stored (schema v15, a `route` column beside the crypto and
addressing ones added for the same reason), and an Arrival carries whether it
was routed. A routed frame with no path has route_known False: it is kept as a
copy that reached us — the count and the time are real — but it is drawn as
"routed · route not carried" instead of a confident zero hops, and it puts no
lane in the fan, because an empty lane there is a claim of adjacency the frame
never made. Frames from before the column read None and fall back to the old
behaviour rather than to a guess.
The round trip is a second bug and needed no new data. The path line closed on
our own ★ unconditionally, and for an outgoing message its head is our ★ too —
so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been
hardcoded to us. Both now take a destination: the far end for a message we sent,
ourselves for one we received. It is the rule the line's head already followed —
a path runs between the nodes it went between — applied at last to its tail. A
channel broadcast keeps us at both ends, because there the copies we log really
are our own message coming back.
collapse now keys on the route type as well as the hops, so a route travelled
and a route intended never fold into one row even when the hashes match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The paths on a DM listed a message's own retransmissions, the other end's
traffic, and both of those for every other message sent in the same minute and
a half. On one real bob_caribou message that was 30 rows over 8 paths, of
which 23 were the same two one-hop routes repeated. Those one-hops were the
tell (JP): they were our *own* sends, overheard coming back off the two
repeaters in earshot, correctly showing one hop because one hop is all they had
travelled — but shown under the message as if they were its route.
The channel view has always matched by decrypted content, and asked for here.
A direct frame cannot be read, but it does not need to be: its body is
[dest 1B][src 1B][MAC 2B][ciphertext], and that MAC is a tag over this
message's plaintext. Copies of one message share it; the next message's differ.
frame_addressing already knew where those two bytes were and threw them away,
and the observations table already had a cipher_mac column its channel sibling
used — so a direct message now groups exactly as a channel one does, with no
schema change and nothing decrypted.
Three filters, in order. The frame ran between the conversation's two ends; it
travelled the direction this message did; it carries its group's MAC. The
middle one is doing more work than it looks: both ends' hashes sit on every
frame either way, so matching on the pair alone was what put our sends under a
received message.
The window is clamped by the conversation's own messages rather than a flat
±90 s — nine messages fell inside one such window. The clamp reads only
messages going the *same* way, which the first cut of it got wrong: an inbound
stamp is the sender's clock, so ordering it against our own sends compares two
clocks, and a received message came back with an empty window. The directions
are also bounded differently, because we know different things about them. A
send is stamped by our clock as it leaves, so no frame of it predates it and
all the room goes forward to the next send, where the retries are. A received
message keeps a centred window, halfway to the received messages either side.
Rows are paths now, not frames: identical routes fold into one line carrying
its copy count, its first sighting, and the best SNR that path managed. Twelve
identical lines said "twelve arrivals" where the truth was "two paths, six
times each".
Measured on the conversation that prompted this, whose history predates the
MAC and so exercises only the direction and window fixes:
outbound "Allo! Ça marche JF" 30 rows / 8 paths -> 2 rows (x3 each)
inbound "J'ai pas de ack..." 30 rows / 8 paths -> 2 rows, 6 and 7 hops
which is exactly what the packet log says by hand. Old history keeps the
address-and-time fallback and is billed as such; frames recorded from now on
match exactly, and say so in the same words the channel view uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🗑 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>
JP's pass over the sweep, and one cut corner it found: the gallery specimen
hand-stamped its percentiles, so three contacts rendered as the 2nd, 7th and
11th of a field of three. The number cannot be written down and still be true
— it is a rank against the others in the same list — so the specimen now
scores through rank_contacts like everything else and reads 10/30/50. The
computation was never wrong; the picture of it was.
The ladder answers in the number the reader is choosing: how many contacts
you *keep*, the one that has to fit the device, with what goes as the muted
remainder. It also stops rounding with round(), whose banker's habit sent 4.5
and 3.75 to the same 4 and made "keep 90%" and "keep 75%" identical rungs on
a five-contact table; ceil is both distinct and the safe direction on a keep.
The preview is editable. Delete lifts one contact out of the sweep and the
list refreshes through replace_items, so sparing the one node you recognised
no longer means backing out to a shallower rung that also spares forty you
didn't care about. Rows h-scroll with the percentile lane pinned out of the
shift — it is your place in a list ordered by it. Neither the scroll atom nor
the keep atom is written into the base hint; the select list splices each in
where its key would act, which is also what keeps all three inside 72 cells.
It is a hub now, held by session.stay: re-running run_screen on a screen that
had already resolved spun forever the first time a row was spared.
Opening a candidate's page from in there offered to delete it, which was two
answers to one question. open_node_detail takes manage=False: the rule is
that management verbs belong to the list a contact lives in, never to a page
opened out of a list about to act on it wholesale.
The confirm is amber and button-only. Two caution tiers exist for two costs,
and a red typed gate here spent the app's loudest warning on its most
reversible bulk action. Red is left to the deletion, which now says what it
is — the key forgotten by both sides, no restore, only being heard again —
and points at the archive as the reversible way to free the same slot. The
page carries all three verbs: archive a live contact, restore an archived
one, delete either for good.
Archived contacts get their own screen rather than a section nailed to the
foot of the Contacts list, reached by a row under the purge carrying the
tally. That needed the shared contact list to stop believing every list has
the same columns: the middle lanes are now a declared tuple of ContactLane
(NAME and KEY stay fixtures), read by the header, the row builder, the sort
metric and the width arithmetic alike, replacing a show_traced flag that had
already made three of those four say "and if it's the trace picker…". The
archived list declares NAME · ARCHIVED · KEY and sorts on exactly those.
ARCHIVED draws as HEARD does — a relative age under recency heat — because
it is the same question about a different event.
One simplification falls out: TRACED sorted on a raw timestamp and opened
descending to mean newest-first, alone among the age columns. Every age lane
now sorts by age with never at +inf, so traced opens ascending like heard,
and reads the same either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A device's contact table is finite, and a busy mesh fills it with whatever
adverts arrived — mostly nodes heard once, in passing, four hops out — until
there is no room left to discover anyone new. The age ladder could only ask
how long ago; it had nothing to say about which of two equally quiet contacts
was the one you would miss.
So contacts are now rated. Six weighted terms, each normalised and summed:
direct correspondence heaviest at 30, then how lately heard (25), how often
(15), channel posting (12), hops (10) and distance (8). Additive rather than
multiplicative, because a product lets one zero annihilate a contact that is
strong everywhere else — a node advertising no position would have scored
nothing however much you talk to it.
The rule the whole thing rests on is that an unknown scores *neutral*. Half a
real mesh advertises no location, plenty of nodes are never overheard as a
relayed frame so carry no hop count, and a channel poster whose name two
contacts share can be attributed to neither — the wire gives a channel frame
no sender key at all. A term that cannot be computed takes the population
median of the contacts that could, so the decision falls to the axes that are
known. Scoring those as zero would have swept by how much metadata a node
happens to broadcast. It is also why the two least reliable terms carry the
two smallest weights, and why distance calibrates against this mesh's own
median spread rather than a constant: 50 km is far downtown and unremarkable
in a valley.
A newcomer gets 25 points decaying to nothing over a fortnight, because every
other term rewards accumulated evidence and a node first heard this morning is
otherwise arithmetically identical to one quiet for a year. Four things are
never candidates at all: a watched node, anyone you have sent a message to, a
repeater whose admin login is stored, and a contact MeshTerm has never heard —
that last one predates this history, and sweeping it would be purging by how
long the app has been running. They still rank, since a percentile that
dropped them would not be a rank against your contacts; they just consume no
kept slot, so starring a node never deepens the next sweep.
Only the percentile is ever displayed. A raw 47.3 means nothing without the
distribution it came from and would need a legend the moment the weights were
retuned.
The victims are archived, not deleted: off the device, which is the scarce
resource, and kept here with a stamp. Everything else was already independent
of the contact row — reception history, map position, transcripts — and the
store keeps the full key, so a restore is one write, which the chat screen
already performs on demand when a send is rejected for an unknown recipient.
merge_contacts learns to withhold them, or the union would put them straight
back and the sweep would look like it did nothing.
The old age ladder survives as the second section of the one picker, because
"everything I haven't heard in a year" is a plain predictable operation a
ranking cannot express. Both routes converge on the same preview — weakest
first, with each row's percentile and the two or three facts that put it
there — so an age rung reaching into contacts that rank perfectly well says so
before the typed confirm rather than after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One braille cell was four coarse levels lit in one colour. It is now a
filled block whose two channels answer two different questions.
Colour answers "roughly how much" from across the room: a lit
foreground over its own darker ground of the same hue — light green on
green over half, yellow on brown over a quarter, light red on red
below. The braille fill inside the block answers "exactly how much" on
a second look: each dot row lights left before right, so one cell
carries eight rungs, one per 12.5% of charge. The band holds still
while the dots climb through it, which is what keeps the two readable
as two.
The charging sweep keeps its five whole-row frames and does not colour
itself — a frame of an animation is not a reading, so the band goes on
answering for the real charge underneath it, exactly as the number
beside it does.
That leaves the alarm nothing to say in hue, so it says it in ground:
under 6.25% — half the last dot's worth of charge — the cell alternates
black dots on red with the light red on the bare page. Where every
other cell is filled, a beat with no fill at all is the loudest thing
the palette has. It is drawn a frame at a time because a terminal's own
blink only ever toggles the foreground, and the PicoCalc's framebuffer
console ignores SGR 5 outright — device-checked, along with its want of
bright backgrounds, which is why the grounds are all dim-bank slots.
Neither animation sits behind Platform.effects any more. The header is
rebuilt on the idle tick regardless, so both cost a colour swap in a
frame already being painted, and the one platform the alarm is *for*
was the one platform never drawing it. effects keeps the braille
spinner, which is what it was really gating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chat pick anchored on its own body, so ^Home landed the frame two lines into
the transcript: the day divider survived only as a pin, the first sender's chip
was gone, and the PicoCalc title bar lit its ↑ over a conversation with nothing
above it. The first message has nothing before its divider, so its pick anchors
at line 0 — the head is drawn, not pinned, and the arrow goes dim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They rode the PicoCalc title bar's tail, appearing and disappearing with the
scroll — and a half-shown pair put a blank cell where the missing arrow would
be, so reading it meant comparing the row against a memory of itself. Both
arrows now sit at the left edge, where the eye starts, and never move: an arrow
whose direction has more takes the border's own accent, one with nothing that
way drops to muted. That is the live/dim language the F-key lane already draws
one row below, and the bar keeps its width whatever the body is doing.
The Esc atom keeps the tail it was given, now on its own — a hair more room for
a long title, since the two arrow cells left that end of the row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quit confirm promised "Enter quit · Esc cancel", which held only while Quit
was the highlighted button — and on a bonded device there is an "Unpair & quit"
between Cancel and Quit, so Enter has three things it might do. The hint also
left out ←→, the one key that changes which. Both dialogs (the TX winner's
"Enter apply" is the same shape) drop back to the dialog default, which says the
mechanism instead of guessing the outcome: ←→ choose · Enter select · Esc cancel.
A row-picking list keeps its licence to name a committing verb, because every
row commits the same way. A lone-button acknowledgement keeps "Enter OK" too,
having nothing to choose between — that is the exception the new standards test
walks the package for.
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>
The footer line already carries the top screen's hint, so a floating dialog was
saying the same sentence twice on one frame: once in its own border, once at the
bottom of the terminal. On the desktop the border now says nothing and the
footer is the single place hints live; its clip arrows fall back to the base
frame's own "more" wording, so a silent border still says the one thing every
border here says.
The PicoCalc keeps the border hint, because it has no hint line to defer to —
the lane stands where the footer would be and speaks only for its five chips.
But what the lane does say it says already, one row below, so the border drops
every atom whose keys are all chips: the packet viewer's "PgUp/PgDn scroll" and
"Home/End ends" go, its arrows and Esc stay. An atom survives unless *every* key
it names is covered, so a half-covered pair never tells half a truth, and one
riding in the verb half (the map's Home/^U) keeps the atom whole.
Resolved per paint rather than folded into a screen's hint, since both halves
move: the viewer rewrites its hint as the list grows past one entry, and every
screen reads its lane fresh each frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five flows asked two or more things in a row as a straight run of awaits, so Esc
anywhere in one abandoned the lot. Mistype the second half of a 32-hex channel
key and the name you typed first went with it; back out of the courier's
schedule and the message you had just written was gone.
menus.run_steps runs a chain as the stack it is. Each step is awaited with the
answers gathered so far — the same list every time, so a step can label itself
with an earlier answer and offer its own previous answer as its default when it
is come back to — and returns None to step back. None from the first step ends
the flow; there is nothing behind it.
Converted: joining a channel by key and editing one (name then key), the custom
firmware variable (name then value), and the courier's queue flow (recipient,
message, schedule). The courier's recipient list now stays pushed for the whole
flow, so stepping back from the message lands on the row it was being written
to, and the schedule's "At a time…" prompt steps back to its own rungs rather
than out of the queueing.
A step signals "back" with None, which is what _pick_schedule returns for the
real answer "when it's next heard" — so _schedule_step swaps the two, carrying
that answer as its own WHEN_HEARD sentinel.
A trailing Cancel/verb confirm is left alone: its left button says Cancel and
means "don't do this", which is a decision rather than a step. That line is now
in CLAUDE.md with the rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four screens carry a find-as-you-type filter and Esc meant two things across
them: the map and the mesh walk cleared the query and stayed, while the select
list and the path composer left the screen, filter and all. Typing "yul" on the
map then pressing Esc showed you the map; typing "yul" on a contact list then
pressing Esc dropped you at the main menu. Same affordance, same glyphs,
opposite outcome — and the select list is by far the most-used of the four.
They peel now, all of them: the typed query is the most recent thing the reader
entered, so the first Esc takes it back and the second leaves. The footer says
which, swapping its trailing Esc verb for "Esc clear" while a filter stands —
the word the map and the walk have always used, via a _esc_verb helper beside
the two atom-splicers that already bend that line. The composer's Esc now
layers exactly as its own backspace does: the entry first, then the screen.
This was finding D in docs/survey-navigation.md, open since the survey was
written and the last thing left telling a reader what Esc does. The rule is now
stated in CLAUDE.md under Navigation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backing out of a thread already landed on the picker rather than the menu, but
on a brand-new one: the tool re-ran the whole picker each round and put the
cursor back with a default= restore, which recovers the cursor alone and only
while the row it names still exists. Anything typed to narrow the list was gone
every time, and the screen was rebuilt around two devstate reads.
The picker moves into the tool's own loop and stays pushed, so a chat nests
above it and Esc is one pop onto the row it was opened from — filter, scroll and
all. The row builder splits out as _picker_items, and the rows are swapped in
place after every chat and every delete: an exchange moves its thread up the
recency order and a deleted history hollows its dot and demotes the row, so the
list has to be re-read, and replace_items follows the highlighted thread there.
The picker tests now read _picker_items directly rather than intercepting a
select() call, and the two Del tests drive _delete_history and assert the
rebuilt row demotes — the same coverage, one layer down from the loop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same defect Trace had, doubled: both entry pickers were gathered in
prompt_params, so both had popped before the sweep screen opened. Esc from the
sweep landed on the main menu, and Esc from the "measure at" list did too —
never on the node list one step behind it. Tuning a second target meant
reopening the tool and re-picking the node.
Both pickers stay pushed now, one inside the other: admin_node_visit keeps the
node list (the stay form repeater admin already uses), _measure_visit keeps the
target list, and the sweep nests on top. Esc walks the flow back the way it was
entered, each list still holding its cursor, filter and scroll, and sweeping the
same tuned node against a second target is one pick.
_pick_target becomes _target_items, a pure row builder, so the rows and the
screen that shows them are no longer welded together; _sweep resolves a picked
name (or typed hex prefix) and opens the screen. Drops an unused ctx.device()
call — the contacts read ahead of it already needs the link.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trace-target picker was gathered in prompt_params, which is a one-shot: it
resolved and popped before the live screen opened, so the trace screen sat
directly on the main menu. Esc out of a walk therefore skipped the list it was
launched from entirely, and trying the next node meant reopening the tool from
the menu — the picker's whole recency sort, the typed filter and the row you
were on, thrown away on the way in.
So the picker moves into the tool's own loop and stays pushed for the visit,
the hub idiom the contacts list and the Time Machine already use. The live
screen nests above it: one Esc pops back onto the row that launched it, the
second leaves for the menu. prompt_params now only marks the interactive path.
Coming back re-reads the lanes, because a returning trace ages the very column
the list is sorted by: ContactListScreen.update_rows swaps the rows in place
(the replace_items counterpart for a list whose lanes are data), so the node
just traced rises to the top of the TRACED sort with the highlight riding it
and the filter untouched. Both ages and counts come from stored history, so a
hand-back costs no device round-trip.
The free-text way in — no contacts at all, or no full-screen session — keeps
its one-shot shape, since there is no list underneath to come back to.
test_navigation gains the two guarantees: the picker is still on the stack
while the live screen runs, and update_rows re-sorts without moving the reader.
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>
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>
Navigation was almost a stack already, and the survey in docs/survey-navigation.md
catalogues where it wasn't. This is the machinery for the strict version, plus
the one shortcut past it.
A screen that owns a loop can now keep itself pushed for the whole visit:
async with session.stay(screen) as visit:
while True:
chosen = await visit.result()
One push, one pop, and the *object* survives its sub-screens — so the cursor,
the sort, the scroll offset and any live filter are simply still there on the
way back, rather than being approximated by a `default=` restore that can only
recover the cursor and only when its row still exists. It also retires the
pop-then-re-push-as-a-backdrop workaround: a screen that never left the stack
already is one.
^W unwinds every frame at once, back to the main menu. It resolves the top
screen with POP_ALL, which each navigation boundary turns into PopToMenu, and
every frame pops itself in a finally on the way out — the same single-pop
discipline applied N times. PopToMenu derives from BaseException so it crosses
the `except Exception` guards that stop a broken tool from taking the menu down;
those must not report an unwind as a tool failure. The flag it sets matters
independently of the future: pressed during a device read there is nothing
armed to carry the sentinel, so the next screen the flow tries to open refuses
to open instead.
It declines to fire over anything modal — an unanswered dialog, a progress bar,
a busy splash — which is what `modal` is for. `floating` is only about being
drawn as a box; a select list floats and is not modal, the busy splash is modal
and does not float.
^Q quits from anywhere. Both chords are deliberately unadvertised: a global verb
has no screen to belong to, so a footer atom or an F-key chip would ride on
every screen, and the lane only has three free slots to begin with. A test
walks every string literal in the package to keep them out of the UI.
Quitting mid-screen used to abandon the coroutine driving the app: the chord
exits the application straight from the key handler, so `run_async` returned
while main() was still parked on a screen, and its finally — which closes the
history and chat runs, stops the services and arms the exit watchdog — never
ran. It is cancelled and awaited now.
The menu is the root: built once, kept for the session, and the one frame that
catches the unwind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The title is the screen's name now, with no status atoms at all. Everything it
used to carry is already on the screen below it: the breadcrumb trail ends on
the focus, the line under that names the node in colour with its hash and its
ring, and how big the neighbourhood is, is what the canvas and the link list are
for. Set once at construction rather than composed per render, so the top row
holds still while the walk moves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Mesh walk — YUL-Cartierville · 42 nodes · 61 links · 3 matches" ran 62 cells,
which a 53-column title bar clips rather than wraps — the link count went, and
so did the bar's clip arrows and its Esc hint. The focus was the longest atom,
the one that rewrote the top row at every step of the walk, and the only
redundant one: the breadcrumb trail ends on it and the line under that names it
in colour, with its hash, its ring and when it was last heard.
So the title reads like the map's — the other spatial screen, whose centre is
likewise the body's to name — status atoms only, and the match count swaps for
the node count instead of joining it, the same way the map's does:
Mesh walk · 42 nodes · 61 links
Mesh walk · 3 of 42 match · 61 links
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This platform has no footer hint line — the F-key lane stands where it would be,
and the lane only advertises what its five slots do — so Esc, the key every
screen answers to and the reason no screen spends a row on a Back item, was
nowhere on the screen.
It now rides the title bar's tail, last, after the clip arrows and in the same
"↑↓ · hint" shape a floating dialog's subtitle already uses. The verb is the
screen's own: the atom is lifted off its footer hint (the grammar puts Esc last,
so the tail of the hint is the clause), which is how a value picker says keep and
the main menu says quit, and a screen naming no way out advertises none. It gives
way before it crowds the title — the verb first, then the key, then the cells
back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A picked row folds its node hues to the cursor white so the row reads as one
thing rather than as a name arguing with its own selection. On a path line that
is wrong: there the hue is not decoration on a name, it is what tells one hop
from the next — and what a route graph above the row is cross-referenced by,
its labels being a marker and one byte of hash. Chips were never folded (their
fills are outside the fold's vocabulary), so the bug was the arrow form: every
path line on the PicoCalc, and on any terminal without the powerline glyphs.
PathLine now stamps every shape it mints with PATH_INK, a style that draws
nothing; Text.append_text carries the stamp into the row as a span over the
hops, and the fold skips identity spans lying inside one. So the Message paths
row keeps each hop in the colour its graph label wears, and so do the TX
screen's "Route — via", the trophy board's walk and the feed's sender → recipient
lane. The node detail's Routes tab, which bought this by hand (no cursor base at
all), is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skeleton card floated between screens carried a Knight-Rider scanning bar
under its caption: eighteen braille lamps, a red head and a persistence-of-vision
trail sweeping back and forth. It drew the eye harder than the words did, on a
card the reader is only ever passing through, so it and its colour ramp are gone
— the one-cell spinner beside the caption is the whole animation, and the card is
two lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list a screen removes from is the union of the device's contact table and
the contacts MeshTerm remembers for it, so an entry can exist only on our side
— or the firmware can drop one since we last read. Removing such a contact hit
ERR_CODE_NOT_FOUND and reported "couldn't remove", leaving a row the reader had
no way to get rid of.
The device layer now raises ContactNotOnDeviceError for that code, the same
class (and the same table lookup) the send path already raises. The single
removal reports it as a warning naming what happened, forgets our half and ends
the visit; the purge sweep counts it as purged rather than failed. The
simulator refuses the same way, so the path is walkable under --mock.
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 ZWJ sequence — 🤷♂️, 👨👩👧, 🏳️🌈 — is a single grapheme the terminal draws
in the width of its base, and Rich's stock measurement already knows that.
prompt_toolkit's does not: it adds the codepoints up, reserving three cells for
the shrug and six for the family, and it is prompt_toolkit that places the right
border. Every joined emoji has been pulling a row's border in by the excess.
Correct it in both authorities the same way, by dropping what the joiner folds
in before measuring, so a sequence inherits whatever its base is worth and the
two curated sets still reach it. Rich needed this too: the selector-skipping
replacement was losing the stock loop's own ZWJ handling and counting the male
sign as a cell.
Measuring is only half of it — prompt_toolkit lays out one codepoint at a time,
so the sequence has to arrive as one fragment or it is still laid out in pieces.
ClusterTextControl merges the run on the finished lines (the last point they are
still ours: split_lines returns plain str and drops the marking), and the window
makes one two-cell Char of it. Every codepoint still reaches the terminal, in
order, so the glyph composes exactly as before.
The rule holds at either emoji width, so the width-2 path installs it alone —
no narrowing, no flag category, Rich untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list could only sweep contacts by age — a purge or nothing. Removing one
named contact now lives where the reader is already looking at it: the last
action on its Node detail page, behind a red Cancel/Remove confirm, and the
list it returns to rebuilds without the row.
The deletion lands in both halves of what the list shows — the device's table
and the cross-session store — because forgetting only the radio's half merges
the contact straight back on the next read. A refusal is put in a dialog and
nothing is forgotten: a contact the radio still holds must not vanish from the
list. Only the contact goes; its reception history and chat messages stay, and
a later send offers to write it back.
An action row's tint now survives the icon lane being dropped — it lands on the
label, as marked_label already does for menu rows — so the delete still reads
as a delete on the PicoCalc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every framed box was post-processed on the way to the screen: the top-left
corner blended toward white and the highlight decayed along the top and left
edges, so a box read as lit from that corner. It reads as a gradient laid over
the chrome rather than as the box being a box. The borders draw flat now.
The pass goes with it — ui/tui/glow.py, and the three composers that ran it
(base, startup, dialog), each of which also carried the platform gate that kept
it off the 16-slot console where it could only ever hand back the same lines.
Nothing else called it, and its memo went with the module. Text colour is
untouched: the pass only ever recoloured box-drawing glyphs, and titles and
subtitles were already skipped.
The four glow tests become one that pins the outcome instead of the mechanism —
every box glyph in a composed frame, nested panels and dialogs included, carries
the border's own colour and no other. Verified against the old pass: with it
back, that set holds sixty-five.
Platform.effects keeps its other two claims (the header blink, the braille
spinner); it just no longer mentions a pass that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two marks sat beside the written pages in assets/ under names that spelled
their widths differently and only by accident — logo.ans and logo_53.ans, where
the first is 71 columns and says so nowhere. They move into assets/splash/ as
logo.71.ans and logo.53.ans: one file per canvas, each named for the canvas.
So the size ladder is now the folder listing. _variants() reads the widths out
of the names and walks them widest-first, which means a new size is a file
dropped in rather than a file dropped in *and* a tuple edited. The name only
orders the ladder — which mark fits is still settled by measuring the art, so a
name that overstates its canvas can't draw a torn mark. A test keeps the two
honest with each other: every mark is as wide as its name says.
The old 8.3 spelling goes with the rename. It was there for art editors that
predate long filenames, which manage a second dot fine, and a name that states
its width is worth more than a name DOS could have opened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sending to a contact the firmware no longer holds came back as
`send failed: failed to send message to 'bob_caribou': {'error_code': 2,
'code_string': 'ERR_CODE_NOT_FOUND'}` — a wire constant in a status line, and
no hint of what to do about it.
It is reachable for a contact MeshTerm itself listed. The list a screen sees is
the union of the device's live table and the contacts MeshTerm remembers for
that device (core/contact_store), which exists so a RAM-only radio bridge still
shows the nodes you know. The store claims those stay messageable without
writing anything back — true for the bridge, false for a firmware companion,
which addresses a direct message by looking the recipient up in its *own* table
by a key prefix and refuses when nothing matches. So a contact the firmware has
dropped still lists, still opens a chat, and only the send finds out it is gone.
Now the rejection is its own class carrying the contact, and the chat offers the
one write that fixes it: "bob_caribou isn't in this device's contacts, so the
radio can't address it. Add it back and send?" — Cancel or Add & send, then the
same send runs again. Declining reports the refusal in words. The write is
offered rather than silent because it changes what the device stores, and a full
contact table refuses it, which is worth seeing. Retry (^R) takes the same path.
Adding a contact is new to the Device contract (the inverse of remove_contact,
ADD_UPDATE_CONTACT on the wire): the record carries the key, name, type and last
advertised position we already hold, with no learned route, so the first message
floods exactly as it would for a freshly-heard node. The name is clipped to the
32 *bytes* the firmware field holds, not 32 characters.
Every other rejection now reads as a sentence too — reject_reason maps the six
error codes to plain words, so a send, an admin command, or any _ok()'d command
says "the device's table is full" rather than printing its payload dict. The
simulator refuses a recipient it doesn't hold, exactly as hardware does, so the
whole offer is walkable under --mock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block was laid out at its full height and whatever overran the screen was
cut off the bottom — which is the box: the device list, the PIN prompt, the
thing the user opened the app to use. The mark kept every row it had.
So a terminal too short for all of it now gives rows back, cheapest first: the
blank line under the mark, which is breathing room and nothing else, and then
the top of the mark itself, up to nine rows of it. Nine is the globe standing
over the lettering — decoration, where the lettering is what says whose splash
this is. Cropping from the top means the lettering and its copyright hold their
place while the mark thins above them, rather than the whole block sliding.
The bound is the art's own height too, so a mark drawn without a globe is never
sheared into its letters, and past the bound the old clip still applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the PicoCalc a repaint costs about 154ms, and these two loops waited 60ms
(the overlay) and 120ms (the splash) between them — rates hardcoded past
spinner_interval(), which every other animated wait in the app already sleeps
on. The animation asked for repaints faster than the console could finish them,
so it took the event loop from the device read it was covering for: the wait got
slower for being animated, and the spinner stuttered anyway. Both defer to the
platform now (0.5s there, 0.12s on the desktop).
Two calls were also blocking the loop outright, which stops the animation dead
however it is paced. Enumerating serial ports is a SetupAPI/sysfs walk — behind
the "Scanning for companion devices…" splash, and again on every poll of the
reconnect dialog — so both go to a thread. The UI itself cannot: prompt_toolkit
owns the loop for render and keys. Moving the blocking work off it is the same
fix from the other side.
Two tests: neither busy screen may carry a spin rate of its own, and the
spinner advances *during* an awaited read rather than only side of it.
Also drops the "· experimental" tag from the network-device row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark's art signs itself now, so no splash adds a line under it — the
picker, the smoke-test spinner, the PIN prompt, the failure notices, the
scan and the settings offer all drop the footnote they were passing, and
nothing draws the notice twice. The written About pages keep their {copyright}
colophon: that is a page of prose, not a screen with the mark on it.
Moebius spells an untouched cell as a NUL, which a DOS console draws blank and
Rich measures as nothing at all — so every one swallowed a column and slid the
rest of its row leftwards, eighteen of them on the worst line. They are spaces
in the file now, and the loader translates any that come back (with codepage
437's hard space) so re-exporting the art can't reintroduce the drift.
Those NULs were also the only reason git guessed the file was binary. With them
gone, .gitattributes has to say so outright, before core.autocrlf starts
rewriting CRLFs that are part of the canvas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repeater-admin picker drew every name in the repeater violet, so a section of them
read as one node repeated — the one list in the app where telling two repeaters apart is
the whole point, and the only one that refused to.
Nothing regressed it; it was built that way. The row was `Text(glyph, style=type_style)`
with the name appended after, and a style on the Text *is* the row's base — Rich lays it
under every span, so the type colour that should have coloured one mark coloured the whole
line. The name never carried a style of its own to argue with it. The TX-optimize "measure
at" picker was the same row, copied.
So both build the row the way every other list of nodes does: the glyph takes its type
style as a span and the name takes `name_style(name, key)`, hued by the node's key like a
contact row, a watchlist row, a relay hop. That also puts the row back inside the selection
language — with no base style of its own, the cursor row's `cursor` white now reaches the
name span and folds it, which a type-styled base was quietly blocking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remote administration still failed. My last two commits classified the failure correctly
and fixed the credential policy, but the login itself kept coming back as no reply from
repeaters that were up, traced, and holding the right password.
The wait I added was counted from the wrong moment. `budget` is the *node's* answer window,
but the clock started before `send_login_sync`, and almost none of what that call spends is
the node's: it first waits its turn on meshcore's mesh-request lock — which every telemetry
poll, status read and courier retry holds for a whole round trip — and only then blocks
until a MSG_SENT the library correlates by event type alone arrives. Whatever that came to
was subtracted from the repeater's window, so the fix read as "wait longer" and behaved as
"wait the same ten seconds, minus the queue". A send that outlasted the budget left the node
no window at all, and the log bears the shape out: both attempts to YUL-UpperSalaberry ended
at 9985 ms and 10015 ms — the budget exactly, spent from before the request was on the air.
So the answer is timed from the send, the way `run_trace` times its reply. And the route
sizing is a floor rather than a verdict: a login is an admin exchange out and back, not a
trace's single small packet, so a contact we hold a one-hop route for was being given a
narrower window (7.2 s) than the routeless contact beside it that floods (10 s). Knowing
where a node is must not make us give up on it sooner.
The rest is making the next failure readable, because this one was not. A no-reply has three
causes that the old line could not tell apart, so it now reports where the time went — the
send's own duration, the budget, and how long the node had — and quotes the frames it heard
but could not act on: a LOGIN_SUCCESS/LOGIN_FAILED naming some other node (a key prefix that
did not match, not a quiet node), and a companion ERROR, which is the one failure that never
reaches the mesh and which `send_login_sync` erases into the same bare `None`. Uncorrelated,
so it is evidence in the log and never a verdict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A repeater that is up, with the right password, was answering "no reply". My last commit
did not cause that — it renamed it. The failure was already there, mislabelled as a wrong
password (and clearing the credential on its way past, which is how it got reported in
the first place).
`send_login_sync` does not start listening until its own send returns, and that send
blocks until a MSG_SENT arrives — which the library correlates by nothing but its event
type. So a scheduled advert, a telemetry poll or the courier can consume ours and leave
the send sitting on its 15-second default, and every answer that lands during the stall
is dispatched to no listener and dropped. The login the repeater accepted is recorded as
silence. The mirror case is as bad: catching another command's MSG_SENT takes its
`suggested_timeout` with it, and a neighbour's second or two is nowhere near a multi-hop
repeater's round trip.
`run_trace` already documents this exact trap and already solves it, so the login now
does what a trace does: subscribe to LOGIN_SUCCESS *and* LOGIN_FAILED before transmitting,
and keep listening across the whole exchange. `send_login_sync` still transmits — it is
the supported path, and its own listener is harmless, the dispatcher delivers to every
matching subscription — but ours is armed first and outlives it. When it gives up early
we keep waiting for the rest of a budget sized to the route: a login walks out along the
contact's hops and back, which is the shape `trace_timeout` already describes, so the
wire carries twice the stored one-way route. A contact we hold no route for floods, and
takes the flood budget.
The classification falls out of that — the two real frames now reach us, so ACCEPTED and
REFUSED are read off the frame instead of inferred from a `None`. The outcome is logged
at debug (budget, verdict, elapsed) so the next "no reply" is diagnosable from the JSONL
rather than by argument.
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>
Teal was still answering three questions it had no business answering. The wordmark's
colour is the app's identity, and it is also a node hue — so a cyan-keyed node could
vanish into the very highlight pointing at it.
- A node's key-derived hue folds to the cursor white on the cursor row. A row is a
column of identities right up until it is the row you are on, and then it is
answering a different question; a hue on it argues with its own selection. Folded
once at the render boundary for any Text whose *base* style is `cursor` (which is how
every list and every screen drawing its own rows already marks it), so no screen has
to remember the rule. `theme.is_identity_style` is node_style read backwards — every
hue a key can mint, on both platforms — and only those fold: the heat of an age, an
SNR reading, a red badge, a path line's chip fills all keep what they were given. So
does the grey of a node no key could place: the highlight knows where you are, not who.
- The reverse-video chip — a dialog's committing button, a running action's Abort, the
arrow-mode composer slot, the editor cursor — is grey. A chip marks where a press
lands. It is chrome, and chrome does not get a hue. Light grey slot 7 on the VT: the
one grey a reverse can put behind text there, and the fill the F-key lane already uses.
- The active sort column's heading and triangle take the same white as the cursor row,
being the same claim one axis over. They were a bare `#22d3ee`, which put a selection
cue back on the node spectrum and, on the console, straight onto the brand's own slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restoring a backup onto a freshly flashed companion put the identity and
the channels back, reported all eighteen operations applied, and left the
node advertising from latitude 0.0 on the factory frequency.
Latitude and longitude reach the firmware through one command, and so do
the four radio parameters, so changing one means re-sending its siblings
unchanged. Those siblings are read from the caller's snapshot — taken
once, before the first write. Applying adv_lat preserved the stale
longitude; applying adv_lon then preserved the stale *latitude*, undoing
the first write. Same shape for radio_freq followed by radio_sf. Nothing
errored: each op did exactly what it was told, with a picture of the
device that was two writes out of date.
So each coupled applier now records what it set. Any later sibling in the
same batch preserves the new value rather than the one that was true
before the batch began — whether the batch comes from config restore or
from the editor's staged changes, which had the same hole.
And the Device PIN row, found while writing the restore instructions: it
read 'device_pin' out of a snapshot that only ever held SELF_INFO, while
real firmware reports the code as 'ble_pin' in the device-query frame. It
could therefore only ever render "?" on hardware — writable, with no way
to see what you wrote, so a typo sat in NVS until the next pairing. The
snapshot now folds in that frame (SELF_INFO still wins where they
overlap, as probe_device already had it), the row reads the firmware's
own name while keeping the key its backups and CLI use, and the simulator
reports it the same way so --mock shows the same screen.
Verified on the reflashed device: the row reads 701307, and model and
firmware version arrive with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Owning the meshcore client was necessary but not sufficient, and the
device said so: the same probe still went off the air.
ConnectionManager.disconnect closes its transport only if _is_connected,
a flag it sets *after* connection.connect() returns. The GATT auth error
is thrown from start_notify, long after bleak brought the link up and
well before the manager records anything — so the manager is certain
there is nothing to close while BLEConnection is still holding a live,
connected BleakClient. Calling mc.disconnect() and stopping there looks
exactly like a teardown and leaks the link anyway.
So _discard_meshcore now runs both halves: the graceful call, which stops
the dispatcher and cancels any reconnect task, then the transport closed
directly, which is what actually drops the link. BLEConnection.disconnect
re-checks client.is_connected, so the second close is a no-op whenever the
first one sufficed. That forced path already existed inside disconnect()
for the dispatcher's queue.join() deadlock; it is factored out as
_force_close_transport and shared, so the two teardowns cannot drift.
The tests grow a fake that models the manager's guard rather than counting
disconnect() calls, so they assert the link went down instead of asserting
a method was invoked — the distinction the first attempt failed on. A
test named for that mistake pins it; reverting to graceful-only fails
seven of the ten.
Verified on hardware, instrumented: the auth path now shows
is_connected True -> False inside the discard, the client ends at None,
and the peripheral keeps advertising. Full lifecycle clean in one session
with no power-cycle — PIN-less probe, PIN retry, bonded session with real
traffic, mid-session bond query, Unpair & quit, and a relaunch after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MeshCore.create_ble builds its client, calls connect() on it, and hands
it back only on success. A connect that *raises* therefore left that
client — and the bleak link it had already opened — orphaned inside the
library with no reference we could close. Our _mc was never assigned, so
disconnect() was a no-op and the probe's _safe_disconnect closed nothing.
That is not a corner: a PIN-protected companion answers the unbonded
notify-subscribe with a GATT authentication error, and the picker's first
attempt is always PIN-less. So selecting an unbonded companion leaked the
link, Windows held it for the life of the process, and the peripheral —
still believing it had a peer — stopped advertising. The PIN dialog then
asked for a code it could no longer deliver: the retry paired at the OS
level (the bond really is created) but could not form a GATT session, and
the user was told their correct PIN belonged to something that "isn't a
MeshCore companion". Since Unpair & quit is what leaves you unbonded, the
whole thing read as unpairing having changed the PIN.
Assemble the two objects here instead. _connect_owned_ble keeps the
handle, so both failure shapes close it: a raise (closed, then re-raised)
and a None handshake (closed, then reported as "not a companion"). The
teardown is shielded — the other way in is a probe whose wait_for expired
and cancelled the handshake, where a plain await would be cancelled the
moment it suspended and abandon the very close it was called to perform.
The picker's accepted PIN now also rides on the context, so a mid-session
reconnect doesn't re-run the whole "needs a PIN" dance against a companion
we already authenticated to. Session-lifetime only; nothing reaches disk.
Tests stop naming a real companion: the addresses and pairing code are
stand-ins now, and the fakes model the class we construct rather than the
create_ble we no longer call.
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>
Emptying a trace's relay path — right, since those bytes were readings all
along — left the card contradicting itself: three legs on the links row,
"direct — no relays" two lines under it, and a note crediting the reading
to a sender nothing had relayed for.
An empty chain means different things by class. For every other frame it
means the sender was heard straight off the air. For a trace it means the
relays went unrecorded, which is not the same claim at all — the readings
are proof something forwarded it. So the row says how far the packet got,
which is the one thing those readings do establish, and the reception note
goes back to naming the last relay.
The row's whole implementation was `super().handle("escape")` — Esc, spelled
out as a cursor stop. It cost a keypress on every platform (End then Enter,
against one Esc), sat below the row that commits so End landed on the way
out rather than on the thing you came to do, and taught nothing: Esc leaves
every other screen in the app without being told to.
The action group now ends where the actions do — Use this path, then Auto
in target mode. Cancel stays where it earns its place: a dialog asking a
real question, where the choice is the point.