Commit Graph
194 Commits
Author SHA1 Message Date
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
Jean-Pierre MartineauandClaude Opus 4.8 6823519503 Trace: size the reply-wait to the route length
A trace is one packet that must travel the whole path out and back before
its reply reaches us, so a flat 10s wait cut long walks off mid-flight —
the packet completed the circuit on the mesh, but our wait had already
expired, so we logged a phantom "no reply" for a route that worked.

run_trace now sizes the wait to the hop count (base + per-hop, capped)
when the caller doesn't pin a timeout, counting the transmitted path
(return leg included). An unknown path-less flood falls back to the old
flat budget. run_traces and the mock thread the optional timeout through,
so every trace surface — live screen, CLI, probe, TX optimizer — inherits
the scaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:05:47 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7d3e4b4412 Channels: bound the never-rejecting slot probe, cache the capacity
The channel manager still felt uncached: its capacity read walked all 64
slots one-by-one on firmware that never rejects an out-of-range index
(the Waymarker), on every open, outside the session cache.

Two fixes for the two slot probes:

- read_channel_slots now stops after a run of MAX_CHANNELS consecutive
  empty slots. The manager packs channels from slot 0 up, so an unbroken
  empty run that long means every configured channel has been seen — safe
  against within-capacity gaps, which can't reach that length. This cuts
  every (re)probe on never-rejecting firmware from 64 reads to ~11, felt
  after each channel edit (a mutation invalidates + reprobes).

- channel_capacity deliberately keeps walking to the firmware's rejection
  (it must discover a larger rejecting firmware's real ceiling — the
  empty-run guess would truncate an empty 12-slot device to 8). Its only
  slow case is never-rejecting firmware, bounded instead by caching: a new
  DeviceState.channel_capacity() holds it for the session (a hardware
  constant, dropped only on reconnect) and prewarm warms it after login,
  so the manager opens from cache like every other screen.

706 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:38:05 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2f0e2687b0 Device state: warm the slow caches in the background after login
Warm opens are instant, but the first open in a session still paid the
one-time reads (contacts, the channel-slot probe) in the navigation path —
so opening the menu after login, then the first Chat, read as two separate
stalls. Fold them into one quiet wait behind the menu instead.

DeviceState.prewarm() spawns a tracked background task that fetches contacts
then the channel slots (sequential — concurrent reads collide on the BLE
UART), best-effort and cancelled on reset/close. _resume_monitor kicks it off
once the link is up (only when connected — a deferred connect warms lazily on
first use). A getter reached before the warm finishes awaits the same
in-flight fetch, so it never doubles a read.

The channel probe itself still walks every slot on firmware that doesn't
reject out-of-range indices; bounding that is a separate change, deferred on
purpose — a long one-time poll behind the menu is fine for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:03:22 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 e07ef3b379 Device state: cache the picker reads that run before each screen
The session cache fixed the reads screens make on open, but opening the
heaviest screens still stalled for seconds — because the pickers and
screen-openers that run *in front of* those screens were still reading the
radio directly. The menu wraps prompt-gathering and execution in one busy
overlay, so a Trace or Chat open pays its picker's slow round-trips before
the (already-cached) screen behind it ever builds, which is why caching the
screens alone showed no improvement.

Route the interactive-open reads through ctx.devstate too:

- Chat's conversation picker read the channel-slot probe *and* the contacts
  table on every open (~5s) — both now come from the cache (channels mapped
  from the cached slots via the new _channels_from_slots, contacts served
  cached). The CLI list/send path keeps reading fresh.
- Trace and TX-optimize target pickers now read contacts from the cache.
- The repeater-admin screen and the channels manager (its slot probe, on a
  local copy so mutations still re-probe the fresh layout after invalidation)
  read through the cache.

Small, session-stable reads (self-info, build_snapshot's tuning/path-hash)
are left as direct reads — they were never the seconds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:41:58 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 736b420a4f Device state: session-cache the reads screens make on open
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.

DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:09:14 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b94e0469db Settings: allow the pytest and beep commands used while iterating
Adds a handful of read-only test-run invocations (targeted pytest selections, a UTF-8 full-suite run) and the done-beep to the permission allowlist, so routine iteration doesn't stop to prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:52:36 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 5ee3bb998c Activity charts: bucket by local time in SQL, storing UTC
The Time Machine's day / hour / quarter-hour activity feeds now group on datetime(observed_at, 'localtime') in SQLite — rotating each stored-UTC timestamp into the machine's zone per-instant (DST-correct) before the key is sliced — so bars break on local midnight and local hour boundaries in any zone, half-hour offsets included, not on UTC edges. The window 'since' filter stays on the raw UTC column: the window is an absolute span, only the labelling is local. The old caller-side current-offset histogram rotation is gone, and _fill_days / _fill_hours and the axis tick labels now walk local wall-clock keys to match. Tests derive their expected local buckets through the same rotation so they hold in any runner zone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:24:29 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 29b1d66c7c Message paths: stack relay labels above/below in the name's hue
Each relay's hash byte now sits straight above or below its marker, fanning outward from the centre line, instead of beside it — drawn in the node's own name-palette hue so it reads as that mesh name and never crowds the line running through the marker. Endpoints keep a beside-the-marker label since they ride the canvas edges. The graph box is sized and centred on the fan's real up/down reach (rarely symmetric) rather than a mirrored block, so it spends rows on the paths. MapCanvas gains an avoid_dots placement and a labels-only vertical anti-stacking margin, so a label may sit immediately above/below a single-glyph marker while dense text still can't silt into a block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:06:51 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 bdd4650133 Full width: reclaim the terminal's under-reported last column
On some terminals prompt_toolkit's size probe reports the window one column
narrower than it really is, so the frame draws to columns-1 and the true
last column sits unused (selectable to the right of the border). Wrap the
real terminal output in _WidthExtendedOutput, which reports columns+1 from
get_size(); since the renderer and the frame compositor both key off that,
the final column now gets drawn.

Gated behind _RECLAIM_LAST_COLUMN (env MESHTERM_FULL_WIDTH=0 to disable) so
it can be turned off on a terminal whose probe is already correct — there
the extra column would fall off-screen and wrap — and eventually promoted
to a user setting. Test-supplied outputs are never wrapped, so headless
rendering keeps its exact size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:47:08 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 dfd7d2332d Battery gauge: a braille fuel gauge pinned to the header's right edge
Add a one-cell braille battery indicator to the status bar, shown only when
the companion reports a pack. The cell fills bottom-to-top in four tiers —
green healthy, orange at a quarter, red near empty — with the true percent
beside it; it blinks red↔dim when critically low and sweeps a filling
animation while charging. A session-long BatteryService polls the pack,
maps millivolts to a state-of-charge estimate off a single-cell LiPo curve,
and infers charging from a rising terminal voltage (firmware exposes no
charging flag). The header pins the gauge flush right and the activity
pulse cedes exactly the cells it needs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:12:43 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 cba3be3f69 Atlas: a sparser fan, every node named to the right of its icon
Halve the neighbours the canvas fans out (a wider _FAN_SLOT_DOTS) so the
shape reads instead of crowding, and name each non-selected node just to
the right of its marker via a new _label_right — the walk's reading way.
The selected node keeps its smart two-sided placement. Widen the east
gutter (_PAD_X_DOTS) and cap fan labels (_FAN_LABEL_W) so even the fan's
outermost name lands without running off the edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:49:17 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 b508f2fe27 Busy overlay: skeleton card with a Knight-Rider braille scanner
Replace the between-screens ring spinner with a titled skeleton card: a
heading, the one-cell working chip beside a "reading from <device>…"
caption, and a two-row braille LED bar in place of the old ring.

The bar is a Knight-Rider scanner — a red head sweeps left↔right and
bounces off both ends over a dark-gray track, dragging a persistence-of-
vision comet trail (per-tick decay) with a white-hot core. It rides the
same tick that spins the chip and fades in from black with the card, so
the wait reads as work rather than a frozen bar.

Thread an optional title/caption through busy_overlay so each gap names
the screen being fetched and the companion it's reading from. Give the
CLI progress dialog the same liveness: a spinning chip on every row and a
slim braille meter, with no dead track on indeterminate tasks. Drop the
now-unused RingSpinner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 03:43:15 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 71b7d44263 Splash footnote: attributed copyright with auto-extending year range
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:34:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 350cf67360 Day/hour ticks: fit to each label's real width, not the longest
The axis reserved a fixed worst-case budget per tick (chars // 8, sized
for "Jul 12"), so short labels like bare day numbers were dropped even
where they had ample room — the 7 d window lost a day it could plainly
fit. Replace the estimate with _fit_ticks: step the tick count down from
every bar until an even subset actually clears the collision rule for the
labels it would draw, rebuilding the labels each try so bare "7" is
measured as one cell, not six. Both ends stay annotated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:12:12 -04:00
Jean-Pierre MartineauandClaude Fable 5 bc1ba7de4c Day charts: even-spaced date ticks back, an hour per column at 24 h
Revert the axis ticks to the evenly-thinned dated labels — one per day
where they fit, else a subset keeping both ends, the month printed on the
first tick and each rollover. The packed right-first scheme read worse; the
even-split bars and the honest y-marks stay. _thinned_picks now holds the
shared fit-or-subset choice.

The 24 h window charts an hour per column instead of a day (its name is
"24 h", not "1 day"): a new Repository.hourly_series buckets by UTC clock
hour like daily_activity does by day, _fill_hours fills the quiet hours,
and _hour_ticks labels the axis with local HH:00 times closing on "now".
Both feed the same bar machinery — only the resolution and labels differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:13:49 -04:00
Jean-Pierre MartineauandClaude Fable 5 8d2096767f Day charts: notch only between days, first bar flush with the axis
The braille glyphs already carry an intra-cell left margin, so the old
leading GAP on the oldest day read as double padding against the axis
border while the right edge showed single. Boundaries now pay the n-1
notch dots and the bars split the rest evenly, flush at both borders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:50:23 -04:00
Jean-Pierre MartineauandClaude Fable 5 7f1d8e7d10 Day charts: even dot-split columns, packed right-first ticks, honest y-marks
Day bars now share the chart's dot columns via one floor-edge split
(_day_spans), so widths never differ by more than one dot and the wider
days interleave with the narrower ones instead of pooling on the left.
Every day at least three dots wide opens with its notch — the oldest
day's doubling as its clearance off the axis border.

Axis ticks pack as dense as two blank cells between labels allow,
claimed newest day first, oldest second, then right to left, so a
crowded axis thins from the middle-left instead of down to an evenly
spaced handful. Months are written on the calendar anchors (the oldest
day and the first of a month) rather than tracked across placed ticks.

The y-gutter's ┤ tick crosses a braille row at its third dot, so each
mark now quotes the value of a bar peaking at that dot — computed with
timeline_rows' own baseline math — instead of the row's top edge, which
sat a dot above where the glyph points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:09:12 -04:00
Jean-Pierre MartineauandClaude Fable 5 ff76f41736 Allowlist pytest and git add/commit so routine runs stop prompting
Generalizes the one-off pytest entries to prefix rules across the
python spellings actually used (python, venv exe, Bash and PowerShell)
and covers the commit workflow CLAUDE.md already sanctions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:07:43 -04:00
Jean-Pierre MartineauandClaude Fable 5 060f4b1eb6 Message paths: hash-byte labels, white-over-gray edges, a taller graph
The graph now speaks in path-hash bytes: every relay gets its marker
plus the first byte of its hash in the marker's colour, while the rows
beneath carry the names annotated trace-style (YUL-Poly (3d)), so the
two halves cross-reference. Edges drop the SNR gradient — the selected
path draws white over the unused paths' gray, which is the one question
the graph is asked. Columns are the distinct depths spread evenly across
the width, height follows the fan's real reach (lanes are no longer
burned on relay-less direct paths), and labels sweep for a spot clear of
the drawn lines (MapCanvas grows avoid_dots) before overprinting one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:54:54 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 adc0e9f04d Clearing a channel drops its unread from the global count
Unread is tracked in-memory in ChatService, keyed by conversation. A
cleared channel's key stayed in that map with its count intact, so the
header badge kept counting messages nobody could read anymore. Route
the clear through set_active so the key's unread is popped before the
slot is wiped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:05:19 -04:00
Jean-Pierre MartineauandClaude Sonnet 5 a52dfc7b12 Dashboard: start with the newest packet highlighted
The feed used to open with nothing selected, a holdover from before the
list scrolled in place. That meant a single Esc after highlighting a
packet only peeled the highlight, leaving a second Esc to actually
leave the screen. Default the highlight to the newest packet and let
Esc back out directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 17:56:22 -04:00
Jean-Pierre MartineauandClaude Fable 5 7f1750df0e purge-test-data: mock pollution housekeeping as a skill
meshterm --mock writes to the real ~/.meshterm database, so simulator
sessions leave fake nodes (Alice, hub-1, hopN hashes, a1b2c3d4… keys)
in the history behind the atlas, dashboard, and trace suggestions. The
new skill (runs on the cheap model, like commit/verify) backs the DB
up, deletes exactly the MockDevice-fingerprinted rows — deliberately
conservative: no short-hash matching, background run rows untouched —
and verifies a clean second pass. The verify skill now names it as the
mandatory last step after any mock run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:35:02 -04:00
Jean-Pierre MartineauandClaude Fable 5 94f565e61f Windowed lists everywhere the chrome should hold still
The atlas's list-in-a-fixed-screen pattern graduates to a shared
ListWindow (tui.screen): fit() settles the window, the marker rows, and
the highlight clamp together, and remembers the PgUp/PgDn stride; the
frame now records the viewport (or a dialog's budget) before render_body
runs so screens can size their chrome to it. Adopters: the dashboard
pins its charts and windows the feed; the trace screen pins route,
aggregates, and actions over a windowed results block; the path
composer pins the route preview over windowed suggestions (and gains
PgUp/PgDn); the nodes table pins its title, column header, and legend
around windowed rows. The composer's route preview and node rows also
join THE path widget, dropping its hand-rolled Name (hash) renderer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:23:41 -04:00
Jean-Pierre MartineauandClaude Fable 5 94aba0cbcd Message paths: a path graph over selectable, sideways-scrolling rows
The ^P view grows from a static scroll window into its own dialog. Up
top, every distinct path the message took drawn in the atlas's edge
style — origin left, us right, the final relay → us link coloured by
that path's reception SNR, earlier links slate — with markers for every
node but labels only along the currently selected path, so a many-path
graph stays readable; the selected path draws bright, the rest dim.
Beneath, one row per arrival with a selection cursor: ↑↓ move it (the
graph follows), and a row longer than the dialog scrolls horizontally
with ←→ — the whole line shifts under an edge …, and snaps back the
moment the selection moves on. crop_cells in tui.render is the new
cell-exact window primitive behind that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:47:56 -04:00
Jean-Pierre MartineauandClaude Fable 5 2dca52fa56 Atlas: focus-left canvas that earns its area, list that scrolls in place
The graph now takes the majority of the screen (tapering a little on
tall terminals), draws only as many neighbours as its area can carry —
strongest links first, the weaker rest collapsed into one … marker that
lights up as whichever hidden row the list highlights — and lays the
walk out left-to-right: focus toward the left with its label west of
the marker, trail-back ducked under it at the far edge, the fan and its
labels east. The link list beneath scrolls inside the fixed screen:
canvas, legend, and heading hold still, faint ↑/↓ n more markers
bracket the window, and PgUp/PgDn stride by a windowful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:41:58 -04:00
Jean-Pierre MartineauandClaude Fable 5 8330c5a5db path_text grows the trace flavour: one widget for every path
Hash annotations (Alice (3d63)), None hops for our own device at a
route's endpoints, and dim_from to fade a boomerang's mirrored return
leg — so the trace screens' walked/planned routes, the per-hop link
tables, and the CLI trace output all render through THE path widget.
Trace routes thereby join the app-wide colour rules (per-name hues,
pure-white you) instead of their old flat brand/accent, and the live
route line wraps under a hanging indent instead of folding to column 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:41:55 -04:00
Jean-Pierre MartineauandClaude Haiku 4.5 534128ab84 Configure verify skill for a cheaper model
The commit and verify skills were burning the full parent-conversation
context on every routine invocation. Both now set model: haiku in
frontmatter so that context is billed at the cheap rate instead of
whatever model the main session is running.

context: fork was tried on both skills to avoid shipping the parent
context at all, but reverted: a bare "/commit" or "commit this" with no
explicit argument caused the forked subagent to skip the skill's steps
and return a generic greeting instead of committing. Fork only works
reliably when the caller always supplies a rich task description as the
argument, which isn't true for these skills' common invocation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-12 11:35:45 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 56b7580e48 Chat shows a message's delivery paths on demand (^P)
The radio's RX log has recorded every frame's relay path all along;
now a chat message can be asked how it got here. ^P floats a paths
window for the picked (or latest) message: one row per logged arrival
— time, reception SNR, and the relay chain through the shared compact
path widget — so a channel broadcast that reached us three ways reads
as three rows over two distinct paths, resends labelled.

Channel messages are matched by evidence, not guesswork: overheard
GRP_TXT frames decrypt under the channel's own key (fetched from the
device when the conversation didn't carry it) and match the message by
content, tolerating the "Name: " sender-prefix convention on the wire
— our own broadcasts included, since repeaters' rebroadcasts of us are
overheard like anything else. Direct frames are ECDH-encrypted, so the
view offers the window's TXT_MSG frames matched by time alone and says
so plainly. The sender-prefix parser moves to core.channels where the
matcher, the transcript, and the dashboard feed now all share it, and
the repository gains the windowed packet_frames_between query.

Both chat kinds now share one message-pick model: ↑ picks, the pick
walks with ↑↓/PgUp/PgDn/^Home and day-jumps with ^PgUp/^PgDn, ^End
returns to compose. Enter on a pick replies in a channel (unchanged)
and opens the paths in a direct chat, each with its banner cue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:37:57 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d4f7b33580 path_text: one compact, single-line path widget
widgets.path_text is now THE way a relay path renders on one line:
each hop as its resolved name in the app-wide per-name hue (our own
node pure white) or, unnamed, as its bare hash through the shared hash
widget with the routing prefix lit — names carry no parenthesized hash
repeat, hops join on muted arrows, and an empty path reads as the
caller's word for it ("direct"). The packet viewer's via row and the
dashboard feed's trailing note both draw through it, so a relayed
frame's path reads identically (and in the same colours) in the feed,
the packet detail dialog, and the message-path views to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:23:37 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 34a3b0a01d Atlas redesigned: walk the mesh from a focus node
The whole-graph radar view drew every node on concentric hop rings at
once — a hairball past a dozen nodes, its pan/zoom camera unable to fix
what was fundamentally overplotted. The atlas is now a graph walker:

* One node holds the focus (you, to open). The canvas draws only that
  neighbourhood — focus centred, direct neighbours fanned around it,
  braille edges coloured by SNR and faded by evidence age — and sizes
  itself to the crowd, so a two-node link is a tight six rows while a
  hub earns the full fan. The node the trail came from anchors due
  west: walking reads as moving right, backing up as moving left.
* Beneath it the links list the same neighbours as selectable rows,
  strongest first: glyph, name (palette hue, you in white), hash in the
  shared hash widget, SNR with a quality bar, samples, source tags,
  age, and how many links continue onward. The highlighted row's marker
  lights white on the canvas.
* Enter walks (the breadcrumb trail grows across the top), ⌫ steps
  back, walking into the trail-back row retraces instead of ping-pong
  growing, Home refocuses you. Typing finds any node in the graph —
  nearest first, islands flagged — and Enter teleports there, the
  trail restarting since the walk never crossed the gap. ^R rebuilds
  from storage and keeps whatever of the trail survives.

The per-node details dialog is gone (focusing a node is its details),
and the ring-relaxation layout service with it — the fan needs no
global relaxation. PgUp/PgDn page the highlight, dashboard-style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:17:03 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 34c6ada1ae Write the UX standards down: CLAUDE.md
The consistency pass's decisions, recorded where every future session
reads them: the lexicon (node/contact/heard/key/hash/path/route/via,
Back vs Quit), the list furniture rules and their enforcement points in
ui/menus.py, title casing and dash semantics, the footer-hint grammar
and Esc verbs, the dialog conventions, the one-glyph-per-concept icon
table, and the colour rules the screens already share.

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