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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:50:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 9341c4c1d9 Twenty-two cells reserved for a name most people spell in five
The chat picker's conversation lane was a flat 22 cells, and every cell
of it past the longest name in the list was padding in front of a short
one — taken straight out of the last-message preview, which is the only
run on the row with something to say. On the PicoCalc's 53 columns that
left the message 12 cells: enough for a sender's name and a colon.

It is measured now, against the names the list actually holds, floored
at the header word and capped at the shape of a repeater-style name.
The preview keeps what it saves: 31 cells to 37 on a 72-column
terminal, 12 to 18 on the console.

What the cap costs is the tail of a long name, so the row gained the way
to read past its own edge that every other lane-and-run list in the app
already had: ←→ slide the message under the pinned columns, one row at a
time (Choice.hscroll_from). The preview had to stop pre-truncating
itself at 40 cells to make that worth pressing — a cut made before the
terminal's own leaves nothing to reveal.

The marker lane was measured too, and was wrong. A contact's dot was
padded to "a channel's double-cell glyph", which the channel glyph is on
regular and is not in the console font: on the PicoCalc every channel
row sat a cell to the left of every direct row, header over neither. It
now pads to what the glyph reports.

The footer had to give a cell back for the scroll atom, since it can
coincide with the delete atom on any thread with history — the same
arithmetic the purge preview does in its own comment. Enter opens a
screen here, so its verb is `open` rather than the default `select`,
which pays for most of it; `Del delete history` spends the rest on an
object the highlighted row is already naming.

The picker joins the gallery, which is what would have caught the marker
lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:34:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 4d8c67358c Release 0.3.0
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
The command line was rebuilt around the fact that it has two readers.
`--json` is the machine contract and now covers all 49 subcommands
instead of two; the plain face is free to be read by a person, which is
what it was always for.

The changelog entry had been written for the intermediate design — it
promised quoted names and absolute timestamps, both of which the finished
work deliberately reverses — so it describes what shipped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:07:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 b76a93808f Three more arguments that were reported as the radio's fault
`4` promises a caller that the node answered and a retry is worth
trying. An unknown admin node, an unknown courier contact and an `--at`
that is not a clock time are all decided before a word goes over the
air, so a caller retrying on `4` would retransmit nothing, forever.
`tx-optimize` had already answered the identical question the identical
way for a missing password; these had not caught up.

A refused admin login stays `4`. The node was reached and answered no,
the password may simply have changed, and that is exactly the case a
retry exists for — the line is whether anything was transmitted, not
whether the command worked.

The manual documented the old behaviour and called the inconsistency
out; it now documents the rule instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:03:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 c920f50584 The manual promised awk; now it says which reader it is talking to
docs/cli.md led with "the CLI is meant to be read by awk" and made every rule
follow from it. That promise moved to --json, so the manual now opens on the
split — the plain face for a person at a prompt, the JSON face for a program —
and documents both per command: what each prints, and what shape and fields come
back. Every sample is captured from a real --mock run in a throwaway
MESHTERM_HOME, not written.

Three sections that were missing:

  - Where MeshTerm keeps its state. --db moves the database and nothing else;
    the outbox, the caches, the stored passwords and the remembered device stay
    in the config directory. $MESHTERM_HOME is what actually isolates a run and
    was documented nowhere.
  - About the examples. They come from the simulator, which opens a fresh radio
    per invocation, never fills PKTS, and invents hop names on a device-routed
    trace. And --mock records into the real history unless given its own home.
  - When it goes wrong. The exit scheme was explained and never once shown.

Corrected: quoting is gone from listings, times are ages with --absolute behind
the cli_time_format preference, routes take arrows where paths keep commas,
contacts gained HASH and LOCATION, preferences regained DESCRIPTION, ack and
closing messages now reach stderr rather than being dropped, and the written
pages wrap. Exit 3 now covers a --port that will not open, and the usage class
covers a missing --yes, a bad --sort or --category, an unknown --profile and a
bad courier id. The awk -F'"' recipe split on quotes that no longer exist, so
the recipes go through jq; the cron entry flooded the mesh every 30 minutes,
six times tighter than the cadence picker's own floor, and is now
config advert-cadence with an absolute path for what still belongs in cron.

README: chat is not interactive, trace-path is its own command, and the exit
table is prose plus a link instead of a third copy to keep in sync.

about.md: everything-in-the-TUI-is-in-the-CLI was never true — the map,
dashboard, live feed, watchtower and mesh walk have no command — so it says
nearly, names them, and points at the JSON the claim is now worth making about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:58:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 b42b0a473e Take the flag where it is typed, and stop the heading from lying
`meshterm contacts --json` is what a person types, and it failed with a
bare usage error: Click binds an option to whatever command declares it,
so a global on the callback is a parse error one word later. For an
output format that is the wrong way round — `-o json` goes after the verb
in every tool that has one, and --json is the first thing anyone reaches
for. The group's own options are now lifted to the front before Click
parses, so both orders mean the same run. `--` stops the lifting and
--help is never lifted, since `contacts --help` must stay the contacts
help rather than quietly becoming the program's.

And a column headed AGE held `2026-09-08T04:18:25-04:00` under
--absolute. Every other time column already names the fact — HEARD,
RECORDED, FINISHED — which stays true whichever form the run asked for;
only chat's named the form. `when()` now says so where the header is
declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:21:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a71284d7d A bare answer with nothing in it prints nothing, not a dash
repeater-admin on a node that never answered, channels share on an empty slot:
the bare form prints the value, and there is no value. A lone `-` there is a
line of output where the exit status is the whole report — and 5 already means
"ran fine, found nothing", which is the branch a script is waiting on.

The document keeps the shape either way, with null where the answer would have
been, because a consumer branching on it still needs the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:10:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 8d2a7b8281 --absolute asks for another form of time, not for one fewer fact
A never-heard node has no instant to print, so the timestamp language dropped
"never" back to the plain absent token and a contacts listing under --absolute
could no longer tell "we have not heard from it" from "this row has no heard
time". The absent word survives the switch; only the form of a real time changes.

Also drops a dead guard in the preferences writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:07:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 5215a1b76c Two things the sweep could not see
A block that renders to nothing still counted toward the separator, so
`config restore --dry-run` opened with a blank line it did not have — the plan
is a listing under an outcome that says everything it has to say in the exit
status. The separator now counts what was drawn.

And the device inventory declared `port` and `address` in its rows and never in
its columns, so both fell out of the document: a caller had to parse the target
for a colon to find out which transport it was holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:59:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 e3ae668f4a Give the person at the prompt back what a redirect never wanted
Five small things the seam left standing, each one a rule applied a step too
far or a question asked of the wrong flag.

ack and a tool's closing message go to stderr instead of being dropped. The
reason they were dropped was sound as far as it went — an acknowledgement is not
the answer, and `meshterm contacts > f` must catch only the answer — but stdout
is not the only stream. Someone watching a five-minute `config restore` gets the
setting-by-setting ✓ back, and the redirect still catches nothing extra.

tx-optimize asked --json whether it could prompt. That is a question about
output format, and it answered this one wrong in both directions: with the flag
a piped run failed cleanly, without it a scheduled run on a terminal sat waiting
for a password nobody was going to type. It now asks stdin — and asks it
properly on Windows, where NUL is a character device that isatty() calls a
terminal and getpass then reads the console directly and blocks forever. A
missing password is a usage error, since nothing was transmitted.

courier send on an id nobody has raised a device failure, which told a scheduled
sender to retry a typo. It is a bad argument: the outbox is a local file.

--mock alongside --port/--ble/--tcp used to let --mock win silently, so a
scheduled `--port COM7 --mock info` reported on a simulator while reading as
though it had reached the radio.

And stderr stops folding at 80 columns off a terminal, which is what made
`grep 'not already connected' errors.log` come back empty — the sentence was
there, with a newline through the middle of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:56:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 109fe7645d Move every command onto the seam, and give the plain face its own premise
Forty-nine leaf commands now state a Report instead of printing one, so --json
works on all of them at once rather than on the two that grew a branch for it.
The two hand-rolled JSON paths are gone: devices no longer builds a payload dict
of its own, and its empty scan exits 5 like the plain path always did rather than
0 — a rendering flag has no business moving the report.

The plain face changes premise along with it. It used to be a serialisation
format a person could squint at, and several of its rules existed only to make
splitting safe. Splitting is the machine face's job now, so:

- Names are bare. Alignment is the delimiter; the escaping under the quoting
  stays, because nothing else stops a name a node broadcast from ending its own
  record.
- Times are ages. "Heard recently?" is the question a contacts listing is opened
  to ask, and an ISO instant makes the reader do arithmetic to answer it.
  --absolute puts the instants back; an appointment, the device clock and a live
  capture's own column were never ages and still aren't.
- A route takes arrows and a path keeps its commas. The lexicon has always split
  these and the output never did, so a route line looked pasteable into --path,
  which it is not.
- The listings gain what was cut for parser hygiene: contacts gets the HASH
  --path takes and a LOCATION the model has always carried, preferences gets the
  DESCRIPTION already written in its registry, and the two live streams get
  pinned lanes — which is what let them have columns at all, and with them the
  last call site for the quoting.
- The four written pages wrap at 72 instead of printing 600-cell paragraphs. It
  is the one exception to "no wrapping", and the rule's own reason says why: a
  paragraph has no fields to land under the wrong headings.

info keeps its keys and gains a gloss on the four readings nobody can hold in
their head (93784 (1d 2h)); it is the one key/value block with no set behind it,
so there is no round trip to protect. config show is untouched, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:43:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:16:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 59f10f904c The escape table was a list where it needed to be a category
Turning markup off on the scripted console stopped a node name being read
as a style. It also stopped every tag MeshTerm writes *itself* from being
consumed — so `config export-key > key.hex` wrote a file with
`[muted]` and `[/muted]` around the private key that was supposed to be
its whole content. `PlainUi.note` is documented to take markup and now
resolves it to text, which fixes the leak wherever it is written rather
than only where it was found; the key itself is bare on both faces.

Escaping covered the newline, the tab and the return, and let ESC
through — a live colour run written into the caller's file, which is the
one thing "not a single escape sequence" exists to prevent. BEL was
worse: dropped in silence, so the printed name was not the advertised
one, and U+2028 ended the record like a newline. It is now every C0 and
C1 control plus the two Unicode separators, named escapes where one
reads and numeric everywhere else. Legitimate non-ASCII is untouched.

The covered-command list is now walked out of the Typer app instead of
kept by hand. It was six commands of forty-nine, and both violations
above sat in the forty-three — the `---` in `about` drawn as a rule the
width of a 16384-cell console, and the tags above. Verified by putting
each bug back and watching the sweep name it.

Three assertions could not fail. `len(set(MEANINGS)) == len(MEANINGS)`
is true of every dict, so "no two outcomes share a status" was unenforced
— renumbering NO_DEVICE to 4 left all three green while status 3 vanished
from --help. `" " * script.GUTTER in record` reads the constant it
checks, narrowing to `" " in record` exactly when the gutter narrows.
Both are literals now.

The four rule tests read `result.output`, which is stdout and stderr
mixed, while the rules they enforce are about stdout — so a heading that
only ever appeared on stderr would have satisfied them.

And $MESHTERM_HOME is isolated for every test, not just the CLI ones:
--db moves the history and nothing else, so any test touching a store,
a preference or the logger was reading the developer's own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:52:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 98c635a62e Refuse the wrong answer instead of quietly giving it
Four places where the CLI answered a question the caller had not asked.

`--profile yagi` naming no profile fell through to ordinary discovery, so
a typo in a scheduled advert transmitted from whichever radio happened to
be attached. Naming a profile is a claim about which device; an
unkeepable claim is a bad argument, not a default.

`--sort bogus` sorted by the default and `--category long_hual` selected
every discipline — which then came back as exit 5, the code that means a
real empty result. A caller branching on 5 could not tell "no records"
from "you misspelled the discipline". `ContactsSort.from_name` still
forgives an unknown name, because a saved preference from an older build
must not wedge the menu's list; the command line refuses instead.

A `--port` that would not open reported exit 4 and "the connection to the
device was lost", describing a connection there had never been. Every
failure while acquiring the device is the other status: nothing was
transmitted. It now says which port, and why.

A message body could hold a newline, which ended its record early and
left the remainder indented under the other columns as a second record
with an empty TIME — the one field a stranger fills in, breaking the one
rule the format has. script.oneline folds it flat.

And an unnamed node read as `""` in one listing and `-` in the next:
seven of the ten places that print a name were claiming "called nothing"
where they meant "never said". script.name draws the distinction once,
which is also the one the JSON rendering will need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:31:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in replaced every
empty setting with a pair of quote marks — and the next dump looked
identical, so nothing ever said so. It also masked an *unreported* PIN
behind six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 4a91845651 A manual for the command line, and two rules it caught us breaking
docs/cli.md documents every command and option, what each one prints, and
what it returns -- with the output conventions and the exit-status table
up front, since those are the two things a script is written against.
Every command, option and flag in it was checked against the running app;
the sample output is captured from the simulator rather than typed out.

Writing it turned up two places the CLI still did the thing the last
commit was about. A destructive command refused for want of --yes printed
its refusal in red on *stdout* and exited 1: a colour and a sentence that
is not the command's answer, in whatever was reading the command's answer.
A missing confirmation is a bad invocation, so it now goes through the
parser like any other -- nothing on stdout, the reason on stderr, exit 2.

The README keeps its cookbook and its summary of the conventions, and
points at the manual for the rest.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 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 690807370b Take them to the better terminal instead of asking permission to
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
The classic Windows console cannot draw a single MeshTerm icon whatever font
it is given, so "reopen in Windows Terminal?" had exactly one sensible answer
-- and was being put to someone who had not seen the app yet and so had nothing
to weigh it against, at the one moment nothing had been drawn.

So it moves. It still says what it is doing and still names the way to stop it,
because a program that opens a window you did not ask for should say so.

Installing a font keeps its consent, and the split is deliberate: moving window
costs nothing and undoes itself by closing, while a font is a file written to
someone's machine that Windows will not let go of until their next logon.

The console_setup preference reads auto/off now rather than ask/off, and its
help says what it does. An older stored "ask" is not "off", so it keeps working
and simply means auto.

JP's call, and it is the right one -- the reference experience should be what
an end user gets, not what they think to ask for.

Release 0.2.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:42:46 -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 a11c43c347 Release 0.2.5
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:43:20 -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 056ac3f770 MeshTerm 0.2.3
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
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>
2026-09-07 14:57:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 3379ea2d44 A crash you can read, on a window that waits
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>
2026-09-07 14:49:54 -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 9b2c5527f8 MeshTerm 0.2.2
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
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>
2026-09-07 13:54:54 -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 2ea8f9fb83 MeshTerm 0.2.1
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
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>
2026-09-07 13:30:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 4f60f44512 A build you can download and run without Python
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>
2026-09-07 05:00:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 660540a84e The version is written once, and there is somewhere to record a change
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>
2026-09-07 03:34:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 b7c629b50f The whole project spells macOS and Bluetooth one way
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>
2026-09-07 02:15:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 37e46bfda5 The project says what it is in one line
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>
2026-09-07 02:09:25 -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 466fd33d34 The package's docstrings lead with a sentence
`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>
2026-09-06 20:30:46 -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