Commit Graph
30 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 217625dcf7 The channel manager kept a receipt nobody could read
Every action banked a "created X" note that only surfaced once the
manager closed - by which time the refreshed list had already shown
the result. Worse, the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup's line budget and
came back as the full-frame result window instead, so the same visit
reported itself two different ways depending on how much had been done
in it. The visit now acknowledges once, in the summary the tool
already returned. An error is the exception and is shown when it
happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:50:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 da05dd402f Give the CLI its own voice, meant for a pipe rather than an eye
The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.

meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.

The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.

Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.

ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 40ecf5151d The tests' docstrings lead with a sentence too
The last 122 `D205` sites, which takes `ruff check .` to zero.

Same shape as the package pass and just as additive: these were a paragraph
with no summary, so each one gains a plain sentence saying what is being
asserted, and every word that was there moves down into the description. The
dense ones benefit most — `test_pathline`'s 23, where "A seam is one cell, not
two" now stands alone and the chevron mechanics explain it underneath, and
`test_tui`'s wide-glyph repaint test, whose seven lines on prompt_toolkit's
relative cursor now sit under the one line that says what the test pins.

Four module docstrings (`test_advert`, `test_gallery`, `test_platforms`,
`test_repeater_admin`) opened with "X tests: a, b, and c" and now name the
subject on the first line with the list below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:37:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 b5006cc415 Every line fits the hundred columns it was configured for
`E501` across 69 lines, wrapped rather than exempted.

The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.

The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:22:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 71b16ec7b7 The annotations, imports, and dead locals answer to ruff
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.

Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.

412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:26:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 972c0b6f03 Screens: Esc is the way out, so no row spends two lines saying so
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.

back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.

Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.

With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.

Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:15:53 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 711621d69e Channels: remember MeshTerm-created channels across sessions
A firmware-less radio bridge holds its channels only in RAM, so every one is
lost when the bridge restarts — the Channels page came up empty each session
even after you added channels. MeshTerm read channels live from the device and
never remembered them.

Add a durable, per-device channel store (<config_dir>/channels.json, keyed by
the device's public key) that records every channel written through MeshTerm.
On connect, reconcile() replays any remembered channel the device isn't
reporting into a free slot — filling gaps, never overwriting an occupied slot.

The gate is provenance, not device type: only channels you wrote through
MeshTerm are recorded, so a firmware radio you never edit through MeshTerm keeps
an empty record and does a single identity probe on connect (untouched). A
bridge, where every channel is necessarily created through MeshTerm, gets its
whole set back. Every mutation (create/join/import/edit/reorder/clear) now flows
through a single write_channel() boundary that keeps the store faithful.

Verified end to end against the live SPI bridge: write a channel, clear it off
the device to mimic a restart, reconnect in a fresh session, and reconcile
restores it with the right key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:20:49 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 c3d712eff5 Channels: offer to add the standard Public channel when absent
An add-action restores MeshCore's built-in fixed-key Public channel on the
next free slot, shown only while no slot already carries the well-known
secret. Leads with the 🌐 globe to distinguish it from the name-derived
# Public channel row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 04:15:58 -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 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 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 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 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 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 Opus 4.8 826ab5b4ee Buttons are chips, status marks are ✓/✗, confirms are dialogs
Three button/status stragglers join the house style. The tracing
dialog's "❯ [ Abort ]" — the one control drawn with a pointer and
brackets — becomes the centered reverse-video chip every other popup
button already is (ButtonDialog, the reconnect dialog's Abort). Chat's
✅/❌ delivery emoji become the app-wide ✓/✗ status marks in their
ok/err styles: the emoji belong to the packet-class icon lane, and the
single-width marks keep transcript rows evenly spaced. And the channel
manager's Clear — the last bare Yes/No confirm in the app — becomes a
standard destructive dialog (Cancel left, Clear right and default,
danger theming) like every other consequence gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:04:35 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 1c267378ef Relative ages speak grammar: "(now)", never "now ago"
format_ago in widgets.py is now the one prose form for relative ages —
bare "now"/"never", "5m ago" otherwise — and every row that stitched
_format_age plus a hard-coded " ago" suffix (packet viewer heard row,
Time Machine record/first-heard lines, Courier finished rows, channel
detail summary) goes through it, killing "now ago" everywhere at once.
The channel summary drops its one-off "just now" special case for the
same app-wide wording. Also: the Device actions description no longer
claims an advert action — that lives in its own main-menu entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 03:30:19 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 d19266bd5c Punch-list pass: dashboard hints, packet paging, decrypt, Time Machine axes
Four small UX gaps closed together:

- Dashboard's footer hint was missing Home/End even though the screen
  already handled them.
- The packet viewer's paging keys were reversed relative to the list
  that opens it: PgUp/PgDn walked packets while the dashboard feed
  walks with up/down. Swapped so up/down page through packets (matching
  the feed) and PgUp/PgDn scroll a tall body (matching every other
  screen's scroll keys).
- A raw "packet" entry (an overheard RX_LOG_DATA frame) now shows its
  parsed payload class and route, and — for a channel-text frame naming
  a channel we hold the key for — its decrypted text. The AES/HMAC
  logic lives in core/channels.py as pure, unit-tested crypto
  (decrypt_channel_text), independent of the vendored meshcore
  library's own reader internals; the dashboard fetches the device's
  channel secrets once at open time and hands them down to each
  PacketViewer it opens. Raw-field dumps now skip fields already folded
  into a flavoured row instead of repeating them.
- The Time Machine's whole-mesh charts (packets/day, nodes/day, rhythm)
  gained the same mirrored numeric y-axis the dashboard's activity
  chart already had. That gutter-drawing logic moved into
  braillechart.axis_chart, a shared widget, and the dashboard's own
  activity chart was refactored to use it too instead of hand-rolling
  its own copy.

meshterm/ui/channels.py's _read_slots is now public (read_channel_slots)
since the dashboard opener needs it too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:29:45 -04:00
Jean-Pierre MartineauandClaude Fable 5 14caab0dff Show key popup: labelled blocks that wrap, not a table
Tables inside popups are border noise and steal the columns the values
need. Each field is now a muted header label with its value beneath, and
the hash, key, and link — strings that exist to be copied out whole —
fold across lines at the dialog width instead of dying at an ellipsis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:05:10 -04:00
Jean-Pierre MartineauandClaude Fable 5 c7afe7998b Channel list: drop the TYPE and HASH lanes
The openness glyph already says public/private and the hash is one
keypress away in Show key; the fifteen freed cells are what keep the
activity sparkline visible on a 72-column terminal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:03:12 -04:00
Jean-Pierre MartineauandClaude Fable 5 8e931efe6f Header + channel sparklines: now on the right, tighter status row
Both sparklines draw through ui.braillechart, so the current minute lands
at the right edge and history trails left (JP reversed the earlier
newest-left convention). The header also sheds the events-hub live-dot —
it cost cells and read as noise — and drops its roomy separators to a
compact " · " whenever they would squeeze the pulse below 24 cells, so a
72-column terminal keeps a readable stretch of graph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:56:50 -04:00
Jean-Pierre MartineauandClaude Fable 5 ba1ff2eedf Sparkline becomes a 2-hour live pulse, newest at the left
The activity window shrinks from a week to two hours in 24 five-minute
buckets (12 braille cells), bucketed newest-first so "now" sits at the
left edge and traffic slides right as it ages. Bar heights switch from
peak-relative scaling to absolute tiers (1/3/8/21 messages per dot), so
the same traffic draws the same bar on every row, and silent cells drop
to a faint two-bottom-dot baseline — a quiet channel reads as a flatline
rather than a gap. The Reorder row's narrow ↕ arrow gains a second space
so its label aligns with the two-cell glyphs of its sibling rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:01:59 -04:00
Jean-Pierre MartineauandClaude Fable 5 7ed31c8f31 Activity lane becomes a 16-bucket braille sparkline
The three-threshold bar meter compressed a week of traffic into one of
four states; braille cells pack two dot columns of four rows each, so
the same 8-cell lane now draws a real 16-point histogram — each bucket
~10.5 hours — and a channel's rhythm (bursts, lulls, decay) is legible
at a glance. channel_stats() gains the per-bucket histogram, bucketing
the window's timestamps in Python off one filtered query; recent is
now simply the histogram's sum.

Bars scale to the row's own busiest bucket — the sparkline shows shape,
not cross-channel volume (the MSGS lane does that) — with non-empty
buckets floored at one dot so a lone message never vanishes. A silent
window keeps the muted ······· placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:53:28 -04:00
Jean-Pierre MartineauandClaude Fable 5 df9cdc4f2f Channel list grows message vitals in config-editor lanes
The channel manager's flat name/hash rows become fixed, column-aligned
lanes under one header line — name, openness, hash, unread badge, total
messages, last-message age, and a three-bar activity meter over the
trailing week — so a glance shows which channels are alive, not just
which exist. A new Repository.channel_stats() aggregates totals, the
week's slice, and the last timestamp per channel identity in one SQL
pass; rows read it through a small TTL cache with callable titles, so
a message arriving while the list sits open updates its row in place
(the conversation picker's trick, unread badge styling included).

Both menus adopt the config editor's persistent-backdrop pattern: the
list stays pushed while every sub-prompt floats over it as a modal
popup instead of replacing the screen. The detail screen leads with a
vital-signs line (slot, totals, unread, last activity) and lays its
actions out in the Device-actions two-lane style, with the live unread
badge on Open in chat and the destructive Clear row tinted err.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:40:04 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 2ec18138a7 Rename project from MeshTools to MeshTerm
Rename the package directory, imports, brand strings, env-var prefixes,
console-script entry point, and the config/data dir (~/.config/meshterm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:40:49 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 18368d6ca1 Resolve inbound channel identity from the live slot, not a stale cache
Channel messages carry only a slot index on the wire, which the firmware
assigns from the channel's current position. The old idx->identity cache
was trusted on a hit, so a slot reorder or re-key (in-app, via the CLI/
config tool, or on another client) left the cache stale and misfiled
later messages under the wrong channel.

channel_id_for now reads the occupying slot fresh from the device on
every call, so identity always reflects who is actually in the slot right
now; _channel_ids is demoted to a priming/backfill warm map and a
read-failure fallback. Inbound recording moves onto a single serialized
worker draining an asyncio.Queue, so channel messages still persist in
arrival order despite each needing an async device read, and a slow read
can no longer stall the event pump.

Second root cause, same symptom: a Public message could still land in
another channel (e.g. #bots) even after the fresh-read fix. The meshcore
library's get_channel waits for "the next CHANNEL_INFO event" with no
correlation to the slot it asked for, and the dispatcher fans that event
to every in-flight waiter. Two concurrent get_channel calls therefore both
resolve on whichever response arrives first, and one caller silently
receives the other's channel — which fired whenever the chat worker's read
raced any other channel read (conversation-list builder, channels screen,
refresh_channels). Fixed in the device wrapper: a per-device asyncio.Lock
serializes channel reads so at most one is outstanding and the response is
unambiguously ours, plus get_channel now validates the response's
channel_idx matches the requested slot and raises rather than handing back
foreign channel data (the chat layer then falls back to the last-known
identity instead of misrouting).

Adds tests for filing by the current slot occupant after an out-of-band
reorder, for arrival-order preservation across a burst, and for the
serialized/index-validated channel read (no cross-talk, mismatch rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:14:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 a03dedb09c Add message delivery retry, channel capacity discovery, and a compose byte budget
Three interrelated improvements to the chat/channel experience:

Outgoing byte budget & unified transcript (ui/chat.py, ui/tui/prompt.py):
- Cap outgoing messages at MeshCore's UTF-8 limits (150B direct, 130B
  channel) and block over-limit sends, keeping the buffer intact to trim.
- Show a live `used/limit` gauge in the compose bar whose used count steps
  green→yellow→orange→red by bytes remaining, with truer yellow/orange hues.
- Color the overflow characters red in the compose line (multibyte-safe).
- Render direct chats with the same grouped, per-sender layout as channels
  (headers, day dividers) instead of one line per message.

Direct-message delivery retry (services/chat_service.py, persistence,
core/models.py, tools/chat.py, ui/tui/session.py):
- Track outbound acked state (None awaiting / True / False) with a persisted
  row_id, add resend_direct + update_chat_ack, and wire Ctrl-R to retry the
  latest unacknowledged message in place with ⏳/✅/❌ delivery glyphs.

Channel slot capacity discovery (core/channels.py, core/connection.py,
ui/channels.py, tools):
- Discover the firmware's real channel-slot count via a bounded read-only
  probe (Device.channel_capacity) instead of assuming 8; show used/total in
  the manager and refresh the chat slot→identity cache after any mutation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:12:25 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 4c725bd22a Key channel history by identity and mark channel openness in chat
Store channel messages under a slot-independent channel identity (sha256 of
the effective secret) instead of the raw slot index, so reordering or re-keying
slots no longer scrambles history. DB schema v3->v4 adds a channel_id column
with an idempotent migration and a lazy backfill that maps legacy rows to the
channel currently in each slot.

Recognize the firmware's fixed-key default "Public" channel as public: add
DEFAULT_PUBLIC_SECRET / KNOWN_PUBLIC_SECRETS and split is_name_derived (key
reproducible from the name, so it can be re-derived on write) from
is_public_channel (shared meshwide). The default channel no longer mislabels
itself as private, and its well-known key is preserved on edit/reorder.

Lead each chat picker row with a glyph: the channel's openness marker
(# name-derived, globe fixed-key public, lock private) or, for a direct chat,
a speech balloon once we've exchanged messages and a person before. A shared
channel_glyph() keeps the channel manager and the picker in sync.

Round out the messaging UX: live callable row titles and plain-text filtering
in the TUI select screen, plus assorted device-picker, monitor, route-map, and
config-editor menu refinements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:31:44 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7330796829 Add a first-class channel manager with QR sharing and reordering
Replace the primitive channel editor that lived inside the config menu with
a dedicated `channels` tool: list the device's slots and, per channel, show a
scannable QR share code, reveal the key, open it in chat, rename/re-key, or
clear it. Create channels four ways — new private (random key), public `#`
(key derived from the name), join-by-key, or import a `meshcore://` link — with
every change written to the device immediately.

- core/channels.py: pure, hardware-free logic (secret derivation, tolerant key
  parsing, share-URL round-tripping, channel hash). full_channel_hash exposes
  the complete digest; the short channel_hash is its leading byte.
- ui/qr.py: half-block QR rendering (segno), drawn black-on-white so it scans
  regardless of terminal theme.
- ui/channels.py: the interactive manager. Channels display by their real name;
  reorder rows by dragging with the arrow keys; the key view shows the full hash
  with the first byte highlighted.
- ui/tui: a ReorderScreen (grab/move/drop) plus a ctx.ui.reorder surface method.
- tools/channels.py: the tool + `channels list/add/join/import/share` CLI.
- Pin Chat + Channels to the top of the main menu via an explicit category order.

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