476 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 2790159d04 Ask for a window MeshTerm fits in, rather than taking whatever is open
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
JP saw the PicoCalc's wordmark on a desktop session. The splash picks the
widest mark that fits, the desktop one is 71 columns, and below that it swaps
to the 53 drawn for the handheld -- so the app was announcing itself as the
handheld and truncating every menu description onto the pager underneath.

The window was 64 columns. reopen() passed no window argument, so wt.exe put
the session in a *tab* of whatever window happened to be open and it inherited
that size; with no window open it would instead have taken the reader's global
launch size, which is a setting about their shell and nothing to do with this
app. Either way the app could land somewhere too small for it, and the failure
was quiet and cosmetic rather than anything that announced itself.

It now asks for a window of its own at the platform's stated minimum plus a
margin -- 80x30 on the desktop, derived from readable_cols/readable_rows so it
follows them. Windows Terminal clamps to what the display can show, so a small
screen degrades exactly as it did before.

The tab is titled MeshTerm too. It was showing the executable's full path,
which on a downloaded build is a line of Downloads folder. The icon is not ours
to set -- Windows Terminal takes that from a profile and a bare command line is
not one.

Release 0.2.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:28:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 564e051d1a The reopened session must not inherit the bundle's own bookkeeping
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Found by testing the release artifact rather than the repo. A PyInstaller
one-file build unpacks itself and re-runs itself, the halves coordinating
through _PYI* environment variables. reopen() handed the whole environment to
the new copy, so its bootloader concluded it was stage two of a launch nobody
had made, checked that its parent was the same executable, found wt.exe, and
aborted:

    [PYI-33368:ERROR] Security validation failure: parent process has
    different executable!
    [process exited with code 4294967295]

A new window holding a message nobody can act on, with the window that offered
the move already gone. Strictly worse than not offering.

child_environment() strips them, and restores the loader paths the bootloader
parks aside. Verified against a locally frozen build: before, the bootloader
error; after, the session comes up in Windows Terminal with its emoji and its
charts.

The source build could never have shown this. Same shape as 0.2.2's empty tool
registry -- the packaged build is a platform of its own, and the only way to
test it is to run it.

Release 0.2.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:57:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 54bbb93233 Offer the terminal, then the font -- in that order
The font work made the classic Windows console as good as it can be, and it
still cannot show a single icon. No font fixes that: the only two fonts on a
Windows machine carrying emoji glyphs are Segoe UI Emoji and Segoe UI Symbol,
both proportional, and a console will not take a proportional font. Measured
across every font installed here, no monospace face has more than one of the
33 emoji MeshTerm draws.

What actually decides all of this is the terminal, not the font. A modern one
borrows a missing glyph from another font on the machine, which is why the app
looks right in Windows Terminal with a font (Hack Nerd Font) that holds no
braille and no emoji itself. The classic console is the one host that does not,
so the fix for it is to leave it.

So the first offer is now to reopen in Windows Terminal -- present on every
Windows 11 machine, free on 10 -- asked with yes as the default, since it
installs nothing and changes nothing. The font offer stays as the fallback for
a reader who declines or has no Windows Terminal, and keeps its own second ask.
One preference, console_setup, remembers a refusal of either.

Verified end to end: launched in cmd.exe, accepted, and the session came back
up in Windows Terminal with colour emoji, braille charts and rounded borders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 5158268a73 A font we installed ourselves has to be loaded before it can be selected
Found by testing the second launch rather than the first. AddFontResourceW
adds a font to the running process's font table; the HKCU registration beside
it is what makes it permanent, and Windows reads that at the next logon. So
between an install and a logoff there is a window where a new MeshTerm process
finds its own registration, believes the font is ready, and selects nothing --
SetCurrentConsoleFontEx reports success and quietly keeps the old font.

consolefont.use() retries once after loading our own copy, which is a no-op
for the Cascadia that came with Windows and the fix for the one we put there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:00:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 f06b593a0f Ship a font the charts can be drawn in, and offer to install it
Every timeline in MeshTerm is made of braille characters, and almost no
monospace font has them. Reading the cmap of every font on this machine:
Consolas, Lucida Console, Hack Nerd Font, JetBrains Mono, Fira Code and
Source Code Pro all carry zero of the 256 -- and so does DejaVu Sans *Mono*,
which is where the widespread "install DejaVu for braille" advice falls down.
DejaVu *Sans*, proportional, has all 256. That is the whole trick: terminals
fall back to a proportional face for the block, and the charts have always
been drawn by a font nobody chose.

Which is fine everywhere except the one host that does no fallback. In the
classic Windows console -- what a double-clicked build gets -- Consolas draws
57 of the 122 characters we use and none of the charts.

So MeshTerm now bundles Cascadia Mono PL (SIL OFL, licence beside it) and
offers it there. Cascadia is one of two mainstream coder fonts with the block;
the PL build adds the powerline separators the path lines want, and at 723KB
covers exactly what the 2.4MB Nerd Font build does. A machine that already has
Cascadia -- every Windows 11, anyone with Windows Terminal -- is only offered
the switch.

Both halves are verified on Windows 10 22H2, and neither needs admin: a font
installs for one user under %LOCALAPPDATA% with AddFontResourceW and an HKCU
registration, and SetCurrentConsoleFontEx selects a face the console's own
properties dialog will not even list -- that dialog offers only what is under
the machine-wide Console\TrueTypeFont key, which here is Lucida Console and
Consolas, so without that API the font could be installed and still unpickable.

Declining is answered with what declining looks like -- charts as rows of
boxes -- and asked once more, because a reader who has never seen the app
cannot picture what it costs. A second no is remembered in the console_font
preference and never raised again.

Windows locks a font file once loaded: it cannot be deleted even elevated,
even after restarting the font cache, until the next logon. So nothing here
offers to uninstall one, and the install is written to be repeatable instead
of reversible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 16:56:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea5f644d73 The console that can't draw an emoji says so before we draw one
The classic Windows console renders through GDI with the console font and no
emoji font behind it, so every icon in the menu came up as a replacement box.
It is what a double-clicked build gets, which is most of the Windows audience.

The fix already existed: the compact single-character glyph table built for the
PicoCalc, reached by Platform.emoji. The seam's flags are independent, so
clearing that one alone keeps the desktop's width, colour and accented names and
moves only the icons — without_emoji() is the whole change to the platform.

Identifying the host has to be structural. It cannot be asked in band, because a
missing glyph still fills its cell: a classic console stores an emoji intact and
advances two cells while showing a box, and a ConPTY reads back as U+FFFD while
drawing it perfectly, so a write-and-read-back probe answers both hosts
backwards. Nor can the environment answer, since WT_SESSION is inherited across
process launches. So two independent facts must agree — the console font has a
real pixel width (a ConPTY's hidden conhost reports zero) and the buffer is
taller than its window (a ConPTY has no scrollback of its own) — and an unknown
never degrades anything. MESHTERM_EMOJI overrides either way, and
`meshterm platform` reports the verdict and its reason.

Verified on Windows 10 22H2 in all three: a real conhost turns the icons
compact, Windows Terminal keeps its emoji, a redirected run is left alone.

_has_wide_glyph asked the same flag to mean "nothing here is two cells wide",
which was true of the PicoCalc's fixed font rather than of the flag. A console
that merely lacks emoji still renders the rest of the BMP, and a contact named
in CJK is two cells wide there like anywhere else, so it now asks ascii_fold —
the fold is what actually guarantees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:47:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 d062b5b706 The Windows console call that broke the app is ours alone
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
MeshTerm died building its screen on Windows with a ctypes type error, and on a
double-clicked build the traceback and the window it printed to were destroyed
together — which is why this needed a log file before it could be looked at.

`ctypes.windll.kernel32` is a cache. Every caller in the process receives the same
object and each function on it is a shared attribute, so declaring `.argtypes` is
not configuration, it is a process-wide mutation where the last writer wins. The
emoji-width probe declared `GetConsoleScreenBufferInfo` as taking a pointer to its
own copy of the screen-buffer struct; prompt_toolkit then called that same function
object with a pointer to its own copy — same layout, different class — and ctypes
refused it.

Nothing here reaches for `ctypes.windll` any more. Five call sites take private
handles from `core/win32dll.py`, built by constructing `WinDLL` directly so each
one gets its own function cache and a declaration made for one purpose cannot be
seen by anything else. Each call returns a fresh handle, because two parts of
MeshTerm disagreeing about a signature is the same bug in miniature.

The regression test was checked against the defect rather than assumed to cover
it: with the old line restored it fails, and with the fix it passes. A test that
passes either way would only have recorded that somebody thought about the
problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:16:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 bdc16fc448 The log is a text file that records what went wrong
Three things were true of the log at once: it was JSON Lines, it never rotated,
and it almost never recorded a failure. Of the last twenty thousand records in a
real one, nine were warnings and three were errors. It faithfully kept the boring
parts and missed the interesting ones, which is exactly backwards for the only
job it has.

It is plain text now, `~/.meshterm/meshterm.log`, one line per record with the
traceback indented under the line that raised it. JSON Lines was there in case
something replayed it; nothing ever did, and the actual reader is a person who has
hit a problem and is about to paste the last twenty lines into an issue. That
reader has a text editor.

It rotates, at 2MB across three files. The old handler grew without limit and a
real one reached 11MB over ten weeks — a lot of disk on a handheld, and an
unreasonable thing to ask someone to attach.

How much it keeps is a preference, `log_level` under a new Diagnostics group,
defaulting to WARNING. That is what makes the file useful rather than large: it
holds the problems, and turning it up to DEBUG for one run is how you chase
something. Loaded before logging is configured, since it decides how logging is
configured, and handed to the context afterwards so the file is not read twice.

And failures are written down at last. Every unhandled error reaches the log with
its traceback and the terminal says where to find it; expected errors — an
ambiguous device, a bad preference — go in as warnings, because a log that only
holds crashes cannot answer "what happened just before". The bug form now asks for
the file, which it had no reason to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:43:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 141698678e The downloadable builds actually have the app in them
The Windows build launched, printed help, drew the specimen card, and showed a
menu containing one item: Quit. Not a permissions problem and nothing to do with
Bluetooth — the tools were simply not in the bundle.

`meshterm/tools/__init__.py` finds every tool with `pkgutil.iter_modules` and
imports it so its `@register` decorator fires. Nothing static ever names a tool
module, so PyInstaller had no reason to include one, and the registry came up
empty: no menu entries, no CLI subcommands, an app that starts and does nothing.
`collect_submodules("meshterm.tools")` fixes it, which is the same treatment
`bleak.backends` already had for the same reason — the spec knew about other
people's dynamic imports and not about ours.

The worse half is that CI said this was fine. The smoke test ran `--help` and
`specimen`, and a build with no tools at all passes both. It now runs a
tool-provided subcommand, so an empty registry fails the pipeline instead of
reaching a release page. Green tests that cannot see the defect are worse than no
tests, because they are believed.

`MESHTERM_HOME` comes out of the same session. Every copy of MeshTerm reads and
writes `~/.meshterm`, so trying a downloaded build meant pointing it at the same
34MB history database as the checkout it was built from, with no way to keep them
apart. The variable moves the whole directory. An empty or whitespace value falls
back rather than resolving to the current directory, because a shell exporting it
blank is common and scattering someone's history into their downloads folder is a
poor way to find that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:49:37 -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 5db0c6adb9 The tables that are a contract opt out of the formatter
Groundwork for the whole-repo `ruff format` sweep, kept separate so that sweep
can be a commit with nothing in it but layout.

An audit of `ruff format --diff` found the sweep is mechanical where it counts —
152 of 153 files come out AST-identical, the suite passes on the formatted tree,
and `meshterm specimen` is byte-identical on both platforms — but it found three
things worth handling first.

One is a defect. `test_a_contact_the_device_never_held_is_still_removed_here`
opens its docstring on a quote character, so the formatter breaks up the run of
four quotes by inserting a space, and that trips `D210`. A straight sweep would
therefore have taken `ruff check .` red — the one commit whose entire point is
that both gates pass. The quoted phrase becomes curly quotes, which is already
how the rest of the tests spell a quotation, and the collision goes away.

One is readability. `sparkline` ends on a subscript of a call that no longer
fits, and the formatter's answer is to hang `[` newline `0` newline `]` off the
end. Naming the intermediate says the same thing in two plain lines.

The rest are fences. Six tables in this codebase are hand-packed on purpose: the
glyph map and the fold table read as two columns because that is how you check a
substitution; `FONT_RANGES` is a device-verified inventory that a reader scans in
rows; `_RAW_ROW_SKIP` groups its fields by which part of a packet they came from;
`_NODE_SLOT_HEXES` runs the wheel in 60-degree steps with the angle in the lane.
The formatter's layout is all-or-nothing — one line, or one item per line — so it
would turn `FONT_RANGES` into two hundred lines and flatten every grouping into
an undifferentiated list. `# fmt: off` says these are data laid out to be read,
not code that happens to be wide.

`test_theme16`'s heat ladder is fenced for a stronger reason: it pairs two steps
per row under one comment naming the band, so exploding it would leave
"under 5 minutes — white" attached to the second half of its own pair and the
first half unlabelled. That is not alignment loss, it is a comment that starts
lying.

Not fenced: the `heat.*` ladder inside `MESH_THEME`. It wants the same treatment
and cannot have it — ruff honours `# fmt: off` for a statement, and those rows
are elements inside a dict inside a call, where the marker is simply ignored. It
loses its aligned comment column in the sweep, along with about ninety other
lanes across the repo. Each comment stays on its own entry, so nothing there
starts lying; if the column is wanted back it needs the whole `Theme(...)` call
fenced, which is a bigger decision than this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:04:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 8d8c8b0f2b The basemap user agent is one an operator can actually answer
`MeshTerm/0.1 (+https://github.com/; mesh node map)` went out on every
OpenFreeMap tile request and was wrong twice over: the URL is a bare host
nobody can visit, and `0.1` was a hand-written number that had already
drifted from the package's `0.1.0` and would drift again on the next bump.
OpenFreeMap issues no API key, so this header is the entire handle a tile
operator has on a client — the difference between being emailed about
traffic and being blocked for it.

The URL now points at the real repository, and the version is the package's
own `__version__`, which is what every other surface that shows a version
already reads (the About page, the splash wordmark, the menu mark, a trace
export). That is the single source of truth here, so reusing it is what
keeps the string honest.

Deliberately *not* `importlib.metadata.version("meshterm")`, despite that
being the usual advice: MeshTerm is run straight from a checkout at least as
often as from a wheel — this repo's own venv has no `meshterm` distribution
installed, nor does the PicoCalc deploy — so the metadata lookup would raise
`PackageNotFoundError` in exactly the setups it is developed and tested in,
and a fallback around it would be a second version literal to keep in step
with the first. Reading the package attribute has no failure mode at all,
which is also why the value stays a module constant rather than becoming a
function: it never changes, computing it cannot raise, and the tile fetch
path is no place to discover either.

Three tests in the basemap section of `test_map.py`: the string is exactly
the repo, the live version and the descriptive tail; it rides the header of
the request that actually leaves; and the module still imports with a real
version in it when the metadata lookup is made to fail outright, which is
the regression guard against someone reaching for `importlib.metadata` later
without a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:48:41 -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 4545173c65 Every public callable says what it is for
The 46 missing docstrings, written rather than generated.

Most of the test ones already carried the explanation — as a comment sitting on
the first line of the body. That prose is the docstring; it was only in the
wrong place, so the config editor's PIN and lane tests keep their own words and
lose the duplicate comment. The rest are new: the two `repo` fixtures, the
counting `FakeDevice` in `test_device_state` (each method now says what it
answers *and* that it counts the call), the PicoCalc chrome and 16-slot theme
tests, and three one-offs.

In the package: `PathLine` and `FastRenderer`'s constructors point at the class
docstring that already documents their arguments rather than repeating it,
`SelectItem.__post_init__` and `courier_screen`'s opener say what they cache and
why, and `emoji_width.create_content` explains the memo it wraps the line lookup
in. `flash.py` and `uart1-mux.py` get theirs too, and `tests/__init__.py`
finally says what the package is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:28:01 -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 ca0023f5b7 The import blocks hold only imports
`E402` read as seventeen deliberate late imports and was in fact two accidents.
A module constant had settled into the middle of `packet_viewer`'s import block
(`_BODY_CACHE_MAX`, now below it), and ten test modules put their `_FIXTURE`
path or a small helper above the last import — the `tests.conftest` screen
reader, which now joins the block it belonged to.

`B008` is the opposite case: all six are Typer declaring a CLI parameter by
calling `Argument`/`Option` in the default, which is the framework's API and
cannot be written another way in this style. The objects it returns are inert
sentinels no command mutates — exactly the thing `extend-immutable-calls` is
for, so the rule now knows about them instead of being silenced per line.

`.claude` leaves the lint's reach: those are helper scripts for the skills, not
package code, and CI has no reason to gate on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:17:12 -04:00
Jean-Pierre MartineauandClaude Opus 5 e9a4f644d8 A line variable is called a line, and a format string is one string
The mechanical tail of the ruff pass, all of it behaviour-preserving.

`E741`: 29 uses of the ambiguous `l`, which reads as a `1` in most fonts.
Twenty-four of them held a rendered line and are now `ln`; three held an MVT
`Layer` and are now `layer`; two were a map label sorted by rank, now `lab`.
Renaming only the flagged line and then re-running `F821` was the check — a
half-renamed comprehension leaves a name nothing defines, so the linter finds
what a careful read might not.

`UP031`: 15 `%`-format strings become `.format` calls. `UP007`: the `CellStyle`
and `LabelT` aliases become `X | Y`; both are annotation-only, so the runtime
value never mattered. `E702`: four semicolon-chained `await` lines in
`test_device_state` get a line each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 20:14:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 61b6387269 Every zip says whether its sides are the same length
`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>
2026-09-06 20:12:07 -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 dda0a38ed0 The cooldown countdown centers its clock, reason, and Cancel chip
The wait dialog drew its three lines flush left while every other dialog in
the app centers its body — so the Cancel chip sat against the border, reading
as an afterthought beside the button rows it is a sibling of. It now centers
line by line the way ButtonDialog and ProgressDialog do, and takes the same
+12 breathing room in its content-sized width (49 cells at its widest, still
inside the PicoCalc's 53).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 12:10:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 bcf387c620 The test fixtures stop being a picture of one person's mesh
Every node in the suite was named after hardware on JP's desk: YUL-Cartierville
and YUL-Poly are repeaters he runs, two more were the BLE
companions, another his own handle, and a long tail of YUL-Far / YUL-Me / bare
YUL besides. A stranger reading the tests learned his callsign, his city and his
gear before learning what the tests check.

They join the family the suite had already invented for exactly this — Alice,
Lounge, Yagi-Repeater, hub-1: Hilltop-Repeater, Lakeside, Waymarker, Homestead,
and Hub for the 3d-keyed node. Hub is not a new name; test_device_config already
called that contact Hub, so the suite was carrying two names for one fixture.

Replacements are the same length as what they replace, so every width budget,
truncation assertion and gallery line is unchanged. The two map tests that type
a literal query picked `yu`/`yul` to match the old name; they type `hi`/`hil`
now, same length, same single match.

Left alone on purpose: the About page and the donate link, where the handle and
the repeaters are the point, the hand-drawn splash art, and the XIAO README
reporting what JP's own radio advertises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 02:01:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 5a2449ebe3 The working notes stay on this machine
.claude/plans/ and housekeeping.md were internal planning — phase worklists,
hardware addresses, unshipped ideas — tracked alongside the code they planned.
They stop being tracked here, and .gitignore keeps them from coming back.

The three source comments that cited .claude/plans/picocalc-platform.md now say
what they mean on their own: a pointer into a file the reader cannot open is
worse than no pointer.

housekeeping.md is deleted outright; it will be replaced with something better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:15:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 95276dd1b1 The map opens without being told how much of the mesh to frame
`meshterm map` with no flags crashed on its own bounds check: --fraction
defaults to None so the preference can answer for it, and the guard compared
that None against 0.0. The one invocation nobody qualifies was the only one
that could not run.

The guard now only speaks when a fraction was actually passed. It still earns
its place next to Typer's own min/max, which admit exactly 0.0 -- a fraction
that frames nothing.

The command had no test at all, so there is a seam now: the CLI is registered
onto a throwaway Typer app with run_tool_command stubbed, and the params the
command builds are read back. Bare, zero, and both ends of the range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:10:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 d3639e0ceb The main menu pads its icon column like every other list
Preferences' title sat a column left of the twenty-three rows above it. Its icon
is the reason: the terminal draws the gear in one cell where it draws every other
menu mark in two, and the menu was writing `icon + " "` -- the exact bug icon_lane
and icon_mark were built for on the node page in September, on a list that had
since been the only one taught the lesson.

The menu is the list most likely to drift, too, because it is the one that never
declares its icons: it takes whatever the tool registry hands it, so the next tool
to arrive with a narrow mark would have re-opened this on its own. The column is
now measured across every visible tool and each mark padded out to it, which on a
platform with no icon lane measures zero and leaves the titles flush, as before.

The label builder lifts out of the menu loop to _menu_labels so the alignment has
a seam to be tested through. The test asserts the shared start column rather than
the gear, and first asserts the menu still mixes both widths -- a menu that lost
its narrow icon would otherwise keep passing while proving nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 00:27:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 34092613f1 The tests read the About pages as written, not as scaffolded
The four pages were scaffolds when their test was written: three headings apiece
standing in for a shape not yet settled, and under each one a muted placeholder
line in the app's empty-state voice. All four have been written since, and six
assertions were still describing the scaffold -- a suite that fails on the work
being finished is a suite nobody can read a real regression out of.

The headings are now one list, PAGE_HEADINGS, because two tests ask two things
of the same fact -- that they render in order, and that each is a stop the sticky
heading can pin -- and a page that grows a section should not be able to satisfy
one while drifting from the other. That list is the pages as they stand: the
author page's four, and the platform and future sections About MeshTerm gained.

The placeholder test loses its subject entirely, so it goes. Its neighbour, the
one holding prose to its indent block, was *passing* on the same absence -- it
looked for placeholder lines that had escaped to column 0 and found no
placeholders at all. It now asks the question directly: on the support page,
which carries neither of the two blocks that are page frame rather than body,
nothing but a heading may sit flush.

The licence check still wanted MIT. It reads Apache 2.0 now, and also holds the
page to the trademark sentence -- the part that says what the licence does not
cover, which is the half a fork most needs to have been told.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 00:10:31 -04:00
Jean-Pierre MartineauandClaude Opus 5 1e4fad4d2d A wait you can see is a wait you can leave
Every feature paced its own bursts privately, so nothing knew what anything else
had just put on the air, and a wait was a frozen screen -- which on a radio app
reads as a dead link rather than as courtesy.

There is a shared clock now. Every real transmission marks it, in the five send
methods that are the only places the radio is actually spoken to (the simulator
marks the same points, so the whole thing is walkable with no hardware). Anything
about to transmit can ask what is still owed.

Two clocks, because two kinds of transmission cost the mesh very differently. The
general one is the transmit cooldown, whose minimum is now 0.1s -- a floor, not an
off switch; there is no zero for duty-cycle courtesy. Flood adverts get their own,
floored at 5s and defaulting to a minute: every repeater in range rebroadcasts one,
and it waits out both clocks.

Under two seconds the wait just happens -- a dialog that flashes up is itself the
interruption. Over it, a countdown floats over whatever asked: the seconds left,
why it is waiting, and a Cancel chip. Enter and Esc both take it, sharing one hint
atom because they do the one thing on offer, and cancelling abandons the action
rather than merely the wait. It resolves itself at zero.

The suite resets the clock between tests. It is process-wide, which is right for a
session and wrong for a run: one test sending something left a cooldown standing
for the next, whose scripted flow then stopped on a countdown nobody wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:19:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 9de428f6ca A cursor stops at the end of its list
Down off the last row wrapped to the first on a good third of the app's
lists, and the select screen shipped that as its default -- twenty-one
call sites had already opted out one at a time, each writing wrap=False
for its own reason. The reasons were all the same one, so the flag is
gone and clamping is simply what a cursor does.

The window follows the highlight, so a roll doesn't move a pointer: it
hauls the whole list back to the other end and flips its edge markers,
which reads as the screen changing under you rather than as one step.
Held-down arrows now settle at an end instead of looping past what you
were trying to reach.

The hand-rolled cursors came along: the trophy case's, the trace and TX
optimize action rows, the reorder list -- where the ungrabbed cursor
wrapped while the grabbed one clamped, so the same two keys meant
opposite things in the two modes -- a prompt's completion list, and a
dialog's buttons.

Tab is the exception, and it stays a ring: it has no reverse of its own,
so on the last chip it comes round or it dead-ends. Same for the three
picocalc F-lane chips that are single-key advancers -- Sort, the node
page's tab strip, the Time Machine's window -- where the lane offers no
way back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:04:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 03f8e1496f Every row says what it does, not what it is
The descriptions were written for someone who already knew the answer. A user
reading "Airtime budget: duty cycle = 100 / (factor + 1) %" or "Per-hop
path-hash size; larger resists collisions, adds overhead" has to ask, which is
the opposite of what a description is for.

So both registries now answer the reader's actual question -- what does this do
to me -- in as few words as it takes. "Wider is faster, narrower reaches
further" instead of naming the unit twice. "Caps how much of the air we use;
0 = no cap" instead of the formula. "How loud this radio transmits."

Labels went with them where the name was ours to choose: Sweep floor and Sweep
ceiling are Lowest power and Highest power, Opening frame is Map fit, Silence
alarm is Alert after, Basemap source is Map tiles. The radio's own names stay --
someone matching Spreading factor against their phone app should find the words
they came for. History kept is just History.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:51:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 fe4780e070 The page keeps what MeshTerm alone decides
Background adverts came off it. The cadence is the *companion's*, remembered per
public key and set on Device config, so a preference could only ever be the value
a radio starts on before you give it one -- a default behind a default, and a row
that says "newly seen device" while meaning "your own radio, the first time".
adverts.json answers for it again; the store is back to the constants it had.

Transmit cooldown now defaults to 5s rather than 1s. It paces every burst we are
allowed to make, and on a shared mesh the polite number is the larger one.

The last-column choices answer their own row: it asks whether to use the column a
terminal hides, so the values are auto, yes, no instead of naming the mechanism a
second time. YAML 1.1 reads a bare yes/no as a boolean, so a hand-edited file that
writes the obvious thing is understood rather than silently dropped -- we still
quote ours on the way out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:46:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 cef7a764af How MeshTerm behaves gets a page of its own
Three kinds of "setting" were tangled into two files and one of them had no
screen at all. The radio's settings live on the radio and are edited on Device
config. Machine setup -- where the database is, which companion to talk to --
stays in config.toml, a text editor's job. Everything in between was app
behaviour: six keys sitting in config.toml with nothing to open them, and a
dozen more that were only module constants a reader could never reach.

Those are preferences now, and they have a page. core/preferences.py is a
PrefSpec registry in the shape of the device-config one -- group, type, bounds,
help, and a default -- and the page is built from the same parts as the device
editor, deliberately: one grouped lane-aligned list, current -> new staged in
warn, Apply/Back appearing only once something is staged, the shared discard
confirm on the way out. Two editors that stage values should not be two screens
to learn. What it adds is a Defaults section holding one row that stages every
changed preference back to its built-in value; it is drawn only while a reset
would do something, and it stages rather than writes, so it is as reviewable and
as Esc-discardable as any other change and the file is still only ever written
by the one action at the bottom. Its rows scroll sideways where the device
editor's don't: the explanation is the widest thing on the row and the only part
that runs past the edge, so <-> slide it under a pinned setting and value.

The file is preferences.yaml and holds only the values you disagree with the
code about -- grouped and commented, each entry above its help and its default.
That is what lets a later change to a default reach an install that never asked
to be exempt; delete a line and the code takes over again. A hand edit gone
wrong costs that line, never the session.

Every preference is wired to something that reads it. ctx.preferences is the
explicit path; preferences.current() is for the render layer, which has no
context to reach through, the same reason get_platform() exists.

The About pages come along into a This app section, the third scope after this
node and other nodes, with Preferences leading it -- the one row there that
changes MeshTerm rather than describing it. A section of one would have broken
the menu's own no-bucket-of-one rule, and the app's scope is the honest home for
both halves anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:36:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 c6892f9b6c The editor covers the PIN too, on one chord for both
Device info stopped printing the pairing PIN; the Device config editor one menu
over still listed it in plain digits, which meant the concealment held only until
the reader walked one screen sideways. It masks there now on the same terms —
and a *staged* PIN masks as well: one typed a moment ago is still a PIN, and a
row that uncovered itself the instant it was edited would leave the secret up for
the rest of the staging session.

The reveal moves to `^S`, on both pages. It was a bare `p` on the info page,
which a read-only body can afford; the editor cannot, its letters being
find-as-you-type, and one value that comes out from under a different key
depending on which screen you are standing on is a second thing to learn for
nothing. The chord also has to be meant — no PIN appears because a hand brushed a
letter. `^S` is XOFF's key and safe for the same reason `^Q` is not XON here: raw
mode clears IXON/IXOFF, which the chord table already relied on. Both pages say
`^S show PIN` / `^S hide PIN` in the footer and carry the same F3 `Reveal`/`Hide`
chip — F3 being the slot a select list spends on its own verb, free here because
this list hands out no deletable rows.

The editor's list becomes `_ConfigMenu`, which owns the toggle and the one row
builder both it and the staging loop call: a toggle is a refresh like any other
(`replace_items` follows the highlighted row by value and keeps the typed
filter), so uncovering the PIN while three letters deep in a filter leaves the
reader exactly where they were. `_menu_items` grew the flag and hands it to the
value lane; nothing else in the row shape moved.

Both pages join the gallery, each in both states — the config editor had no
specimen at all before this, and the revealed state is the widest its PIN row
gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:28:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 42937ce411 The pairing PIN waits to be asked for
Device info is the page you open to read your radio out to somebody, the one that
gets screenshotted into a forum thread and left up while a colleague leans over
your shoulder. Every value on it is yours to give away except one: the BLE
pairing PIN is what lets another phone onto the radio, and it was sitting in the
Identity section in plain digits alongside the node name and the coordinates.

It now draws as the field rather than the value — six mask bullets, the same
glyph a password prompt types in, promoted to marks.MASK_MARK so the two can
never drift apart. Six is the width of the *field* (the setting's own bound is
999999), not of what is in it: masking per character is right while someone is
typing and counting their own digits, and wrong for a stored secret, where it
would say how many to guess. A device that reports no PIN keeps its "?" — an
absence is not a secret, and a "?" behind bullets would promise something to
uncover.

`p` uncovers it, and `p` puts it back. A bare letter is a read-only page's idiom
for its own verb (the Time Machine's `w`) and this page has no find-as-you-type
to compete for it; the footer names what the press would do *now* — `p show PIN`,
then `p hide PIN` — and on the console the same toggle is F3, `Reveal` then
`Hide`, since the lane is the only place that platform can learn the key exists.
A device with no PIN advertises neither: the atom drops and the slot stays empty,
because the action isn't a thing on that page at all.

The page is therefore its own screen now (ui/device_info_screen.py) rather than a
generic result window, and ScrollScreen grows `replace_content` — replace_items
for a read-only body — so the toggle swaps the page under a reader who keeps
their scroll position; nothing reflows, six cells become six. The CLI has no key
to press, so `meshterm info` and `config show` print it masked and
`config get device_pin` is the way to name it deliberately: one rule, which is
that the whole-device dump never carries the PIN. The editor one menu over still
lists it plainly — you go there to change it, a row at a time, and its letters
already belong to find-as-you-type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:13:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 1d951052bf The widest lane gets the widest column
The device config table let its two narrow lanes size themselves to their
content first: SETTING stretched to the longest label it happened to hold and
CURRENT to the longest value, and DESCRIPTION — the one lane whose content is
systematically the widest — lived on what was left. That was 19 cells of 72, so
nearly every explanation wrapped two or three lines deep and the reader scrolled
a 67-line table to read twenty settings.

The narrow lanes are capped instead, at 24 cells for the setting and 15 for the
value, and their cells are pre-folded to fit so `no_wrap` still holds each lane
to exactly that width. DESCRIPTION keeps the whole remainder: 27 cells, and the
table falls to 52 lines. Four rows fold — three telemetry labels and the
path-hash value — and every one of them sat on a wrapping description already,
so the fold costs nothing the reallocation didn't already buy back. A folded
label hangs two further in, so a continuation can never be read as a section
heading at column 0, and a value folds before its parenthesised label rather
than through it: `1` over `(2-byte hashes)`, not a torn `1 (2-byte`. A long node
name — the field is 31 bytes — now folds too, where before it dragged the whole
lane across the screen.

The cap only exists to buy the prose room, so where that lane isn't drawn it
lifts. Two places don't draw it: the PicoCalc, whose 53 columns are spent by the
setting and its value, and the staged view, whose third narrow lane leaves
nothing for prose. Rich was already dropping the column outright at both widths
— now the table says so itself, and shreds nothing on the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:52:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 c4a718c28a Both ends of the walk are cut the same way
The head was cropped and cracked; the tail still dropped whole hops behind a ⋯,
because that mark was left over from when the fit itself was hop-based. So one
line carried two different claims about what was out of view, and the reader had
to know which end they were reading to know which one they were being told.

The trail is now one line read through a window, the shape every other path row
that outgrows its lane already has (the node page's routes, the Message paths
lanes, a trophy row under ←→): composed once at natural length, cropped at
whichever edge the walk continues past, each cut wearing cut_mark — the chip
sheared on its own half block, the faint … in arrow mode. Nothing is elided, so
nothing says it is.

←→ slide by the app's own 8-cell step rather than a hop: hops were the old fit's
unit, and a cropped line has no hop boundaries to land on. cut_from goes with
them — the window covers the unscrolled case in the same six lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:14:43 -04:00
Jean-Pierre MartineauandClaude Opus 5 23e89633fb The walk's start is cropped, not given up whole
The breadcrumb fitted itself with ellipsized(ELIDE_HEAD): the head hop went
behind a leading ⋯ and whatever fitted after it snapped right. That mark cost
three cells and, worse, the whole hop it was standing in for — a 46-cell lane
opened on "⋯ › Repeater-06" when it had room for the tail of Repeater-05 too.

The trophy case has always cut its rows the other way: composed at natural
length, handed over whole, cracked at the lane's edge in the sheared chip's own
fill. Same crop here, mirrored to the end the trail is anchored on — cut_from,
cut_to's head-side counterpart, added to the widget so no surface hand-rolls it.
The trail draws whole while whole fits and crops its beginning when it doesn't;
a crop lands exactly on the width, so the right snap comes free.

Each end now carries the mark that is true of it: the head opens on the crack,
which says this segment continues, and the scrolled tail keeps the ⋯, which
says whole hops are missing — because off that end they really are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:02:59 -04:00
Jean-Pierre MartineauandClaude Opus 5 5ca5ea7d56 The viewer's top is two stops as well
Enter on the live feed names *this* packet, so the dialog rightly opens holding
one — but there was then no way back to the stream from inside it. The reader
who walked up to the newest packet hit a clamp, and following the feed again
meant Esc, ↑ on the list, and re-opening whatever had landed since.

The pin is now the viewer's stop too, on the same key it is on the feed: ↑ off
the newest packet, and Home from anywhere, holds the top *position* rather than
a packet, so every arrival becomes the card being read. The title says
`following` where it would say `n/total` — what changes under the pin is the
card, not the number — and ↓ steps back off onto whichever packet is showing at
that moment, keeping the scroll the reader had in it, because that card is the
one they were already reading.

Both ends move together: the viewer tells the feed when it pins (`on_pin`), the
way it already tells it when it lands on a packet, so a dialog closed while
following leaves the list following, and one closed on a packet still lands on
that packet. Only a live list has the stop at all — over a snapshot there is
nothing to follow, so ↑ on the newest packet clamps as it always did.

The footer follows the moves rather than the count: `↓ hold packet` on the pin,
`↑ follow · ↓ older` one stop below it (two keys doing different things, so an
atom each), and the plain pair everywhere else. On the console the `Newest`
chip reaches the pin, exactly as the feed's `Top` chip does, which is also why
the jump chips light over a live list holding a single packet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 12:36:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 24e4da9176 The walk hangs off the edge its start isn't on
The breadcrumb snapped flush right whenever the line was fitted, which was
right at rest but wrong the moment ← had scrolled the walk's head back into
view: a line showing its own rounded start chip sat pushed against the far
edge with a lane of blank page in front of it.

Alignment now follows one thing, whether the start is on the line. It fits ⇒
flush left, where a path that begins at its beginning belongs; it doesn't ⇒
the right snap, which is what a cut head looks like, holding the ⋯ against the
edge so it reads as "the line continues off this side" rather than as a gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 12:33:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 6924ff7f45 The presets are MeshCore's, not ours
Every one of the region presets named a modulation no mesh runs any more, and
two of them named a frequency nobody was ever on.

MeshTerm's list was three regions on 250 kHz / SF11 — the shape MeshCore
shipped with — plus two trade-off rows built on the EU frequency. Through 2025
most regions left that modulation for a narrow 62.5 kHz one, so the EU row is
what MeshCore now calls "EU/UK (Deprecated)", the US row had the right
frequency with the wrong modem (910.525 is SF7/BW62.5/CR5), and "AU / NZ 915"
at 915.525 was not a preset at all: Australia is 915.800 wide, 916.575 narrow,
923.125 in SA/WA and QLD, and New Zealand is 917.375. Four parameters have to
match for two nodes to hear each other, so a stale table is a radio staged onto
an empty channel.

The list is now MeshCore's own suggested settings, mirrored verbatim — names,
order and values — from what its apps, the web flasher and config.meshcore.dev
all read at api.meshcore.nz/api/v1/config. Twenty-three regions, including the
two MeshCore still lists under its own "(Deprecated)" names, because nodes that
never re-tuned are on them and picking one is how you go and listen. The names
are spelled as MeshCore spells them so the row can be read against the phone
app.

Four regions also name a path-hash size, which the preset now stages alongside
the radio fields as the firmware's mode (size - 1), exactly as MeshCore's own
config GUI converts it. The rows moved to the two-lane menu shape — name, then
the parameters in MeshCore's print order — which fits 53 columns where the old
one-line title ran to 85, and the list opens on the preset the radio is already
tuned to, staged values included, so picking a neighbour is a comparison rather
than a guess.

The trade-off pair is gone with them. They were ours rather than MeshCore's,
they pinned a deprecated EU frequency onto whoever picked them, and the wide /
mid / narrow choice they were reaching for is one the region list now makes
properly per region.

The simulator boots on the firmware's build defaults too (869.618, BW 62.5,
SF8, CR5 per MeshCore's platformio.ini), rather than on the settings the EU
mesh left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:17:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 0b719a4736 The client nobody closed, closed
A Bluetooth session that lost its companion could not get it back, and the
reason was one call that four separate layers each declined to make (JP,
2026-09-04).

When the peripheral is what goes away, bleak fires `BLEConnection`'s
dropped-link callback, which restores the connection's fields to what the
caller originally passed — setting `client` back to None and letting go of the
live object. `BLEConnection.disconnect` then guards on `self.client and
self.client.is_connected` and has nothing to close; `ConnectionManager
.disconnect` guards on a flag its own drop handler already cleared;
`_force_close_transport` reaches past both, but only as far as that same
`BLEConnection`, whose disconnect is the no-op above. Every guard is locally
right and the sum of them is that `BleakClient.disconnect()` is never called
on a link that dropped by itself.

On Windows the WinRT `BluetoothLEDevice` and its GATT session then stay open
for the life of the process, and every later connect to that address is served
a broken service table: the link comes up and the UART subscribe fails with
"Characteristic 6E400003-… was not found!". The device advertises at -61 dBm,
answers a hand-written APP_START over raw GATT with a valid SELF_INFO, and is
unreachable to this app until it restarts — while a fresh process connects to
it in 0.6s. That asymmetry is the whole tell, and it is what turned every
reconnect attempt into a fast, inexplicable failure.

So the client is ours now: taken at connect while the library still has one to
give, and closed unconditionally at teardown — never guarded on `is_connected`,
because the case that matters is exactly the one where it is already False.
Bounded and silent, since a teardown may not raise or hang, and a no-op on the
transports that have no client to hold.

Measured on the real companion: the session drops, is heard again 7.2s after
the device comes back, and is rebuilt 0.97s later — 8.3s from drop to live,
where it previously never recovered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 c090db829c A returning companion is heard, not guessed at
Bluetooth notices a drop cleanly — the client's connection flag flips the
moment the peripheral goes — but the way back was two guesses stacked on each
other, and neither held (JP, 2026-09-04).

The reconnect flow gave serial a real question to ask ("has the port
re-enumerated?") and gave Bluetooth nothing: it simply attempted the connect
every couple of seconds. Each of those blind attempts drove bleak's own
internal address lookup into a device that wasn't advertising, which is a slow
failure, so the attempts sprawled across and past the window in which the
companion actually came back. And when one finally did land, it landed holding
the `BLEDevice` the startup scan produced — a handle that names a peripheral
the OS no longer has, because a device that power-cycles comes back as a new
one. The session opened over BLE and then, having dropped, could not find its
way back to a radio sitting a foot away with its light on.

BLE now gets the same shape serial has, asked of the only registry Bluetooth
keeps: `find_ble_device` watches for that one address's advertisement and
returns the instant it is heard. The answer arrives with a live handle, so the
listening and the fixing are one step — the reconnect opens the peripheral
that is on the air now, and `reconnect(ble_device=...)` prefers it over
anything the picker scanned. A miss costs a scan window rather than a failed
connect, and nothing is attempted against a device that isn't there.

The ordering is load-bearing, so the teardown splits out as `release_link` and
runs before the first scan: a companion we are still holding a link to does
not advertise, and the old flow's first act was a connect attempt that dropped
the dead client on its way past. Waiting first would have waited out a device
that was powered on and silent purely because we hadn't let go of it. The
resume intent is captured there and held across retries exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:11:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 99bfdade64 The feed opens on its columns, not on a status line
The live feed's heading said three things and none of them earned a row:
the order is what the TIME lane reads down, Enter is already named by the
footer hint and the F-key lane, and the live-light reported the hub's
subscription rather than any traffic — green over a silent mesh, and the
one screen that never starts the hub itself. Dropping the line gives the
feed a content row back, which is a row in thirteen on the PicoCalc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:03:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 12d42a8804 Off the highlighted route, a node recedes
A fan is one route read against its alternatives, and it only says which route
that is if everything not on it dims. The unused lines already drew grey, but
every marker on them kept its node-type colour and every label its name hue —
so the loudest things in the picture were the nodes the selection had just
ruled out (JP, 2026-09-04).

The widget owns the rule now: where one layer is emphasised over the rest,
`render_path_graph` wraps the caller's marker and label colour callbacks and
draws every node outside the emphasised path in one dark grey. Membership is
tested in the graph's own id space — after the prefix coalesce, the identical-
path collapse and the revisit split — so a route reaching a relay by a short
hash still lights the wide marker its hop folds into. The endpoints are on
every path and never dim; the glyphs keep their shapes, only the ink recedes;
and a picture with nothing emphasised (a lone path, or a fan of equals) fades
nothing at all, so the packet viewer and the Trophy case are untouched.

Node detail had been doing half of this by hand — labels only, its own copy of
the coalesce — and drops that wrapper for the shared rule. Message paths gets
the whole of it for the first time, on both platforms: the grey lands on the
console's dark-grey slot through the same quantizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:38:38 -04:00
Jean-Pierre MartineauandClaude Opus 5 a5fd4357f1 A windowed list stops at its ends
Four screens hand their rows to a ListWindow and let the highlight drive it:
the mesh walk's links, the path composer's suggestions, the node detail's
routes, and Message paths' arrivals. All four wrapped — ↓ off the last row
landed on the first — and in a windowed list that is not one step. The window
follows the highlight, so the wrap hauls the whole list from the tail back to
the head and flips both edge markers on the way: it reads as the screen
changing under the reader rather than as a move they made (JP, 2026-09-04).

All four clamp now, the way the live feed's cursor already did and the way a
grouped select list does with `wrap=False`. Held-down arrows settle at an end.
The rule is written down where the pattern lives, on `ListWindow` itself —
each screen owns its own cursor, so the class can state it but not enforce it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:20:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 0eb9845cd9 The graph holds still while the arrivals scroll past it
Message paths laid its whole body out as one strip — quote, fan, captions,
then every arrival — and let the frame scroll it under the selection. Walk
far enough down a busy message and the picture the routes are being compared
against slides off the top of the dialog, which is the one thing the reader
was looking at (JP, 2026-09-04).

The head is pinned now and only the list moves, the app-wide windowed-list
pattern the node detail's Routes tab already runs: the quote and the fan take
their lines, the arrivals window into what is left with faint `↑ n more` /
`↓ n more` edge markers, and the fan compresses its own lane pitch before the
list would lose its window — floored at two arrivals and a marker, because
comparing one arrival against another is what this dialog is for. PgUp/PgDn
now page the selection by a windowful of arrivals rather than by body lines,
and both they and the F-lane's chips say so only while there is more than one
arrival to move between.

The variable-height fit both screens need moves to `ListWindow.fit_blocks`,
beside the single-line `fit` it is the sibling of; node detail drops its own
copy and its hand-kept top/page state for the shared window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:03:25 -04:00
Jean-Pierre MartineauandClaude Opus 5 6fc4e7afc9 The community has a page, and the page is its invite
A fourth About page, `Join Discord`, sitting between the author and the ask:
what is this, who made it, where is everyone, how do I help. Like its three
siblings it is written markdown under assets/pages, reads nothing and
transmits nothing, and keeps a CLI face — `meshterm discord`.

It is short on purpose. The invite link, and the code for it in a `qr` fence,
because on a PicoCalc there is no clipboard and the code is how a link leaves
the screen at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:38:44 -04:00
Jean-Pierre MartineauandClaude Opus 5 155c0b5b21 A QR is drawn from its link, not pasted in as art
The three codes on the Support page were half-block art pasted into
support.md, and markdown had been quietly shearing them. A module row whose
first module is light opens on a *space*, and every CommonMark parser eats
leading whitespace on a continuation line — so four of the fifteen rows in the
Buy-me-a-coffee code, and rows in the other two, were rendered one module to
the left of where they belong. A sheared row is not a QR; a phone gets
nothing.

Markdown grows one construct instead: a fence tagged `qr`, whose body is a URL
and whose rendering is the code for it, through the same `qr.qr_text` widget
the channel and contact share popups draw. So the link is written once, the
code can never drift from it, and a QR looks the same everywhere in MeshTerm
— black on white, which is what makes it read under any terminal palette
rather than inverted on a dark one.

The three fences replace 45 lines of art.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:38:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 211f38255b A path field means two different things, and now says which
No, it isn't the ack — ACK is its own payload class and was never in the match.
Both symptoms come from the same place: a packet header's route type, which we
never recorded, decides what its `path` field *is*.

A FLOOD packet accumulates: every relay appends its hash, so the path is the
route it travelled to reach us. A DIRECT packet is the opposite — the path is a
routing instruction the sender wrote and the relays consume — so it says where
the packet is going, and an empty one means the route was used up, not that the
packet arrived in a single leap. Reading every path the first way is what drew a
message from five hops out as having appeared from nowhere.

The evidence is in your own log. The four-hop route on those sends is identical
across four consecutive messages, where a flood-accumulated path varies every
time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*;
and a PATH frame carrying exactly those four hops crosses the log seconds before
the first message that uses it. That is a learned route to hairu being announced
and then used — not a route anything took to us.

So route_typename is stored (schema v15, a `route` column beside the crypto and
addressing ones added for the same reason), and an Arrival carries whether it
was routed. A routed frame with no path has route_known False: it is kept as a
copy that reached us — the count and the time are real — but it is drawn as
"routed · route not carried" instead of a confident zero hops, and it puts no
lane in the fan, because an empty lane there is a claim of adjacency the frame
never made. Frames from before the column read None and fall back to the old
behaviour rather than to a guess.

The round trip is a second bug and needed no new data. The path line closed on
our own ★ unconditionally, and for an outgoing message its head is our ★ too —
so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been
hardcoded to us. Both now take a destination: the far end for a message we sent,
ourselves for one we received. It is the rule the line's head already followed —
a path runs between the nodes it went between — applied at last to its tail. A
channel broadcast keeps us at both ends, because there the copies we log really
are our own message coming back.

collapse now keys on the route type as well as the hops, so a route travelled
and a route intended never fold into one row even when the hashes match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 00:16:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 c8c7eab128 A direct message's frames are its own, by the MAC over its ciphertext
The paths on a DM listed a message's own retransmissions, the other end's
traffic, and both of those for every other message sent in the same minute and
a half. On one real bob_caribou message that was 30 rows over 8 paths, of
which 23 were the same two one-hop routes repeated. Those one-hops were the
tell (JP): they were our *own* sends, overheard coming back off the two
repeaters in earshot, correctly showing one hop because one hop is all they had
travelled — but shown under the message as if they were its route.

The channel view has always matched by decrypted content, and asked for here.
A direct frame cannot be read, but it does not need to be: its body is
[dest 1B][src 1B][MAC 2B][ciphertext], and that MAC is a tag over this
message's plaintext. Copies of one message share it; the next message's differ.
frame_addressing already knew where those two bytes were and threw them away,
and the observations table already had a cipher_mac column its channel sibling
used — so a direct message now groups exactly as a channel one does, with no
schema change and nothing decrypted.

Three filters, in order. The frame ran between the conversation's two ends; it
travelled the direction this message did; it carries its group's MAC. The
middle one is doing more work than it looks: both ends' hashes sit on every
frame either way, so matching on the pair alone was what put our sends under a
received message.

The window is clamped by the conversation's own messages rather than a flat
±90 s — nine messages fell inside one such window. The clamp reads only
messages going the *same* way, which the first cut of it got wrong: an inbound
stamp is the sender's clock, so ordering it against our own sends compares two
clocks, and a received message came back with an empty window. The directions
are also bounded differently, because we know different things about them. A
send is stamped by our clock as it leaves, so no frame of it predates it and
all the room goes forward to the next send, where the retries are. A received
message keeps a centred window, halfway to the received messages either side.

Rows are paths now, not frames: identical routes fold into one line carrying
its copy count, its first sighting, and the best SNR that path managed. Twelve
identical lines said "twelve arrivals" where the truth was "two paths, six
times each".

Measured on the conversation that prompted this, whose history predates the
MAC and so exercises only the direction and window fixes:

  outbound "Allo! Ça marche JF"   30 rows / 8 paths -> 2 rows (x3 each)
  inbound  "J'ai pas de ack..."   30 rows / 8 paths -> 2 rows, 6 and 7 hops

which is exactly what the packet log says by hand. Old history keeps the
address-and-time fallback and is billed as such; frames recorded from now on
match exactly, and say so in the same words the channel view uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:50:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 026e78078e The icon column is measured, not assumed to be two cells
🗑 is drawn in one cell where 💾, 🌍, ⏳ and 📱 are drawn in two, and a command
row that wrote `icon + " "` started its label wherever its own icon ended. So
Delete contact… sat a column left of Archive contact above it (JP). Ten of the
app's lexicon icons are like this — 🗑 ✎ ⚙ ▶ ★ ↻ ↕ ⇄ ⌨ # — so any list mixing
them with the other twenty-seven was out of line; the Contacts tail's 🗑 Purge
against 📂 View archived was the same fault one screen over.

Not an emoji-width entry. The classifier is clear that both authorities and the
terminal agree 🗑 is one cell: it sits outside Emoji_Presentation and carries no
VS16, so the font draws a one-cell text glyph and everyone is right. Forcing it
into the wide set would reserve a cell nothing draws and pull the border in —
the mistake 🛣 already cost the Trophy case. The measurement was never wrong;
the layout assumed it away.

The trace and TX sweep screens had solved this properly and privately, each
with its own copy: declare the marks a list can draw, measure the widest, pad
every mark out to it. That is now menus.icon_lane/icon_mark, THE icon column,
and those two delegate rather than duplicate. marked_label takes the lane so a
mixed list can pass it, defaulting to measuring the single icon — which stays
right for a list whose rows all lead with the same mark, and is precisely what
misaligns one that doesn't.

The node page measures its column once over every action it can offer, so the
group ends aligned however many of archive/restore/delete a given contact
earns. PicoCalc is unaffected and stays that way by construction: it draws no
icon lane at all, so the column measures zero, the separator goes with it, and
the tint lands on the label as before.

The tests assert in display cells rather than characters. That distinction is
the bug — 🗑 plus two spaces and 📂 plus one are three cells and different
string lengths, and an index-based assertion would have called the fix broken
and the break fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:19:19 -04:00