F4/F5 keeps up on the right; an F1/F2 pair puts up on F1, so a select
list reads Sect ↑ then Sect ↓ — the hand anchors on the lane's edge and
rocks inward, whichever side the pair lives on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The map and the mesh walk echo the query you are typing in their footer
hint. The PicoCalc draws no footer hint -- the F-key lane took that row
-- so on the device both screens silently filtered themselves while the
reader had no way to see what they had typed.
The app already had an answer: the select list and the path composer
each draw the query as a warn-styled /query line above the rows it
narrows. That is now render.query_line, one definition, and the map and
the walk draw it too -- but only where the footer isn't, so the desktop
is untouched and no screen ever shows the query twice.
The row it costs comes from what the query narrows: the walk's link
list, the map's canvas. The map rebuilds its viewport a row shorter
through the ordinary resize path, so beginning a find loses a strip of
ground along the bottom rather than pushing the last row out of frame.
base_body_size was still mirroring only compose_base's bordered branch,
so a screen sizing itself against it left one row of a borderless frame
permanently blank -- which is the row the echo now wants back.
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>
A public key is 64 hex digits; the Info tab's value lane is 63 cells at
the 72-column standard and 44 on the PicoCalc. So the key never fit, and
wrapping it spent a whole second line on the handful of digits that fell
off — a line the location preview then had to give up.
It holds one line now and scrolls with ←→, the same step the route
pathline one tab over uses. The marks that say "more this way" sit
beside the window rather than over its first cell, so every window the
lane can settle on starts on a byte boundary: the step is four whole
bytes, and a key read from a mid-byte cut spells it wrong. The shift
clamps against the width actually being drawn, to the first step that
brings the tail into the lane, so scrolling can never run off into empty
lane and a resize only pulls an out-of-range shift back in.
The lane is pinned chrome, not a cursor row — walking down to the action
rows leaves the reader's window where they put it; only leaving the tab
drops it. Both the keys and the footer atom naming them are gated on the
key actually overflowing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.
So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.
A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.
The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.
Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.
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>
Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.
The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.
Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.
Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Street names got rarer the further you zoomed in, which is backwards.
A line label anchored to the midpoint of the feature as the *tile* drew
it -- a fixed geographic point, not a point in your view -- and the
tighter the view, the less likely that point was still on screen. Of the
265 named streets in the Montréal fixture, 37 anchors landed on a 53x26
canvas at zoom 14 and 6 at zoom 16, of which 2 survived collision.
The line is now clipped to the canvas first and the label goes on the
longest piece that survives, so the anchor is always somewhere you are
actually looking. Clipping rather than testing the endpoints matters:
at high zoom a street often crosses the whole view with both of its ends
outside it, and that is exactly the street you most want named. The
next-longest pieces are kept as alternates and tried in turn when the
first spot is already taken, so a street whose middle sits under a node
marker still gets named further along. A feature with nothing on screen
queues nothing at all.
That made a second bug visible: OpenStreetMap splits a long street into
several named features, and once each one anchored to its own visible
stretch, René-Lévesque was drawn twice on one screen. A name is now drawn
once per frame -- on a terminal's worth of columns the second copy only
ever takes space from a street that has none.
Zoom 16 goes 2 -> 6 street names at 53x26 and 2 -> 8 at 72x24; zoom 15
goes 10 -> 13. Waterway labels take the same path and improve with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map's decoded-tile dict was never evicted from, so it grew for the
life of the screen with every tile the view had ever touched. Measured on
the Lyra, a decoded tile is ~0.9 MB resident (62 bytes a point) against a
device with ~100 MB in total and, in practice, under 20 MB free -- so a
long enough pan was an out-of-memory kill waiting to happen. It predates
the caching work, but making tiles cheap to load makes it easier to reach.
The budget is relative to the view rather than a fixed number, so it
scales itself: twice what the current viewport needs, floored at 8. That
lands around 8-12 tiles on the PicoCalc's 53x26 and grows on a desktop
terminal, without either one needing to know about the other.
Two details worth keeping. Tiles on screen are moved to the end of the
order before the trim runs, so eviction starts at the far end and can
never take something being drawn. And only entries holding geometry are
counted or dropped: a None is the memory of having *asked* -- the tile was
absent, or the source never answered -- which costs a dict slot rather
than a megabyte, and dropping it would buy nothing back while costing a
re-request on the next repaint.
Getting this wrong is now cheap, which is what makes a tight budget the
right call: an evicted tile comes back from the decoded cache in ~25-50 ms
having already been parsed once, so the cache is trading a little memory
for instant back-panning, not for correctness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layer narrowing cut what a tile costs to parse; this stops us paying
it twice. Decoding is a pure function of the bytes and the layer set, but
the cache only held the bytes, so every session re-parsed the same tiles
from scratch -- and parsing is the single most expensive thing this app
does.
A decoded/ sidecar now sits beside the raw tiles holding the parsed
layers, via marshal: stdlib, C-speed, and it understands the plain
tuples and scalars a decoded tile reduces to. Deliberately not pickle --
this reads a file off disk, and marshal cannot be talked into importing a
module or calling a constructor.
The sidecar is derived data throughout, and the design leans on that. It
is never what makes a tile "known", so losing it costs only the decode it
was saving. Every way it can fail to be what this build expects -- bumped
wire version, a Python whose marshal differs, a truncated file -- reads
as a cache miss rather than an error. It carries a stamp of the layer set
it was decoded under, so widening DRAWN_LAYERS re-decodes instead of
serving a narrower blob. And because it runs ~2.4x the size of the bytes
it came from, it is the half of the cache that gets a budget: 64 MB, LRU
by mtime, swept once per 8 MB written rather than per tile, since the
walk is slow on the SD card the PicoCalc runs from.
Measured on the device over the 50 real cached tiles: a first session
costs 126 s and a later one 13 s, 9x, with all 50 tiles restoring
identically. Disk goes 7.8 MB -> 7.8 + 19.0 MB. Serialising adds ~13% to
the first decode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.
Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.
Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.
Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.
_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fetch failure — offline, DNS, timeout, reset, 5xx — collapsed into
None and then into b"", which the loader wrote to disk as "this tile is
legitimately empty". One 12-second Wi-Fi stall on the PicoCalc therefore
poisoned that tile for good: every later pan and every later session read
the zero-byte marker and drew a black square. A body cut short mid-link
did the same by another door, cached and undecodable ever after. The PC's
network simply never flaked long enough to trip it.
So the cache now holds only tiles that decode to real geometry. A GET
returns whether the source actually answered: a 2xx body or a 404 is an
answer, everything else is silence and is never written down, so the next
pan asks again. A body short of its Content-Length is truncation, not a
tile. A tile the source really calls empty is remembered in memory for
the session alone — no repeat requests while the map is open, no
permanent hole on disk.
Cached entries that no longer decode are pruned on read, so a cache
already poisoned this way repairs itself as you pan over it. Writes go
through a temp file and os.replace, since the map and a mini-map share
one source and can fetch the same tile from their own threads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.
The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.
Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP's spec for the heard-age scale, replacing the four-step ladder: white
under 5 minutes, then yellow at 5m, light red at 1h, brown at 1d, red at
1w, light grey at 1mo, and one cold grey for over a year — which is also
"never heard", since past a year the distinction stops earning a colour.
The `heat.*` styles are renamed for what they describe: a node heard
eight minutes ago is `heat.minutes`, one heard eight days ago is
`heat.days`. The old hot/warm/cool/cold read as temperature but had to
carry an age, and at seven rungs that stopped being sayable. The steps
themselves move into a table, so the ladder is one thing to read.
Three of the seven land in the VT's dim bank, so they say `not bold` — a
selected row would otherwise jump them a rung (brown to yellow, red to
light red), which is the one way a heat colour can lie about an age.
The splash wordmark's vertical ramp becomes white / yellow / light red /
red / light black, each picked to quantize onto exactly one palette slot
(15, 11, 9, 1, 8) while staying a warm fade in truecolour. Its rows are
art rather than renderables, so they had never passed through the fold
and the console was downsampling them on its own — they do now, which is
what makes the slot choice hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reports on the PicoCalc's message paths, one root cause each.
The graph drew an unnamed hop's hash — and the sender's name — as white,
the "you" colour. MapCanvas emits its own bold for markers and labels,
and its colours arrive already quantized to a palette slot; the kernel VT
then draws bold as *brightness*, so anything that landed in the dim bank
was promoted to its bright partner. Light grey became white. A purple
repeater would have become pink the same way. The canvas now drops
emphasis where bold is brightness — the colour is the load-bearing part.
The same trap reaches the theme through inheritance: Rich merges a row's
base style into every span, so a dim-slot span left silent about bold
changes colour inside a selected row. Every dim-slot style now says which
it means — `not bold` to keep its colour, `bold` where the promotion is
the point (only title.muted) — and a test forbids staying silent.
The pathline drew the same hop dark grey, because `muted` collapses onto
the console's dark grey while the desktop's is light. That conflated two
things: `muted` is chrome and may recede a step, but a node we can't
identify is content you can still act on. It gets its own name,
`node.unknown` — light grey, identical to today's desktop muted, so
nothing moves there — and it is what `name_style` returns keyless, what
an unlit `highlighted_hash` wears, and what the `○` ring is.
Also fixes a crash from the previous commit: the graph's label colour
resolved a marker through `parse_hex`, which the type marks' theme names
are not. A repeater relay we couldn't name would have raised.
The gallery's node-detail header spelled a marker hex by hand; it goes
through the app's own styles now, so a palette bug can't hide behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A platform's ceiling may change a rule's resolution, never the rule. P3
had picocalc colour node names by *type* instead of by key, so a name's
colour there meant something different than it does on the desktop — the
node's kind, not its identity. Both platforms now run one rule: the key's
first byte picks the hue. The console just spends fewer of them.
node_style gains the binding the heat scale already had. Regular keeps
the full 256-hue wheel; picocalc snaps the same hue to its sixth of it —
the six chromatic bright slots, ~43 first bytes each. It returns the
slot's own hex rather than a color(N), so every downsample on the way out
(Rich's, and the fold's quantizer) lands on that exact slot instead of
guessing a neighbour, and the callers that read an RGB back out of a
style string keep working.
The type marks needed the opposite treatment: their hues are fixed, not
derived, and a naive nearest-RGB match drained the repeater's violet to
grey — a repeater read as an unknown node. They move to the theme's
type.* entries so each platform picks its slot deliberately, and a
braille raster resolves the same entry through theme.mark_rgb, so the
canvas marker and its Rich-drawn twin can't drift.
With names off the type registry, nothing reads it: Platform.name_colour
and core/nodetypes.py go, along with the three reception-layer feeders.
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>
The header's unread badge summed every conversation the recorder filed a
message under, but the picker only builds rows from what the *device*
reports — its channel slots and the messageable contacts in its table.
Anything outside that set was counted and unreachable: no row to open,
so no way to clear it. The badge sat there for the rest of the session
saying there were new messages while the Chat list showed none.
The badge is a pointer, not a tally — its job is to send you to a thread
you can open — so it now counts only what the picker can list. The rest
is still recorded to history, just silently. In practice what that drops
is machine traffic rather than correspondence:
* a direct message from a node that isn't a DM recipient — a repeater,
room server, or sensor. Every remote-CLI reply from a repeater you
administer arrives this way, so an admin session left a stuck badge;
* a direct message from a sender the contact table doesn't hold;
* a channel message the device has no configured slot for, including
one whose slot couldn't be identified at all.
Muting joins those under one rule instead of standing alone, and the
device is consulted through the session cache, so a message costs no
extra round-trip. Anything the cache can't answer notifies: a cold or
broken cache is a reason to over-notify, never to swallow a message.
That leaves one real divergence to fix rather than paper over. The
recorder resolves a channel slot fresh per message while the screens
read a session-cached list that only an in-app channel edit drops — so
a channel re-keyed or cleared on another client (the phone app) had the
picker listing the old one all session, and now would have silenced the
new one too. Resolving a slot to a different identity drops that cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP's clarification: a lane slot is for functionality that's
otherwise hard to reach, not a shortcut to a key already close at
hand. Enter and Esc sit right on the keyboard, so they never
occupy a slot; PgUp/PgDn have no physical key at all, so they take
over the prime F4/F5 pair from OK/Back. Top/End (Fn-layered) keep
F1/F2. F3 stays the lone slot a screen promotes its own fiddly
Ctrl-chord onto (chat's Paths/Retry, contactlist's Sort already
follow this and needed no change). Default Shift bank is now
empty across the board — pure overflow, per the original spec.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Rebuild the PicoCalc F-key lane per JP's spec: five fixed 9-cell
chips (F1 DESCRI, "10" standing in for the overflow F10 label),
gray fill on the plain F1-F5 bank and green while Shift's bank is
showing, white text on both. DEFAULT_LANE moves to an
adjacent-plain-key priority model: commit/leave take the easiest-
to-reach F4/F5 with no Shift needed, jump-to-top/end take F1/F2
with paging as their Shift companions, and F3 stays the one lone
slot a screen's own lane reaches for first. FPair's Shift half is
now optional, so a lone slot renders blank in the shifted bank
instead of forcing an unrelated opposite action onto it.
Also bridges the on-device quirk where the MCU's F6-F10 translation
can appear to release Shift an instant before the translated code
lands, flipping the lane back mid-chord: modifier_watch now latches
shift_down() true for a short grace window after any F6-F10 press
resolves, on top of the raw watcher state.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The console keeps its stock kernel palette - black background, classic
PC hues - instead of the custom setvtrgb remap. MESH_THEME_16 is
re-tuned against the stock slots, which hands back the map's own
language for free: clients wear bright pink (13), repeaters purple (5),
rooms cyan (6), sensors brown (3). The cost is one dark grey - muted,
faint and track all collapse onto slot 8. Heat quantizes on plain human
units (15 min / 1 h / 1 day). The custom design is archived, not
deleted: theme._VT_SLOTS_CUSTOM + MESHTERM_CUSTOM_PALETTE=1 on the font
script reinstalls it, with the parity test keeping the two in sync.
Undoing the remap on the device exposed a trap: setvtrgb overwrites the
kernel's DEFAULT colormap, so the OSC palette-reset "resets" straight
back to the custom values - the script's default mode now programs the
stock palette explicitly (verified live; specimen screenshot in
.claude/plans/p3-specimen-stock.png).
The header also follows JP's spec: the handheld brands the app -
"MeshTerm vX" - instead of naming a soldered port that never changes,
and the activity pulse takes whatever room remains.
Also root-causes the intermittent suite flake: Rich memoizes a parsed
Style's ANSI codes on first render (Style.parse is globally cached), so
a raw hex style first rendered by a truecolor console replays 38;2
verbatim on a standard console later in the process. The specimen's
nodes row now wears the type.* theme styles - which is also just
correct: the marks now match the names row. Six consecutive clean
full-suite runs.
Deferred at JP's request: the glyph substitution map (he will redo it),
the F-key lane design (awaits his explicit spec), the CLI fold gap.
Co-Authored-By: Claude Fable 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>
- BatteryService grows the host provider (Platform.battery="host"): the
PicoCalc's own pack through the kernel power_supply driver - a true
percent and charging flag from sysfs, no voltage-curve estimation, no
radio connection needed. Unreadable supply = absent, gauge hidden.
- The console-font script now builds TWO 512-glyph PSF2 fonts: the 6x12
Terminus-based default (53x26) and a 6x8 A/B candidate (53x40) based
on the kernel's own font_6x8 (CP437 coverage - documented as having
no Cyrillic), with the braille block at 2px dot pitch and all 18
marks redrawn for the shorter cell. Built and verified live on the
Lyra: stty reports 53x40 under meshterm8, specimen legible
(.claude/plans/p5-specimen-6x8.png); flip with setfont, persist the
winner in vconsole.conf. JP judges the A/B by eye.
- calculinux-setup.sh puts the deploy user in input/dialout/video
(input is the modifier watch's /dev/input access).
- theme's platform binding now drops the fold table and cache on every
switch: its emoji pads bake in cell_len at build time, which the
regular platform's emoji-width calibration can re-measure - a stale
table survived platform swaps in randomized test runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- compose_base grows a borderless mode (Platform.frame_border=False): a
one-row title bar (rule + title + clip arrows) replaces the Panel's
border and padding, so the body wins back four columns and one row on
the 53-column console. Regular keeps its Panel untouched.
- The header composes only Platform.header_atoms: picocalc drops the
version mark and the activity pulse, keeping device, badges, battery.
- The F-key lane (ui/tui/fkeys): five FPair slots per screen, F1-F5
primary and F6-F10 their opposites - which is physical Shift+F1..F5,
translated to plain keycodes by the keyboard MCU. The session binds
all ten and resolves a press against the top screen's lane into a
normal screen action, so screens gain F-keys with no key handling of
their own. Default lane duplicates the Fn-layered nav cluster plus
OK/Back. Alt+Fn stays unbound (kernel VT switch).
- services/modifier_watch: a hand-rolled evdev reader (no keyboard-lib
dependency) tracking physical Shift so the lane flips to the F6-F10
labels mid-chord; engages only when the platform asks and the device
exists, degrades to the static lane on any failure.
- The popup-qualifying width gate (surface._dialog_max_cells) shrinks
to 43 on picocalc, matching the 47-outer dialog cap; the dialog frame
itself already clamps to cols-6, so prompt.py needed nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first P3 (a84abf3) ran on the wrong model and its palette never
reached the TUI: MESH_THEME_16 was written in 256-colour cube indices
instead of the 16 VT slots, and the platform theme pick was wired into
the scripted-CLI console while the real rasterizer stayed hardcoded to
truecolor. Replace it end to end:
- theme._VT_SLOTS: the canonical slot->RGB design (conventional hue
families kept; slots 5/6 become the track/faint slates), MESH_THEME_16
rewritten against color(0..15) with the VT's bold-as-brightness and
dim-bank-background rules pinned by tests. /etc/vtrgb + a boot oneshot
install it (console-font script), OSC fallback when kbd is absent.
- platforms.on_platform: set_platform now re-runs registered bindings,
so theme, rasterizer and widgets swap implementations once per switch
and hot paths read module globals - no per-call get_platform().
- The render-boundary fold (theme.fold_text): one str.translate pass
(NFKD accent folds - the Terminus base has no accented Latin - plus
symbol stand-ins and the width-padded emoji map), an embedded-SGR
quantizer for canvas truecolor, and a ?-at-own-width catch-all that
makes the no-wide-glyph short-circuit true by construction. Applied at
the only two places every visible byte passes: render_to_ansi and
MapCanvas.to_ansi_lines.
- ui/fontset.py freezes the installed font's inventory (dumped live);
the gallery now asserts rendered picocalc ANSI carries no truecolor/
256-colour SGR and no character outside it - the parity gate the
first attempt faked by asserting a dataclass field.
- Font build: nine new 6x12 marks (ellipsis, warn, backspace, shift,
gear, refresh, clock, crosshair, padlock) drawn into donor slots, with
donor misses now fatal and a keeper canary (donating pi once erased
Cyrillic pe - shared slot). Table re-dumped on-device: identical to
fontset.py, 519 = 519.
- Type-based name colouring: registry moved to core/nodetypes (services
no longer import ui), fed from monitor observations, heard_nodes and
merge_contacts. Heat gradient quantizes to heat.* steps on picocalc.
- ui/marks.py: the P2-deferred constant relocation - widgets no longer
drags map_render/mapcanvas/pathgraph for tuples and aliases.
- meshterm specimen: the visual language on one card through the real
funnels - the on-device acceptance screen (JP's request).
- tools/chat: delivered/no-ack state uses the standard check/query
marks, not the banned emoji pair.
Verified on the Lyra: font+palette installed, specimen framebuffer
captures in .claude/plans/. 1259 tests green, 13 xfailed (P6 list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the PicoCalc visual system:
- MESH_THEME_16: 16-slot palette with deliberate RGB values for truecolor=False
- make_console(): picks theme based on platform.truecolor
- glyph(): central emoji→single-BMP-char mapping for the 512-glyph font
- Type-based name_style() and register_node_type() for PicoCalc colour (key→type)
- Text folding: fold_text() with NFKD accent stripping and emoji→placeholder
- Platform-aware icon rendering in packet_viewer, dashboard_screen, courier_screen
- Reception layer registers node types for colour lookup
- Gallery tests check truecolor constraint on PICOCALC
All 1239 tests pass; 13 xfail (P6 width worklist).
Co-Authored-By: Claude Haiku 4.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>
One frozen Platform spec (REGULAR/PICOCALC), resolved once at boot
(--platform flag > MESHTERM_PLATFORM env > /proc/device-tree/model
auto-detect > REGULAR) and read via get_platform()/set_platform() -
never a stale `from platforms import PLATFORM`, which the module
docstring calls out. `meshterm platform` reports the resolution and
everything it looked at.
Wires the two correctness bindings that matter before any flavour
work lands: width_reclaim off on PicoCalc's exact-width console
(session._reclaim_last_column, env override still wins), and the
emoji-width calibration probe skipped outright when a platform never
draws emoji.
tests/test_gallery.py renders all 16 screens the interactive menu
opens against REGULAR (72x24) and PICOCALC (53x26 live floor, 53x40
lux case), asserting no line overflows its platform - 35 passing
cases, 13 xfailed as a real P6 worklist (contacts, map, walk,
livefeed, remote_cli, path_composer). Also caught and left alone: the
map screen's footer hint already overflowed the *existing* 72-col
standard before this seam existed, unrelated to picocalc - xfailed
rather than fixed as a drive-by. tests/test_platforms.py unit-tests
the module directly; conftest.py gained an autouse fixture resetting
the platform between tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A soldered SoC UART like the Luckfox Lyra's /dev/ttyS1 is never enumerated by
pyserial's comports(), so a serial [profiles.*] entry pointing at one could only
ever be reached via `meshterm --port` on the command line -- it never surfaced in
the startup picker, which listed only scanned devices (and TCP profiles). Fold
configured serial profiles into the picker too, mirroring the existing TCP-profile
folding, de-duplicated by port so a port pyserial *does* enumerate keeps its richer
scanned row.
Also fix the liveness poll for the same ports: serial_port_present() now treats an
existing /dev character device as present (platform UARTs aren't in comports() but
their node persists exactly as long as the port does; a USB node vanishes on unplug,
so this never masks a real disconnect). Without it the watcher would falsely report
a soldered radio unplugged mid-session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A contact's last_advert is stamped by the advertising node's own clock;
a mis-set one lands in the future, where the age clamp read it as a
permanent "now" and the heard sort pinned it to the top — for as long
as the bogus stamp stayed ahead of the wall clock, since the firmware
only ever advances the field (its replay filter).
advert_time (models.py) is now THE converter for every last_advert
epoch — the live contact table and the cross-session store both pass
through it — and refuses a stamp more than 5 minutes ahead as unknown.
The devstate contacts fetch then fills the gap from our own recorded
receptions (stamped by our clock), so a refused contact we really do
hear shows an honest age, and one we never heard reads never.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Choice.title accepts a width-taking callable (arity read once from the
signature; the zero-argument live form is untouched), resolved at the
row's content area each repaint while filtering, h-scroll measurement,
and dialog_width keep reading the natural unbounded form. The two
route-bearing lists use it: trophy rows and Explore-paths candidates
middle-elide through PathLine.ellipsized so both endpoints survive a
narrow terminal — a right truncation amputated the return leg — while
the highlighted row of an hscroll list keeps its natural length so ←→
still slide the full line (housekeeping H-007).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eleven files defined the same _plain (plus test_chat's _strip_ansi and
test_timemachine's renderable flavour), and two of the copies had
already diverged into join-without-strip. tests/conftest.plain is now
the single strip-and-join reader — a list of lines or a bare string —
and every file imports it; the local variants keep their names so call
sites stay put (housekeeping H-003).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A full-frame rewrite belongs to incoming data or to something the user did — a
scroll, a keystroke, a resize. It has no business being on a metronome. The 1 Hz
refresh exists for the header alone (the activity pulse, the battery sweep, the
low-battery blink), and on an emoji-bearing screen it was dropping pt's cached
frame every tick, which erases the terminal and redraws all of it to move one
braille cell in the corner.
The requirement a mis-measured glyph imposes is narrower than the answer was.
prompt_toolkit's cursor is *relative*, and the drift is confined to a row: a
width-2 glyph the terminal draws in one cell leaves everything right of it on that
row a column left of pt's model, so a later paint that jumps into the row writes
one past the content it meant to overwrite. But stepping down a row is a carriage
return — pt writes "\r\n" and then counts forward from a true column 0 — so the
misalignment can never reach the row below. Rewriting a row whole, from column 0,
is the entire fix; erasing the screen was never part of it.
So the frame's changed rows are rewritten, and nothing else is: the background
composes one line per terminal row, so its own diff *is* the row list. A ticking
header repaints row 0 and leaves the board under it alone. An idle frame changes
no rows at all.
Two cases still fall back to the whole frame, both of them user-driven and
occasional rather than timed: a float, whose centred box the layout places (so its
lines map to no rows we know) — a dialog opening, changing, or closing — and a
frame whose height moved, where no row mapping holds at all.
Verified against pt's own renderer, and on a composed Trophy case: 24 lines for 24
rows, and a pulse tick reports exactly row 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Trophy case flickered about once a second, and so did every other
emoji-bearing screen — the more the frame held, the more obvious it was.
The app repaints on a 1 Hz timer to keep the header's pulse ticking. Every one of
those repaints re-composed the same frame, and _emit read the same verdict off it:
this frame carries a glyph the terminal may draw narrower than prompt_toolkit
reserves for it, so drop pt's cached frame and let the next paint be a full
erase_down + redraw. That upgrade is right — it is what keeps a row with an emoji
in it from stranding stale cells — but it was being applied to a frame that had
not changed at all. Erasing the whole terminal and rewriting it identically, once
a second, is precisely a flicker.
So a layer that composes byte-identically to its last frame is exempt. Nothing
needs rewriting when nothing changed: pt writes no cells, so its cursor cannot
have drifted, and the previous paint already left the terminal aligned.
What decides is the frame, not the layer that changed: a plain dialog moving over
a base row that carries an emoji is rewritten from a model of that row the
terminal disagrees with, so any change upgrades the paint while a wide glyph is
drawn anywhere. And a layer *leaving* is a change too — a float is hidden by
dropping its window, so a closing dialog simply stops rendering, and the cells it
hands back to the base would otherwise be rewritten piecemeal. The background
composes first, so the paint reconciles the drawn layers there.
An animating header (a charging battery, a low-battery blink, a busy pulse) does
change the frame every second, and still repaints in full. If that reads as
flicker too, the next step is to make the full repaint rewrite in place rather
than erase first — pt only erases when the cached frame is missing entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🛣 (U+1F6E3) heads the Trophy case's Longest distance board, and it pushed that
heading's row a column past the border. Both width authorities call it one cell —
it sits outside Emoji_Presentation and its East-Asian width is Neutral — while the
font draws it as a two-cell colour glyph.
That is a new shape for the wide set, which until now held VS16 bases the narrow
verdict got wrong (🛩, whose selector-stripped base would otherwise collapse to
one). Nothing narrowed the motorway; it was never measured right in the first
place, so it overran its row on any screen that pads to a width. The fix is the
same either way — force the base to two in Rich and in prompt_toolkit — so the set
is now `_DEFAULT_WIDE_BASE`, seeded with both confirmed glyphs and still overridden
outright by MESHTERM_WIDE_EMOJI.
Curated, as always, because which glyph a terminal draws wide is the font's
business and a cursor probe reads the PTY, not the renderer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A board of records is a list of routes, and the routes were the part with no room
left. Every row spent thirty-odd cells before the first hop — `#1 Jul 12 14:30`,
a score padded to a fixed twelve — and then opened the walk by naming our own node
in full, `Homestead (cc)`, twice, on every row, on a screen where every record
starts and ends on us by construction.
So the rows draw on THE path widget with both ends bare: the app-wide ★, which the
graph on the record card already plants on us and its legend already reads back as
"you". The stars keep the `you` white — path_line's fade means "not yours to
compose", and nothing on a board of walks already made is being composed, which is
what `dim_self` now says. The lanes in front are held to what they actually say:
the day the record was set (the card carries the time), and a score lane fitted to
the widest score on *that* board rather than to the worst case everywhere.
The record card was carrying the same kind of slack. Its route ran under a `route`
label lane, hanging in the twelve cells left over — under a graph captioned
"you → … → you", where the line can only be the route. It now runs unlabelled
across the whole card, wrapping at hop boundaries, on the same widget as the rows,
with the same ★ at both ends.
And the card was three rows taller than its content. The route graph took the
shared widget's five-row floor, which reserves room for a fan of alternatives; one
walk is one lane and never fans, so it draws in three — and both stacked blocks now
hand back the rows nothing landed on. The area drawing keeps two rows in hand for a
tall shape, and a walk that comes out flat never reaches them: that, plus the
graph's unused label row, is where the three blank lines under `round trip` came
from. Trimming happens after the drawing, so nothing placed is ever lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.
The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.
The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).
A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.
The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The right-Ctrl rescue existed because some layouts claim that key and deliver
the chord as a bare letter, but it only covered what the *screens* answer: the
letter table listed ^R and ^P, while ^C and ^V were bound straight to their own
handlers above the dispatcher. So on the affected layouts right Ctrl-V typed a
`v` into the compose line and right Ctrl-C did nothing — the two chords a user
is most likely to reach for with the right hand.
Both now funnel through _dispatch like every other key, and the letter table is
the single source of truth for both halves of a chord's life: the Keys.Control*
bindings are generated from it, and the rescue reads it. Adding a chord is one
line, and a chord can no longer be bound without its rescue — or rescued into an
action nothing binds. `quit` and `paste_clipboard` are answered in the
dispatcher and never forwarded, so no screen sees them.
m/i/h stay out of the table on purpose: prompt_toolkit spells Enter, Tab and
Backspace as ControlM/ControlI/ControlH, so claiming them would rebind those.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 1-byte path hash off an RX-logged relay chain often can't be widened: `3d`
opens both YUL-Cartierville (3d63…) and T MeshPocket (3d1f…), so coalesce_prefixes
refused to guess and left it standing as its own node. On the live graph that was
26 stubs — and they are not harmless labels. `3d` sat in the first ring beside
YUL-Cartierville at the same +13.5 dB with 885 of its own samples, claiming *more*
onward links than the real node. Worse, a stub pools the links of every node it
could be, so it acts as a phantom hub joining neighbourhoods that were never
adjacent, and every hop distance measured through it comes back short.
Prefix alone is a coin toss, but the graph is not out of evidence: the stub carries
the links of whichever node it really was, so the company it keeps names its owner.
Neighbours that can tell the candidates apart — ones linking to exactly one of them
— vote; neighbours both candidates share abstain, because two repeaters in the same
city have plenty of those and they would drown the signal in local density.
The bar is high, since a wrong merge does the very thing this routine exists to
prevent. A winner needs two discriminating votes and a 3× lead, so a genuinely
contested stub — one whose neighbours point both ways, i.e. one that really is two
nodes' traffic pooled under one hash — stays exactly where it was: under-named but
honest. The two passes interleave, certain-first, because every merge can resolve
the next ambiguity.
On the live graph: 244 nodes / 852 links → 229 / 731, ambiguous stubs 26 → 11, and
29 nodes land on a truer ring. Adds ~2% to a build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Atlas" named a book of maps — a thing you leaf through — and this screen has
never been that. It holds one node in focus, fans its neighbourhood, and moves
when you press Enter. The trail across the top is a walk, the list heading says
Enter walks, the tests call the helper walk_to. Only the name disagreed.
So the name goes: the tool is `walk` / "Mesh walk", the screen is WalkScreen in
ui/walk_screen.py behind open_walk, and every prose mention follows — the
README feature table, the node-vs-contact boundary in CLAUDE.md, the
windowed-list pattern note that cites this screen as its origin, pathline's
separator example, topology's whole-graph view and its prefix-coalescing
rationale, and the purge-test-data skill that warns about fake nodes showing up
here.
No behaviour moves. The tool keeps its 🌐 icon, its Mesh category and its
order beside Map — the logical shape next to the geographic one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The breadcrumb across the top was hand-rolling its own hop sequence — its own
separator loop, its own width arithmetic, its own head-dropping. A walk *is* a
path, so it now renders through PathLine like every other hop sequence in the
app: powerline chips wherever the terminal draws them, the trail's own › arrows
where it can't, each hop in its node's key-derived hue.
It stays one line — no wrap, no scroll. When the walk outgrows the width the fit
eats into its head rather than a route's usual tail, and what survives snaps
flush to the right edge, so the focus and the steps that just led to it are the
ones always in view.
PathLine.ellipsized grew the parameter that makes that possible: `elide` names
the side the ⋯ eats into. ELIDE_TAIL (the default, unchanged behaviour) anchors
on the head and rescues the destination off the far end, so both endpoints of a
route survive; ELIDE_HEAD anchors on the tail and spares the origin nothing.
Along the way the trail stops losing its colour: the old focus emphasis built
"bold you" / "bold muted" style strings, which Rich can't parse and silently
renders as no style at all — so our own node at the root of the walk was drawing
plain instead of white. Identity styling now comes from the hops themselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list's question is who is on the mesh right now, and a freshest-first
order answers it on sight — an A→Z roll call makes you read every row. Open
the Contacts screen (and the CLI's default) on heard, ascending age; the
Ctrl+arrows still steer from there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The feed was the one screen in the app with no Back row. Esc worked, but
the exit group is not a shortcut — it is the app's standard closing
furniture, and a screen that lists rows and offers none reads as a screen
you have to already know how to leave.
So the cursor gains a stop past the oldest packet: a blank separator and
the bare word Back, exactly the shape back_rows() gives a select list,
drawn by hand because the feed is a rendered screen rather than a list of
choices. It is pinned chrome, struck from the viewport before the feed
windows into what is left — so it sits at the foot of the screen however
far the packets have scrolled, and the body still never scrolls as a
whole. Enter on it resolves what Esc resolves.
That makes the cursor one index over len(feed) + 1 stops, which collapses
three special cases into a clamp: the "nothing highlighted" state the
page keys used to slide the bare window in is now simply "the cursor is
on Back", and an empty feed opens with the only stop it has already
highlighted. End lands on the list's last row, as it does on every other
list — the oldest packet is one ↑ up from there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The NODE lane suited the two classes that name a node and left every
other one reading a dash. On this mesh that is half the traffic: the
direct messages, requests, responses, returned paths, acks and traces
that relay past us naming no origin. So the lane asks each class its own
question instead, and the header says SUBJECT — the word that stays true
down the whole column.
An advert or a telemetry frame is still about its node. A channel text is
about its channel, named by the key whose MAC confirms the frame — never
by the fingerprint alone, which 256 buckets make no proof at all; a
channel we hold no key for reads as that fingerprint and says so. A
direct message, request, response, returned path or anonymous request is
about the two nodes it travels between, drawn sender → recipient on the
hop-sequence widget, so endpoints read exactly as route hops do: resolved
names, key-derived hues, our own node white the moment a frame is
addressed to us. Too wide, and the sender drops to its hash — the frame
is addressed, so the addressee is the half worth the cells. An ack is
about the message it answers, a trace about its tag. And an unsigned
channel post is filed under its channel rather than the bare word
"channel", which named nothing.
Half of that was unreadable before because nobody had opened the frame.
The companion hands every overheard packet to the library, which decodes
the body for exactly two classes — advert and channel text — and leaves
the rest as bytes. Those bytes are not opaque: MeshCore addresses a frame
in the first few of them, and core/frames.py now reads them, class by
class, against the layouts the protocol's own docs/payloads.md publishes.
The trace tag is the little-endian uint32 the firmware writes, so it
reads as the same number a trace reply reports; an ack's checksum keeps
wire order, the form the companion reports its own delivery acks in. Two
checks guard every decode, because a wrong slice yields not an error but
a plausible-looking hash: the frame's payload version must be v1 (the
header reserves two bits for a v2 that widens both hash and MAC), and the
body must be long enough for the layout its class promises. Either
failing yields nothing at all.
The feed opens seeded from history, so any of this that lived only in a
live event's raw payload would leave the opening screen blank — the same
problem the channel-text crypto trio had. Same answer: three columns
(dest/src/tag), written on capture and restored to the very keys the
event used, so a replayed frame and a fresh one read identically all the
way up. Older rows carry NULL until re-heard.
The viewer gained the matching rows: to, from, tag, acks, and a channel
for a datagram — whose body is not text, but whose MAC still names it.
Verified against the MeshCore protocol documentation and firmware source,
then corroborated on the air: every frame captured off the radio has a
body length that decomposes exactly into its documented layout, an
anonymous request's 51 bytes (1 + 32 + 2 + one AES block) included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bits of chrome the screen had been going without.
A column header, pinned above the rows. It needs none of the base
screen's sticky-header machinery: the feed windows inside the space the
chrome leaves, so the body never scrolls and the header cannot travel off
the top of it. Nothing here sorts — a feed is a stream and its one order
is the status line's to state — so no column carries the contact list's
sort triangle or its lit active-column style. These are signposts, not
controls.
Getting a label to sit over its values meant the row and the header
having to agree about the lanes, so both now measure them from one set of
constants. That turned up a real defect: the RSSI lane rendered 11 cells
when a packet carried a reading and 10 when it didn't, so rows were
ragged by one and the class-label threshold was a cell short — at exactly
its own width the row lost its last character. Both readings now
right-align their number in a shared field, their labels right-align over
the same field, and an empty lane pads to what it would have filled.
And the highlight is the app's own at last: ❯ over a brand row, as every
select list draws it, in place of the ▸/accent this one screen had
invented. The lanes keep their colours over the tint — those are the
content; the pointer is what says "this row".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the via chain from the feed row. Whatever the fixed lanes left it
was a handful of cells, so a route never actually arrived — it arrived
elided to a stub that named one hop and hinted at the rest, which is the
least useful form a route can take. A route is a shape rather than a
lane, and the packet viewer already draws it properly one keypress away:
a wrapped path line over the route graph, with the room to say the whole
thing. So the feed says what arrived and how well it was heard; Enter
says how it got here.
What that buys is the whole row fitting a 72-column screen — 69 cells,
class label included. The label was being dropped on exactly the screens
where it mattered most, to make room for a route that never fitted
either; now nothing competes for those cells and the class reads in words
down to the width where it genuinely can't.
←→ stays: it just has less to do, firing only on a terminal too narrow
for the lanes themselves, and its footer atom appears only there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>