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>
Restores the WIP parked on 2026-08-31 before the PicoCalc sync.
The feed seeded itself from `utcnow() - OBSERVATION_WINDOW`, borrowing the
two-hour window the dashboard's rolling stats are bounded by. Two bounds for
one list, and the wrong one usually won: on a quiet mesh the screen opened
nearly empty, not because little had ever been heard but because little had
been heard *lately* — which is the one thing the reader can already see from
the ages in the rows.
So the cap is the only bound, and it goes from 100 to 500. A busy hour runs to
some hundreds of packets, so a hundred rows forgot the last ten minutes at
exactly the moment the feed was worth watching. Depth is close to free here:
the paint is windowed, so the cap costs a pointer per entry and a few
milliseconds at open, never anything per frame. Widening the seed costs
nothing either — `observed_at` is indexed, so the query walks the index
backwards and stops after _FEED_CAP rows whatever the floor.
Measured on a 700-packet history spread over a year: the unbounded seed fills
all 500 rows and reaches 249 days back, where the two-hour window seeded one.
OBSERVATION_WINDOW keeps its name and its meaning, now solely the dashboard's:
it is what the RF health card means by "reception over 2 h", and its docstring
says so rather than leaving the next reader to find two screens sharing it.
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>
Surveying every popup dialog's hint turned up three that said the arrows were
something they aren't. The yes/no confirm's "←→/Y/N choose" put a key inside the
arrow atom that does not choose at all — Y/N answers outright — and the
autocomplete's "↑↓ Tab complete" did the same with Tab, which completes rather
than moves. Several keys may share an atom, but only when they do the same
thing: "↑↓ PgUp/PgDn scroll" and "Home/End ends" are the shape that is fine.
The composer's "←→ cursor" named the wrong thing rather than too much. The
cursor in this app is the row ❯ points at, which is what ↑↓ moves there; the
arrows move the insertion slot along the path, and "slot" is the word the rest
of the app already uses for it.
The confirm takes the ButtonDialog hint verbatim rather than a longer one of its
own — a yes/no prompt is a button row like any other, and its y/n accelerators
are already hinted by the Yes/No labels. It reads the same everywhere now, and
the Esc verb stops falling off the end of the box on a 53-column console.
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>
Sits between the week and the fortnight, where the ladder is now fine enough to
separate "quiet for a bit" from "gone".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ladder jumped straight from a week to a month, which is the widest gap in
it and lands either side of the span a contact is most often judged on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D was the last of the original findings still open, and re-running the survey
against the tools (rather than the screens) turned up two shapes the first pass
never looked for: a picker gathered in prompt_params, which pops before the
screen it feeds, and an entry chain of prompts, which abandoned instead of
stepping back. Both are written up as H and I with their sites, and the
shortlist gains them.
Only G is left, and it is not navigation-visible.
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>
The channel detail was the app's last screen rebuilt per round: a fresh
SelectScreen each time, with the cursor restored by default=cursor and anything
typed to filter dropped. It stays pushed now, and its rows and vital-signs line
are swapped in place after each action — both are live (the mute row is a
toggle, the summary counts unread and ages the last message), so both are
re-read, and the highlight follows the row that was just used.
replace_items gains a prompt= so a summary line above the rows can be refreshed
the same way the title already could; without it, keeping the screen would have
frozen the vital signs at whatever they said on the way in.
That leaves _menu_round with no session path at all — both callers now reach it
only when there is no screen stack — so it shrinks to what it always was
underneath: select once, dispatch, be called again. The push/await/pop loop and
the asyncio import go with it.
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>
^Q and ^C exit straight out, and the next reader of that dispatch block would
reasonably wonder whether the missing confirm was an oversight. It isn't: the
quit dialog is there to catch one Esc too many at the main menu, where a single
reflexive keystroke would otherwise end the session (JP, 2026-08-30). A hidden
two-key chord cannot be pressed by accident, so there is nothing for a confirm
to catch — and a confirm is itself a screen that has to paint and take a key,
which is precisely what an escape hatch cannot depend on.
Comment only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rules the last three commits established, stated where they bind: a
Navigation section in CLAUDE.md covering the strict stack, `stay`/`Visit` for a
hub, `replace_items` for a list whose content is data, and the modal-vs-floating
distinction ^W turns on.
The two global chords get their one statement on the About page, since neither
has a screen to be advertised on. It also says what happens when the host takes
Ctrl+W first (VS Code's integrated terminal reads it as close editor) — the key
is a convenience, and nothing depends on it.
docs/survey-navigation.md gets its ledger updated: A, B and C are closed, with
what was actually done to each. D and G stay open, and D is now recorded as the
reason Esc Esc was rejected as the pop-all key — on the map, the mesh walk and
chat, Esc Esc is already an ordinary peel-then-leave.
Also drops an asyncio import the watchtower no longer needs, and an escape
sequence in a new docstring that made ast.parse warn.
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>
Two comments claimed the pick was channels-only, one of them flatly stating that
direct threads never select. Neither is true: _move_selection and _select_section
carry no is_channel gate, and footer_hint has offered a picked-message hint for
both kinds all along. What actually differs is what Enter does with the pick — a
reply @mention in a channel, the delivery paths in a direct thread.
Both also called it the "reply target", which names only the channel half of
that; it is the picked message, and only one kind replies to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unpainted cell exports as 0x00, which no terminal draws as nothing — it
comes out as tofu, as a stray glyph, or swallowed with the rest of the row
shifted left. One byte wide to fix, and this makes that one substitution and
no other: same length out, every escape sequence where it was.
The trap it exists to avoid is the obvious version of the job. An ANSI file's
last 128 bytes are a SAUCE record whose NULs are structure — binary fields and
the padding on the font name — so a tr over the whole file corrupts the
metadata and fixes no art at all. It cuts at the 0x1A that opens the record and
counts what it left alone. Both splashes are in exactly that state today: 24
NULs each, all of them SAUCE.
A space is only black if the span it lands in left the background black, so
the ones that would come out coloured are named by row and column rather than
quietly repainted — that is an editing call, not a substitution.
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>