b76a93808f07e7ed097d34e9e047d00122d3e277
642
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
360c0c131e |
The keystroke raced the screen that reads it
`_run_live` pushes the picker itself, so the Enter could not be queued before the call — it was sent blind and lost about one full-suite run in six, on a machine busy enough to change the timing. `_screen_at` exists for exactly this and says so in its docstring: wait until the stack is the depth the flow reached, then press. Fifteen consecutive runs of the test, and three of the whole suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
de0f60cbe0 |
Rewrite the CLI standard for the two faces it now has
The section described a command line "read by awk", which was the premise that changed: the plain face is read by a person and --json is read by the program. It stated the quoting, the absolute times and the comma-joined path line as rules, and all three now say the opposite. What replaces it is the seam first — a tool states, a renderer draws, the boundary picks — then each face with the rules an implementer follows and the reason behind each one, then the exit statuses, with the two classifications that were wrong somewhere stated out loud: nothing transmitted is not a device failure, and a rendering flag never moves the status. docs/cli.md is untouched and now stale in three places: the --json row still says only two commands honour it, --absolute is undocumented, and every console sample carries quoted names and ISO timestamps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>v0.2.8 |
||
|
|
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>v0.2.7 |
||
|
|
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>
v0.2.6
|
||
|
|
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>v0.2.5 |
||
|
|
476294a8db |
Say whose font this is and which licence it keeps
The OFL permits bundling a font with any software, on conditions we now meet explicitly. The file is byte-identical to Microsoft's own release (sha256 528f9214...), so it is not a Modified Version and the Reserved Font Name clause does not apply. Condition 2 wants the copyright notice and the licence in every copy: CascadiaMono-OFL.txt ships beside the .ttf and travels into the bundle with the rest of the assets. Condition 5 wants the font under that licence and no other, which the Apache LICENSE at the root would otherwise have appeared to claim -- so NOTICE and the README now carve it out by name. Also says plainly that Microsoft does not endorse MeshTerm, since condition 4 allows acknowledgement and nothing more. The README's font section predated the Windows Terminal offer and named a preference that has since been renamed; it now leads with the terminal, as the app does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7be4b8514b |
A sentence the last commit left in two halves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>v0.2.4 |
||
|
|
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>v0.2.3 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>v0.2.2 |
||
|
|
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>
|
||
|
|
8cc1cae213 |
Say, in the changelog, what happens to it at 0.9.0
A note to whoever cuts the first public release, left where they will be looking rather than in a plan they may not have open: at 0.9.0 everything above the fold collapses into one entry saying "first public release" with the headline features under it. The reasoning, so it does not read as vandalism later: every version below 0.9.0 was released nowhere. Their entries are notes to ourselves about getting ready — a formatter pin, a runner label, a workflow that fired on the wrong branches. None of it is news to somebody arriving on launch day, and a changelog that opens with a fortnight of housekeeping buries the thing they came for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>v0.2.1 |
||
|
|
eb61c55f22 |
The Intel Mac leg asks for a runner that still exists
`macos-13` is retired. GitHub's image list starts at macOS 14 now, and the current x86_64 labels are `macos-15-intel` and `macos-26-intel`. The failure mode is worth writing down, because it is not the one you would expect: a job asking for a retired label does not fail. It queues, forever, and holds the entire run open behind it — so the four legs that had already built sat there under a run that never finished, and anything waiting on that workflow would have waited with them. That includes the release workflow, which calls it, so a tag pushed today would have produced no release and no error to explain why. `macos-15-intel` rather than `-26-intel`, for the same reason the Python in this matrix is 3.10: built against an older system, runs on more machines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0c12bd4fcf |
Somewhere to download a build from
Pushing a version tag now builds the binaries and puts them on the Releases page, with a combined SHA256SUMS and notes lifted out of the changelog section for that version. It stays inside GitHub — while the repository is private the release is private too — and it is emphatically not the PyPI publish, which is `release.yml` and still needs a person to run it on purpose. `installers.yml` gained a `workflow_call` trigger so the release calls it instead of keeping a second copy of the matrix. Two matrices would agree for about a month and then quietly stop, and the files people download should be built exactly the way the ones CI checks on every push are. The README now leads its install section with the download rather than with pip, because most people who want MeshTerm have a radio and a terminal and not a Python toolchain. It also says plainly that the first launch will be blocked: these builds are unsigned, macOS will refuse them until the quarantine attribute is cleared, and Windows will show its SmartScreen warning. Signing costs real money on both platforms, so the honest move is to say so and give people the checksum file rather than let them think something is wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ebb3c80f15 |
Pin the formatter, and stop it editing the record
The first CI run went red on `ruff format --check`, on a tree that was clean on this machine. Three separate problems, all found by pushing. `ruff>=0.4` is not a pin. CI installed 0.16.6 where the laptop had 0.15.18, and 0.16 formats Python code blocks inside Markdown while 0.15 leaves them alone — so the two disagreed about whether the repository was formatted, which is the one thing a formatter exists to settle. Pinned to the 0.16 line. Dependabot will offer the next one, and CI will say whether it is safe. The file it tripped over is `docs/survey-ui-consistency.md`, which quotes four lines of a dialog call as it looked on 22 August. That excerpt is a fragment — two arguments, no call around them — so the formatter parsed it as two statements and rewrote them into a tuple and an assignment that never existed anywhere. The surveys are dated records of a past state, so `docs/` is now excluded: a formatter editing a quotation is not tidying, it is falsifying. And the installer workflow was firing for branches it had no business building. Dependabot opened three action bumps within a minute of the push; each one edits `.github/workflows/installers.yml`, each matched its own path filter, and each started a five-platform build. `branches: [main]` stops it. Everything else was green on the first try, including the part that was actually uncertain: all seven test legs passed, macOS among them, and the installers built on Windows, macOS on Apple silicon, and Linux on both x64 and arm64 — so the uConsole build works and the ARM runner was available after all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
ab8ef2aa23 |
Say how the project is run, and ask better bug questions
The bug form now asks what JP's Discord post asks: how often it happens, whether there's a workaround, what hardware you're on, and which companion device — with the note that "a Xiao" doesn't narrow anything down. Three things his post didn't cover got added: the MeshTerm version, whether it still happens under `--mock` (which splits an app bug from a radio bug before anyone spends an evening on it), and whether you're on the regular or PicoCalc look, since those draw differently enough that a bug in one often doesn't exist in the other. The README gains the part that was missing: what to expect from a maintainer doing this on evenings and weekends. Issues welcome, ask before opening a pull request, no promised timelines, and a nudge on a quiet issue is fine. Report on GitHub or on Discord, whichever you have — the real ones get moved across either way, and being told beats filing it tidily. Install is rewritten for someone who wants to run MeshTerm rather than work on it. It said `pip install -e ".[dev]"`, which is a contributor's command and puts pytest and ruff on the machine of somebody who just wants to talk to a radio. Now it's one pip line, a pipx alternative, `--mock` for people without a radio yet, and an honest "coming" list for installers and PyPI. Badges now include CI, the licence and Discord, so they say something checkable rather than only decorating. The two survey documents get a line at the top saying they're a snapshot of a date, not a to-do list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
628b665420 |
The project can be joined, reported to, and argued with
The rest of the community files, which are the half of open-sourcing that is not code: how someone tells you about a hole, what happens if a thread turns nasty, and what you will be asked when they file a bug. SECURITY.md points at GitHub's private vulnerability reporting rather than an address that will be scraped, and promises only what one person in his spare time can keep — an acknowledgement inside a week, which is not a fix inside a week, and a decision inside ninety days. Its scope section is the MeshTerm-specific part: a channel key or an admin password reaching a log, an export or the screen is a vulnerability here, a crafted packet corrupting the observation database is one, and a hole in the MeshCore firmware or in `bleak` is somebody else's to fix. It also says plainly that the database is not encrypted, because a backup of it carries a mesh's whole history. The code of conduct is Contributor Covenant 2.1, taken verbatim from source rather than paraphrased, with a real address in the enforcement section. The bug form asks the seven things that otherwise get asked in every thread: version, flavour, transport, OS and Python, terminal and its size, companion firmware. Flavour is there because the PicoCalc draws itself in 53 columns and sixteen colours, so a layout bug in one flavour frequently does not exist in the other. It asks for the version from the splash screen rather than from `meshterm --version`, which does not exist yet. Dependabot is weekly and capped low, with minor and patch grouped into one pull request: a solo maintainer facing five dependency PRs every Monday stops reading any of them. Funding points at the three destinations the app's own support page already offers, so the two cannot drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
117b958edf |
Everything PyPI needs, and no way to reach it by accident
The distribution is named `mesh-term`, because `meshterm` is on PyPI's prohibited list and a request to free it is 319 deep in a queue. The name after `pip install` is the only thing that changes: the import package is still `meshterm`, the console script is still `meshterm`, and nothing in the code reads the distribution name, so this is one line rather than a rename. Proven rather than assumed — the wheel was built, installed into a clean interpreter, and the command run from outside the source tree. The rest is what a stranger sees on a package page and what the app never shows them: project URLs, trove classifiers for the console, the three operating systems and the four Python versions, and the licence moved to its PEP 639 expression form. No `License ::` classifier, which that form forbids, and no `Typing :: Typed` either — there is no `py.typed` marker, and a classifier that claims something untrue is worse than an absent one. `release.yml` publishes to PyPI and is built to be hard to fire. It has no `push: tags` trigger, and that absence is the design: a workflow that published on a `v*` tag would make tagging v0.9.0 on launch day claim a name on a public index, permanently, as a side effect of an unrelated action — and PyPI has no rename and no real delete. So a human runs it from the Actions tab and types the version, the job compares that against the version the tree actually built and refuses on a mismatch, and a `pypi` environment can hold it for a reviewer. Trusted Publishing means there is no API token in the repository to leak. It publishes whatever version is committed; there is no version argument, so it cannot publish something other than what is there. CI gains a packaging job that does the same build and clean-install check on every push, publishing nothing. Packaging breaks quietly, and the alternative is finding out on the one day that is worst for finding out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
365df236b9 |
The promises in CONTRIBUTING are checked by a machine
`.github/` starts here: the three commands the contributing guide asks a contributor to run, run for them on every push and pull request. Two jobs rather than one. Style is a property of the source, so `lint` runs once on Linux and a failure means one thing. Behaviour is a property of the machine, so `test` runs across the systems the description now promises — Windows, macOS and Linux — which until this file existed had only ever been checked on one of them, whatever the description said. The matrix is shaped for a private repository, where minutes are metered and billed by a multiplier: Linux 1x, Windows 2x, macOS 10x. Linux therefore carries the whole 3.10 to 3.13 range, and the expensive runners cover its ends, since a version-dependent break shows up at an end rather than in the middle. Seven legs, about fifty billed minutes a run. The comment says to widen it to the full product once the repo is public and standard runners stop costing anything. Two small decisions worth their lines. `fail-fast` is off: the point of a run is the whole picture of what broke, and a first red leg cancelling its siblings hides the rest until the next push. And `concurrency` cancels a superseded run, because a second push makes the first run's answer obsolete and those are metered minutes spent on a stale commit. One line of `.gitignore` came with it. The catch-all `.*/` that hides `.venv` and the caches was hiding `.github/` too — the same rule `.claude/` already needed an exception to. It fails silently in the worst way: a workflow that is never committed looks exactly like a workflow that never runs. The version risk this was expected to surface is already gone: the suite was run locally on 3.13 as well as the usual 3.10, and both report 1888 passing. What remains genuinely unchecked is macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>v0.2.0 |
||
|
|
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> |
||
|
|
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> |
||
|
|
60e2cd1e2d |
Blame skips the sweep and answers with the author
The commit before this one rewrote a hundred and fifty-three files without changing what any of them does. Left alone, that is what `git blame` would name for half the lines in the repo — the formatter, standing in front of whoever actually wrote the line and whatever they were thinking at the time. `.git-blame-ignore-revs` is the file that fixes it. GitHub reads it with no setup at all, so the blame view on the web is already right; locally it needs `git config blame.ignoreRevsFile .git-blame-ignore-revs` once, which is now step two of the dev install in CONTRIBUTING alongside the editable install, since a contributor who never runs it gets the wrong answer silently rather than an error. The file's header states the bar for being in it: whole-tree, provably behaviour-free. That is a deliberately narrow door — an entry here makes a commit invisible to the tool people use to ask why a line looks the way it does, and a commit that changed anything real must never be able to hide behind it. Verified rather than assumed: `git blame` on a line the sweep rewrote now reports `f07f8c1b` from July, which is the commit that actually wrote it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |