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>
_lane padded its label inline, the spec row rebuilt the same column as a
hand-spaced string with a bare indent=12. _LABEL_W plus a shared _label
builder is now the lane's single source; menus.lane_row was considered
and declined — its three-lane shape (label pad, value pad, muted help)
is not the card's muted-label/value pair (housekeeping H-010).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five words had grown for one key — select, pick, open, choose, commit —
so CLAUDE.md now fixes the Enter verb by action (open pushes a screen,
select picks a value or action row, set commits a value picker) and the
outliers come along: the purge and discipline pickers say select, the
record card's action rows say select, "Esc keep current" and the three
"type filter"s were typos against existing rules (housekeeping H-008).
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>
The channel share and the contact card built the identical renderable —
intro, code, URL — by hand, one copy each. share_popup in ui/qr.py is
now the single spelling; callers bring their own intro and URL builder
(housekeeping H-001).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tools/chat._ago carried its own copy of the now/5m/3h/2d/4w ladder plus
an inline _age_seconds; _format_age is THE column age widget, so change
the ladder once and every surface follows. The picker keeps its one real
difference — a naive timestamp reads as a blank lane, not "never" —
and now says so (housekeeping H-002).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chat picker and the TX-optimize target picker appended their own
Separator + Back rows; back_rows is the enforcement point that keeps the
exit language from drifting (housekeeping H-004).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chat picker's no-contacts line and the CLI's no-serial-devices line
were the last two wearing parentheses; the standard shape is bare prose
with an em-dash explanation (housekeeping H-005).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listen summary said "stopped — saw n messages"; heard is the app's
word for having received something (housekeeping H-006).
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>
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>
Merging a repeated hop into one marker is right across paths and wrong
within one. A via chain that names the same hop twice closes a cycle, and
the left-to-right flow has nowhere to seat it: the balanced-x relaxation
iterates a cyclic edge set, never reaches its fixed point, and returns
wherever the sweep budget stopped. The loop's relays then land in one
column — markers piled a cell apart, labels colliding or dropped, the back
edge falling as a bare vertical — while the hops outside it are crushed
against the ends. Worse, the residue depends on set iteration order, so
the pile's column swung with the hash seed: the very frame-to-frame jump
the first-appearance node order exists to prevent.
render_path_graph grows allow_duplicate_nodes, which hands each revisit
its own marker (occurrence-qualified ids the caller's callbacks never see,
so both markers draw as the same node). The walk is acyclic again, spreads
evenly, and reads in the order it was heard. Relays shared *between* paths
still merge — that fan is what the widget is for.
Opt-in, because the two situations want opposite things. The Trophy case
scores a path we composed, so collapsing a revisit is deliberate there —
re-walking a node must not make the shape look bigger than the ground it
covered. An overheard chain has no such duty: nothing in it is ours to
inflate, and a repeat is evidence.
Evidence we cannot read, though — hops are addressed by a one-byte hash,
so a repeat is as likely two colliding nodes as a genuine loop. The packet
card and the Message paths dialog now say exactly that under the via row
and the graph, naming the repeated hops through the shared path widget.
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>
An arrival was one line — time, SNR, then "via" and the route, all competing for
the same 72 cells, with the route (the only part that varies, and the only part
the graph above draws) last in line and first to be scrolled away.
Split in two. The route gets a lane of its own, introduced by nothing: on a line
that holds nothing else, "via" tells the reader what they can already see. Under
it hangs the frame's own reception facts — time heard, SNR, which resend — muted
and stepped in, so a pair reads as one arrival. The picked line is the route: it
is what one arrival differs from another by, so it is what ←→ scrolls and what
the ❯ points at, and the facts beneath hold their lane while it slides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The composer's insertion point was a reverse-video arrow living between two
hops, which forced the whole preview back to plain arrows — chips fuse the seam
it needed to sit in — and made a wrapped route carry the cursor by hand, riding
a line's trailing cue when the break landed on it.
An insertion point is a position in the route, so it is now a hop of its own: a
`+` slot in the brand accent (the same colour the row cursor below it wears),
spliced in by `path_line(cursor=…)`. It measures, wraps, and colours like every
other hop, so the preview keeps whichever mode the terminal earned and can never
strand its cursor on a fold. `cursor_arrow` goes with it, out of both `PathLine`
and `path_text`.
Message paths: the arrival rows draw through the path line too — chips in each
node's hue, names alone, no hash repeated after one. The hash bytes are the
graph's job one row up, and the shared node hue ties the two together without
spelling the hex twice. The graph loses the blank line above it; its canvas
already opens with air over the topmost lane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gap between chips was one-sided: the previous chip tapered out to a
point onto the page, but the next one started on a flat edge. Both faces
now angle the way the path flows — the seam is two cells, a taper out and
the same point notched back inward out of the next chip's fill, with a
sliver of bare page between them. It is the notch a wrapped line already
opened on, so a fold now looks exactly like the seam it interrupted.
The composer's suggestion list could show one node twice. The evidence
graph keeps a hop hash it can't pin down as its own vertex — a 1-byte "3d"
matching two contacts stays "3d", apart from the "3d63c6429436" its wider
sightings landed on — and it is right not to guess; but once the name
resolver names them, they are one row drawn twice. They now fold onto the
longer id with their readings pooled, so the row's sample count and median
SNR describe the whole node. Merging takes both a shared name and a shared
prefix, so two unnamed hashes never fold and neither do two same-named
nodes on unrelated keys.
Folding the stub away must not fold its links away with it, so a step off
the merged node gathers from every id it reads as — otherwise merging would
quietly cost the composer whatever was only ever heard under the stub. Name
lookup and that alias walk are both memoized: they now run over the graph
on the way to each repaint, and neither the topology nor the resolver moves
while a composer is open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trace surfaces' route lanes were spending their width on hex nobody
reads there: every name carried the hash it is addressed by, and the wire
spec one lane below already spells that out verbatim, at the width it goes
on the air. The route lane, the planned-route preview and the composer's
live preview now name nodes and nothing else; an unnamed hop still reads as
its hash, since that is the only identity it has.
The composer's preview becomes that same lane rather than its own dialect —
pathline chips, hop-boundary wrapping, our two ends the bare star.
Both stars fade. Setting out from us and landing back on us are fixtures of
every such route, no more composed (or removable) than a mirrored return
leg, so they wear the automatic grey — dark slate as a chip, never the loud
you white. Finding the turn a wrapped route folds at now reads the faded
*tail* rather than the first faded hop, which the leading star had become.
Chips are separated, not interlocked: every seam draws its point with no
background, so the page shows through the taper and a route reads as a row
of blocks the way oh-my-posh spaces its segments. Two neighbours landing on
one fill can no longer fuse, without that being a special case.
Finally, two places that had the right information and didn't use it: the
composer's suggestion list fell back to bare hex for a short hop matching
several contacts (the topology won't guess, so it names nothing), and the
not-a-trail confirm printed whole 12-hex ids. Both now go through the
owning screen's own resolver and its chosen path-hash width, so a node
reads the same in the dialog as in the window behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A folded route hung flush under the value column, so a two-line route and
two stacked values looked alike at a glance. Continuations now step in
WRAP_OFFSET columns past the indent; the packer charges for the step, so
every line still fits its width. The continuation marks — the trailing
arrow, the notched chip edge — still say it in detail; the step says it
from the block's shape alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bare_self now stands our node on the app-wide ★ rather than stripping it
to nothing, so both ends of a route are marked, not merely implied. The
star we set out from wears the you white; the one we land back on fades,
because coming home closes every route automatically — nobody composed
that leg, same reasoning that dims a mirrored return. A lone faded tail
no longer counts as the route's turn, so a walked boomerang still folds
at its own middle.
The wrapped line's opening cap carried the previous chip's colour into
the new line. It now draws the point in the line's own fill under reverse
video, which paints the notch in the terminal's own background — the only
way to name the page's colour without knowing it — so the carry-on mark
holds no trace of the chip above.
And the extended powerline block finally earns its detection: where the
font is a full Nerd Font patch (powerline_full), a path's two outer ends
round off into a lozenge. Core-only terminals square the opening and
point the close exactly as before, so nobody sees tofu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wrapped line was opening on U+E0B2, which points backwards — it read as
an arrow going the wrong way, not as a carry-on. It now reopens the seam
the break interrupted: the same right-facing point, arriving out of the
previous line's last fill into this line's first chip. U+E0B0 is once
again the only glyph the widget draws; what varies is whether the point
lands on a field (a seam) or on the page (a closing edge), which is also
the same-fill gap rule, now factored into _seam().
A bare hop keeps the one-cell padding every chip wears — it has no words
between them, not no chip — so our end of a route reads as a stub of the
you white rather than as nothing at all.
Explore's candidate paths go bare too: every candidate starts from us, so
the word was the same on every row and the cells are the candidates'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An interlocked seam is the point in the previous chip's fill drawn on the
next one's — invisible when the two fills match, so a hue collision, a run
of dimmed hops, or two keyless greys read as one fused block instead of
two nodes. Those seams now draw over the page instead, leaving the sliver
of background that says where one chip ends and the next begins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hop with no label now draws as its seam alone — the arrow, no chip, no
word. The trace screens' route lane uses it (bare_self) for both ends of
every walk: a trace leaves us and comes back to us, so naming ourselves
twice only pushed the hops that *are* news out of the lane. Arrow mode
drops the padding the vacated label sat in ("→ 3d → f2 →"); chip mode
lets the seam point into the page.
Wrapped chip lines now open on the female point (U+E0B2, core set) from
the second line down, so a continuation reads as one — only the very
first chip of a path keeps its square left edge. The packer pays for that
cell, so lines still fit their column.
Also carries the round already sitting in the tree: the wrap evens hops
across the fewest lines it can take and folds at the route's turn, the
trace/node-detail/select surfaces route their rows through the widget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pinned preview breaks between hops under a 2-column hanging indent
instead of Rich-folding mid-name at column zero; the insertion cursor
stays visible on whichever line owns its seam, including a seam that is
the break itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The measured-route lane joins the app-wide colour rule — each name in its
key-derived hue (keys now passed in), us the white you, the mirrored
return dimmed — and wraps at hop boundaries under its value column. The
Route action row and the tuning lane wear the same hues.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Routes tab's rows are one PathLine end to end (chips where the
terminal draws them, the same characters where it doesn't), and a feed
row's via chain fits its lane by eliding middle hops behind a mark — the
origin and the last relay survive where the right edge used to eat them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explore-paths candidate rows render through the path widget — every hop in
its own key hue, unnamed hops prefix-lit — instead of one brand-coloured
join; both the explore and probe-results pickers scroll their highlighted
row under arrows; and the path lane hangs under its value column like the
route above it instead of folding a long spec back to column zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sweep table title used ASCII ->, TraceResult.path_str was an unused
ASCII-arrow renderer, and the trophy browser truncated long walks at the
fold — SelectScreen's own hscroll now slides the highlighted row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
path_line() builds a PathLine from path_text's exact vocabulary (verified
character-for-character in tests), wrapped() carries the composer's
insertion cursor — riding the trailing arrow when its seam is the line
break — and conftest pins MESHTERM_POWERLINE=0 so screen assertions never
depend on which terminal runs the suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PathLine renders PathHop sequences three ways (full line, middle-elided to
a width, hop-boundary wrap under a hanging indent) in two looks: the
app-wide muted-arrow presentation, or interlocking powerline chips filled
with each node's key hue when the terminal can draw U+E0B0 (auto mode asks
termfont). Same words in both modes; call sites migrate separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MESHTERM_POWERLINE override, then the configured face (Windows Terminal
profile, VS Code settings, genuine conhost via Win32) matched against the
recommended fonts and their Nerd Font alias spellings, then renderer-backed
terminals that draw the triangles themselves; ssh and strangers stay an
honest unknown. Plus an installed-font scan for a future nudge screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dialog ranked layers by selection (priority 3 for the picked path, 2 for the
rest), so every ↑↓ re-elected the spine and re-laid the whole graph out. Rank by
first-heard order instead and move `emphasis`, the way the node detail's Routes
tab already does — the geometry is fixed by evidence, only the white lane moves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Routes tab mutes every label off the selected route, testing
membership against the route's raw draw hops. But the path graph folds a
short hop into the wide id it can only be among the drawn routes (a
1-byte trace hop beside the same relay's full contact id), so a route
recorded at the short width greyed the very marker its white line rides
through — TSFCT SUTTON-680M's bf→8e→3d route muted YUL-Cartierville.
Decide membership in the graph's own id space instead, by running the
layers through the same coalesce the widget applies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A route that skips a relay another route rides (the A-B-C-D spine
beside the shorter A-C-D) drew its skip edge as a level run straight
through the skipped marker's cell — and under emphasis the subset's
white repainted the spine's own run, so the skipped relay read as
selected (Furthur -> YUL-Cartierville lighting C14903).
_bypass_vias now bends any edge that would run level through a marker
strictly between its ends — by construction a node that edge skips —
around it, through an unmarked via point in the skipped node's own
column on the innermost genuinely free lane: no seated marker, no other
route's level run through that cell, and a lane outside the band only
while _band_rows still fits max_rows (the same budget the detour fold
answers to). No room anywhere keeps the honest level pass-over. _route
threads the vias with its usual level-or-shift grammar, so the bypass
eases out, sits level dead over the marker it clears, and eases back.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>