Commit Graph
209 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 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 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 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 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 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 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
Jean-Pierre Martineau 30bffd70c3 Revert "Scroll smear + ragged border: align the width model to a one-cell emoji"
This reverts commit 1254f72b8239e8e85226cc27d28504e50d7585c3.
2026-07-16 23:41:50 -04:00
Jean-Pierre Martineau 966900a9dc Revert "Drop the +1 last-column width reclaim"
This reverts commit 62422c176426be11f356d92d4d081bd65eb1961e.
2026-07-16 23:28:37 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ee047f1212 Drop the +1 last-column width reclaim
The MESHTERM_FULL_WIDTH reclaim reported the terminal one column wider than
probed, to draw a final column some terminals under-report. With the emoji
width now aligned to the terminal, that extra column pushes the frame past the
real screen edge and tears the layout. Remove it: the size probe is used as-is
again (_WidthExtendedOutput, _RECLAIM_LAST_COLUMN, _resolve_output, and the
MESHTERM_FULL_WIDTH gate all gone), and the app renders to prompt_toolkit's
default output. Drops tests/test_session_width.py with the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:26:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 20991acc91 Scroll smear + ragged border: align the width model to a one-cell emoji
The erase_down workaround stopped the lingering but left the real fault in
place, which showed as the leftover artifact: a bordered row with an emoji
rendered one cell short, so its right border sat a column in with a blank
beside it.

Root cause is a three-party disagreement about one glyph. Rich lays out every
row (and places the right border), prompt_toolkit diffs against the last frame
stepping a *relative* cursor by its own width model, and the terminal draws the
result. Rich and pt both measure an emoji as two cells (wcwidth); the terminal
draws it in one. That one-cell gap is both symptoms: pt's cursor drifts a column
past the terminal and strands stale cells (the smear), and Rich sizes the row
one short so the border lands early (the ragged edge). "Drawing the border last"
can't reach it — pt diffs per row across all columns, and the terminal has
already eaten the cell no matter the compositing order.

Fix it at the source: teach both width authorities the terminal's truth. New
glyphwidth.align_emoji_cell_width() patches pt's _CHAR_SIZES_CACHE and Rich's
get_character_cell_size so an emoji measures one cell — while CJK/fullwidth
glyphs (which terminals do draw two wide) stay two, and the width-1 marks and
braille are untouched. With all three parties agreeing, ordinary differential
painting stays synced (no lingering) and Rich pads rows to a flush border (no
notch) — so the erase_down/_has_wide_glyph machinery is gone entirely. Applied
at TuiSession construction, before anything renders; gated by MESHTERM_NARROW_EMOJI
(default on), the sibling of the MESHTERM_FULL_WIDTH column-reclaim shim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:09:16 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7e4fa23205 Scroll smear: full-repaint any frame with an emoji, not just floating dialogs
The lingering characters are an emoji-width desync, and the earlier fix aimed
at the wrong glyph and the wrong screens.

prompt_toolkit paints differentially with a *relative* cursor, advancing by its
own width model. That is sound only while every glyph is one cell. An emoji is
width-2 in both Rich and pt, but the terminal draws it in a single cell, so from
the emoji onward the row's cursor model is one column off. On the next paint, if
the emoji itself didn't change it is skipped, and any cell pt repositions to on
its right lands a column early — stranding the stale cell it meant to overwrite.
That is the lingering, and it needs an emoji on the line (confirmed: 👋).

The node-type marks (▲●■◉○★) and chart braille the previous attempt chased are
width-1 in Rich, pt, and the terminal — they never desync. That is why scoping
the repaint to floating route-graph dialogs did nothing: the real trigger is
emoji, which live in chat content and menu icons on ordinary full-frame screens.

Fix the invariant instead of the screen list: differential paint is only sound
when every glyph is one cell, so when a composed frame carries a width-2 glyph,
drop pt's cached frame (_emit → _invalidate_last_frame) and let the next paint be
a full erase_down + redraw — every cell rewritten contiguously, the terminal's
own cursor advance keeping the row aligned, nothing stale left behind. Checked at
compose time, so it covers a scroll, resize, or timer tick, on any screen. Pure
width-1 frames keep the fast differential paint. Drops the _SCROLL_ACTIONS /
_has_float dispatch hook the float-only theory needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:47:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 9ce1e63dc9 Dialogs: full-repaint on scroll to clear a smear off the static frame
The real scroll-smear cause, and why the previous fix could not work.

A floating dialog draws a small box over a static background: the frame
around it and the box's own border are identical frame-to-frame, so
prompt_toolkit's differential renderer never rewrites them. Its paint is
relative-positioned (cursor_forward/backward from pt's own assumed
column, advanced by pt's own width model). A glyph the terminal renders
wider than pt models — a route graph's braille lanes, node-type marks, a
chart's braille — desyncs the terminal cursor and pushes a stray cell
onto that static frame, where it lingers because pt believes the cell is
unchanged. Full-frame screens masked this: their body redraws wholesale
on scroll, healing the smear itself (the map scrubs only its static edge).

The prior attempt (822db52) filled pt's cached frame with a sentinel to
force an in-place rewrite. But that keeps previous_screen non-null, so pt
never takes the erase_down + cursor-reset-to-origin branch — it repaints
the same desynced garbage. Only dropping the cached frame triggers the
full erase_down that blanks the terminal and re-syncs the cursor. It also
only covered three route-graph screens, missing scrolled result windows.

Fix, structural rather than per-screen: when a scroll/movement action
reaches a screen that is floating over a backdrop, drop pt's remembered
frame so the next paint clears the whole terminal and redraws clean. No
smears flag, no geometry — any floating dialog, any wide glyph. This is
the "clear the screen before redraw" the symptom calls for; the cost is
one full repaint per dialog scroll, negligible for a modal box.

Removes the smears flag and _reflush_frame; the map's own edge scrub is
untouched. Full suite: 807 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:11:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 82ed64ffcd Dialogs: reflush the frame on scroll so a braille body can't leave a smear
A dialog that draws a route graph (message paths, the trophy case, the packet
viewer) paints braille and node-type marks. On a terminal whose font lacks a
glyph, the double-width fallback shoves that row right and smears a static cell
— a box border — that prompt_toolkit's differential renderer never rewrites (it
only redraws each row up to its new content and only erases a shortened tail),
so the fragment lingers once a scroll slides the offending row away.

The map fixes its own case by scrubbing two right-edge columns after a pan, but
that only works for a full-frame panel whose border sits at the terminal edge;
a route-graph dialog is a centred box (or, on an empty stack, a full-frame base)
whose smear can land anywhere, so edge geometry can't target it reliably.

Instead: a screen opts in with `smears = True`, and on a scroll key the session
reflushes — fills pt's remembered frame with a sentinel so the next diff rewrites
every cell *in place* and erase-to-end-of-lines each tail, scrubbing the leftover
wherever it sits. Because the cached frame is kept (not dropped), pt overwrites
rather than erasing first, so there's no blank-flash flicker. Plain dialogs keep
the efficient differential paint untouched; the map/atlas keep their own edge
scrub. Functionality is unchanged — only the smear is cleaned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:38:44 -04:00