Commit Graph
255 Commits
Author SHA1 Message Date
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
Jean-Pierre MartineauandClaude Opus 4.8 f07f8c1b93 Activity sparkline: scale to a peak, shared across a column
Bar heights now scale relative to a peak the way the dashboard's tall
activity chart does, instead of stepping at fixed count thresholds: the
busiest bucket fills the column and the rest draw in proportion.

A new `peak` argument is the scaling ceiling and the sharing mechanism.
Left None, each sparkline self-scales to its own window (the header
pulse, alone on its row). Passed a value, every sparkline handed the
same one shares a scale, so their bars are comparable — the channel
manager computes the busiest bucket across all channels via a new
_LiveStats.peak() and hands it to each row. No hidden cross-instance
state: the scale is a pure function of each call's inputs, which stays
correct for the windowed channel list where only visible rows render.

Drops the _HEADER_ACTIVITY_LEVELS / _ACTIVITY_LEVELS threshold ladders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 03:32:44 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a524c610e1 Dashboard traffic bars: blank the bottom dot row
The full-height meter profile now lights only the top three dot rows, so
each traffic lane lifts a hair off the cell floor instead of fusing with
the row beneath it. This profile is used solely by the dashboard traffic
tallies; the slim/tracked SNR and progress meters are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 02:37:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 434ffda253 Observations: capture the full public key, keep node as the 12-hex id
Adverts carry the transmitting node's whole public key, but only its 12-hex
prefix was ever stored (observation_from_event truncated it with [:12]). That
prefix is the app's identity/join key — heard_nodes groups on it, and the map,
nodes, and watchtower screens correlate it against contacts' 12-hex key_prefix
— so it must stay short. Instead, record the whole key alongside it in a new
observations.public_key column (schema v12) and keep the prefix as node.

The Time Machine picker prefers the captured full key, then the live contact
resolver, then the bare prefix — so a heard node's key now shows as fully as
the hash lane fits, offline included. HeardNode surfaces the key from whichever
row captured it; the simulator's observations carry it too.

No backfill: the full keys past adverts arrived in were never persisted (the
raw payload wasn't stored) and no local source has them, so old rows can't be
completed. The database was wiped to start fresh under the new schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:38:32 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5e8190d3b6 Time machine: add our own node, a self stats page, and a sortable picker
Add our own node to the subject picker — first in the Nodes list, always,
matching the Nodes screen's own-node-first order — and give it a full activity
page built from the outbound record, the one subject reception history can't
describe since we never overhear ourselves: Activity (traces + messages sent),
Reach (trace bottleneck SNR), Rhythm (by local time of day), and a Ledger.
Backed by three new repository reads (self_transmissions, self_trace_reach,
self_activity_ledger) and a SelfActivity model.

Make the picker sortable and filterable: Ctrl+left/right pick the column
(name / heard / packets / hash), Ctrl+up/down force direction, a two-colour
header lighting the active sort. Supported by letting a Separator title be a
Rich Text and mapping Ctrl+Up/Down to actions.

Anchor the columns to the left: size the name lane to its content, not the
terminal, so the columns stay put as the window widens, and flex the hash lane
to fill the freed width — showing a heard node's full public key when a contact
holds it (new make_key_resolver) instead of the stored 12-hex prefix. Truncate
hashes on a byte boundary (even digit count) in the shared highlighted_hash
widget, floor the picker hash lane at 8 cells.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 01:17:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1edd4a3bf4 Remove Plotly and the TX-optimize HTML chart
The viz package existed solely to render one interactive HTML chart for
tx-optimize sweeps, pulling in Plotly — a heavyweight dependency serving a
single artifact while every other timeline renders in-terminal via
braillechart. Drop it with no replacement: delete meshterm/viz, the
--viz/--no-viz flag and its render call, the now-unused output_dir setting
(config field, default, loader, example), the Plotly test, and the README
and pyproject references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:53:08 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 0b8ec9f75f Chat ragged border: fix country flags, add-narrow-emoji skill
A country flag (🇨🇦) is a Regional Indicator pair, not a lone codepoint:
Rich and the terminal draw the pair as one two-cell glyph, but prompt_toolkit
counts each indicator as two (wcwidth), measuring the flag as four and placing
the border two columns short — a worse notch than a lone emoji's one.

Handle flags as a category, not per-country: an indicator is measured as one
cell, so any flag sums to two in both authorities. The indicators are shared
across flags (🇨🇦 and 🇨🇳 both start with C), so listing a single flag would
half-fix its neighbours and skip the rest — the block is the only correct unit.
prompt_toolkit's cache is now always swapped when the renderer draws emoji
narrow (not only when the lone allowlist is non-empty), since flags need it
even with an empty MESHTERM_NARROW_EMOJI.

Add the add-narrow-emoji skill: a classify.py that reports an emoji's
codepoints and its width under wcwidth/Rich/pt (now and fixed) and names the
class + action, plus a SKILL.md procedure. It steers a lone wide codepoint into
the allowlist, recognises flags and VS16 as already handled, and refuses ZWJ /
keycap / modifier clusters (their parts would misalign) — encoding the
per-glyph, confirm-visually, never-blanket discipline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:41:03 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 c155aa4216 Chat ragged border: narrow a curated allowlist of lone emoji
The residual after the erase_down smear fix: a chat row with a lone emoji
like 👋 framed a column short, its right border notched in with a blank
beside it. Root cause is the same three-party width disagreement — Rich and
prompt_toolkit both measure 👋 as two cells, the renderer draws it in one —
but for a lone codepoint (no VS16 selector) the existing emoji_width fix
didn't reach it: that path only stops Rich promoting a narrow base, and 👋's
base is already wide.

There is no rule that separates the lone emoji this terminal draws narrow
(👋) from the ones it draws wide (📡, 💬, the menu icons): the split is the
font's glyph, not the codepoint, and it can't be probed (a cursor probe
reads the PTY, not the renderer). So this is a curated allowlist — seeded
with the one confirmed glyph, extended without a code change through
MESHTERM_NARROW_EMOJI (empty string turns it off). It only engages once the
renderer is judged to draw emoji narrow, so a wide-drawing terminal is left
untouched and nothing regresses; an unlisted narrow emoji is no worse than
before (the erase_down repaint still covers its smear).

Because it's prompt_toolkit that places the panel border, a lone-emoji
correction patches both width authorities (its _CHAR_SIZES_CACHE and Rich's
_cell_len), where the VS16 fix only needed Rich. With 👋 measured one cell
everywhere it also stops tripping _has_wide_glyph, so a 👋-only frame keeps
the fast differential paint instead of forcing a full repaint.

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