Commit Graph
225 Commits
Author SHA1 Message Date
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
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
Jean-Pierre MartineauandClaude Opus 4.8 583be7c4f7 Compose fields: share one UTF-8 byte gauge, and put it on the courier entry
Lift the chat compose bar's byte budget into a reusable gauge and reuse it
wherever a field feeds a size-capped LoRa packet.

- prompt.py gains byte_counter/byte_style and the DM/CHANNEL byte limits, plus
  a byte_limit option on TextScreen: a field so limited rides the shared
  used/limit gauge (right-pinned, green→yellow→orange→red) and blocks Enter
  once the text tops the cap, with a "Too long by n bytes" note. Ui.text and
  session.text thread byte_limit through.
- chat.py drops its private copies and calls the shared byte_counter; the
  limits now come from prompt.py.
- The courier's "Message for …" entry passes byte_limit=DM_BYTE_LIMIT so a
  queued message is gauged and capped the same way a live chat message is.

Also fixes a courier scheduling bug uncovered here: the "When it's next heard"
row carried the value None, indistinguishable from session.select's own cancel
sentinel — backing out of the picker silently queued the message. It now
carries its own WHEN_HEARD sentinel, so Esc cancels and the row means no
schedule.

Full suite green (806).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:05:23 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ec41de0339 Route graph: draw the featured path in plain white, and give the packet viewer one
Two related passes on THE route graph (ui/pathgraph).

1. **Featured path is always white.** The Message paths dialog and the
   Trophy case coloured their selected/scored path by reception SNR
   (theme.snr_rgb). But on a graph that draws only *one* real path over a
   grey backdrop, colour carried no comparison — it just competed with the
   node-name hues on the same canvas. Drop the SNR tint: the selected/walked
   path reads white (the single-walk convention), the unused paths grey.
   Removes the now-unused theme.snr_rgb / _SNR_RGB and updates both captions.

2. **The packet viewer draws a relayed packet's route.** A `packet` frame
   that actually crossed a relay now slots the fan-lane graph between its
   parsed detail (heard/from/class/via) and its extras — origin → relays →
   us, the same picture Message paths and the Trophy case draw. A direct
   arrival skips it (its `direct — no relays` via row already says all there
   is). render_body splits into head/tail grids sharing one label-lane width
   so the rows stay aligned across the graph; the dashboard threads a
   node-type resolver through so relays wear their map marker (▲, …).

Full suite green (806).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:04:58 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 310783c6a3 Repeater admin: float the login over the node picker, not a blank frame
The admin-login password prompt ran on an empty stack (the picker was popped
when it returned), so session.text pushed a blank base and the prompt — and any
"wrong password" rejection — floated over an erased background.

open_repeater_admin now redraws the node picker as a static backdrop (extracted
admin_picker_rows, shared with the live pick so they can't drift) and keeps it
pushed across _login, with the picked node highlighted. The password box and the
rejection float over the picker, matching the device-picker and trophy-case
dialogs. floating=True stays on the prompt as a belt-and-suspenders fallback.

Also drop the inert floating=True on tx_optimize._resolve_password: that
resolver only runs on the scripted CLI path (PlainUi, no stack), so the flag
never did anything — the menu path logs in inside the pushed sweep screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:33:12 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 59b26ae616 Dialogs: float confirms/prompts over their backdrop, never replace the screen
Enforce the UX standard "modal popups over the pushed backdrop" across three
flows that were drawing a full-frame replacement instead of a floating popup:

- Trophy case: keep the record browser pushed as a static backdrop while its
  delete-record / delete-discipline / delete-all confirms float over it, and
  run any trace hand-off after popping.
- Device picker: pass the picker's rows through to the removal confirm so it
  floats over a redraw of the list (backdrop_items/backdrop_default on
  confirm_startup), with the row being removed left highlighted.
- Remote-admin password: add an opt-in floating flag to Ui.text so a mid-flow
  password prompt draws as a centered popup over a blank base instead of the
  full frame; apply it to repeater_admin and tx_optimize logins. Tool-entry
  text prompts (Trace/tx target fallbacks) stay full-frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:07:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 115fe6895d Dialogs: multi-layer floats, red data-loss tier, unknown-relay hashes
Three independent consistency passes:

1. **Message paths: unknown relays as grey full-hash**
   The message-paths dialog showed unnamed relay hops as a single lit byte (e.g.
   `e8`). Now they render at the device's path-hash-mode width, muted grey, and
   annotated with their addressed byte — `e839f2 (e8)` — so the rows stay
   readable and cross-reference the route graph's byte labels (matching named
   hops' trace presentation). Added `hash_as_name` flag to path_text widget.

2. **Dialogs: multi-layer compositor + backdrop for empty stack**
   Opening a confirm over a floating popup used to stretch that popup to
   full-frame (the single-float compositor's bug). Now the compositor maintains
   a pool of _MAX_DIALOG_LAYERS=8 centered-box floats, each rendering one layer
   above the background — so a popup and its confirm both stay their own size.
   Also: button_dialog/typed_confirm/autocomplete now float over a blank base
   when the stack is empty (via _run_dialog_screen), guaranteeing popups render
   as small boxes on an empty stack, not full-frame chrome. Simplified
   message_dialog to delegate its backdrop logic to button_dialog.

3. **Data-loss dialogs: red `destructive` tier**
   Added a second caution tier to the dialog API: danger=True stays amber
   (disruptive: discard edits, reboot), destructive=True is red (irreversible
   data loss). Updated: single-record delete, channel "clear slot", courier
   "clear finished" (new confirm), and startup "remove device" (splash confirm).
   Per UX spec: bulk/irreversible deletions gate behind typed_confirm (also red);
   single-record delete is a red Cancel/[verb] button dialog.

All changes are backward-compatible. Full suite: 793 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:30:58 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ed391ab16e Trophy case + route graph: node-type marks, SNR colour, reliability
Route graph (both the record card and the Message paths dialog):
- Relays now wear their own map marker — ▲ repeater, ■ room, ◉ sensor —
  instead of a generic ●, via a node-type resolver threaded into
  route_graph_style (built from contacts; an untyped relay keeps the dot).
- A shared node-type key (★ you ▲ repeater ● node ■ room ◉ sensor) sits
  under every fan, minted once as widgets.node_type_legend.
- The drawn path is coloured by SNR (the SNR palette): the record's walk
  by its weakest link, the Message paths selection by its reception SNR;
  unselected paths stay gray. Per-path, not per-hop — overheard frames
  only carry the reception reading.

Record card:
- New 'reliability' stat: the success rate of traces aimed at the walk's
  far node (the one delivery figure history can answer — a failed trace
  records its target, not its route), with the sample count beside it.
- Area drawing reworked per feedback: our star is drawn last so it is
  never hidden, hops are pinned in their node-name hue (star stays
  yellow), the fill is one flat tone (even-odd shades only the enclosed
  side; no dimmer interior wash), and the box is larger.

New shared helpers: theme.snr_rgb, widgets.name_rgb,
trace_runner.make_node_type_resolver, repository.target_trace_counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:26:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 8a1c069a96 Trophy case: draw a record's swept area beside its stats
Opening a record now sketches the walk's enclosed area on a braille
mini-map to the right of the stat lanes — us at the origin, every
positioned hop pinned in its shared map colour, no labels — projected
onto the same local km plane the area was scored on. The far-point lane
names the node the walk reached, and the card scrolls (PgUp/PgDn/Home/
End) once it outgrows the frame while the arrows keep driving the actions.

New shared widgets.node_marker / self_marker mint the map-palette glyphs
as (glyph, rgb) pairs for the braille canvas, the way _NODE_GLYPHS mints
them for a Rich row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:17:07 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f1b06bf2e2 Trophy case: draw each record's walk on the shared route graph
A record's dialog showed its route only as a text line; add the fan-lane braille
graph above it so a walk's shape reads at a glance — us starred at both ends,
each relay marked and labelled by its first hash byte, the same presentation the
Message paths dialog draws a delivery with.

Extract that presentation into widgets.route_graph_style() — the glyph/label/
colour callbacks both screens hand render_path_graph — so the two graphs cannot
drift; pass source==self_name to draw both ends as us for a walk. A route that
revisits a node (a boomerang's mirrored return leg) collapses to distinct-in-
order before drawing, since the depth graph can only seat a node once; the route
line below still carries every hop. Move the shared _UNKNOWN marker down to
map_render alongside _SELF/_NODE/_REPEATER so widgets can reach it without a
cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:18:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 4f78ba5a54 Path graph: keep node labels whole until they overflow the canvas
The fan-lane route graph clipped every label to a fixed 12-cell budget, so
node names past twelve characters ellipsized even with room to spare. Cap the
label at the physical canvas width instead: a name is kept whole unless it is
wider than the whole canvas (nowhere for the cells to go), and the placement
pass — which already slides an endpoint's label inward or drops a crowded
relay's — decides where it lands. Message-paths endpoints now read in full.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:50:56 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 ccac419880 Trace: score every walk home into the trophy case
Both trace tools now feed the boards: on a successful trace the session
derives the walk's spec, route, and stats from the reply and offers it to
every discipline, and a walk that sets a record surfaces a ★ note under the
run's aggregates (the walk still records nothing extra — it's the same one
trace per transmission). Scoring is defensive: a hiccup never breaks a
trace.

Trace path can open armed on a given spec, so the trophy case's Trace this
path reopens a record's exact route ready to walk again. The shared route
graph is unchanged; its docstring just drops the pathfinder example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:24:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5020c924f3 Trophy case: browse the record-setting walks from the main menu
A read-only screen over the boards (tools/records.py opens
ui/records_screen.py): the six disciplines, each under its heading with a
one-line, word-wrapped description of the game, then that discipline's
records ranked best-first with the walked route through the shared path
widget. A discipline holding records at more than one hash width tags each
row with its width.

Opening a record floats its full story — every stat it was measured by, the
route and spec, when and by which app version it was set — with two actions
besides Back: Trace this path reopens Trace path with the record's route
prefilled (a claim worth re-testing), and Delete removes it. Deletion comes
in three grains: one record, one discipline, or everything (the last two
behind a confirm, the wholesale one typed). The CLI face prints the boards.
Adds the 🏆 icon to the lexicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:59:53 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f1385666a8 Records: score every walk into a trophy case, per discipline
A walk is any trace — it starts and ends at our node (a Trace path route
directly, a Trace target boomerang just the same) — so every walk home can
be measured and scored. services/records.py owns that scoring: measure a
walk once (distance, reach, node counts, weakest link, enclosed area) and
offer it to every one of six disciplines it places in — Longest distance,
Farthest node, Most nodes, No revisits, Weakest link, Biggest loop.
walk_from_trace turns a reply into its re-walkable spec, canonical route,
and stats in one call.

Records persist per (category, hash width) in the discovered_paths table
(schema v11) — the 64-byte path field makes each width its own game — kept
top-5 with spec-dedupe and worst-first pruning, stamped with the app
version. Disciplines carry plain descriptive names; their ids stay stable
database keys so stored records survive the naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:16:18 -04:00
Jean-Pierre MartineauandClaude Fable 5 7ac50ade53 Path graph: extract the fan-lane route graph into a shared widget
The Message paths dialog's braille route graph becomes ui/pathgraph.py —
layered paths with per-layer colour and draw priority, caller-supplied
glyph/label callbacks — so the Pathfinder can draw best-ever/session/attempt
routes through the same widget. Endpoint labels lose their beside-the-marker
restriction: they place above/below like relay labels (with an edge-clamped
anchor so a name at the canvas margin still lands), falling back beside only
as a last resort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:14:01 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 efb26dd93f Trace: drop the 8-byte width from the Path width picker
The path-hash mode is a four-value enum (1/2/3/4-byte routing hashes), so a
device's width never resolves above 4 and 8 was only ever a value you could pick
by hand. Eight bytes puts 64 bits of every hop's key on the air per hop — airtime
we would never spend for collision resistance a LoRa mesh cannot need. Offer the
range that is actually useful (1/2/4); the device default still lands inside it.

The wire decoder keeps its 1/2/4/8 flag mapping, so received traces addressed at
any width still read back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:17:18 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 32734cc51a Trace path: reverse a walk to measure it the other direction
Radio links rarely read the same both ways — a route that carries cleanly
outbound can be marginal on the return — but Trace path only ever walked a
route in the direction it was built. Add a Reverse path action that flips the
walk's hop order (us → a → b → c becomes us → c → b → a), so one composed
route can be measured in both directions.

It lives only in path mode: a target-mode route is a symmetric boomerang, so
reversing it changes nothing. Reverse sits in the build-path group beside
Compose and adopts the flipped spec exactly like any other path change — the
forward run's aggregates clear (they described the other direction) and Trace
stays idle, so you flip, then trace; reverse again to go back. Both readings
persist to history for side-by-side comparison. Disabled, with the same
"compose a path first" note as Trace, until there's a multi-hop path to flip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:11:28 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5a63574a9e Trace path: seed Compose from the route on screen, not the empty spec
The composer opened seeded from the stored spec, which is empty until a path
is first composed — so the very first Compose opened blank even though the
screen was already showing the auto-resolved route (the last stored walk, or
the device/history route in target mode). Seed the composer from the visible
route instead (the effective spec: the composed one if it stands, else the
auto plan), so opening Compose always resumes from what the user sees.

The adopt test still compares against the stored spec, so re-confirming an
auto plan verbatim just pins it — the same outcome as adopting it by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:07:46 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 53b7aa5724 Trace path: set the Compose row apart from the trace settings
Target mode groups the build-path actions (Compose, Explore) and sets them
off from the width/samples settings with a blank line. Path mode has no
Explore, so Compose sat flush against Path width with no such break. Give
path mode the same grouping — a blank line under Compose — so both modes
read with the same rhythm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 03:06:34 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 965783d276 Network (TCP) companions: connect, remember, and manage from the picker
Add TCP alongside serial and BLE as a first-class companion transport, end to
end: discovery/connection/selection learn a tcp: stable_id and host:port target,
--tcp and TCP config profiles reach a WiFi board or network proxy, and a network
companion -- which isn't discoverable -- is rebuilt into the picker from its
remembered endpoint.

Device picker gains the network-device affordances:
- "Add a network device" prompts for host:port, smoke-tests it, and remembers it.
  The row is marked "- experimental" in the cautionary hue.
- Delete on a network row opens a chromeless Cancel/Remove confirm (Cancel left,
  Remove right and default: Enter removes, Esc cancels), then DeviceStore.forget
  prunes it and re-draws the list. Serial/BLE rows stay inert.
- The "Del remove" footer atom now follows the highlight, surfacing on the box's
  bottom border only while the cursor sits on a deletable row; the box is sized
  to the fullest footer so it never widens mid-navigation.

Folds in the accompanying courier, channels, and config refinements built along
the way. Screens stay readable at 72 cols; full suite green (745 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:38:11 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a27b5951b7 Copyright: one canonical notice, hung under the logo
Consolidate the copyright into a single meshterm.copyright_notice() beside
__version__ (was defined twice, disagreeing on wording, holder, and year
span). Every startup splash draws from it, so it can never drift.

Place it immediately under the wordmark, right-justified to the logo edge, so
the two read as one signed block. Simplify the display logic: it now rides
every splash that shows the logo (picker, spinner, PIN, notices) rather than
being dropped after the first selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:07:53 -04:00
Jean-Pierre MartineauandClaude Haiku 4.5 216a6549f5 CLI: strip the framing from scripted output, keep a legible splash
Scripted runs print straight to the terminal, so a tool's heavy Rich
framing — a Panel's border, a Table's full box, Typer's boxed help
panels — is chrome between the reader and the data. Lighten it to a
middle ground: PlainUi de-frames tool output (Panel/Table titles become
bold-accent headings, tables keep just a single rule under their header),
and the Typer help drops its rounded Options/Commands/Error panels for
the same heading-over-content shape while keeping rich mode's colour
(yellow usage, cyan options, green switches). Functional, easy to scan,
none of the interactive menu's weight. The full-screen TUI is untouched.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-14 01:44:26 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d977aeb22a Channels: mute a channel's notifications from its detail screen
Some channels — an automated #wardriving beacon, a chatty public relay —
carry traffic worth recording but not worth being notified about. Muting
a channel now stops its new messages from raising the unread badge: the
recorder skips the unread bump for a muted channel (its transcript is
still written to history), and muting zeros whatever unread it had so the
badge drops immediately. The mute lives in a new MuteStore
(<config_dir>/mutes.json), keyed by the channel's intrinsic identity so
it follows the channel across slot moves and survives restarts.

The toggle is a state-reflecting row in the channel-detail subscreen
(🔕 Mute / 🔔 Unmute), and the manager list marks a muted channel with a
🔕 in its (then always-empty) unread lane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:18:41 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 6aecb58e59 Battery: use the standard BLE charging flag when a device exposes it
The companion protocol reports only a battery voltage, so charging is a
voltage-trend inference. Some devices could instead expose the standard
GATT Battery Service (0x180F) whose Battery Level Status characteristic
(0x2BED, Battery Service 1.1) carries a real, firmware-reported charging
flag. Dumping the GATT table shows no current MeshCore build implements
it — the BLE profile is just the Nordic UART pipe plus DFU — but wire it
up now so it lights up automatically the day one does.

get_hw_charging() reads 0x2BED best-effort over BLE (reaching the bleak
client the forced-teardown path already uses) and prefers its flag; any
failure — service absent, read refused, short value, non-BLE transport —
is a quiet None, so the poller falls back to the inference and every
current device behaves exactly as before. Verified against a live device:
the read resolves and returns a clean, steady None. The 0x2BED bitfield
decode is a pure function unit-tested against the spec's Charge State enum
so the parse is correct ahead of any hardware that populates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:59:19 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e73cea9f42 Battery: read the charging trend robustly so noise stops faking it
The firmware exposes no charging flag, so charging is inferred from the
terminal-voltage trend. The old test compared the first and last samples
in the window against a 15 mV threshold — but a single-cell LiPo sags
tens of millivolts under each LoRa transmission and springs back when the
radio idles, so a lone sagged sample at either endpoint read as a
+tens-of-mV "rise" and the gauge falsely showed charging on a discharging
or resting pack.

Read the trend from the median of the window's older half against the
median of its newer half (discarding the transient sag/recovery spikes)
and require a large, sustained rise, with hysteresis: hard to start
(25 mV), held while still clearly climbing (8 mV), and dropped the moment
the pack goes flat — topped off or unplugged.

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