The build to diagnose a double-click with: a window that waits after a failure, a
log that keeps what happened, and a level you can turn up before reproducing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Double-clicking the executable on Windows gives the process its own console, and
Windows destroys that console the moment the process ends. So a traceback was
drawn and erased in the same instant: the report was "it displays a trace but the
window closes almost instantly", which is not a rendering problem, it is the
window being owned by the thing that died.
Two changes, because one of them is not enough.
The window now waits. `GetConsoleProcessList` says how many processes share this
console; when the answer is one, nobody else is holding the window open and it
will close with us. On that path only, and only after a failure, MeshTerm asks for
a keypress before it goes. A run started from a shell leaves its output on the
screen anyway, and a run that worked should never make anyone press a key to be
allowed to leave.
And the interactive session logs what killed it. It was catching the missing
Windows console and re-raising everything else untouched, which is precisely
backwards for the case that needs a record most: the session whose console is
about to vanish is the one whose traceback nobody will get to read. It goes into
the file first, and the terminal is told where the file is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
The first build that contains the whole app. 0.2.1's binaries start and then
offer you nothing, so this replaces them rather than adding to them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Closes out the changelog's Unreleased section under a date, which is the whole
ceremony: everything since 0.2.0 was written down as it landed, so cutting a
release is renaming a heading rather than reading a week of commits back.
0.2.0's link points at its tag rather than a release page, because 0.2.0 was
never released anywhere — it was tagged locally before there was anything to
build or anywhere to put it. 0.2.1 is the first version that exists outside this
machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most people who want MeshTerm have a radio and a terminal, not a Python
toolchain. This is the install for them: one file, download it, run it.
`packaging/meshterm.spec` freezes the app with PyInstaller in console mode —
which is not a detail, since a windowed build of a terminal program starts with
nowhere to draw and exits. The assets land at `meshterm/assets` inside the bundle
so that `Path(__file__).parent.parent / "assets"` keeps resolving exactly as it
does in a checkout, and bleak's per-platform backends are named as hidden imports
because nothing static ever sees the one that actually gets used. UPX is off: a
few megabytes are not worth being flagged by antivirus on a first release.
The build was run here rather than only written. The Windows executable comes out
at 14 MB, runs from a directory with no source tree in sight, and draws the
specimen card — which is the real test, because the specimen reads the asset
files.
`installers.yml` does the same on five machines, because frozen builds cannot be
cross-compiled. Windows, macOS on both Apple silicon and Intel (plenty of people
still have one, and an arm64 build will not run for them), and Linux on x64 and
ARM64 — the last for the uConsole. Each leg runs the binary from a temporary
directory before uploading it, so a build that only works beside its own source
fails on the runner instead of on somebody's laptop, and each one gets a checksum
written beside it. It publishes nothing; attaching these to a release stays a
separate decision.
Also here: `meshterm --help` now opens with the same sentence as the package
summary, rather than a fourth description of the project written in its own way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two pieces of release machinery that have to exist before a first bump, plus
the bump: 0.1.0 to 0.2.0.
The version was written twice — `pyproject.toml` and `meshterm/__init__.py` —
and kept in step by hand. That is a drift waiting to happen, and an unusually
visible one here: `__version__` is read by the splash wordmark, the menu mark,
the About page, a trace export and the tile-server user agent, so the number a
user reads on screen is the package attribute, while the number in a built
wheel would have been the `pyproject` literal. `[project] version` is now
`dynamic` and hatchling reads the attribute, so there is one place to edit and
the two cannot disagree. Verified against hatchling rather than assumed: it
resolves `0.2.0`.
`CHANGELOG.md` starts in Keep a Changelog form, with the `[Unreleased]` heading
that makes release notes a rename rather than an excavation of the log. Its
first entry is 0.2.0 and it says plainly that the history before it is not
reconstructed — 596 commits of pre-history would be a worse document, not a
more complete one.
0.2.0 rather than something nearer the announced 0.9.0: the numbers between here
and the release are for real feature sets, and there is no reason to spend them
early. 0.9.0 is a number chosen for the launch, not one to be arrived at by
counting — landing on 0.7.0 on the day and bumping straight to 0.9.0 is fine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two words the project was saying two ways, now settled to one each.
`macOS` is how Apple has styled it since 2016, and the repo was already split:
`connection.py`, `flash.py` and `test_disconnect.py` had it right, while the
About page and the new Summary said `MacOS`. Three files change, all of them
prose. Same five characters either way, so nothing about a rendered width moves.
`BLE` becomes `Bluetooth` in the Summary and the package docstring. Those two
lines are read by people deciding whether this runs on their machine, and
`Bluetooth` is the word they know and the word the About page already uses. The
identifier stays `BLE` everywhere it is one — the transport name, the connection
code, the config keys, the CLI. That is the boundary: the acronym is what the
thing is called in the code, the plain word is what it is called when introducing
the project to someone who has not met it yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Summary is the only description most people will ever read: it is the line
under the name in PyPI search results, the one a package manager echoes, and the
sentence that decides whether anyone opens the README. It said "A modern
command-line toolkit for tuning and exploring a MeshCore mesh", which named a
category rather than the thing, and left out every fact a reader is actually
deciding on.
The new one is JP's own wording and answers those instead: what it is (a
full-featured TUI-based MeshCore client for your terminal), how it reaches a
radio (USB, BLE, TCP), and whether it runs on the reader's machine (Windows,
MacOS, Linux). "USB" and "MacOS" are the spellings the About page already uses,
so the front door and the metadata now say it the same way.
The package docstring carries the same sentence, split at the summary line as
`D205` wants, so `help(meshterm)` and the PyPI page agree.
The README's opening is untouched. It is doing a different job — a paragraph that
gets to breathe, with the links and the longitudinal-record pitch in it — and a
one-line summary is not a replacement for it.
Co-Authored-By: Claude Opus 5 (1M context) <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>
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>
`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>
`D205` across the 20 sites in `meshterm/` and `scripts/`, which leaves the
package and the scripts fully ruff-clean.
None of these were a summary that happened to wrap. They were a paragraph with
no summary at all, so the fix is additive: a plain sentence saying what the
thing *is* goes on the first line, and the prose that was there becomes the
description under it. "The transport of the current/selected connection:
``"serial"``, ``"ble"``, ``"tcp"``, or ``None``" now opens with "Which
transport the current or selected connection uses" and lists the four values
below; `_emit` leads with what it returns before the four paragraphs on why a
wide glyph forces a whole-row repaint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
`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>
`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>
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>
`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>
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>
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>
.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>
`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>
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>
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>
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>
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>
The section holding the sweep bounds was called Transmit power, which is also
the name of a device setting one screen over -- so it read as a second place to
set the radio's power rather than as the search that finds one. It is TX
optimize now, after the feature it configures, and its rows are Range min and
Range max: the two ends of what that feature sweeps.
Their descriptions lost "the power search" along the way, which had been a third
name for the same thing. The heading says which feature; the rows only have to
say which end.
Pause between sends goes back to Transmit cooldown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>