Commit Graph
262 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 4.8 598b272483 Emoji width: carve wide-VS16 exceptions back out of the narrowing
A width-1 terminal isn't uniform: the ☀️ probe paints in one cell but
🛩️ (U+1F6E9 VS16) paints in two, so blanket-narrowing VS16 sequences
pulls the airplane's chat-row border a column short and smears the line.
Add a curated wide set (_DEFAULT_WIDE_VS16, MESHTERM_WIDE_EMOJI) — the
mirror of the lone-narrow allowlist — that forces those base codepoints
back to two in both width authorities, selector still skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:29:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 3ac43b7c35 Node graph: a layered redrawing, one marker per node, names throughout
The contact page's route graph fanned every path as a free lane from the two
endpoints and averaged shared relays together, so a well-connected node drew as
an acute tangle of near-overlapping lines. Worse, the same physical node could
appear twice: a 1-byte trace hop (27) standing beside its own wide contact id
(27d4396a2967) whenever the short hash was too ambiguous to widen — YUL-Poly
drew YUL-UpperSalaberry as two separate markers.

Redraw the route graph as a layered left-to-right graph (pathgraph.py):

* longest-path ranks put every node in the column of its hop distance, so each
  edge steps strictly forward and a relay shared by several routes is drawn once;
* an edge that skips a column routes through invisible waypoints, bending around
  the nodes between its ends instead of slicing through them;
* nodes are ordered to cut crossings (barycentre sweeps) then spread down the
  full height with their ys pulled toward their neighbours' (PAVA packing), which
  straightens each route into a lane and opens the branch angles up;
* first-appearance ordering keeps the layout identical on every repaint.

Kill the double-drawn node at two levels. build_topology now coalesces an
under-specified id into the one longer node it can only be (27 -> 27d4396a2967),
folding their links — fixing the atlas and the scenario ranker too, not just this
graph — while leaving a genuinely ambiguous stub (c5 -> two nodes) alone. The
widget then closes the same gap locally across its own layers, catching a hash
that is ambiguous against the whole contact list but unmistakable among these
paths (be -> be1d1c1dbc4b).

On the node detail page specifically: name every node, not just the two ends
(a relay reads by its contact name, unknown ones keep the honest hash byte), and
draw the target with its own map glyph (repeater, room, sensor) to match the
header. us stays on the left, the target on the right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:47:23 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 62a2d76e8c Node detail: a full-screen page for each contact, reached by Enter
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.

Four fixes fell out of driving it on real hardware:

Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.

Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.

Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.

Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:41:52 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 198111095a Atlas graph: flatten the fan arc so the rim leaves fill the width
The neighbour fan drew on a true circle, so only the due-east leaf reached
the far side; the top/bottom leaves curled back toward the focus (~0.31 of
the reach), wasting the right corners. Decouple a leaf's east reach from its
vertical spread — the reach now traces a flatter arc (_FAN_X_FLATTEN of the
fan angle) — so rim leaves land well east and the fan spreads across the
width. The setback still scales with fan size, so small near-due-east fans
don't ease back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:45:09 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 41c2d3168e Atlas graph: key-hue every marker, air the fan out west, always breadcrumb
Colour each node's marker (and its body-text glyph) by its own key-derived
hue instead of the node type — the glyph *shape* still carries the type — so
the graph reads by identity. No marker is whited out any more, not even the
selection or our own repeaters/nodes: the selection is shown by its white
label and the lit route. Our own node keeps the white `you` mark; the legend
becomes an honest shape key (role glyphs muted) now that colour means identity.

Anchor the focus at the far west with its name to the right and let the fan
spread across the whole width east of it — the graph aired out rather than
squeezed into the middle. The focus and the came-from node keep their icon on
the left with the name to its right, but every edge now attaches at the *right
end of the name* (not the icon) so a connecting line never crosses a label; the
came-from ducks in just west of the focus and lower.

Render the breadcrumb trail always, even at the root where it is just our own
node, so the header keeps a steady height rather than shifting up a row on the
first walk step.

The fan's promote/collapse ranking already folds SNR, sample count, and recency
together via Link.strength() — documented, not changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:17:34 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8054d064e8 Atlas: document that "N hops out" is the ring, not the walked trail length
The focus line's hop distance is the shortest observed path from us to the
node (a BFS over the evidence graph), which legitimately reads shorter than the
breadcrumb trail when the walk wandered or doubled back. Record why the two can
differ so it isn't mistaken for a miscount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:49:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f113e6a784 Atlas graph: light the whole route to the selected link
Selecting a link only brightened that one hop's edge; the path that reaches it
read no differently from the rest of the fan. Draw the route to the selection —
its own edge plus the approach the walk took into the focus (came_from → focus)
— at top priority and full strength, shedding the evidence-age fade the other
edges keep, so it reads as one lit thread running west through the focus to the
selected neighbour east. The age stays legible in the row's age column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:46:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e5aa5810dc Atlas graph: name nodes in full, clamping labels to the room actually there
Fan and focus labels ellipsized at a flat cap (12 cells for a fan node, 16 for
the focus/selection) even when the canvas had ample room beside the marker, so
"YUL-Polytechnique" read "YUL-Polytechniq…" with half the row empty to its
right. Clamp each label to the cells genuinely free between its marker and the
canvas edge instead — a shared _clip helper feeds both the generous cap and
the per-marker room — and widen the east gutter so the fan sits a little west
of the edge, giving even the tightest due-east marker space to name itself.
A long name now loses letters only when it truly won't fit; the list below
still carries the fullest name regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:44:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 bd13b117ce Atlas links list: shrink the key, not the name, when a row is tight
The links (and match) rows sized a flexing name lane against a fixed 12-hex
key lane, so a long contact name lost letters while the whole id sat there
untouchable. Flip the priority: size the name lane to content first, then let
the key lane fill what's left — and when the two together outrun the row, the
key gives ground first, shrinking on a byte boundary down to a floor that
still shows its whole lit hash plus a "…". Only once the key is at that floor
does a long name start to clip. The name is the last thing truncated, and the
hash never is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:38:15 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 380133a6e0 Map: drop 0/0 'null island' fixes so a lone match can't frame all-black
A companion with no GPS lock advertises latitude and longitude both zero.
Contact.has_location treats that as located, so gather_markers plotted the
node at null island in the mid-Atlantic. The default frame (fraction=0.5)
trims the far outlier away, hiding it — but filtering the find down to that
one node and pressing Enter frames only it, dropping the whole view onto open
ocean: a screen of solid dark water fill that reads as all black.

Share the 0/0 guard _self_marker already applied to our own node with the
contact and heard-node markers, so a both-near-zero fix counts as no fix at
all and never reaches the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:30:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 969989ab45 Node hues: a 256-colour spectrum keyed on the first byte
Node-identity colouring drew from an 8-entry palette, folding the first
key byte down with `% 8` so ~32 keys shared each hue and a handful of
nodes on screen often collided. Map the first byte straight onto the HSV
wheel instead — 0x00 red, sweeping the full spectrum to 0xff — at a fixed
saturation/value (0.65/0.95) tuned to stay vivid and readable on the dark
theme across every hue. All 256 first-byte values now give 256 distinct
hues.

Only node identity changes: node_style was the sole consumer of the old
NAME_COLORS palette, which is removed. The rest of the theme (brand,
status, SNR, battery, you-white) is untouched. Keying stays on the first
byte alone, so a 2-hex path hop and a full 64-hex key still agree — one
node, one colour everywhere. Red is no longer reserved out of the node
spectrum (0x00 = red), but node names are always bold and in name lanes,
distinct from the err-styled status marks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:53:10 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1c89956d08 Map: Enter zooms in on a single filtered node
Pressing Enter with the find filter down to one node recentered on it but
always at the fit's neutral default zoom (14) — no extent to frame — which
could sit more zoomed-out than the current view. Frame a lone match (or
several at one spot) at a street-level closeness instead, capped at the
tile source's max so it never over-zooms onto blank tiles. Multi-node
framing is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:40:36 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 293fdeb5da Path composer: let the / key type like any character
The hop entry silently swallowed "/" — a reservation left from an earlier
design that no longer does anything (path specs use commas, and the entry
filters suggestions by name or hex). Drop the special case so "/" types
like any other printable character, e.g. to filter a name that contains one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:23:38 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1b20989e6c Filters: ignore leading and trailing spaces
Every type-to-filter surface — the shared select list, the map's node
find, the atlas find, and the path composer's hop entry — now refuses a
leading space (the query never begins with whitespace) and strips the
query when matching (so a trailing space is ignored). Spaces still count
mid-query, so a multi-word name like "Homestead R&D" filters fine; only
"  Homestead" and "Homestead   " are prevented from mattering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:22:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ee8731e4d4 Atlas links list: widen the name, show the whole hash
The neighbour/match rows capped the name at a fixed 16 cells and drew the
key lane just 6 wide — a 2-byte prefix with an ellipsis. Size the name
lane to content instead (flexing up to whatever the fixed lanes leave, so
a long name shows as much of itself as the row can spare), and widen the
key lane to the full 12-hex canonical id with no ellipsis — the node's
whole hash, its addressed prefix still lit in the node's hue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:19:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 3d11aba32c Atlas: drop the ^R graph rebuild
The atlas snapshotted the evidence graph at open and re-read it from
storage on ^R. In practice that reload earned nothing — the graph is a
reading of already-stored evidence, and reopening the screen picks up
anything new — so remove the feature: the `retry` handler, the
`_do_rebuild` helper, the `rebuild` constructor argument, and the now
single-use `build()` closure in `open_atlas`. The global ^R binding stays
(Chat still uses it to retry a failed message).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:16:20 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e46d286b13 Atlas focus line: name with its parenthesized hash
The current-node line drew the name followed by a bare 12-hex slice of the
key and then spelled out the node type. Drop the type atom — the leading
glyph already carries it — and render the identity as name (hash): the
addressed path-hash in parentheses, its digits lit in the node's hue. A
keyless own-node ("local") has no hash, so the parenthetical is omitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:14:07 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 0f97c0d752 Atlas breadcrumbs: key-coloured, loop-truncated, tail-anchored
Three changes to the walk trail:

- Each name now carries its node's own key-derived hue (ours the white
  you-style, a nameless node muted), the focus bold — instead of a flat
  muted/bold trail.
- Walking to a node already on the trail truncates the stack to that
  node's first appearance, dropping the loop we walked to get back there
  rather than recording the round trip (this also subsumes the old
  step-back-through-the-west-node special case).
- When the trail outgrows the line the head is dropped behind a leading
  "…" rather than the tail being cut, so the focus and the steps that led
  to it stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:12:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 223baee3ec Trace target: keep the sort arrow clear of the next header
The sortable-list header folded each column's direction triangle into a
2-cell reserve that doubled as the gap to the next column, so an active
arrow sat immediately against — or, for the TRACED lane, flush against —
the following header. Widen the inter-lane gap to 3 cells across both the
header and the value rows (staying aligned), so the triangle keeps a clear
space on each side. The per-column KEY nudge is now the uniform gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:09:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1dd4195637 Footer hints: lead with navigation, Esc last
Time Machine, Remote CLI, and Live feed each led their footer with an
action key (bare `w`, `Enter send`) or wedged one between two navigation
groups. Reorder each to the standard shape — navigation keys, then action
keys, Esc last — and rename Live feed's `↑↓ packets` to the `↑↓ move`
verb the other lists use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:04:38 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ee85286330 Chat: Ctrl-V pastes into a message, behind an amber confirm
Pasted text never reached a screen: the key dispatch forwarded only single
printable characters, so a bracketed paste (what a terminal sends for
Ctrl-V) was dropped whole. Route a multi-character run to a new "paste"
action instead, and add a Ctrl-V binding that reads the OS clipboard for
terminals that deliver the key literally rather than as a bracketed paste.

In chat, a paste is confirmed before it lands: a chat message goes out over
the air, so an amber Cancel/Paste dialog ("Paste N characters into your
message?") guards it — the disruptive (danger) tier, not data loss. On
confirm the run drops into the compose line, newlines folded to spaces
(a message is one line) and the byte gauge flagging an over-budget result
exactly as typing past the limit already does. Plain text prompts gain
silent multi-character paste through the shared line editor; only chat adds
the confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:21:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8daf3f1294 Dashboard: advertise scroll keys only when the overview overflows
The footer named "↑↓ PgUp/PgDn scroll" unconditionally, even on a tall
terminal where the whole overview fits and those keys do nothing. Gate the
scroll atom on the body actually being taller than its viewport, so Esc is
the only key advertised when there is nothing to scroll.

Add a reusable Screen.content_overflows predicate (from the metrics the
frame already records each paint) for the pattern, so any scrolling screen
can gate its footer the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:12:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 79fb014c52 Select lists: horizontal scroll rides the highlighted row alone
The opt-in hscroll shifted every row (and separator) sideways together, so
reading one long line dragged the whole screen with it and ←/→ advertised
themselves even when nothing overflowed. Make the shift per-row: only the
highlighted row slides, and only as far as its own tail, under a pinned
pointer; every other row and each column header stays put. A row that fits
the width can't shift at all, and moving the highlight to another row (or
editing the filter) drops the scroll back to the start — each row scrolls
on its own.

Surface the ←→ atom from the list itself, inserted right after the move
atom only while the highlighted row actually overflows, so the key shows
up exactly when it would do something. Watchtower's alert log — the one
hscroll user — drops the hardcoded "←→ scroll" from its footer and lets
the list add it on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:09:09 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f164201ed6 Watchtower: lead an alert's node name with its type glyph
The watchlist rows and the star-a-node picker already mark each node with
its shared type glyph (▲ repeater, ● node, …); the alert log didn't. Add
it there too, immediately left of the node name — the leading ●/○ on an
alert is the acknowledgement state, so the type marker belongs with the
name, not the row head.

The type is resolved from the node's key: the contact table's advertised
type wins, a watched entry's stored type backfills (so an alert still gets
its glyph when the node has left the device's contacts), and an unknown
type falls back to the plain-node ● exactly as the watchlist rows do. An
acked alert mutes the glyph with the rest of its history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:02:21 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ebb1d40361 Trace target: pick from the shared sortable list, with a TRACED lane
The target picker was a hand-rolled two-section list ("Recently traced",
then "Contacts") — the one contact enumerator in the app that didn't ride
the shared ContactListScreen. Move it onto that widget so it sorts,
filters, and steers exactly like the Contacts screen and the courier
recipient: full NAME · HEARD · PKTS · KEY lanes, Ctrl+arrow sort ring,
type-to-filter. Every node type is listed — a trace answers "can I reach
this node?" for a repeater or room, not just a companion.

The recency the old section carried becomes a first-class sortable lane:
ContactListScreen grows an optional TRACED column (opt-in via show_traced)
between name and heard, showing how long ago each node was last traced,
recency-heat coloured. It is the picker's default sort, descending, so the
most-recently-traced node leads and never-traced ones gather at the
bottom. A new repository query (target_last_traced) feeds it, folded onto
contacts the same way the old recent-targets list was.

The free-text prompt stays as the fallback when there are no contacts to
list — still the only way to trace a bare key prefix off-list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:59:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 40c46d3c75 Courier: direct messages go to companion contacts only
The recipient picker listed every contact, but a courier message is a
direct message — and a DM to a repeater, room, or sensor is meaningless.
Filter the recipient list to companions (an untyped contact gets the
benefit of the doubt), and open it sorted A→Z by name instead of by
heard, with heard/packets/key still a Ctrl+arrow away.

Fold the rule the chat picker already applied inline into one shared
predicate, is_direct_messageable(node_type), so "DMs go to companions
only" is enforced in a single place and both pickers speak it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:46:03 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 24273f1e00 Key dispatch: the right-Ctrl rescue reaches the ^R/^P letter chords
The physical-key probe promoted only the eight navigation chords, so a layout-claimed right Ctrl (Canadian Multilingual Standard, which strips the ctrl flag) still could not reach the letter shortcuts: ^R (chat retry, atlas rebuild) and ^P (chat paths). A bare r/p typed while the right Ctrl is physically held now promotes to retry/paths via _CTRL_LETTER_CHORDS. It is safe because a genuine third-level character arrives as a different glyph and is never in the map; a layout that swallows the key makes this a harmless no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:19:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 69526e76e0 Chat colour: an unknown @mention in a direct chat stays muted
A direct thread's sender label borrows the peer's key hue so a name that won't resolve still carries a colour. That peer-key fallback was leaking onto @mentions in the message body, painting an unknown mentioned name with the peer's hue instead of leaving it gray. A mention names an arbitrary person, so _sender_style(mention=True) now skips the fallback — an unresolved mention stays muted, matching channel behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 12:19:23 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 733a68857c Contacts: rename the mislabeled Nodes feature; sharpen node vs contact
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>
2026-07-19 11:42:11 -04:00
Jean-Pierre MartineauandClaude Fable 5 b49ef9f2c6 Render bridge: NO_COLOR must not strip the TUI's hues
The five "pre-existing" test failures (four corner-glow, one message-
paths muted-hash assertion) were all one thing: under a NO_COLOR
environment, Rich's console honoured it inside the TUI's headless
render bridge and stripped every named-style colour — but that console
is a rasterizer whose ANSI prompt_toolkit re-parses, not terminal
output, and the TUI's hues are semantics (node identity, recency
heat), not decoration. The bridge now pins no_color=False; the
scripted CLI's real console (theme.build_console) keeps honouring
NO_COLOR, where it belongs. Nothing was wrong with the tests or the
hash-widget commit they seemed to implicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:30:59 -04:00
Jean-Pierre MartineauandClaude Fable 5 7b689dfd04 Chat picker: companions only, and Del deletes a thread's history
The Direct section now lists only companion nodes (a contact whose
type was never advertised gets the benefit of the doubt) — repeaters,
rooms, and sensors are not DM targets. Pressing Del on a contacted
thread offers the reserved-red Cancel/Delete confirm; on commit the
peer's stored messages are removed (new Repository.delete_chat_history
— channel history and other peers untouched), the unread count clears,
and the re-entered picker shows the row hollow-dotted in the
uncontacted tail. The Del atom rides the existing deletable-row
machinery, with delete_hint now threaded through Ui/TuiUi/session
select so the footer advertises it only on rows it can act on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:21:43 -04:00
Jean-Pierre MartineauandClaude Fable 5 3fe4e5b4e1 Map: labels take the name hue, ours the you-white
Node labels were coloured by marker type (self yellow, repeater
violet, leaf pink); they now carry the app-wide key-derived name hue —
MapMarker grows a key field threaded from contacts, stored heard
nodes, and self-info — with our own label the pure you-white while the
★ glyph keeps its yellow. Find-filter matches still force full white,
non-matches still dim bare. The CLI legend follows suit (and its SEEN
column header becomes HEARD, per the lexicon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:17:32 -04:00
Jean-Pierre MartineauandClaude Fable 5 6ccdfbddc2 Time Machine arrivals: the key lane fills the terminal
The mesh page's arrivals showed a fixed 16-cell lane — six stored
bytes and blank padding, however wide the terminal. The lane now
absorbs whatever width the name lane and the FIRST HEARD tail leave
(floored at the old width), and each arrival's key expands to its
fullest known form the way the picker's lane does: the key captured
with an observation, else the device's contact list, else the stored
12-hex prefix — as many whole bytes as fit, ellipsized on a byte
boundary. The column header reads KEY, per the lexicon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:16:00 -04:00
Jean-Pierre MartineauandClaude Fable 5 93c1937de7 Courier recipient picker: the shared node list, Enter commits
The queueing flow's inline glyph rows give way to the app's one node
list: full NAME/HEARD/PKTS/KEY lanes, key-hued names, recency-heat
ages, PKTS from the recorder's overheard tallies, and the same
Ctrl+arrow sort ring and type-to-filter as the Nodes screen and the
Time Machine picker. It opens on the heard column freshest-first (the
old fixed order, now just a default), name sorts A→Z, and Enter
resolves the highlighted Contact into the flow; the footer spells the
grammar out with Esc cancelling the step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:13:34 -04:00
Jean-Pierre MartineauandClaude Fable 5 98aa6ef7dc Courier outbox: live rows, live sections
The outbox used to freeze at whatever it showed when it opened —
"retry in ~4 m" would still read 4 m ten minutes later, and a delivery
only moved to Finished after some unrelated keypress rebuilt the list.
Entry rows become callable titles recomputed on every repaint (the 1 s
app baseline), so countdowns and ages tick for real; a new
CourierOutboxScreen fingerprints the store's shape once a second from
an opener ticker and recomposes the sections in place only when an
entry appeared, finished, or was cleared — keeping the highlight on
its entry, the node-list rebuild idiom. No store callback needed: the
courier's own pass cadence is 30 s, so a 1 s shape check lands every
transition promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:11:51 -04:00
Jean-Pierre MartineauandClaude Fable 5 c4622d529a Watchtower: type glyphs, key-hued names, sideways scroll
Watchlist rows now lead with the shared node-type marker in its own
colour (the type is stored at star time; legacy entries resolve
through the contact table) with the name in its key-derived hue —
silence keeps its ⚠ tail instead of tinting the glyph. Unacked alert
labels resolve back to a key for their hue (contacts first, then the
watchlist's own names, so a starred node colours even offline); acked
rows recede to muted whole. The star-a-node picker draws the same
glyph-and-hue rows. Long rows can now be read to the end: SelectScreen
grows an opt-in hscroll flag — ←/→ slide every row under the pinned
pointer via crop_cells, clamped at the widest row, surviving ↑↓ and
resetting on filter edits — enabled only here, with the footer
advertising it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:09:56 -04:00
Jean-Pierre MartineauandClaude Fable 5 4dc9608761 Colour: every hue rides a key — name-seeding retires
A new make_name_key_resolver (beside its trace_runner siblings) maps a
display name back to the node's key — contacts win, the recorder's
stored advert names fill in strangers — and name_style's keyless
fallback becomes muted: colour now always marks a keyed identity,
never a character-sum accident that two spellings of one node disagree
on. Wired through every keyless call site: the chat transcript's
sender headers and @mentions (a direct thread falls back to the peer's
key), the picker previews, the live feed's channel-sender lane, and
the route graph's origin label (new key_of hook on route_graph_style,
threaded through the packet viewer and Message paths). The chat
picker's contact dot switches to the standard companion pink — shape
still marks history — and contact labels take their key-derived hue.
name_rgb guards the muted style so braille canvases render the grey.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:04:57 -04:00
Jean-Pierre MartineauandClaude Fable 5 dcc98f9cd8 Dashboard traffic: the packet bucket breaks out by payload class
The lone "packet" meter lumped every overheard raw frame together; the
traffic panel now meters each parsed payload class on its own row —
channel text, trace, path, request, … — glossed and iconed from the
packet viewer's shared tables, with only class-less frames left as a
bare "packet". The composite packet:<TYPENAME> bucket shape runs
through the stored seed (Repository.kind_counts) and the live tally
(MonitorService) alike; an overheard ADVERT/ACK frame reads "raw
advert"/"raw ack" so no two meters share a label. Raw-class meters
stay muted — colour keeps marking the decoded families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:55:01 -04:00
Jean-Pierre MartineauandClaude Fable 5 79410c4f16 Live feed: the dashboard's packet stream, promoted to the menu
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>
2026-07-19 00:52:45 -04:00
Jean-Pierre MartineauandClaude Fable 5 24c70c487f Packet viewer: the class headlines the card, icon and all
Every packet now leads with its class — icon + UPPERCASE label — as the
first row of the viewer, whatever the kind: a raw frame shows its parsed
payload class, everything else its own kind. The new PAYLOAD_ICONS table
gives each raw payload class a concept glyph (📻 channel text, 🎯 trace,
…, matching the tools that own those concepts) and joins the CLAUDE.md
icon lexicon; the dialog title drops its emoji, per the title rule the
icon was violating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:45:06 -04:00
Jean-Pierre MartineauandClaude Fable 5 56cc220ee3 Lexicon: a lane shows the key, the hash is the lit slice
The node lists' HASH column header becomes KEY: the lane renders as
full a key as the caller resolved, and the hash is only the short
path-hash-mode prefix highlighted_hash lights inside it. CLAUDE.md's
key/hash lexicon rows now carry the sharper definitions so UX text
never again calls a truncated key a hash. The "hash" sort-ring id
stays, for saved-sort compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:43:07 -04:00
Jean-Pierre MartineauandClaude Fable 5 f7dcda0575 Hash widget: the addressed prefix lights in the node's own hue
highlighted_hash lit a key's path-hash prefix in the fixed brand cyan;
it now takes the node's hash-derived palette hue — the same colour the
node's name wears — so a hash carries its node's identity colour even
where no name is known, and a key's prefix, its name, and its map
labels all agree. The remainder (and a hash standing in as a name)
stays muted, and the truncation ellipsis keeps inheriting the colour
of the digit it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:39:42 -04:00
Jean-Pierre MartineauandClaude Fable 5 79d00defdd Node colour: the hue now rides the key, and heat moves to the age
The per-node palette hue was keyed on the display name's characters; it
is now derived from the node's key — the first byte picks the colour, so
a 2-hex path hop, the stored 12-hex id, and the full 64-hex public key
all agree, and a rename keeps its colour. name_style grows a key
parameter (theme.node_style is the raw derivation); the name-character
fallback remains only for genuinely keyless senders (a channel message's
inline name). Keys are threaded through the path widget, the route
graph's labels, the atlas lists, the trophy case's far point and area
pins, and the packet viewer's node lane.

In the Nodes screen and the Time Machine (the shared node list, the
static CLI table, and the mesh page's arrivals), names drop their
recency-heat colouring for the node's palette hue; the heat now glows on
the HEARD/first-heard age lane instead, where the recency actually
lives. A nameless node's "unknown" placeholder stays muted — colour
marks a name, and the hash lane already carries the identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:37:10 -04:00
Jean-Pierre MartineauandClaude Fable 5 b40caf6891 Nodes screen: the Time Machine picker's node list, now shared
The picker's lane layout - NAME/HEARD/PKTS/HASH under a sort-aware
header, own node pinned, Ctrl+arrow sorting, type-to-filter, a
content-sized name lane flexing the hash lane - moves to a shared
ui/nodelist.py (NodeRow + NodeListScreen). The Time Machine picker
becomes a thin subclass over stored history; the Nodes screen is
rebuilt as the same list over the device's contacts, replacing its
plain-arrow-sorted table (its ring gains the hash column). Enter is
deliberately inert on the Nodes screen for now - per-node actions
come later. The one-shot CLI keeps the static table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:41:26 -04:00
Jean-Pierre MartineauandClaude Fable 5 4b6e56889a Key dispatch: a physical-key probe rescues right-Ctrl chords
Layouts that claim right Ctrl as a character-group modifier (the
Canadian Multilingual Standard) hand the console a right-Ctrl'd arrow
with no ctrl flag, so only the left Ctrl ever steered a sortable list.
A GetAsyncKeyState probe at dispatch promotes bare navigation actions
to their Ctrl chords while the physical right Ctrl is down - a no-op
whenever the console already reported the chord.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:41:15 -04:00
Jean-Pierre MartineauandClaude Fable 5 13db8c4df3 Path composer: an insertion cursor riding the route's arrows
←/→ slide a reverse-video → along the editable leg; Enter splices the
chosen hop in at the cursor and ⌫ removes the hop to its left, so a route
can be edited anywhere, not just at its end. It opens on the last arrow,
keeping the classic append flow unchanged. Suggestions, the fetch row,
and the "Next hop from" heading all follow the node left of the cursor,
and the fetch-neighbours round trip now resumes at the same position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:01:15 -04:00
Jean-Pierre MartineauandClaude Fable 5 a90d93c4b3 Path composer: a grow-only dialog, and the ratchet now holds width too
The composer's box hugged its content, so every added hop, new suggestion
set, or filter keystroke resized it. It now opts into grow_only like the
packet viewer, and the frame ratchets a grow-only dialog's natural width
alongside its height, so the box holds its largest extent for the session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:32:16 -04:00
Jean-Pierre MartineauandClaude Fable 5 0e93d21835 Trace records: the trail rule, a New record dialog, shallower stacks
A record walk must now be a trail — never the same link twice in the
same direction (the reverse crossing stays legal, so target boomerangs
still qualify). Repeating a stretch would let its score be farmed, so
walk_scores disqualifies such walks from every board at once; the
composer shows a yellow "not a trail" note while one is being built,
and Trace gates it behind an amber Cancel/Trace confirm. Matching
non-trail records were purged from the local database (one-off; the
rule never shipped, so no migration).

The inline "new record" note becomes a floating dialog once a run
settles (one per run, Close default, Trophy case on the left). Choosing
Trophy case resolves the whole trace session first, and the trophy
case's own Trace this path no longer reopens the browser behind the
trace screen — both directions unwind to the main menu instead of
piling up states to Esc through.

Committing the path composer also parks the action cursor on Trace, so
compose-then-Enter walks the new route; Esc leaves the cursor put.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:25:55 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 fa07d23adb Activity sparkline: drop the recency term that collapsed the scale
The shared scaling peak (activity_peak) was fully-lighting ~60% of the
header pulse's bars on a healthy 1–18 packets/min history. Cause: the
recency damper decayed each bucket's *count* by 0.5^(age/half-life)
before taking the 90th percentile, then that percentile scaled the
*undecayed* bars drawn against it — a unit mismatch. With a 30-min
half-life over a 6h pool, most traffic mass sits older than one
half-life, so the weighted percentile collapsed (1.14 here) and the
floor (3) became the ceiling; every minute with ≥3 packets, anywhere in
the drawn history, then saturated.

Drop recency; keep the two dampers that actually deliver the steady,
shared ceiling: an outlier-robust high percentile over a pool deeper
than is drawn (steady — the percentile barely stirs as one bucket
scrolls off), plus a floor (a lone packet stays a green nub). On the
live DB this restores peak 8, ~4–13% of bars full, heights spread 1–4.

activity_peak loses its bucket_seconds/half_life_min params; menu.py and
channels.py drop the now-unused bucket-seconds plumbing. The tall
dashboard chart was already on an honest max and is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:52:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 241c2c062a Activity sparkline: a steady, shared scaling peak instead of the bare window max
Relative-to-peak scaling reads a sparkline's shape well but is jumpy: the plain
window maximum lurches whenever the busiest bucket enters or ages out, and a lone
packet in a quiet window fills the column because it *is* the max. Add
`activity_peak`, a pure function that folds three dampers into one ceiling, each on
a different axis:

- recency — each bucket discounted by `decay ** age` (half-life 30 min), so an
  aging burst releases its grip smoothly and the ceiling glides rather than snaps;
- robustness — a high percentile (P90) of the weighted counts, not their max, so a
  freak-busy bucket clips to full height instead of crushing every other bar;
- floor — never below a small floor, so a stray packet in a lull stays a green nub
  on the baseline, not a full column.

The header pulse scales over the monitor's full six-hour history (deeper than the
row draws), and the channel manager pools every visible channel's histogram into
one shared ceiling (memoised per snapshot), so the rows stay comparable. To give
the channel peak history to decay over, widen its query to 6h/72 buckets while the
sparkline still draws only the newest 24 (2h). Split the dashboard's 2h
observation window off into its own OBSERVATION_WINDOW so it keeps its "reception
over 2 h" meaning. The tall dashboard chart is left on its honest max — it quotes
real magnitudes on its axis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:31:02 -04:00