"Section spacing" (section_spacing_min_rows, in the Display group)
replaces "Author spacing" and "Spacing from". It is the smallest
terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never (default 24). Each section heading of a list gets the line, and
the chat keeps it between authors and before a new day.
The frame records the terminal rows on the screen before the body
renders (note_terminal_rows), and Screen.section_spacing is the one
test. section_heading marks its row as spaced, and the select list
adds the line, never at the top, after a blank line, or under a
heading. The hand spacers before headings are gone, except the seam
between the region table and its actions, which is two lists.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).
The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
M5Stack makes three Cardputers and only the Zero runs Linux; the PicoCalc
takes several cores and only the Luckfox Lyra runs MeshTerm. So the bare
names were ambiguous, and every reference to the two devices now carries
the variant: the --platform values and MESHTERM_PLATFORM, the Platform
constants (PICOCALC_LYRA, CARDPUTER_ZERO), their lane decks and every
screen's picocalc_lyra_lane / cardputer_zero_lane, the font inventories,
the lane chips' theme styles, the scripts/ folders, the two chrome test
files, and the docs and CLAUDE.md that name them.
There is no alias: --platform picocalc is refused with the three real
choices listed, rather than falling back to the desktop layout. The
PicoCalc is auto-detected from its device tree, so it needs nothing.
What names hardware or a user's data keeps its name: the PicoCalc
keyboard's input-device name, its battery's sysfs supply, and the serial
profile the Lyra setup script writes. .gitignore follows the firmware's
folder, and the changelog keeps the words each release used.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A chart's y-axis labels got exactly the cells their widest mark needed,
so the gutter grew under the reader as a busy minute took the peak from
9 to 10 to 100, sliding the whole chart sideways. Every gutter now gets
at least three cells (AXIS_LABEL_MIN), which hold every mark below a
thousand and every compacted one, so it stays put.
The Time Machine's Rhythm heading ended on its slice width ("20-min
slices"), which on the PicoCalc's and Cardputer's 53 columns wrapped the
heading onto a second row. Where the whole heading would wrap, the slice
width goes; the axis under the chart already marks the day's hours.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the PicoCalc's lane (and the Cardputer's, which deals the same) the
scope chip read "▸ yul" and the Time Machine's window chip "▸ 30 d". The
triangle took two of a chip's six cells to say what its place on the lane
already says, and cut a region's name to four. Both chips are now the
view a press goes to and nothing else, so a name gets all six cells.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A lane's keyboard-bound half is now a LaneDeck named by Platform.lane_deck: which
keycodes drive the slots, where the chips sit, how they are drawn, and which of a
screen's lane definitions it reads. Screens define picocalc_lane; cardputer_lane
follows it until a screen overrides it, so the two can part ways one screen at a
time. The Cardputer deck is Fn+4..8, Shift with them as F16-F20, eight-cell chips
centred over their keys in the fn key's orange-red and the Shift blue.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
s (F2 on the PicoCalc) steps all → unscoped → each region heard in the window
A–Z → unknown scope, beside w's window cycle. A narrowed page charts that
scope's floods per day (per hour at 24 h), their time-of-day rhythm, and its
own ledger; nodes per day and arrivals leave, being read off adverts, which
carry no scope. A window's stored floods are read once (Repository.flood_frames)
and resolved against the known region names for the page's life.
The node and own-node pages offer no cycle: a node's page is its adverts and
telemetry, and what we send is traces, which go direct.
The ring's order, words and chip move to ui/scopering.py, shared with the
dashboard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A leading underscore says "internal to this file, don't import me", and the
column-age formatter and the node glyph table wore one while seventeen modules
imported them and CLAUDE.md named them as the one place each concept lives.
format_age sits beside format_ago now (prose age, column age), NODE_GLYPHS and
DEFAULT_GLYPH beside them, age_seconds with the ages. A mechanical rename, 140
references across 24 files, CLAUDE.md's three lines with them so the guide and
the code move as one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pre-release architecture and QA pass, applied as one change because the moves
share files and only the whole set was gate-tested (2480 passed, ruff clean).
Three new modules pull logic down out of layers that should not own it:
core/channel_probe.py (ChannelSlot, read_channel_slots, probe_channel_slots)
so services/device_state.py no longer imports a screen; services/markers.py
(gather_markers, with MapMarker a core/models dataclass) so two screens stop
importing a tool; core/tracing.py (trace_timeout, path_hash_flags) so
core/connection.py no longer imports services. The routing-prefix width is
DeviceState.routing_prefix_bytes(), replacing five private cross-module imports.
One definition per thing: is_path_hash, the NodeResolver/NameKeyResolver
aliases, TILE_RETRY_SECONDS, haversine_km, marks.SELF_MARK, MENTION; nine
private names that crossed module boundaries got public ones and menus.__all__
lists what other modules import. A preference's default is written once
(ALERT_CAP, DONE_CAP, DEFAULT_VIEW_FRACTION read the registry), the stranded
PREFERENCES doc comment sits on its subject, and three docstrings stop naming
questionary, a library dropped long ago.
The command line gains --version (eager, exit 0, documented, tested) and drops
the -p short form on trace, trace-path and tx-optimize: the root lifts -p as
--profile before a leaf ever sees it, so the leaf's could never work, and a
derived test now checks every leaf against every root short option.
tx-optimize with a one-hop --path is a usage error, exit 2, because nothing
transmitted is not a device failure. Log handlers are closed before they are
cleared (197 unclosed-file warnings gone), chat's fire-and-forget tasks keep a
reference, coroutines use get_running_loop(), the never-awaited MapScreen._load
in a test is awaited, and the CLI sweep passes --no-ble to devices so a test
run stops turning on the Bluetooth radio three times (the suite is 40 % faster).
The about page lists every dependency, and the release skill bumps the README's
download names, which still said 0.2.8.
apply_ops stays in tools/: it and its helpers build report blocks and open two
screens, so moving it to core/ would invert core over ui. The widgets CLAUDE.md
names by their private spellings (_format_age, _NODE_GLYPHS) keep them until
CLAUDE.md is edited alongside.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three body headings — Stats, Area walked, Route — each with the note that used to be a
faint caption under its block, so a page this tall can be skimmed by its landmarks. The
record's identity leads its stats: the spec walked and when it was set, then what the
walk measured. The area drawing takes the page's whole width and rows in step with it,
a quarter of the columns (half as tall as wide in braille dots), so a wider page is a
bigger shape rather than a padded one; rows a flat shape leaves blank are still
trimmed, so a tall box costs it nothing.
widgets.body_heading is THE form for a heading inside a page's body — accent title,
muted note — hoisted from the Time Machine's private copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`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>
`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>
`B905` over 56 sites, and the answer differs by idiom rather than by file.
21 were the sliding window `zip(xs, xs[1:])` — ragged by exactly one, on
purpose, so `strict=` is the wrong question entirely. Those become
`itertools.pairwise`, which is what they always meant and needs no flag: the
walk's SNR stops, the heat ladder, the route chains in `topology`/`records`,
every polyline in `mapcanvas`, and the graph's edge sets in `pathgraph`.
The other 35 pair sequences that must line up, so they now say `strict=True`
and a mismatch raises instead of silently truncating the longer side. Running
them under that assertion is what sorted the two groups: the first pass put
`strict=True` everywhere and 208 tests failed, all of them a sliding window.
One goes the other way. `mvt._decode_feature` reads tags off a network tile,
where an odd-length list means the tile is malformed — and this decoder answers
that with a shrug everywhere else (`if not geom_ints: return None`, bounds-
checked key and value indices). It keeps `strict=False`, now with a comment
saying why, so a bad basemap tile still can't crash the map.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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>
Six lists rebuilt themselves as a whole new screen every round, because their
content is data: an editor's rows carry a staged `current → new` value, its
title counts what is staged, the outbox loses the message it just sent, the
watchtower's log rewrites the row you acknowledged. Each approximated the cost
with `default=cursor`, which recovers only the cursor, and only while the row it
names still exists — the typed filter went every time.
SelectScreen.replace_items swaps the rows (and optionally the title) in place
instead. The place is kept by value, so the highlight lands back on its row
wherever that row moved to, and falls back to the same position in the list when
the row is gone entirely — which is what you want after deleting the row you
were sitting on. The filter rides along.
So the config editor, device actions, the channel manager, the courier outbox,
the watchtower and the repeater-admin editor all hold one screen for the whole
visit now, refreshed after each action. The courier's ticker is started once for
the visit rather than per round, and folds a flow's effect in immediately rather
than waiting up to a second for the next tick.
The admin node picker gains a `stay` form. Repeater admin used to draw a second,
identical SelectScreen as a static backdrop so the password prompt had something
behind it, then drop the list before the session; the real list stays up through
both now — so cancelling the password lands back on the node list rather than
bowing out of the flow, and leaving a node's admin session lands there too.
Administering two repeaters in a row is an Esc and a pick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every icon on the console is a one-glyph stand-in for an emoji, and the
menu is where that pinches hardest: the main menu drew @ for both Map and
Mesh walk, … for both Live feed and Time machine, and ?? for Trace path,
in a lane costing three of fifty-three columns on every row (JP,
on-device).
So drop the lane there, mark and emoji alike. A command row's icon is
decoration — it names the action's family while the label names the
action — so where it can only be approximated it is worth less than the
cells it takes, and the label and its description get them back. This is
a platform choice (Platform.menu_icons), not a per-emoji one: a glyph
that carries *data* is not an icon in this sense and never goes, which is
why the Channels page keeps its #/🌐/🔒 openness mark, the feed keeps the
class icon it shows in place of the class word at 53 columns, and node
types, status marks and the mute mark in the channel lane all stay put.
Three funnels do the work, so a screen never asks the platform itself:
command_label for a label carrying its own icon, command_icon for a row
that builds its own lane, and marked_label where the icon carried a tint
— a delete row's red moves onto its words rather than leaving with the 🗑.
Section headings go through the same cut (a stand-in beside the ── rule
is pure noise), which also retires the ?? that No revisits was drawing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The right-gutter marks come back on the desktop (redundant but nice)
and the console keeps the bare tick - axis_chrome is the one number a
caller budgets, platform-bound like the rest of the chrome. The Rhythm
ladder widens to 1/5/10/15/20/30/60-minute slices, finest that fits:
the desktop keeps its 15-minute sweep at 72 cols and earns finer on a
wide terminal; the mesh page re-slices one minute-grid scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-item chips (node page F1 Info / F2 Routes, Time machine
F1-F3 spans) revert to the single next-thing toggle on F3, per JP's
call. The PicoCalc window ring still stops at 30 d; its F3 chip just
cycles the three spans that remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 15-minute slices outran the PicoCalc; the chart now takes the
finest of 20/30/60-minute slices that fits (the mesh page re-slicing
one ten-minute base scan), and every section lays out for the single
left gutter. The window ring rides F1-F3 as direct chips - 24 h, 7 d,
30 d, the span on screen dimmed - and the handheld skips all time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.
That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:
select lists Sect down/up, the section jumps. Ctrl+PgUp on a
keyboard with no PgUp is the one binding in the app
that was physically unreachable. Plus Delete, lit on
exactly the rows the key acts on.
map Region (Home, renamed -- it frames the ground the mesh
covers, it does not "reset" anything), You (new: Ctrl+L,
recentre on our own node at the zoom you chose), and
Frame for the find matches.
node page the tab it would switch you to.
time machine the window span it would take you to -- w was
undiscoverable on the device.
The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.
Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).
The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every repaint re-rasterized every row and re-ran every layout; every
screen open re-scanned the whole history. Now work is done once per
change instead of once per frame:
Render pipeline — a content-keyed ANSI cache in render_to_ansi (every
Text/str row app-wide), memoized corner glow, and panel-wrap memos for
the base frame and stacked dialogs, all dropped on a platform switch or
emoji recalibration. Idle-frame compose falls 5-100x per screen
(contacts 5.6->0.1 ms, record card 8.5->0.07 ms, trace 7.6->0.6 ms).
Repository — an observed_at index turns every since-window query into a
range walk (recent_observations 7.5->1 ms); heard_nodes and first_seen
aggregate in one streaming pass by ISO-string comparison instead of
hydrating dataclasses and sorting; node_names groups in SQL (NOT
INDEXED past a slow planner choice).
Per packet — livefeed no longer repaints on events that drew nothing,
the channel list's stats TTL rises above the tick so the repository is
not queried per frame, watch-store alerts batch through the throttled
flush instead of a full-state disk write per new node, and the header's
360-bucket histogram/flags tuples are memoized per minute and packet.
Per open — the resolvers, topology canonical()/display_name(), and
Yen's route search memoize over their frozen inputs; timemachine, chat,
and livefeed drop duplicate queries; the config editor, device actions,
info, advert, and tx-optimize reuse the devstate session cache instead
of re-asking the radio.
Per frame, per screen — dashboard folds five window scans into one
digest; walk caches everything derived from its frozen graph (the
onward-count quadratic included); packet viewer, record dialog, trophy
rows, trace stats/log, and the node-detail route fan memoize on their
frozen inputs, invalidated by revision counters where the input can
move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing Enter on a contact now opens its whole story on one page instead of
a bare row: identity (name in its hue, key with the routing hash lit, type,
first/last heard, packet tally, reception SNR/RSSI), an inline location
preview centred on the node (a small static basemap via the new MiniMap), the
observed routes to it, and an action list (trace armed with the suggested best
path, open full map, time machine). Contacts owns the show/open/reshow loop;
topology.suggested answers "the best evidence-backed way to reach this node",
wired ahead of the firmware route in the trace arming precedence. Timemachine
and Trace grew the entry points the page opens.
Four fixes fell out of driving it on real hardware:
Slow open. Each located-node page built a fresh BasemapSource and resolved its
TileJSON -- a blocking HTTPS round-trip (~100ms, up to the 12s timeout on a bad
link) -- on every open. Hold one source for the session (ctx.basemap_source),
shared by the map, the picker, and the preview, and warm it behind the menu at
login, so the resolve is paid once, off the navigation path.
Black map. A companion advertising nonsense coordinates (Homestead reports
lat -97, lon -1042) flung the viewport off the world, leaving the map a screen
of empty/water fill that reads as solid black -- both from "open full map" and
from filtering to it and framing. The old guard only caught the 0/0 no-GPS
null island; the new geo.usable_fix() rejects any out-of-range fix too, applied
to the map markers and the location preview alike. Verified on-device: of 265
located contacts, only Homestead drops.
Path to node. The route/suggest rows render through THE path widget with a
1-byte hash in parentheses (a "name (3d)" tag), so they cross-read hop for hop
against the route graph's byte labels.
Route graph. Draw us on the left and the target on the right (us to target,
"how we reach it", left to right) via a local endpoint swap that leaves the
shared route_graph_style untouched for its other callers, and give the fan a
generous lane step and row budget so a well-connected node's candidate paths
spread into separate, legible tracks instead of a cramped tangle -- the height
stays adaptive, so a one- or two-route node still draws compact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Time Machine, Remote CLI, and Live feed each led their footer with an
action key (bare `w`, `Enter send`) or wedged one between two navigation
groups. Reorder each to the standard shape — navigation keys, then action
keys, Esc last — and rename Live feed's `↑↓ packets` to the `↑↓ move`
verb the other lists use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Nodes" menu was a contacts roster mislabeled — it lists the device's
contacts (get_contacts), which upstream meshcore calls "contact" too. Rename
throughout, no CLI alias:
- tools/nodes.py -> tools/contacts.py (ContactsTool, `meshterm contacts`)
- ui/nodes_screen.py -> ui/contacts_screen.py (ContactsScreen/open_contacts)
- ui/nodelist.py -> ui/contactlist.py (ContactListScreen/ContactRow)
- widgets: NodesSort->ContactsSort, nodes_table->contacts_table
Courier's recipient is a contact you address, so its prose and CLI arg
(`courier queue CONTACT ...`) follow; persisted node_key/node_name stay.
The reception/hardware layer keeps "node" — HeardNode, heard_nodes(),
observations.node, trace_hops.node, map/atlas participants, node types, admin
targets, and the Time Machine's reception picker (its "Nodes" heading stands).
CLAUDE.md's lexicon is rewritten to the refined definitions: node = any mesh
radio; contact = a node your device knows (discovered or added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mesh page's arrivals showed a fixed 16-cell lane — six stored
bytes and blank padding, however wide the terminal. The lane now
absorbs whatever width the name lane and the FIRST HEARD tail leave
(floored at the old width), and each arrival's key expands to its
fullest known form the way the picker's lane does: the key captured
with an observation, else the device's contact list, else the stored
12-hex prefix — as many whole bytes as fit, ellipsized on a byte
boundary. The column header reads KEY, per the lexicon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Every screen and dialog title now follows the same three rules. Titles
are sentence-cased ("Trace — YUL-Poly", "Dashboard — mesh overview",
"Nodes"), never bare lowercase ("trace · x", "nodes", "command line ·
x", "path width", "admin login" and a dozen siblings). An em dash
introduces a title's subject or qualifier while middle dots stay for
chained status atoms ("Map · z12 · 34 nodes · 2 km across"), so the two
separators mean different things instead of being used at random. And
no title carries an emoji — the icon belongs to the menu row; a
content-sized floating dialog whose title emoji the terminal draws a
cell narrower than Rich measures leaves the border short (the bug the
Send advert popup already documented), so the Watchtower, Courier,
Time machine, identity-key, reboot, sync, restore, location, factory
reset, and advert dialogs all drop theirs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every select list now builds its closing furniture through one module,
so the conventions hold by construction instead of by copy-paste:
* Back is THE exit word — bare, no arrow, no icon — with exactly one
blank separator above it (back_rows). Courier and Watchtower lose
their "← Back"; the rules popover's "← Close" becomes Back too.
* The staged-changes exit pair (✓ Apply … / ✗ Back — discard) moves to
exit_rows, shared by the config editor and repeater admin, along with
changes_phrase and one confirm_discard dialog (apply/send verb
parametrised).
* Label + description rows share menu_rows (cell-padded lanes): Device
actions, channel detail, repeater actions, and — converted from the
inline "label — description" style — Send advert, Identity key, and
the channel manager's Add-a-channel section, whose rows now read as
two aligned lanes with the trailing … that marks "opens a prompt".
* Grouped-list headings share section_heading (── Label ── in accent):
Watchtower, Courier, the Time Machine picker, and the admin picker
(which also drops the stray 🔑 inside its heading).
* Status marks normalize to ✓/✗ (U+2713/17): Watchtower's ✔ and the
✖ on Cancel-this-message / Stop-watching become err-tinted ✗ rows.
* fit_cells replaces the four private _fit copies (channels, dashboard,
time machine, chat picker) and measures display cells, so a wide
glyph in a name can't skew a lane; empty-state notes drop their
parentheses ("no channels configured yet", "no matches").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Carried over from the last punch-list pass: the packet viewer opts into
a grow-only floating dialog (the box ratchets to its tallest body and
blank-pads shorter ones, so paging never re-centres it); raw payload
field names are shown whole by widening the label lane instead of
clipping at eight chars; the Time Machine node rhythm matches the mesh
page — 96 fifteen-minute slices sharing the Volume gutter — and its
time axis shortens marks to bare dates on multi-day windows; Courier
and Watchtower gain a visible Back exit beside Esc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A follow-up interactive-polish pass over the charts, the menu popups, and the
Time Machine.
Charts (braillechart)
- axis_chart now notches ┬ x-axis ticks under a continuous chart's labels too, the
same grammar the columnar day charts use — one axis grammar everywhere. The
dashboard activity chart and the node-history Volume/SNR/Rhythm charts pick this
up through the shared widget; single-line sparklines are untouched.
Time Machine (mesh page)
- Empty days show as zero bars instead of being skipped, so the day charts read as
real calendar time (a fortnight with two dead days no longer fuses to twelve
adjacent bars). Never invents days from before recording began.
- The rhythm chart shares the day charts' y-axis gutter, so its left edge lines up
with them; its own narrower width is unchanged. Its whole-window peak is folded
into the shared gutter sizing (a slice can top a single day's total).
Packet viewer
- Holds a minimum body height so paging between a short packet and a tall one no
longer resizes the dialog out from under the reader.
Menu popups
- Floating dialogs force a full frame rewrite as they're torn down, scrubbing any
double-width glyph a terminal painted a cell past where the differential renderer
tracks it — the "stray characters in a dialog's wake".
- Send advert: reworded a choice, and the fixed list is non-filterable (a stray key
can't narrow/resize it); a select's default footer now only mentions type-to-filter
when it actually filters.
- Watchtower Rules: a blank separator sits above the Close item.
Menu
- The Mesh atlas tool icon is a single-codepoint 🌐 instead of a VS16 🕸️: the
emoji-width calibration strips VS16 on terminals that render it narrow, which
measured the old icon a cell short and drifted that row's help text right.
630 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two rounds of interactive-polish fixes across the dashboard, Time Machine,
chat, and the menu popups.
Dashboard
- Feed name lane names what a row actually is: a channel message's parsed
sender, or a relayed packet's payload class (channel text / trace / …),
instead of a bare "?" or a redundant "ch N". The class is persisted so
history-seeded rows carry it too (observations.payload_typename, schema v10).
- PgUp/PgDn walk the feed selection so the highlight travels with the view.
Packet viewer
- Takes a live source and re-locates the viewed packet by identity, so packets
arriving after it opens become reachable; the n/total title stays current.
- Decrypts overheard channel text straight from stored history (crypto fields
persisted: chan_hash/cipher_mac/crypted, schema v9).
Time Machine
- Day charts label their bars with column ticks: each date sits under its own
bar with a ┬ notch, shortens (month only on the first tick and on rollover),
and thins to fit. axis_chart grows an optional ticks path for columnar charts.
- Node history charts render through the shared axis_chart widget; the mesh
Rhythm is 96 fifteen-minute slices at 4x width, "now/newest" hints dropped.
Chat
- The byte counter pins to the right edge of the input's last line, so it stays
bottom-right when the compose wraps instead of trailing the cursor.
Menu popups
- The per-node Rules popover gets an explicit Close row.
- The Send advert popup drops the emoji from its floating title (a terminal that
paints it narrow left the content-sized dialog's border short, bleeding the
backdrop through); the menu row keeps its icon.
625 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The day-boundary notch left its bottom dot lit: the zero baseline is
drawn per braille cell (both dot columns), so it filled in under the
notch's blank column, and the notch connected to the axis instead of
extending down to it. Worse, when the neighbouring bar was tall the
notch's bottom dot took the bar's colour, not even the faint grey.
Introduce a GAP sentinel in braillechart: a timeline column that
renders fully blank — no bar and no baseline — the one deliberate
break in the "grey is zero" flatline. _assemble now decides the
baseline per dot column rather than per cell, so a GAP column goes
blank clean to the axis while its cell-mate keeps its own zero line.
The Time Machine's day charts mark their notches with GAP instead of
None.
A genuine zero-value day still draws its faint grey baseline as
before — only the notch column breaks it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_day_columns floor-divided chars*2 by the day count and dropped the
remainder, so the stretched bars often fell short of the width the
axis border and caption assumed — the chart reading as shifted left
of its own axis. Now splits in character units (falling back to plain
dot columns when there are more days than characters) and spreads the
remainder across the oldest days, so the total always lands exactly on
2 x chars.
A day spanning more than one character also opens with a blank dot
column now — the left half of its leading braille cell — so
same-height neighbouring days still read as separate bars instead of
fusing into one solid block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The per-day charts drew one dot column per day, leaving a short
history as a sliver in a wide terminal, and captioned themselves with
raw ISO dates. Days now stretch into day-wide bars that fill the
chart's width (falling back to per-day density once history is deep),
and the captions speak the house style - "Jul 05" marks with "today"
closing the right edge, like the node page's "now".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chart captions now draw through axis_caption, so a wide volume/SNR/day
chart gains quarter/mid time marks between its edge labels. Nodes whose
stored observations never carried a name ask the device's contact list
before rendering as "unknown" (picker and arrivals alike), and the hash
lanes go through the shared hash widget. make_node_resolver learns an
optional stored-names fallback and the repository can serve the latest
advertised name per node, for the dashboard feed to use next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The subject picker becomes real columns under a header — type glyph,
name, hash, packets, heard — with the name heat-coloured by recency
("unknown" spelled out for nameless nodes, still heat-tinted so a fresh
mystery isn't dressed as a cold one) and the hash in its own lane with
the routing-width prefix lit, exactly as the Nodes list draws keys. The
whole-mesh page gains the same name/hash lanes for Arrivals (the per-row
"first heard" prefix now lives once, in the FIRST HEARD column header), a
mesh-wide hour-of-day Rhythm chart fed by a new O(24) hourly_activity
repository query, and the node page's volume heading says the direction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>