Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).
Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.
One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.
The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.
apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.
Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.
The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.
Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.
The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.
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>
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>
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>
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>
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 row `❯` points at borrowed `brand` — so the app's identity ink and
"the row you're on" were the same hue, and worse, teal/cyan is itself a
node hue: a cyan-keyed node's name vanished into its own highlight.
Give the highlight its own name. `cursor` is white on both platforms
(slot 15 on the VT, the top of the bright bank, so the row's bold cannot
promote it further and every dim-slot `not bold` pin inside a highlighted
row goes on meaning what it meant). White sits outside the per-node
spectrum, so it can never collide with an identity; it does share ink
with `you`, which is the accepted cost — spans that set their own colour
keep it over the base, so only unstyled text goes white, and only on the
row already under the reader's eye.
Every surface that draws its own cursor row follows: the select list and
its reorder mode, the feed, chat, routes and actions on the node page,
the composer, records, trace, tx, the walk, the paths screen, the prompt's
suggestions. The composer's insertion-slot chip takes the same white — its
docstring already tied it to "the colour the list cursor below it wears",
and the chip ink was already picked to stay readable on white.
Reverse-video chips keep `selected`: on the VT a reversed white lands on
the light grey the F-key lane already owns, so a white default button
would stop reading as the committing verb.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A transcript's ``── Wed Aug 12 ──`` does exactly what a grouped list's
heading does — it pins to the top row while its day scrolls under it, and
^PgUp/^PgDn step by it — but it was drawn muted, which reads as chrome and
put the one landmark in the scrollback below the messages it labels. It
takes the accent every other section heading in the app wears.
The rule above the compose line keeps its grey: that one really is chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits in a chip, and not a chip of its own invention: ui/widgets.
name_chip builds a one-hop PathLine and asks it for its line, so the fill,
the ink, the padding, the rounded cap and the closing point are whatever a
route's segments are wearing today and cannot drift from them. A sender
wears exactly what that node wears as a hop — hue in the fill under dark
ink, the keyless grey where no key places them, and our own messages
headed by the ★ on its neutral grey rather than by the word "you".
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
Degrading comes free with the borrowing: where the terminal can't draw
separators — the PicoCalc console among them — a path line is arrow text
and one hop is just the name in its hue, which is what a sender label was
before it was framed. The specimen card grows a chips row, which doubles
as a one-hop sample of the route language.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits on a field: the node's own hue as ink on the neutral dark grey
pathline already fills our ★ chip with, closed at both ends by the rounded
caps a path line closes on, or by half blocks where the font has none.
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
The widget is ui/widgets.name_chip — the ink arrives as whatever style the
caller would have drawn the name in and is *added* to the fill as a Style,
because Rich silently drops "node.unknown on #3f3f46" and renders the name
bare. The console has no field to offer (its one dark grey is slot 8 and
backgrounds stop at 7), so the name draws unframed there, bound on
truecolor like the heat gradient. The specimen card grows a chips row so
both dresses are visible on the acceptance screen.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Map. Enter framed the matches, which is only ever half of what finishing a
find means. Typing a query dims everything it doesn't match, and the common
finish is "yes, that one -- now let me look around it": the query has done
its job and only the dimming is in the way. So Enter drops the query and
leaves the view where the reader put it, and ^Enter is the other finish --
take me to them -- keeping the query, because a frame is a place to arrive
and framing tighter from there is one more press.
^Enter is not a keycode any terminal has to offer: Enter *is* ^M, so a
console without modifyOtherKeys has nothing left to modify. What most do
send is LF, which prompt_toolkit spells ControlJ and nothing here was using,
so it is bound; the right-Ctrl rescue covers the rest; and where a console
spells neither, the F3 Frame chip is the affordance -- which is the platform
where that was already the whole story.
Mesh walk. The breadcrumb elided its head and snapped right, so a long walk
had a beginning that could not be read at all. Left and right now scroll it
a hop at a time, and a scrolled line grows its own trailing elision: the
mark means the same thing on either side, more walk that way. It stops where
the head comes into view rather than running the line off the edge, and any
change to the trail rewinds it -- the focus is what the next move is read
from. The hint trades "type to find" for the arrows exactly while the trail
overflows: typing announces itself the moment a letter lands, the scroll had
no way to.
And the find now narrows the canvas fan, not just the list. The east of the
picture is the ways onward, which is what the query is asking about; the
focus and the node we came from are the walk itself, so they hold through
whatever is typed and the graph keeps its bearings while the choices thin.
Chat. The transcript's sections are days and its section jump was already
bound, so it takes the free left pair the same way a grouped list's does --
Day up outside Day down, the lane's handedness, ctrl_pageup/ctrl_pagedown --
lit only when there is a second day to step to. Read off the two end
messages rather than by re-dating the whole transcript every paint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.
That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:
select lists Sect down/up, the section jumps. Ctrl+PgUp on a
keyboard with no PgUp is the one binding in the app
that was physically unreachable. Plus Delete, lit on
exactly the rows the key acts on.
map Region (Home, renamed -- it frames the ground the mesh
covers, it does not "reset" anything), You (new: Ctrl+L,
recentre on our own node at the zoom you chose), and
Frame for the find matches.
node page the tab it would switch you to.
time machine the window span it would take you to -- w was
undiscoverable on the device.
The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.
Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).
The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where two adjacent chips are opposite ends of one axis, the up/in/more end
now takes the right slot: the map reads Zoom - then Zoom +, the shared lane
Bottom/Top and Page down/Page up, and the chat's relabelled jump pair follows
with Latest/Oldest. The actions move with their labels, so one handedness
carries across every screen and F1 always means the near end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane read as a keyboard: Top, End, PgUp, PgDn — the names of keys
this keyboard doesn't even have, standing in for whatever the screen did
with them. A chip's job is to name the action it performs *here*.
The shared lane now speaks the vocabulary of the one thing it assumes —
a scrolling body: Top, Bottom, Page ↑, Page ↓. The chat speaks its own,
since neither end of a conversation is a "top": Oldest reaches back to
the first message, Latest returns to the newest and the compose line.
The map already got Reset and Zoom ±.
Empty and dim now make different claims. Dim says "a thing here, just
not right now"; empty says "not a thing here at all" — so a channel
chat's F8 carries no Retry, because a channel message is never
acknowledged and there is nothing a retry could mean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The direct-message path view looked for frames of class TXT_MSG. The
meshcore library reports that class as TEXT_MSG — the spelling every
other reader uses (core.frames, the packet viewer's lane, the dashboard's
traffic keys) — so the filter matched nothing, and every direct message
reported an empty window no matter how much of the conversation the radio
had overheard. Its test fabricated rows under the same wrong name, so it
passed against a mesh that can't exist.
With the class fixed, the evidence can also be far better than "±90 s of
every direct frame we heard": a TEXT_MSG frame is encrypted, but its
addressing is not — both ends ride in the clear as one-byte key hashes,
and core.frames has been recovering and storing them all along. The
window now narrows to frames running between this conversation's two ends
in either direction, and the summary bills it as matched by address and
time. A frame whose addressing was never recovered drops out rather than
being counted: paths are evidence, and a hash that isn't there proves
nothing.
Still not a content match — a one-byte hash collides — so the view keeps
its warn-coloured summary and makes no claim it can't back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
^P used to fall back to the last message in the transcript when nothing
was picked. A path is the route one particular message walked, so that
fallback showed the paths of whichever message happened to sit at the
bottom — never something the user asked for, and easy to mistake for the
paths of the message they were looking at.
^P now opens the pick and nothing else. The footer drops its ^P atom
while nothing is picked, and the F-key lane dims F3 Paths on the same
gate, so neither offers a key that does nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PicoCalc lane replaces the footer hint outright, so it inherits the
hint line's standing rule — never name a key that would do nothing. Until
now it named several: Retry with nothing unacknowledged, the paging pair
on a body that already fits whole, and, worst, the map's End, bound to
nothing at all while PgUp/PgDn were labelled as paging when they zoom.
An FPair now carries enabled/opp_enabled per bank. A cleared slot keeps
its label and drops its fill, landing in the same muted "nothing to
press" class as an unassigned one — so the key still reads as what it
would do, it just plainly isn't live. The dimming stays presentational:
action_for still resolves a dim slot, and the screen's own handle()
remains the authority (it no-ops), so a lane built from stale metrics can
never swallow a key that works.
Applied where a screen can tell honestly: default_lane(nav=…) for the
shared slots (result windows and the dashboard gate on content_overflows,
the chat on having a transcript), the chat's Retry on there being an
unacknowledged direct message, and the map gets its own lane — Reset,
Zoom +, Zoom - — with F2 left unassigned rather than dead. The chat's
regular-platform hint drops ^R on the same gate.
compose_base now takes the lane as a builder and calls it after the body
renders, so those gates read this paint's scroll metrics instead of the
previous paint's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:
Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).
Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).
Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.
Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.
Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DEFAULT_LANE's F1/F2 dispatch the plain "home"/"end" actions. On the
chat screen those are already claimed by the compose line's cursor
(Home/End move within the message you're typing, tested behavior) —
handle() has no case for them, so both fell through to the same
"clear the pick, stick to the tail" edit fallthrough, making F1 and
F2 indistinguishable.
ChatScreen.fkey_lane now overrides F1/F2 to dispatch ctrl_home/
ctrl_end instead — the actions that actually walk the transcript —
so "Top" jumps to the first message and "End" returns to the tail,
matching their labels.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The transcript cache added in 9f600db cut off at the first "volatile"
message (pending ack or picked) and cached only the prefix before it.
Two bugs fell out of that:
- Deselecting a message never invalidated its cached (highlighted)
rendering, so the old pick's ❯ stayed visible after moving to a new
one — cache reuse only checked identity/acked, not whether the
cached slice was rendered while picked.
- Picking a message near the start of a long transcript shrank the
safe prefix to almost nothing, so every interaction re-rendered
nearly the whole history again — performance regressed the further
back you scrolled, recovering only once the pick returned to the
tail.
Now each message's lines are memoized independently (keyed on
identity, acked, and whether it was rendered picked) and spliced in
or redrawn one at a time, regardless of position. Grouping context
(day/sender headers) is cheap enough to recompute unconditionally
every render rather than trust from the cache.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ChatScreen._render_grouped rebuilt every message through Rich's
renderer on every single repaint, including every keystroke, the 1s
idle tick, and (worst) the ~120ms delivery-spinner tick while a
direct message is in flight. On a long transcript this could burn a
third of a CPU core just to animate one glyph.
Now it memoizes per-message rendered lines and only re-renders from
the first "volatile" message onward — one still awaiting its ack, or
the currently picked one. Everything earlier is proven unchanged (by
identity plus acked, the only field ever mutated in place) and reused
verbatim. A 200-message benchmark with one pending send: per-tick
render time dropped from ~41ms to ~1ms.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The P1 xfail list (contacts, map, walk, livefeed, remote_cli,
path_composer) turned out to be footer_hint overflows plus one real
body bug - and picocalc never draws the hint strings at all since P4's
F-key lane replaced them. So:
- The gallery asserts the footer actually drawn per platform: the hint
string on regular, the screen's F-lane (both banks) on picocalc.
- path_composer's wrapped empty-state note emits one entry per wrapped
line instead of smuggling a newline into a single windowed row - the
one genuine 53-column body overflow.
- _KNOWN_WIDE is empty: all 32 picocalc gallery cases (16 screens x two
geometries) are now hard gates. Only the pre-existing regular map
footer xfail remains, documented as not this seam's to fix.
- First per-screen F-lane verbs, proving the override pattern: the
contact list puts sort cycling on F3/F8, chat puts paths/retry there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).
The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.
Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.
Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.
That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.
The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.
tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.
The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.
The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).
A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.
The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pasted text never reached a screen: the key dispatch forwarded only single
printable characters, so a bracketed paste (what a terminal sends for
Ctrl-V) was dropped whole. Route a multi-character run to a new "paste"
action instead, and add a Ctrl-V binding that reads the OS clipboard for
terminals that deliver the key literally rather than as a bracketed paste.
In chat, a paste is confirmed before it lands: a chat message goes out over
the air, so an amber Cancel/Paste dialog ("Paste N characters into your
message?") guards it — the disruptive (danger) tier, not data loss. On
confirm the run drops into the compose line, newlines folded to spaces
(a message is one line) and the byte gauge flagging an over-budget result
exactly as typing past the limit already does. Plain text prompts gain
silent multi-character paste through the shared line editor; only chat adds
the confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A direct thread's sender label borrows the peer's key hue so a name that won't resolve still carries a colour. That peer-key fallback was leaking onto @mentions in the message body, painting an unknown mentioned name with the peer's hue instead of leaving it gray. A mention names an arbitrary person, so _sender_style(mention=True) now skips the fallback — an unresolved mention stays muted, matching channel behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new make_name_key_resolver (beside its trace_runner siblings) maps a
display name back to the node's key — contacts win, the recorder's
stored advert names fill in strangers — and name_style's keyless
fallback becomes muted: colour now always marks a keyed identity,
never a character-sum accident that two spellings of one node disagree
on. Wired through every keyless call site: the chat transcript's
sender headers and @mentions (a direct thread falls back to the peer's
key), the picker previews, the live feed's channel-sender lane, and
the route graph's origin label (new key_of hook on route_graph_style,
threaded through the packet viewer and Message paths). The chat
picker's contact dot switches to the standard companion pink — shape
still marks history — and contact labels take their key-derived hue.
name_rgb guards the muted style so braille canvases render the grey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lift the chat compose bar's byte budget into a reusable gauge and reuse it
wherever a field feeds a size-capped LoRa packet.
- prompt.py gains byte_counter/byte_style and the DM/CHANNEL byte limits, plus
a byte_limit option on TextScreen: a field so limited rides the shared
used/limit gauge (right-pinned, green→yellow→orange→red) and blocks Enter
once the text tops the cap, with a "Too long by n bytes" note. Ui.text and
session.text thread byte_limit through.
- chat.py drops its private copies and calls the shared byte_counter; the
limits now come from prompt.py.
- The courier's "Message for …" entry passes byte_limit=DM_BYTE_LIMIT so a
queued message is gauged and capped the same way a live chat message is.
Also fixes a courier scheduling bug uncovered here: the "When it's next heard"
row carried the value None, indistinguishable from session.select's own cancel
sentinel — backing out of the picker silently queued the message. It now
carries its own WHEN_HEARD sentinel, so Esc cancels and the row means no
schedule.
Full suite green (806).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route graph (both the record card and the Message paths dialog):
- Relays now wear their own map marker — ▲ repeater, ■ room, ◉ sensor —
instead of a generic ●, via a node-type resolver threaded into
route_graph_style (built from contacts; an untyped relay keeps the dot).
- A shared node-type key (★ you ▲ repeater ● node ■ room ◉ sensor) sits
under every fan, minted once as widgets.node_type_legend.
- The drawn path is coloured by SNR (the SNR palette): the record's walk
by its weakest link, the Message paths selection by its reception SNR;
unselected paths stay gray. Per-path, not per-hop — overheard frames
only carry the reception reading.
Record card:
- New 'reliability' stat: the success rate of traces aimed at the walk's
far node (the one delivery figure history can answer — a failed trace
records its target, not its route), with the sample count beside it.
- Area drawing reworked per feedback: our star is drawn last so it is
never hidden, hops are pinned in their node-name hue (star stays
yellow), the fill is one flat tone (even-odd shades only the enclosed
side; no dimmer interior wash), and the box is larger.
New shared helpers: theme.snr_rgb, widgets.name_rgb,
trace_runner.make_node_type_resolver, repository.target_trace_counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.
DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ^P view grows from a static scroll window into its own dialog. Up
top, every distinct path the message took drawn in the atlas's edge
style — origin left, us right, the final relay → us link coloured by
that path's reception SNR, earlier links slate — with markers for every
node but labels only along the currently selected path, so a many-path
graph stays readable; the selected path draws bright, the rest dim.
Beneath, one row per arrival with a selection cursor: ↑↓ move it (the
graph follows), and a row longer than the dialog scrolls horizontally
with ←→ — the whole line shifts under an edge …, and snaps back the
moment the selection moves on. crop_cells in tui.render is the new
cell-exact window primitive behind that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The radio's RX log has recorded every frame's relay path all along;
now a chat message can be asked how it got here. ^P floats a paths
window for the picked (or latest) message: one row per logged arrival
— time, reception SNR, and the relay chain through the shared compact
path widget — so a channel broadcast that reached us three ways reads
as three rows over two distinct paths, resends labelled.
Channel messages are matched by evidence, not guesswork: overheard
GRP_TXT frames decrypt under the channel's own key (fetched from the
device when the conversation didn't carry it) and match the message by
content, tolerating the "Name: " sender-prefix convention on the wire
— our own broadcasts included, since repeaters' rebroadcasts of us are
overheard like anything else. Direct frames are ECDH-encrypted, so the
view offers the window's TXT_MSG frames matched by time alone and says
so plainly. The sender-prefix parser moves to core.channels where the
matcher, the transcript, and the dashboard feed now all share it, and
the repository gains the windowed packet_frames_between query.
Both chat kinds now share one message-pick model: ↑ picks, the pick
walks with ↑↓/PgUp/PgDn/^Home and day-jumps with ^PgUp/^PgDn, ^End
returns to compose. Enter on a pick replies in a channel (unchanged)
and opens the paths in a direct chat, each with its banner cue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three button/status stragglers join the house style. The tracing
dialog's "❯ [ Abort ]" — the one control drawn with a pointer and
brackets — becomes the centered reverse-video chip every other popup
button already is (ButtonDialog, the reconnect dialog's Abort). Chat's
✅/❌ delivery emoji become the app-wide ✓/✗ status marks in their
ok/err styles: the emoji belong to the packet-class icon lane, and the
single-width marks keep transcript rows evenly spaced. And the channel
manager's Clear — the last bare Yes/No confirm in the app — becomes a
standard destructive dialog (Cancel left, Clear right and default,
danger theming) like every other consequence gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The key hints now follow one sentence shape across every screen:
navigation keys first, action keys next, Esc last — the quit dialog and
TX apply dialog had Esc leading. The Esc verb is now systematic: "Esc
back" leaves a screen, "Esc close" dismisses a read-only floating view
(result windows and the share/key popups switch over), "Esc cancel"
abandons a prompt, "Esc keep" leaves a value picker unchanged. Bare
hints fill in their missing verbs ("↑↓ move", the map's and composer's
naked "Esc"), the confirm prompt says "Enter select" like its button
siblings, and key notation unifies on the compact forms the app already
half-used: ^R/^End for Ctrl chords (chat's "Ctrl-R" included) and ⌫ for
backspace (the map/atlas "Bksp").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two rounds of interactive-polish fixes across the dashboard, Time Machine,
chat, and the menu popups.
Dashboard
- Feed name lane names what a row actually is: a channel message's parsed
sender, or a relayed packet's payload class (channel text / trace / …),
instead of a bare "?" or a redundant "ch N". The class is persisted so
history-seeded rows carry it too (observations.payload_typename, schema v10).
- PgUp/PgDn walk the feed selection so the highlight travels with the view.
Packet viewer
- Takes a live source and re-locates the viewed packet by identity, so packets
arriving after it opens become reachable; the n/total title stays current.
- Decrypts overheard channel text straight from stored history (crypto fields
persisted: chan_hash/cipher_mac/crypted, schema v9).
Time Machine
- Day charts label their bars with column ticks: each date sits under its own
bar with a ┬ notch, shortens (month only on the first tick and on rollover),
and thins to fit. axis_chart grows an optional ticks path for columnar charts.
- Node history charts render through the shared axis_chart widget; the mesh
Rhythm is 96 fifteen-minute slices at 4x width, "now/newest" hints dropped.
Chat
- The byte counter pins to the right edge of the input's last line, so it stays
bottom-right when the compose wraps instead of trailing the cursor.
Menu popups
- The per-node Rules popover gets an explicit Close row.
- The Send advert popup drops the emoji from its floating title (a terminal that
paints it narrow left the content-sized dialog's border short, bleeding the
backdrop through); the menu row keeps its icon.
625 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The counter was its own last body line, and the pinned day-divider
header reserves the top viewport row without relaxing the bottom scroll
clamp - so exactly when the transcript filled the screen, the counter
fell off the bottom. The budget now trails the compose text inline
(wrapping along with it, costing no row), and the frame slides the
window down a row when a sticky header is pinned at the very bottom,
so the last body line always wins over the duplicate heading.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
highlighted_hash grows a width budget - shorter than the key shows an
ellipsis, shorter keys pad so lanes align - making it THE hash widget.
The chat sender palette moves to theme.name_style as the app-wide node
name colour rule; our own node is always the pure-white "you" style
(the nodes table's self row now follows it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make sticky headers a reusable Screen feature and lift paging and
section jumps onto the base Screen, so every scrollable surface shares
one implementation: the frame records each render's body height and
viewport, and the shared helpers page by a screenful, clamp to the
content, and walk between the boundaries a screen records in
_sticky_headers (a select list's group dividers, the chat transcript's
day dividers).
Remap keys to match a good editor:
- PageUp/PageDown scroll by a screenful rather than a fixed step.
- Ctrl+Home/End reach the top/bottom (the live tail in chat).
- Ctrl+PageUp/PageDown jump to the current/previous and next section.
- In chat, Home/End and Ctrl+Left/Right act on the compose line; word
motion (start of current word, else previous/next) is added to the
shared line editor, so every text prompt gains it too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Color-code the chat picker rows: a contact's dot takes that person's
chat hue (filled once talked-to, hollow before), previews light their
sender names and @mentions in-hue, and a relative age column sits
between the unread badge and the message text.
Pin a grouped list's section heading to the top row once it scrolls off
(sticky header in the frame), add PageUp/PageDown to SelectScreen, and
drop arrow wraparound in the conversation picker so it doesn't jump
across the section dividers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrapped message bodies now align their continuation lines under the body
of the first line instead of falling back to the timestamp gutter, via a
new render_hanging helper. And an @[Name] token primed by the reply flow
renders as a bare @Name in that sender's stable hue, so a mention reads
cleanly and is tied to the person it names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the static ⏳ hourglass on an in-flight direct message with the
reusable tui.Spinner, animated for the whole wait-for-ack window:
- _delivery_glyph is now a method returning the spinner's current frame when
acked is None (✅/❌ once resolved); the _SENDING constant is gone.
- _spin_while wraps the awaited send and retry, running a background ticker
that advances the shared spinner and repaints, then cancels-and-awaits it
(same no-stray-task discipline as the startup splash). Only one send or
retry is ever in flight (both gated by _sending), so one spinner suffices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the package directory, imports, brand strings, env-var prefixes,
console-script entry point, and the config/data dir (~/.config/meshterm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>