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>
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>
- Swap the active tab's bottom corners: the flat rule turning up into
the tab's wall is west+north (╯), and turning back down out of it is
north+east (╰) — had these backwards.
- The left margin belongs to the tab boxes, not the bottom rule: the
rule is one continuous line, so it now runs the full width including
through the margin instead of being indented with the tabs.
- Inactive tab outlines move from "muted" to the dimmer "faint" shade.
- The whole bottom rule is now a single colour (accent, the active
tab's) rather than mixing accent/faint segments — it's one line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Only the active tab now breaks the bottom rule (up, across open, back
down); inactive tabs sit flush against it with no corners or seams of
their own, so they read as behind/in-the-dark rather than each having
their own raised box. Top border and its z-order layering (active tab
always wins both corners) are unchanged. Adds a 2-column left margin
when the width has slack for it.
Tab switching moves off the shared Left/Right handler onto Tab/Shift+Tab
only, freeing the arrow keys for other use on this screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All tabs now render as full boxes at all times (muted by default,
accent when active) instead of only the active one — so every tab's
width is fixed regardless of selection, and labels never shift
column when the active tab changes. Tabs share a single vertical at
each seam; the shared corner "opens" only at the strip's leading
edge and at the active tab's own left edge, "closing" (ceding to the
tab on its left) everywhere else, so the active tab reads as lifted
in front of its neighbours.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Since the strip already spent a blank line above and its own line,
box the active tab in accent-coloured rounded corners with its
bottom edge opening into a full-width rule, instead of a plain
── Label ── heading, now that a third line is on the table anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The detour fold ran unconditionally, so a route heard as a sibling plus
an inserted relay always dropped that relay onto the sibling's lane —
and the sibling's straight run drew right over a marker it never visits
(the real YUL-Poly fan collapsed to one flat line through three routes,
with label muting as the only cue). Now the whole fan is measured
against the row budget before the shape is committed: while the rows
allow, a detour nests — its relay takes the lane just outside its
sibling's, on the same flank (a depth floor plus a side tie in the
pack/balance passes), so the sibling visibly skips it and the detour
reads as the wider arc it is. Only a viewport too short for the extra
lane folds detours onto the sibling's lane, weakest first, re-measuring
after each — the last resort, never the default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Braille (one dot per module) would not scan, and the quadrant mosaic
reads worse on screen than the plain half-block form. The original
qr_text render scans and stays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One braille dot per module drew smallest but starves the scanner of
ink (each dot carries its white surround), and phones would not read
it. Quadrant mosaics keep every dark module a solid quarter-cell —
the same ink geometry that made the half-block form scannable — at
half its width: 27x27 cells for a contact URL instead of 53x27.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A braille cell packs a 2x4 block of modules, so the code draws at half
the width and a quarter of the height of the half-block form (27x14 vs
53x27 for a contact URL) — small enough for the 40-column PicoCalc
panel. Simulated-camera decode tests pass with dot fills down to 50%,
since a phone's blur merges the dots into blobs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The advert menu's own-node contact card generalises into
show_contact_card (QR code over the raw meshcore://contact/add link,
in one floating view); the Info tab gains a 📱 Share contact row that
pops it for the page's node. Offered only when the full 32-byte key is
known — a prefix-only heard node has nothing scannable to encode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The identity block was eating the stage's vertical budget on every tab.
The pinned chrome shrinks to the one-line identity header + tab strip;
the vitals rows (key, heard, packets, signal, where), the location
preview, and the Open full map / Time machine actions all move into a
new Info tab (replacing the Map tab — the preview now grows into
whatever rows the tab spares). The Routes tab spends the freed lines on
its graph and route list, and the strip costs a single line of air (the
graph's own top padding provides it).
Opening the full map from the page now also seeds the map's find filter
with the node's name, so it opens centred on the node *and* lit among
the rest — exactly as if the user had typed the find.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page no longer scrolls as one strip — which could carry the route
graph off the screen while the selection below was still driving it.
The identity block, tab strip, and stage are now pinned; the graph's
row ceiling is struck per render against the viewport (compressing its
lanes before anything would overflow), a faint rule closes the stage,
and the route list windows itself into the leftover lines with the
shared ↑/↓ n-more edge markers, whole hanging-wrapped rows at a time.
Enter on a route row now arms a trace on that route directly (the rows
wear the opens-further-prompts …); the dedicated Trace action survives
only as the auto-route stand-in when there is no evidence to list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Map tab's inline preview centres on the node's own fix ("… centred here"),
but its "Open full map" action called open_map with only the markers — so the
full map fell back to the persisted global view (wherever the map was last
left) or a fit-to-all-nodes frame, landing somewhere other than the node the
preview had just shown.
open_map gains an optional focus=(lat, lon): given it, the map opens on that
point at the preview's zoom (min(13, max_zoom)) and — mirroring how
LocationPickScreen uses its transient `initial` — deliberately wires no
on_view_change, so a focused peek never overwrites the user's saved global
map_view, even as it is panned. The node detail passes the node's own
coordinates, the same spot the preview is centred on.
Tests drive the real open_map both ways: focused (centres on the node, leaves
the saved view untouched after a pan/zoom) and plain (still restores and
persists the global view).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "🗑 Purge stale contacts…" action at the foot of the Contacts list. It
offers an age ladder (1 week → 1 year, plus a never-heard bucket), each rung
showing a live count of how many contacts it would sweep, so the impact is
visible before choosing; the pick is gated behind a typed "delete" confirm.
A purge deletes each contact from the device table (new Device.remove_contact,
over the meshcore remove_contact command) and forgets it from the cross-session
contact store so a firmware-less bridge doesn't merge it back — but only the
contact is dropped: each node's reception history in MeshTerm is untouched, and
our own pinned node is never a candidate. The contacts cache is invalidated and
the list rebuilt so swept contacts are gone from it.
ContactListScreen gains a `tail` param (symmetric to `lead`) for the action and
Back rows; age bucketing (stale_past / never_heard) is factored out pure and
tested, alongside MockDevice.remove_contact and the screen's tail composition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A firmware-less radio bridge holds its contact table only in RAM, so the Contacts
screen and the chat recipient list come up empty (or only as sparse as this
session's adverts) after the bridge restarts, even though you were messaging those
nodes last time. Record every contact MeshTerm reads, keyed by the device's own
public key, and union any the device is no longer reporting back into the list at
the one read chokepoint both lists share (DeviceState.contacts()).
No device write is needed: a direct message addresses a node by a prefix of its
public key, which the remembered contact carries, so a restored contact is
messageable without being written back onto the device. The union is inert where
it isn't needed — a firmware radio reports its whole table, so nothing is added,
no device-type check required — and recording never removes a contact on an empty
read, so a bridge that just restarted doesn't wipe what it knew.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A firmware-less radio bridge holds its configuration only in RAM, so the node
name, radio parameters, and tuning set through MeshTerm are back to firmware
defaults when the bridge process restarts. Record every setting changed through
MeshTerm (editor, config CLI, and TOML restore all funnel through _apply_setting),
keyed by the device's own public key.
Unlike channels — silently replayed because filling an empty slot overwrites
nothing — a setting is a single canonical value, so replaying a stale one could
clobber a change made elsewhere. So the store never writes on its own: on connect
it detects drift between what it remembers and what the device reports, and a
startup offer lets the operator restore the saved values, adopt the device's
current ones, or stop remembering the device (the "don't ask again" exit). The
offer is quiet unless a connected device actually drifts, so a firmware radio you
don't manage through MeshTerm is never interrupted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A firmware-less radio bridge holds its channels only in RAM, so every one is
lost when the bridge restarts — the Channels page came up empty each session
even after you added channels. MeshTerm read channels live from the device and
never remembered them.
Add a durable, per-device channel store (<config_dir>/channels.json, keyed by
the device's public key) that records every channel written through MeshTerm.
On connect, reconcile() replays any remembered channel the device isn't
reporting into a free slot — filling gaps, never overwriting an occupied slot.
The gate is provenance, not device type: only channels you wrote through
MeshTerm are recorded, so a firmware radio you never edit through MeshTerm keeps
an empty record and does a single identity probe on connect (untouched). A
bridge, where every channel is necessarily created through MeshTerm, gets its
whole set back. Every mutation (create/join/import/edit/reorder/clear) now flows
through a single write_channel() boundary that keeps the store faithful.
Verified end to end against the live SPI bridge: write a channel, clear it off
the device to mimic a restart, reconnect in a fresh session, and reconcile
restores it with the right key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An add-action restores MeshCore's built-in fixed-key Public channel on the
next free slot, shown only while no slot already carries the well-known
secret. Leads with the 🌐 globe to distinguish it from the name-derived
# Public channel row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A [profiles.*] TCP entry in config.toml was reachable only via
`meshterm -p <alias>` — the interactive picker built its list from the
live scan plus TCP devices remembered in the DeviceStore, never from the
user's own config. So a hand-authored network companion stayed invisible
until after it had been connected to once.
Fold configured TCP profiles into the picker the same way remembered ones
are: _profile_tcp_devices() rebuilds each into a DiscoveredDevice carrying
its alias as the DEVICE-column name, deduped by stable_id so an endpoint
that's also scanned or remembered keeps its richer row. An unconfirmed
profile shows the muted "network companion" tag and turns "MeshCore
device" once its smoke test passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three or more repeaters that relay each other in every order are a
strongly-connected component the left-to-right flow graph can't order. The
bidir-pair merge collapsed all of them onto one column, where their markers and
labels piled up illegibly (labels rammed into the trunk line, the ordering
lost). A two-node pair draws fine as a vertical; three-plus does not.
Contract each such knot to a single super-node — the standard SCC-condensation
move, the one honest way to seat a cycle in a DAG layout. pathgraph gains
`bidir_clusters`, which finds the 3+ mutually-linked groups; node detail
rewrites every route's draw sequence so a knot's members become one synthetic
cluster stop, drawn as a single marker labelled by count and shared type
("3 repeaters", or "n nodes" when mixed). Only the graph geometry contracts —
each route row and its trace spec keep every member named in order, so the list
under the graph still carries the exact ordering the marker folds away, and a
trace still arms on the real path. The tidy two-node pair is left exactly as it
was.
Also completes _collapse's emphasis handling: routes made identical by the
contraction now fold to one line that lights when either of its rows is
selected, keeping the strongest priority for geometry and the emphasised copy's
colour for the highlight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting a different route on the Node detail Routes tab relaid the whole fan:
priority drove both the geometry (which route is the spine, who owns a shared
relay, the lane packing) and the highlight, and the caller raised the selected
route's priority — so changing the pick seized the centre lane and shuffled
every marker.
Split the two. PathLayer gains an `emphasis` field that governs only the draw
colour and z-order (the highlighted route drawn on top, winning any shared
cell); `priority` alone fixes the geometry. Node detail now pins priority to a
fixed evidence order (route 0, the best-evidence route, is always the spine)
and varies only emphasis with the selection, so the picture stays put and just
repaints — the selected route white, the rest grey, off-route labels muted.
Emphasis wins a shared edge over a higher-priority spine, so a highlighted
alternative paints its whole run rather than dropping out where it overlaps.
The Message paths caller is untouched (emphasis defaults to 0, so its colour
still falls to priority exactly as before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committing the Back action resolved a bare "back" token, but the opener's
action loop only breaks on CANCEL (or None) and has no "back" branch — so it
fell through and simply re-showed the page. Back appeared inert while Esc
worked. Resolve CANCEL for the Back row so it leaves the screen on the same
path Esc does, back to the contact list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page stacked the location preview and the route graph down one long
scroll, and the graph was inert — no way to pick which route it lit. Give
it a tabbed stage instead: a one-line strip (new widgets.tab_strip) pages
Map and Routes one full-height view at a time, switched with ←→ (Tab /
Shift+Tab backup; BackTab now dispatches as "shift_tab"). Tabs are
adaptive — only views with content show, a lone tab collapses to a lit
heading, and our own fix-less node falls back to a bare action list.
The Routes tab folds the old route/suggest info rows into a selectable
route list (best marked ★ best, the firmware's device route, then the
good observed alternatives), each spelled contact→relays→us through the
path widget with a hanging wrap. ↑↓ over the rows drives the graph: the
picked route lights white while the rest go grey and every node off it
fades its label to grey (a per-render label_rgb_of closure — the pathgraph
widget stays generic). A dedicated 🎯 Trace this route row opens the trace
flow armed on whichever route is selected (nothing transmits here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the Node detail page for a well-connected contact could hang for
tens of seconds. The cost was topology._best_routes: a best-first walk over
partial paths to the target. On a densely-heard graph every wandering prefix
through the core is cheaper than the one weak link a distant node sits behind,
so the frontier floods the core — millions of dead-end prefixes — before it
ever reaches the target. Measured against the live DB, a single node took 28 s
(4.4M heap pushes), and scenarios() runs twice per page open.
Replace it with Yen's k-shortest-loopless-paths: find the shortest route once
by Dijkstra, then derive each next-shortest by rerouting around one edge of the
last. The work is polynomial in the graph size however dense the core. Verified
against the old search over the real DB: identical k-cheapest costs on all 148
nodes it could finish, and the worst node drops from 28 s to 22 ms (~1250x),
with the full 5 alternatives still found. Tie-ordering among equal-cost paths
may differ, which is moot — scenarios() re-scores every candidate by weakest
link anyway.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Column-packing already collapsed a per-path lane down to the rows a
column actually needs, but it left each node on the *side* of the spine
its jog-order lane fell on. The jog order seats relay-sharing routes
adjacent, which piles column-sharing routes onto the same flank — so a
five-route fan through at most two nodes per column still drew five lanes
whenever one column stacked two above the spine and another stacked two
below, the band being as tall as the deeper flank plus the other.
Balance the flanks instead: 2-colour the alternatives (above/below) to
flatten the deeper flank, so a same-column pair splits across the spine
and the band shrinks to spine-plus-one-each-side. A hard ceiling keeps
balancing from ever drawing a taller band than the jog order would — a
column that genuinely stacks three nodes forces one flank two deep, and
padding the opposite flank to match it (prettier, but a row taller) is
refused. A graph the jog order already draws flat keeps its airy layout
rather than lopsiding a row shorter, since its one-sided alternative has
the same deepest flank and loses the balance tie-break.
Across the live topology this tightens 23 graphs (one by two lanes, e.g.
7bc505 5 -> 3) and leaves 78 unchanged, none taller. The best spine stays
straight where the flanks come out even and leans only gently otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A lane per route stacked the band as tall as the graph had routes, even
where the routes barely overlapped: TSFCT SUTTON-680M's five observed
routes drew five lanes deep though at no column did more than two nodes
coexist. A lane is a whole-width row, but two routes need distinct rows
only in the columns where they both carry a node — endpoint to endpoint
every route already shares the origin and us.
So after _assign_lanes seats the routes (best centred, sharing routes
adjacent), _compress_lanes squeezes them: each node keeps its side of the
best spine but slides to the innermost free row in its own column. A
column with one off-spine node claims one flanking row however many routes
fan past it; only a column where routes genuinely stack pays for deeper
rows. SUTTON now draws three lanes / ten rows instead of five / sixteen.
The best path's nodes stay on the spine, so it still runs straight through
the centred endpoints; the even/odd endpoint-centring is untouched (its
single-column shapes can't compress). Adds pack/stack tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The origin and destination rode the best path's discrete lane, which for
an even lane count is the upper of the two central lanes — so they, and
the spine, sat half a lane above centre. Anchor them at the band's
geometric centre (max_lane / 2.0) instead: identical to the best lane
for odd counts (spine stays dead straight), the midpoint of the two
central lanes for even counts.
Back that with a row-quantized vertical layout so the centring is exact.
Lanes now sit on integer text rows a fixed pitch apart (_LANE_PITCH_ROWS
= 3, two blank padding rows per gap) rather than a continuous dot step;
on an even count the two central lanes are opened one extra row apart, so
the endpoints land on the exact middle row between them instead of a
fractional row that snapped off centre. The band's top is anchored to a
cell-mid row so integer lane rows land dead-on with no per-lane snap
drift.
The public lane_step (dots) parameter becomes lane_pitch (rows); the
node-detail override is dropped so that page shares the 2-row pitch,
keeping only its larger max_rows for busy nodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pair walked in both directions is a 2-cycle in the edge set; the
longest-path relaxation loops through it and inflates every downstream
depth toward the node-count cap, dragging other relays hard against the
ends (a one-hop relay landed at x=0.09 instead of its natural 0.33).
Rank balanced_x over the graph with each such pair merged to one
representative — the acyclic flow the picture really is; the pair, drawn
as a single vertical, rightly shares an x anyway.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A relay a balanced rank pins near a canvas edge centred its long label
off-canvas, placed nothing, and — unlike an endpoint — had no inward-slide
or side-placement fallback, so its marker went silently unlabelled. Give
every node the endpoint's inward-anchor clamp and marker_label fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every node now sits on a level platform: an edge leaves level, shifts
once, arrives level, so an off-lane relay reads as a flat-topped
trapezium instead of a pointed peak or valley. The shift itself is a
cubic-bezier S-curve with horizontal tangents at both ends, easing out
of one platform and settling into the next with no corner anywhere.
Every edge is keyed by its unordered node pair and drawn exactly once,
killing the doubled/offset lines that came from a pair walked in both
directions. That bidirectional pair is the sole edge drawn as a bare
straight segment (near-vertical when the layout stacks the markers) —
the one place an up-and-down line is the honest picture.
A direction arrow embeds in the trunk just before us, so the whole
graph reads as a directed run node → us.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>