The Messages list's preview draws its author as an origin chip (no colon: the
point says it) and its mentions as destination chips, as the transcript does.
The packet viewer's from rows close on the point and its to row opens on the
notch, around the via chain that already opens and closes on both. The live
feed's addressed subject is an origin chip and a destination chip facing each
other across one cell of page (widgets.route_ends): a seam would claim the two
are neighbours. A pair too wide for the lane cracks instead of eliding. The
courier's recipient is a destination chip, in the hue of its key while it
waits and muted once finished.
Arrow mode reads exactly as before everywhere. A lone hop there carries no
PATH_INK stamp, so a highlighted list row whitens it like any other name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The viewer now opens on the stop the feed has. On the pin (^), the card
follows the stream and the feed stays pinned; on a selected packet, the
viewer holds that packet as before. Enter no longer drops the pin.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The conversation list now reads CONVERSATION, NEW, LAST, MESSAGE: LAST
over the last message's age, MESSAGE over the message, shortening to MSG
on a narrow terminal. The live feed's timestamp lane is TIME again, and
the channel list keeps NEW.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The chat picker's unread count is headed NEW and its last-message age
WHEN; the channel list's unread count is NEW; the live feed's timestamp
lane is WHEN. Short words over a badge and an age, which UNREAD outgrew
by a cell and had to borrow the gap for. The command line's columns keep
their names: they are a documented contract of their own.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both labels were right-aligned over their numbers, so each ended
mid-value with the unit trailing past it; every other label on the row
starts at its lane's left edge. RSSI also borrowed the SNR's five-cell
field for a reading never wider than four (-120), which set the two
readings three or four cells apart instead of the row's usual two.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dir messg, chan text, chan data and anon req replace direct message, channel text, channel data and anon request, so the longest class (telemetry, chan text, multipart) is 9 cells and the live feed's class lane gives 5 of its 14 back to the row. The dashboard's traffic tallies and the viewer's headline read the same names from the same table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The class name was dropped below the width the whole row needed, leaving only its icon. It is now drawn at every width, like the scope; a row wider than the screen is read by scrolling the highlighted one with ←→. The class icon beside the name is a family cue once the name is always there, so it follows Platform.menu_icons: on the PicoCalc, where it could only be a one-glyph stand-in, the lane is gone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It came and went with the terminal's width: hidden between 70 and 83 columns, where the class label won, and always on the PicoCalc. It is now drawn at every width, and the class label is what gives way on a narrow screen, the icon still saying what the packet is. Beside the subject, the lane is on screen even at 53 columns; there it is the readings after it that scroll off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
`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>
`E402` read as seventeen deliberate late imports and was in fact two accidents.
A module constant had settled into the middle of `packet_viewer`'s import block
(`_BODY_CACHE_MAX`, now below it), and ten test modules put their `_FIXTURE`
path or a small helper above the last import — the `tests.conftest` screen
reader, which now joins the block it belonged to.
`B008` is the opposite case: all six are Typer declaring a CLI parameter by
calling `Argument`/`Option` in the default, which is the framework's API and
cannot be written another way in this style. The objects it returns are inert
sentinels no command mutates — exactly the thing `extend-immutable-calls` is
for, so the rule now knows about them instead of being silenced per line.
`.claude` leaves the lint's reach: those are helper scripts for the skills, not
package code, and CI has no reason to gate on them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every node in the suite was named after hardware on JP's desk: YUL-Cartierville
and YUL-Poly are repeaters he runs, two more were the BLE
companions, another his own handle, and a long tail of YUL-Far / YUL-Me / bare
YUL besides. A stranger reading the tests learned his callsign, his city and his
gear before learning what the tests check.
They join the family the suite had already invented for exactly this — Alice,
Lounge, Yagi-Repeater, hub-1: Hilltop-Repeater, Lakeside, Waymarker, Homestead,
and Hub for the 3d-keyed node. Hub is not a new name; test_device_config already
called that contact Hub, so the suite was carrying two names for one fixture.
Replacements are the same length as what they replace, so every width budget,
truncation assertion and gallery line is unchanged. The two map tests that type
a literal query picked `yu`/`yul` to match the old name; they type `hi`/`hil`
now, same length, same single match.
Left alone on purpose: the About page and the donate link, where the handle and
the repeaters are the point, the hand-drawn splash art, and the XIAO README
reporting what JP's own radio advertises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enter on the live feed names *this* packet, so the dialog rightly opens holding
one — but there was then no way back to the stream from inside it. The reader
who walked up to the newest packet hit a clamp, and following the feed again
meant Esc, ↑ on the list, and re-opening whatever had landed since.
The pin is now the viewer's stop too, on the same key it is on the feed: ↑ off
the newest packet, and Home from anywhere, holds the top *position* rather than
a packet, so every arrival becomes the card being read. The title says
`following` where it would say `n/total` — what changes under the pin is the
card, not the number — and ↓ steps back off onto whichever packet is showing at
that moment, keeping the scroll the reader had in it, because that card is the
one they were already reading.
Both ends move together: the viewer tells the feed when it pins (`on_pin`), the
way it already tells it when it lands on a packet, so a dialog closed while
following leaves the list following, and one closed on a packet still lands on
that packet. Only a live list has the stop at all — over a snapshot there is
nothing to follow, so ↑ on the newest packet clamps as it always did.
The footer follows the moves rather than the count: `↓ hold packet` on the pin,
`↑ follow · ↓ older` one stop below it (two keys doing different things, so an
atom each), and the plain pair everywhere else. On the console the `Newest`
chip reaches the pin, exactly as the feed's `Top` chip does, which is also why
the jump chips light over a live list holding a single packet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The live feed's heading said three things and none of them earned a row:
the order is what the TIME lane reads down, Enter is already named by the
footer hint and the F-key lane, and the live-light reported the hub's
subscription rather than any traffic — green over a silent mesh, and the
one screen that never starts the hub itself. Dropping the line gives the
feed a content row back, which is a row in thirteen on the PicoCalc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.
back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.
Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.
With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.
Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The newest row now carries a pin — the stop above row 0, which holds the
topmost position rather than a packet. Every arrival takes the highlight
with it, so the cursor stays on the traffic instead of being pushed down
into history by it, and it draws ^ rather than ❯: the mark points past
the top of the list, at the packets still to come, because that is what
it is attached to.
One ↓ off the pin does not move a row. It lands on that same newest
packet, now normally selected under a plain ❯, and from there the
highlight belongs to that packet and rides down as newer ones arrive. So
the pin follows the stream and a selection follows a packet, and the
cursor lane says which you are doing. ↑ from the newest packet re-pins,
Home jumps back to the pin from anywhere, and the F-lane's Top chip is
how the PicoCalc reaches it with no hint line to name it.
The pin is a real cursor stop, not a flag beside one, so every move
stays a clamp on the single index _cursor already was — which is what
makes ↓ off it land on the newest packet rather than the second. Opening
a packet drops the pin: Enter names *this* packet, and arrivals during a
long read would otherwise walk the cursor off it and land the closing
viewer somewhere the reader never chose.
^ is plain ASCII, so it needs no console-font glyph. It is deliberately
not ↑, which this screen already spends on the window's "↑ n more" and
would read here as "there is more above" — the one thing the top of a
feed can never mean; nor ▲, which is the repeater in the node-type set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu asks "What would you like to do?" and then grouped its rows by
subject, which is not an answer to that. Mesh had grown to seven of the
nineteen rows — live watching, passive browsing, and one transmit action
in the same bucket — and Tools said nothing at all, since everything in
the menu is a tool.
So each section now names a doing, and holds what shares it:
Message Chat · Channels · Courier · Contacts
Watch Dashboard · Live feed · Watchtower · Time machine
Explore Map · Mesh walk · Trace target · Trace path · Trophy case
This node Device info · Device configuration · Device actions · Send advert
Other nodes Repeater admin · TX optimize
Four rows moved for a reason. Contacts is the address book the three
features above it pick a recipient from, not a mesh view. Time machine is
the past tense of the Dashboard, and was stranded in the grab-bag. Send
advert is your own node announcing itself — a device action, filed with
the mesh views only because it was about the mesh. And Repeater admin and
TX optimize are the same job — someone else's radio, over the mesh, with
credentials — which the old Device-management/Tools split hid.
The two walks join the topology views they walk over, with the boards
that score them underneath: see a node, walk to it, get scored. Nothing
is renamed (a menu row still names the screen it opens) and no section is
now larger than five rows or smaller than two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes to what a path line shows at its edges and at its ends.
The edges: the crack only reached the two surfaces that drew their own marks.
Everywhere else a path still met render_to_ansi(no_wrap=True) or a plain
truncate, both of which ellipsize — so the Message paths rows you were not
on trailed off in dots while the one you were on cracked. Those cuts now go
through cut_to, which reads the line and decides: a chip breaks off on the
crack, prose keeps its ellipsis. That covers the unselected arrival rows,
every select-list row (the trophy walks and probe candidates), the TX sweep
action rows, and the composer's suggestion rows. The generic surfaces are
safe to convert precisely because the fallback is the old behaviour.
The ends: a path's ends are the nodes it went between, so a line that opens
on its first *relay* is naming a node that only passed the message on. The
Message paths rows now run origin → relays → us — the same two endpoints the
graph one row up already drew between, so the row and the picture finally
cross-read. A hopless arrival stops collapsing to the word "direct":
Alice ▶ ★ is what direct delivery looks like, on the same rails as every
relayed row.
And our own end is the ★ everywhere, not our name — it was already so on the
trace lane, the trophy card and the composer, and now also on the node
detail Routes rows, the TX sweep route, the mesh walk trail and the live
feed's subject lane. It is the one node the reader never has to be told, and
in an 18-cell feed lane or a width-starved trail the cells it gives back are
hops that stay in view.
Left alone deliberately: the packet viewer's `via` row (the dialog has a
`from` row naming the origin, so `via` really is just the relay chain), the
verbatim wire spec, and widgets._link_text — a table of FROM → TO *links*,
still on the legacy path_text builder.
Co-Authored-By: Claude Opus 5 (1M context) <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>
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>
The dashes were the symptom; the fixed node column was the disease. Most
of what a real mesh carries is relayed flood traffic that names no origin
at all — on live traffic roughly five rows in six — so an 18-cell node
column sat empty down the whole screen while the route, the one thing
those frames do say, was crushed against the right edge. Worse, the cells
it held hostage were exactly the ones the class label needed, so at 72
columns the label was dropped and the row degraded to an icon beside a
column of dashes.
So the node column is gone. Time, class and reception stay fixed; after
them comes one lane holding whatever the row is actually about — a node
that named itself followed by its detail, or, when nobody did, the via
chain given the whole width. The lane still opens at a fixed column, so
names remain scannable down the screen; what changed is that a nameless
row spends those cells on its route instead of on a placeholder.
At 72 columns that buys back both things at once: the class label now
reads in words, and a flood's route gets more room than it has ever had.
A name is capped rather than padded, so a short one hands its spare cells
straight to the route behind it. Nothing stands in for a node that isn't
there — a message with no sender prefix now reads as its conversation
alone rather than the bare word "channel", which also stops a direct
message being mislabelled as one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The class lane and the node lane were saying the same thing. `packet`
names only the event family a frame arrived in, so a relayed flood —
which carries no origin, and so has no node to name — fell through to a
stand-in that showed its parsed payload class. The row read
`📦 packet channel text`: a generic bucket beside the actual class, laid
out as though they were two facts.
The viewer had already settled this — `class_chrome()` headlines its card
with `📻 CHANNEL TEXT`, not `PACKET`. That decision now lives in
`class_marks()`, which both surfaces read: the card shouts it, the feed's
lane takes it in plain case. So a raw frame files under 📻 channel text /
🎯 trace / 📩 direct message, and even on a terminal too narrow for the
label the icon alone finally says which class it is.
With the class named once, the node lane goes back to being about the
node — and an origin-less flood admits it with a dash instead of
borrowing the class a second time. ANON_REQ's gloss becomes `anon
request`, the spelling the standards already use, which also lets the
lane hold every class whole at 14 cells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three surfaces, one thread — a route should be readable where it is,
not only where it fits.
The feed's highlighted row now scrolls horizontally under ←→ (the
app-wide hscroll convention: the ▸ pointer lane pinned, the shift
abandoned the moment the selection moves on, the footer atom surfaced
only while the row overflows). A selected row carries its relay chain
whole so the scroll has something to reveal; every other row keeps
eliding its middle hops, so both ends of the route stay in view.
The packet viewer's `via` row leaves `path_text` for THE path line, and
folds instead of running off: a PathLine handed to Rich now renders
itself as the wrapped shape at whatever cell width the layout works out,
so a value whose lane is decided by the *other* rows' labels no longer
has to compute that width twice. Hops break at their own boundaries and
hang under the value block.
And the viewer's `from` row leads with the shared node-type mark — ★ us,
▲ repeater, ■ room, ◉ sensor, ● node, ○ when neither the packet nor the
contacts can type it — so a sender reads as the same kind of thing here
as it does on the map, in the contact list, and in the route graph three
rows down.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Nodes" menu was a contacts roster mislabeled — it lists the device's
contacts (get_contacts), which upstream meshcore calls "contact" too. Rename
throughout, no CLI alias:
- tools/nodes.py -> tools/contacts.py (ContactsTool, `meshterm contacts`)
- ui/nodes_screen.py -> ui/contacts_screen.py (ContactsScreen/open_contacts)
- ui/nodelist.py -> ui/contactlist.py (ContactListScreen/ContactRow)
- widgets: NodesSort->ContactsSort, nodes_table->contacts_table
Courier's recipient is a contact you address, so its prose and CLI arg
(`courier queue CONTACT ...`) follow; persisted node_key/node_name stay.
The reception/hardware layer keeps "node" — HeardNode, heard_nodes(),
observations.node, trace_hops.node, map/atlas participants, node types, admin
targets, and the Time Machine's reception picker (its "Nodes" heading stands).
CLAUDE.md's lexicon is rewritten to the refined definitions: node = any mesh
radio; contact = a node your device knows (discovered or added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The feed panel moves out of the dashboard into its own first-class Mesh
tool, right below it: a full-screen newest-first stream with the same
row lanes, highlight walking, and Enter-into-the-packet-viewer, wired
to the hub by its opener exactly as the dashboard is. The dashboard
becomes pure overview — activity, traffic, RF health — scrolling as
one body, and sheds the viewer-only plumbing (prefix bytes, channels,
self name, node types) it only carried for the feed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>