JP's call: rebuild 0.9.0 rather than wait for 0.9.1. It has never been public, so the
release is recut from main: Python 3.14 inside the downloads, the corrected written
pages inside the app, companion and room server as the only names for those types, and
the legend that fits a PicoCalc.
[Unreleased] folds into the 0.9.0 entry. On a first release there is nothing for those
changes to have changed from, so they are not listed as changes; the entry gains the one
fact a reader needs, that pipx works on Python 3.10 to 3.14.
The release workflow writes the install half of the notes from its own template, which
still had the two lines fixed by hand on the first 0.9.0 page: "every Mac since 2020",
and "every file below" for a SHA256SUMS that lists only the binaries. Both are fixed at
the source. The changelog extraction also stops carrying the link definitions at the
foot of CHANGELOG.md into the notes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP builds with 3.14 now, so the workflows do too. The lint job moves from 3.10, the
packaging build job and the release's site rebuild and PyPI build from 3.12, and the
installers from 3.10. The test matrix is untouched: it still runs every version from
3.10 up, because 3.10 is still the floor.
The installers' comment said the oldest Python was chosen on purpose, for reach. The
reach that matters on Linux comes from glibc, and that comes from the runner, so the
Linux legs stay on ubuntu-22.04 and the comment now says that. The binaries carry 3.14
from the next release; 0.9.0's were built on 3.10.
The changelog gains the 3.14 entry JP wrote in another session, plus a line for the
downloads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP moved to 3.14. Every dependency installs on it -- the Windows Bluetooth packages
included -- and the suite passes: 2705 passed, 1 skipped, on 3.14.7 as on 3.10.
- pyproject gains the 3.14 classifier. requires-python stays >=3.10: the uConsole on
bookworm runs 3.11, and a pip or pipx install on an older system should keep working.
- CI keeps its shape, which is Linux over the whole range and the other two runners at
its ends. Linux gains a 3.14 leg, and 3.14 replaces 3.13 as the top end on Windows and
macOS.
- Three tests reached a loop through asyncio.get_event_loop_policy(), which 3.14
deprecates and 3.16 removes, and never closed the loop they made. The two countdown
tests now make one with new_event_loop() and close it; the sweep test uses
asyncio.run(), which does both. On 3.14 the suite is back to no warnings.
The standalone installers still bundle 3.10; moving their build to 3.14 is for 0.9.1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.9.0 called the same advert type three things: `companion` in info, `client` in
diagnostics, and `node` in contacts and every other screen that names a node's type.
Room servers were `room server` in two places and `room` in the rest. JP picked
companion. It is the ecosystem's word -- companion firmware, companion apps -- and the
one MeshTerm's own docs already used most. "client" now belongs to MeshTerm itself, which
the one-liner calls a MeshCore client, and "node" is the lexicon's umbrella for every type,
so a TYPE column that says "node" for one of them tells a reader nothing.
There is now one table, NODE_TYPE_LABELS, and one helper, node_type_label(), which gives
"type N" for an advert type MeshTerm does not know. info and diagnostics lose the two
role tables that had drifted apart, and the packet viewer its own copy of the fallback.
The longer names made the node-type legend 59 cells wide, which the PicoCalc's 53 would
have cropped, cutting "sensor" off. node_type_legend() now takes the width it has and
breaks between entries, never inside one, so it stays on one line on a desktop and takes
two on the PicoCalc. The four screens that draw it pass their width and take its lines.
This changes --json output, so it goes under [Unreleased] for 0.9.1. docs/cli.md's samples,
the value lists and CLAUDE.md's lexicon say companion and room server. Two tests pin the
names and the PicoCalc legend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It read as working notes between JP and the tool that wrote it -- "Question for JP"
blocks, and calls that were his to make -- rather than as a record for a stranger. The UI
consistency survey stays, and docs/README.md and that survey no longer point at the one
that went.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.claude/settings.json is tracked, so it goes public with the repository and applies to
anyone who opens it in Claude Code. It carried JP's own working allowlist -- one-off test
invocations, a `python -c` wildcard, a console beep -- and "defaultMode": "dontAsk",
which would have quietly changed how Claude Code behaves for a contributor.
All of that moved to the gitignored settings.local.json on JP's machine, where Claude Code
still reads it, so nothing changes for him. What stays shared is what a contributor
actually needs to be allowed without asking: the three gates CONTRIBUTING promises,
pytest, ruff check and ruff format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four different one-liners were in circulation: the repository's "interactive, scriptable
CLI toolkit", the README's "full-screen terminal companion", the About page's "client that
lives in your terminal", and the package summary's "TUI-based client ... companion
connection". JP picked one:
A full-featured TUI MeshCore client for your terminal
It now opens the README, the About page, the package summary, meshterm.__doc__ and the
`meshterm --help` header, which cli.py already says must stay the same as the summary.
The summary's second sentence loses its grammar slip on the way: "Connects to a companion
over USB, Bluetooth or TCP." CLAUDE.md opens with the same line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every manual was read against the code, the scripts and the upstream sources, and the
samples in docs/cli.md were recaptured under --mock where they had drifted.
The one a newcomer would hit first: config.example.toml set default_profile to a COM5
profile. Copied verbatim, as configuration.md says to, it broke every device command on a
machine with no COM5. default_profile is commented out now, so the copied file leaves
discovery working. default_tx_power goes from both, because nothing reads it.
docs/cli.md: config show has 22 settings and preferences show 21, not 20 and 8; monitor's
blocks have no blank line between them; the diagnostics sample is labelled as a real-radio
example with absolute instants and every table; an unknown key exits 1; devices, role and
records describe the values they really produce; no maintainer paths; and the "used to"
passages describe the present, since a first-time reader never saw the past.
picocalc.md: the Calculinux image carries a build stamp, so the gunzip and dd lines take
a wildcard; the front SD slot stays empty on first boot; masking wifi-kick is what stops
it, and time-sync is disabled with it; updates run the scripts from the pulled checkout
with TMPDIR set; the tested list names only what a test checks; git ships in the base
image; a bare meshterm shows the splash, and -p picocalc skips it.
uconsole.md: an AIO v2 needs MESHCORE_EN_PINS=27 whichever runtime is installed; the
bookworm paragraph states what upstream actually ships; the bridge shares the key but
keeps its own contacts and radio settings; the defaults are the US/Canada preset; Step 7
downloads what it installs; updating ends with a restart; no claim of test coverage the
suite does not have.
terminals.md, features.md, hardware.md, cookbook.md, configuration.md and the scripts
READMEs get the smaller corrections their audits found.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sweep of the README, CONTRIBUTING and the 0.9.0 changelog entry against the running app.
- Not every screen has a subcommand: the map, dashboard, live feed, watchtower, mesh walk
and time machine live in the menu only. "Most screens".
- Not every command speaks --json: specimen refuses it. "Nearly every".
- On Windows the download is meshterm-windows-x64.exe, not a file called meshterm.
- Apple silicon is M1 and later; Intel Macs were sold until 2023.
- The global options list gains --ble-pin, --absolute and --platform, which it missed.
- The meshcore library speaks TCP too.
- The standalone builds are single files, so their licence texts are bundled inside, not
in an archive to extract.
- 64-bit ARM covers the uConsole; the PicoCalc is 32-bit and installs from a checkout.
- --mock keeps its own history database; the caches it shares are not "none of it".
The README, CONTRIBUTING and the PR template disagreed on when a pull request needs an
issue first. All three now say what CONTRIBUTING said: small fixes can come straight in,
anything bigger waits for a yes. CONTRIBUTING drops "or discussion", since Discussions
are off, and points bug reporters at the form, the diagnostics file and Discord. The bug
form link goes to the form rather than its YAML.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
About, Author and Support are drawn in the app and copied onto meshterm.net word for word,
so a typo in them is published twice. Fixed: through, knows, built, spent, software,
mentioned, "want to pitch in", "more than welcome", a doubled "the", PayPal's capital P,
3.3 V and Wi-Fi.
A few sentences said something untrue. The radio is recorded while MeshTerm runs and is
connected, not from the moment the radio is switched on. One 18650 runs a PicoCalc and a
second roughly doubles it, as docs/picocalc.md says, where "two batteries, all day" had no
source. The maker is ClockworkPi, and the uConsole bridge serves the AIO v1 and v2. The
support page says repeaters, the mesh's own word, and names the Author page rather than
"the about pages". The AI sentence on the Author page had lost its object; it reads as
one sentence again.
Support opens with a whole sentence, because its first line becomes the page's meta
description on the site, and "MeshTerm is free." unfurled as three words. The licence URL
on About is printed once rather than twice.
The binary that shipped as 0.9.0 keeps the old text until 0.9.1; the site picks this up on
its next build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things on the new-issue page would have gone wrong for the first stranger to use it.
The second contact link was named `No GitHub account? Report it in the #bugs forum
instead`, unquoted. YAML reads ` #` as the start of a comment, so GitHub would have shown
"No GitHub account? Report it in the" and nothing after it. The name is quoted now.
The diagnostics and log fields said to attach a file, and both had `render:` set. GitHub
turns attachments off on any textarea that renders, so the one required field asked for
something it would not accept. Neither field renders now; the diagnostics block is
markdown and reads fine as is, and the log field says to fence pasted lines.
Also: the "not recognised? you are on 0.3.7" paragraph goes, since nobody outside ever ran
0.3.x; the Diagnostics page's save key is named for the PicoCalc too; Log detail is
spelled the way the Preferences page shows it and says it needs a restart; the Cardputer
leaves the placeholder, since nothing else mentions one. The pull-request template now
states the same rule as CONTRIBUTING, and FUNDING points at Patreon's canonical /cw/ URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first public release. The version goes to 0.9.0 and the changelog carries one
entry, which the release job lifts verbatim as the release notes.
[Unreleased] had refilled since the last fold with one change -- --mock keeping its
invented mesh out of your real history. On a first release there is no previous
behaviour for it to have changed from, so it is folded into the entry's existing --mock
sentence, in that entry's voice, and [Unreleased] is empty again.
Reading the notes as a newcomer would, three claims were not true, and each was checked
against the running app rather than argued:
- `meshterm map` is not a command. It was the first example a stranger would type, and
it answers "No such command". The map is a picture, so it lives in the menu; the
example is `meshterm info` now.
- The Time Machine does not wind the map back. It is braille charts of recorded history
over a day, a week, a month or all of it -- no map in it at all.
- Only the PicoCalc gets the handheld layout. It is chosen solely by the device tree
reading "Luckfox Lyra"; the uConsole has no detection and runs the desktop layout on
its 1280x720 screen, which has the room for it.
The manual's three sample outputs move from 0.3.7 to 0.9.0 with it, so the version a
reader sees in docs/cli.md matches the one they downloaded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2dbae7a went red on lint, and it was never ruff check -- that passed. It was
ruff format --check, on two files from the last few commits: a nested def with no blank
line above it in the session, and a sum() in test_logo that fits on one line once
joined. Both pure layout; neither touches a hand-aligned table, and ruff check stays
clean after, which is the thing this repository has been bitten by before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The device rows pinned their fixed lanes and slid only the hardware model
underneath them. The reasoning was borrowed from the Trophy case, where the
rank, date and score always fit and only the walk overflows -- there the lanes
really are the reader's place in a long list, and sliding them off would cost
the row its identity to read something the columns already told you.
This list inverts that. On a narrow terminal it is the device name and the
address that get cut, so pinning them pinned the truncation in place: the one
thing the arrow keys could not reach was the text somebody most wanted to
finish reading. A name is not a landmark when it is the thing being elided.
So no lane is pinned, and the row slides whole. That needed the scroll to be
asked for outright, because what normally turns it on is a row pinning a head
block, and none of these do any more -- `hscroll` travels from the picker
through the splash surface to the list, the same path `live` took.
The test that covered the old behaviour is rewritten rather than removed. It
was a fair test of a decision that has changed, and the argument for the new
one belongs where the old one was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`live` went onto `TuiSession.select_startup` and nowhere else, but the picker
calls the *surface* -- `ui.select_startup`, which delegates. So MeshTerm crashed
on the startup path with a `TypeError` about an unexpected keyword, on the first
screen, every time.
The whole test suite passed through it. Every fake Ui in `test_tui.py` takes
`**kwargs`, which is the reasonable thing for a double to do and is exactly what
hid this: the picker's new argument was absorbed by a stand-in that would accept
anything, while the real surface would accept nothing of the sort. Five tests
written specifically for the rescan ran green against code that could not start.
So the double stops being infinitely accommodating. The test added here binds
what the picker sends against the real `Ui.select_startup` signature, which
raises on an unknown keyword exactly where a running MeshTerm does. It fails
against this commit's parent and passes against this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It scanned on the way in and never again, so a radio plugged in ten seconds
late was invisible until MeshTerm was restarted. The worst version of that is
the first run: the screen says no companion devices detected and offers a
network address typed by hand, or quitting. Going to fetch the cable is the
obvious thing to do and it was the one thing that did not work.
The three transports want three different answers, and the reason is cost.
Enumerating serial ports is a SetupAPI walk -- not free, but cheap enough to
repeat every couple of seconds off the event loop without anyone noticing, and
it covers the case this is really about. Bluetooth is a bounded listen, so
"keep scanning" there means keeping the radio listening for as long as the
splash is open, on the handhelds this program exists for, on a battery, on the
screen somebody leaves up while they walk away. It gets a window every fifteen
seconds instead, eight of them, and the count is ignored while the list is
still empty -- an empty splash is precisely where somebody is waiting, and
another listen is the cost of being useful. TCP is not scanned at all, because
it never was: a network companion does not advertise, and its rows are read
from the registry and the profiles, which change only when the reader changes
them.
Only a change redraws. A poll that finds the same ports says nothing, which
matters for somebody part-way through arrowing down the list: a redraw they did
not ask for, on a list that has not moved, is the screen shifting under their
hands for no reason. When it does redraw, `replace_items` keeps their place by
value rather than by index, so a device appearing above the highlight does not
carry the selection with it.
`select_startup` grew a `live` argument to make this possible -- work to do
while the list is on screen, handed a `redraw(items)` rather than the screen
itself, since a caller holding a screen could not repaint it anyway. It is
cancelled when the screen resolves, so nothing outlives the list it was
redrawing and no repaint lands on the PIN prompt or the smoke test that opens
over it.
Fixes: #13
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving brightness out of bold and into the `9N` bank settled what a span says
when it names a colour. It left unsettled what happens when a sequence changes
the intensity and names no colour at all -- which is exactly how the art
announces a bright run. `ESC[1m` had its `1` stripped, the brightness was
remembered for whichever later sequence happened to name a foreground, and the
colour already in force was never restated. The run carried on in the dim bank
wearing the `22` that had been forced in front of it.
The wide mark does this twenty times, and five more as `ESC[1;4Nm`, where the
background survived and the brightness did not. On screen it reads as pairs of
half-blocks that should fade dark into light both coming out dark, most visibly
in the run beside the copyright line.
So the standing foreground is tracked, and restated in whichever bank now
applies whenever the intensity changes without one being named. A reset takes
the standing colour with it, because it takes the terminal's too.
There were no tests over the art loader at all -- `test_theme16.py` covers the
theme's own bold-brightness rules, which is a different question -- so this adds
some. The per-sequence invariant is the one that matters: counting bright
emissions across a file proves nothing, because one `1m` brightens every later
sequence and the totals stay high while individual runs go missing (149 emitted
against 64 requested, in a file that was drawing the bug). Every `1m` that fires
with a colour standing has to leave a bright foreground behind, and now each one
is checked.
Fixes: #11
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine of the bug form's fourteen fields were transcription: version, host, OS,
terminal, size, platform, device model, firmware, transport. `meshterm
diagnostics --out` writes every one of them, correctly, from the machine that
has the problem, and the form went on asking a tired person to copy them out by
hand.
A field that demands precision from someone who does not have it does not
produce precision. It produces an abandoned form, or an invented answer -- a
reporter guessing at their radio's chip name because the form would not let them
past without one. A guess is worse than a blank, because it reads as a fact.
So the required set drops from nine fields to five, and not one of the five asks
for something the reporter has to look up: what happened, how often, how to
repeat it, whether `--mock` changes it, and the diagnostics block itself.
Two of the cut fields came back as optional, because the block genuinely cannot
know them. It reads the model and firmware off the radio, so a report about a
radio that will not connect -- the most common first-run report there is -- has
nothing to read them from. And `platform` only separates PicoCalc from regular,
so a uConsole and a laptop look alike to it. Both fields now say exactly when
they apply, which is rarely.
The command itself lands in 0.9.0 and the released build is 0.3.7, so both the
form and the forum say what to do when it is not recognised: name your version
and send what you can. That line comes out once nobody is running an older one.
The trailer workflow inverts the obvious design. It does not nag about commits
with no issue trailer, because most commits here legitimately close nothing and
a workflow that complains about all of them gets muted within a week. It checks
the trailers that are there: `Fixes: #42` against an issue that does not exist
is not an error in GitHub's eyes, it is silence -- the issue stays open, the
reporter is never told, and the commit looks like it did the job. That is the
failure worth a comment.
Four labels join the stock set for the states a report actually passes through:
needs-diagnostics, confirmed, needs-hardware, cannot-reproduce. They say what
state a thing is in. The hardware goes in the title, where it does not
multiply.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recording is the highest-leverage thing the front page can carry, and it now sits
above the screenshots rather than waiting for launch day.
It is a link, not a player, and that is forced rather than chosen. GitHub strips <video>
out of markdown and renders an mp4 behind an image as a broken image; the only thing that
plays inline is a user-attachments URL, which cannot be created until the repository is
public. A linked still works today, works while the repository is still private, and goes
on working afterwards -- camo fetches the image from meshterm.net, which has been public
all along.
The picture is the poster frame with a play mark and the running time on it, so it reads
as a video rather than as a sixth screenshot, and so a reader knows it is under two
minutes before they commit to a click. The screenshots stay underneath: a still is what
someone skimming actually reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--mock` is a fake radio, not a fake MeshTerm: what it adverted was recorded to
`meshterm.db` like anything a real companion said, so its four invented contacts
turned up in the mesh walk, the dashboard and the map of the mesh actually being
run. A `--mock` run that names no database now records to
`<config_dir>/meshterm-mock.db`, created on first use.
`--db` still wins — where a run records and which radio it talks to are different
questions, so an explicit path is a precedence rule and not the usage error
`--mock --port` is. `$MESHTERM_HOME` remains the only thing that isolates a run
whole: the contact and channel caches, the outbox and the remembered devices are
untouched by this, and the docs say so where they used to warn that `--mock`
recorded into the real history.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_CATEGORY_ORDER lists six: Message, Watch, Explore, This node, Other nodes,
This app. Every table we have ever published stopped at five. So Preferences,
Diagnostics and the four written pages -- About, About the author, Join
Discord, Support -- were documented nowhere, while the CLI has answered
`meshterm about` and the rest all along.
Folding the changelog is what surfaced it: writing diagnostics into the 0.9.0
entry meant naming where it lives, and where it lives is a section the
features table did not have.
The framing is fixed too, and the fix comes from base.py's own comment rather
than from me. "Five sections named for the doing" was never quite right --
three of them name a doing (Message, Watch, Explore) and three name the scope
a setting belongs to: the radio in your hand, someone else's over the mesh,
and the program in front of you. That is why This app sorts last, and it now
reads that way in both the README summary and the catalogue.
Every command in the new table was checked against `meshterm --help` rather
than inferred from a tool's name attribute, `diagnostics --out` and the four
`preferences` subcommands included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[Unreleased] had been collecting since the collapse: the diagnostics page
and command, the markdown report face behind it, and the path lane
constructor. Those are not unreleased. They are inside the 0.9.0 binary, so
leaving them above the 0.9.0 heading would have shipped release notes that
describe a version nobody can download.
Folding them was not a paste. [Unreleased] is written for people who follow
the development; the 0.9.0 entry is written for someone who has not heard of
a mesh network, and says so in its own first line -- everything is new today,
so instead of a list of changes, here is what it does. So diagnostics
arrives as one bullet in that voice, about what it gives a person filing a
bug, and the internals retire: on a first public release there is nothing to
have "changed", because there is no previous version to have changed from.
[Unreleased] keeps its heading and its compare link, empty, the way Keep a
Changelog leaves it between releases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had grown to 580 lines, and most of what a newcomer scrolled past was
reference material: the five feature tables, the CLI cookbook with both
output faces and the exit statuses, the configuration reference, and the
package layout. None of that is what somebody reads while deciding whether
to install this.
It is 375 lines now and it does onboarding and install -- what MeshTerm is,
the screenshots, how to get it running, a quick start, and how the project
is run. The rest moved under docs/ behind a new docs/README.md index, which
GitHub renders when you browse to the folder. The index also lists the two
code surveys, which the README's own table never did.
The prose moved by extracting line ranges rather than retyping, so anything
that did not need rewording is byte-identical. All 255 relative links across
the 13 markdown files resolve, anchors included.
Two changes are not relocations. "## Development" is deleted rather than
moved, because CONTRIBUTING.md already carried every command in it plus the
blame-ignore config and the 72-column gallery gate. And the Windows console
section is summarised in Install with the full explanation in
docs/terminals.md, since a Windows user meeting empty boxes cannot afford to
miss it.
Licensing stays where it was. Acknowledgements, the map and font credits, the
dependency table and the licence are all still in the README, which is where
a redistributor looks and where SignPath Foundation will look for its credit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
message_dialog draws the message it is handed and reads its spans to pick
a border tone, so a markup string reaches it unparsed: the reader saw a
literal [ok] and [/ok] around the checkmark, and a failed write would have
been framed in the success colour, since a plain string has no spans to
read a tone from. It is a styled Text now, the way every other caller
writes one.
Only the mark is styled. That is enough for the border to read the tone,
and it leaves the path in body ink where it stays legible -- which is what
the mark is for. The reason for the failure follows it muted, on its own
line.
The test asserts on the spans rather than on the words, because the bug
was invisible to anything reading the text: the tags were *in* the string
either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block is a title, a standfirst, sections and a list under each. That
is a page of writing, not a screenful of rows, and the app already knows
how to draw one: the About pages' markdown renderer. So the tool states
its report as before and a third projection turns it into markdown, which
is exactly what the report seam was built for -- no tool touched, no
report changed, one new function beside the other two faces.
Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. Its ## headings are landmarks, so each
pins to the top row while its own section scrolls under it and ^PgUp/^PgDn
step section by section; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.
The frame comes back with it. The bare frame was there to keep a border
off every line a mouse would drag through, which mattered while copying
off the screen was the only way out; saving is the way out now, so the
page can be a normal screen and the save can be advertised where this app
advertises verbs -- the footer hint on a desktop, the lane's one free F3
slot on the console -- instead of in the page's own body, which belongs to
the report. The answer moves to a popup with it: a path is worth reading
carefully, a write can fail, and the page underneath is unchanged and
still there when the popup goes.
Saving markdown is the same decision as drawing it. One source is what the
page renders and what the file holds, and an issue tracker renders it as
written, with no fence and no apology. Every value is a code span, which
is correctness and not taste: a Windows path is full of backslashes that
markdown reads as escapes, and an error a stranger's firmware wrote can
hold an asterisk. A value already holding a backtick gets a longer fence.
Rows are a list and not a table, measured rather than assumed -- a
markdown table's cells are cropped with an ellipsis at 53 columns, and a
bug report whose one long value is cut is missing the half that mattered.
A list item wraps and hangs under its own text at any width.
Two smaller things fell out. A block may carry a caption, the heading a
format that has headings draws above it -- plain and JSON have no use for
one and ignore it. And the host block splits in two, because a "it looks
wrong" report is about the terminal and its reader should not have to pass
the processor architecture on the way to it.
The command line is untouched: aligned records, because that face is a
stream to grep and headings are furniture on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified on the Mac, and three of the four things it taught were the
opposite of what the code assumed.
RunCommandAsShell is false. Under *true* Terminal takes the whole command
string as the name of a program and the window reads "Command not found:
MESHTERM_REOPENED=1 / Could not create a new process and open a
pseudo-tty". The key's name says otherwise; the measurement wins.
The environment rides on /usr/bin/env rather than on a VAR=value prefix,
for the same reason. env is a real executable, so the line is valid as a
bare argv and as a shell command both, and the window cannot land on that
message however Terminal decides to read the string.
Terminal names an imported settings set after the FILE, not the name key
inside it -- and re-importing the same bytes reuses that profile, while
different bytes under the same filename add "MeshTerm 1", then
"MeshTerm 2". Opening one file four times, three identical and one a word
apart, left exactly two profiles. So the command cannot live in the
profile: it carries argv and the reader's MESHTERM_* variables, and a
profile per command line would fill their settings list with junk only
they can remove. The variable part moved behind a fixed path instead --
the profile says ~/.meshterm/launch.sh and never changes, and the script
underneath says whatever this launch needs.
Three launches with three different command lines now leave one profile
named MeshTerm, the window drawing CascadiaMonoPL-Regular at 13pt on
opaque black at 80x30, with the app running in it. The reader's default
profile is still theirs, blur and all.
shellExitAction 1 while here: tidy the window away on a clean exit, keep
it on a crash, because a crash nobody sees is a bug report nobody writes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copying off a screen is the wrong shape for anything long: you select,
you scroll, you select again, and on the PicoCalc there is no clipboard
to select into at all. So the page saves. s writes
meshterm-diagnostics.txt into the config directory beside the log, which
is the folder "send me both" already names, and meshterm diagnostics
--out PATH does the same from a shell -- spelled the way config
export-key --out already spells it rather than inventing a second word
for writing an answer to a path.
The save key forced the one concession on the bare frame. A bare frame
has no footer and no F-key lane, so a page that could save and never said
so would be a key nobody could find, and on the PicoCalc the lane is
normally the only place an affordance is advertised at all. The page
carries its own last line instead. One trailing line after a blank row
costs a paste far less than a border costs it on every line, and a drag
that follows the block stops above it of its own accord.
Saving answers on that same line. A dialog would cover the block the
reader was about to copy, and the line has to be able to say a write
failed -- a read-only home, a full disk -- without inventing an error
screen for it. The block stays readable either way, which is the whole
reason both routes exist.
The file is always the plain block, even under --json: it is there to be
read by whoever opens the issue, and a document of the same facts helps
them less. It is rendered at a fixed wide width so no lane crops, and
with the trailing pad stripped, since Rich pads every cell to its column
and that is invisible on a terminal and noise on disk.
With --out the answer becomes the path, the trade config export-key
already makes: a caller who asked for a file wants to be told where it is,
not handed the contents they just redirected into it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.
The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.
Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.
The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.
Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.
Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No Mac monospace face carries the Braille Patterns block, so Terminal --
which falls back generously rather than drawing a box -- borrows the
charts and the map's terrain from a proportional face and lands them at
the wrong width. Nothing announces itself as broken; it is just crooked,
and a reader with nothing to compare against never finds out why.
Fixing it means a font, and a font means a profile. The question that
shapes this module is whose terminal it is. MeshTerm's palette is the
16-colour VT set, crude on purpose, and nobody should have to read their
mail in it -- so nothing here touches the default profile or edits their
preferences. It adds a profile named MeshTerm and opens our own window in
it; theirs keeps its translucency, and quitting leaves nothing behind.
That is also the only route that works at all. Terminal rewrites its
preferences from memory when it quits, so a file written while it runs --
and MeshTerm always runs inside it -- is discarded later. Handing Terminal
a .terminal file lets Terminal do the importing.
The profile is generated from _VT_SLOTS rather than shipped, so the window
cannot disagree with the screen it holds. The NSColor and NSFont archives
are pinned to bytes read back off a profile that was installed on a real
Mac and confirmed rendering, not to a reading of the format.
relaunch.py is the half that was never Windows-specific -- own_command,
child_environment and its _PYI trap, the wanted size -- moved out of
winterminal so both terminals share one copy.
Not yet wired into startup: the launch itself wants a Mac to confirm, and
that machine is asleep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A browser download is the one route that does not work on a Mac, and the
README said so in a note *below* the commands -- which is no help to
somebody who followed the commands. macOS now leads the download section
with curl, states why in its first sentence, and Linux follows in the same
three lines.
Those three lines name no version, because the release now attaches a
version-less copy of every binary beside the stamped one. That is what
makes releases/latest/download/meshterm-macos-arm64 resolve, so the README
points at whichever release is newest instead of at one that was current
when somebody last remembered to grep the old number out of it. The
stamped files stay for anyone keeping several builds around.
And the release page itself now opens with those commands written out for
its own version, above the changelog, built from the tag -- so the links in
the example are the assets attached to the page printing them.
The release skill loses the step that re-pointed the README by hand and
gains the asset count to check instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Import order, a docstring reworded rather than have "macOS" capitalised
into something that is not the product's name, two over-long test lines,
and _tls_context moved below the logger it uses so the module keeps its
shape: docstring, imports, constants, logger, then code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a Mac the splash drew muted, and the dithers that fade one colour into
another faded toward the wrong end.
An art editor spells brightness the DOS way: 1m lifts the foreground into the
bright bank and the spans after it inherit that. It is true of a DOS console and
of the PicoCalc's, and it is false on macOS Terminal, where bold asks for a
heavier face and leaves the colour alone. There 1;30 is not dark grey but plain
black -- and the wide mark carries seventy-nine such spans, much of it on a
black ground, so the shading and bevels flattened into the background. The
gradients went with them: a fade drawn as a bright stipple over a dim ground
loses its top end when the bright half stops being bright, and what is left
reads as a dotted pattern that has not come out right.
So the bright bank is emitted as the colour it means -- 9N, which cannot be
mistaken for a font weight, and which is how the PicoCalc's own console spells
bright anyway. Which colour is being asked for does not change, and neither does
a single cell: only the spelling, from one a terminal may read as a weight to
one that can only be a colour. Bold is now asked for nowhere in the mark.
The art files are untouched. They stay authored the way the editors that drew
them write, and the loader goes on being the place where what DOS assumed is
translated into what a terminal can be told.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Capping the address lane stopped it eating the row, but left the old priority
in place underneath: DEVICE was still sized from whatever the address had
finished taking. Turn it around. The name is sized first, in full, and the
address takes what is left.
That is the right way round because of what each lane is for. A name is what
the reader recognises their own radio by; an address is a disambiguator, and a
disambiguator does its whole job from its last ten cells -- which is already
why the cut takes the front and keeps the tail. So the address shrinks to that
floor before the name gives up a single cell, and never reserves more than it
actually wants: a lone COM3 hands its slack to the name rather than sitting in
a ten-cell lane holding four.
The name still has to stop somewhere. A USB adapter's product string runs to
forty-odd characters, and a lane sized to that pushed everything after it off
the edge -- the row ended mid-port with HARDWARE not on screen at all. So
HARDWARE keeps its readable stub out of the budget, and past that the name
ellipsizes. Where it must, the address is handed back its full width instead
of being ellipsized alongside: the name is being cut whatever happens, and
spending two more of its cells leaves one cut in the row rather than two.
On a Mac the name now shows whole at 28 cells where it had six, and the 36-cell
UUID behind it draws as its last nine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a Mac the splash's DEVICE column was squeezed to the width of its own
heading, and HARDWARE went with it.
The lane sized itself to the longest target with no cap, and `_name_width`
sizes DEVICE from whatever that leaves. On Windows and Linux the assumption
held -- a MAC is 17 cells, /dev/ttyUSB0 is 12 -- so nothing ever pressed on it.
macOS breaks it twice: CoreBluetooth reports no MAC at all but a per-machine
128-bit UUID, 36 cells of it, and even the ports run long (every Mac carries a
/dev/cu.Bluetooth-Incoming-Port, which is 31). One of those took 36 of the 58
cells a row has, leaving DEVICE six.
So the lane is capped and ellipsizes, which is what the name lane beside it has
always done. The cut takes the **front**: what tells two targets apart is their
tail -- usbmodem1101 from usbmodem1102, ttyUSB0 from ttyUSB1 -- while the head
they share is boilerplate, and cutting the back would draw two different ports
identically, which is the one thing a picker must never do. A CoreBluetooth
UUID reads the same from either end, so the rule that is right for ports costs
the platform that needed the cap nothing. That is also why this is not
`menus.fit_cells`, whose ellipsis keeps the head.
At 20 cells the cap is above every target the other platforms produce, so they
are untouched by it and the change shows up only where something was wrong.
DEVICE goes from 6 cells back to 18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a Mac the map showed nodes floating over nothing: overlays come from the
local database and arrived fine, while every base tile silently failed.
A frozen build can have no trust store. The default TLS context asks OpenSSL for
one, and on macOS and Linux that is a filesystem path baked into the
interpreter's own build -- which need not exist on a machine that never
installed that Python, which is every machine a PyInstaller bundle lands on.
Each tile fetch ended in CERTIFICATE_VERIFY_FAILED, and since the fetcher cannot
tell a refusal from empty terrain, the map drew what it had. Windows is the
exception that hid this: its default context reads the OS certificate store, so
the binary there always worked, and so did every checkout on every platform.
So the context now keeps the default when that default actually loaded
certificates, and otherwise points it at the bundle the operating system ships.
Those are the trust decisions the machine's administrator has made and the OS
keeps current; carrying our own copy would freeze a snapshot of them into every
release, and certifi -- the obvious way to do that -- is MPL-2.0, which this
project's own dependency policy refuses. `tests/test_notices.py` said so, which
is what that gate is for.
Verification is not weakened anywhere. With no bundle to be found the fetch
still verifies and still fails, because a map tile is not worth teaching the app
to skip certificate checks, and an unverified fetcher would be one import away
from being reused where it matters.
Confirmed against the 0.3.6 arm64 build on a Mac: handing the same binary the
OS bundle turned one hard failure into a fetched TileJSON and 7.3 MB of tiles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a PIN-protected companion on a Mac reported an error, and selecting it
a second time connected -- the first attempt having quietly done the bonding.
CoreBluetooth has no pairing API: a peripheral pairs implicitly when something
touches a characteristic requiring encryption. The companion firmware puts its
UART characteristic at ENC+MITM precisely so that happens, so the unbonded
subscribe is answered with Insufficient Authentication *and* that answer is what
makes macOS raise its Passkey dialog. The GATT operation is dead by then; the
bond it began arrives seconds later, once a person has read six digits off a
device and typed them. Treating the refusal as a verdict therefore reported
failure for a pairing that was succeeding.
So on macOS the connect now waits for the dialog to be answered instead of
giving up on it, bounded so a dismissed dialog still fails in the end. Elsewhere
a rejection keeps meaning what it always did -- Windows and Linux can pair
explicitly, and do, before the link is opened.
The message that survives a dismissal no longer recommends --ble-pin there
either: macOS collects the code itself, so the flag cannot help, and naming it
sends the reader somewhere with nothing to offer. It names the dialog, and the
Bluetooth permission the terminal needs for one to appear at all.
The tests that cover a rejection now state which platform they mean, rather than
inheriting whichever runner they landed on -- the macOS leg of the matrix would
have failed on the old wording, and on a PIN it no longer forwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three faults, one cause: macOS is the only platform that permanently presents
ports nobody attached. Every Mac carries /dev/cu.Bluetooth-Incoming-Port and
/dev/cu.debug-console, with no USB VID/PID between them.
Selection counted them. `len(devices) == 1` was therefore unreachable on a Mac --
one real board attached made three devices -- so auto-detection could never fire
there and every Mac user met "Multiple companion devices detected" on a first
run, naming two virtual ports as the companions to choose between. Which device
is plausible was already known and already printed in that very listing as
"[likely LoRa]"; it now decides with it instead of only saying it. A lone
unrecognized adapter still resolves, because an unlisted VID is a real board more
often than not, and several implausible ports no longer claim to be companions.
A refused Bluetooth scan read as silence. bleak states the case precisely and
hands over a typed reason, and that was caught as a driver hiccup and logged at
debug -- under a log that defaults to WARNING, so a denial left no trace at all
and looked exactly like an empty room. On macOS this is the routine case rather
than an exotic one: Bluetooth is granted to the terminal MeshTerm runs in, never
to MeshTerm, so an ungranted terminal scans and hears nothing. The sentence bleak
wrote is kept and shown, since it already names the remedy.
The PIN broke the connection it was for. Handed one, the meshcore transport calls
bleak's pair(), and CoreBluetooth has no pairing API -- the macOS backend raises
outright, and the library then disconnects and re-raises, so a correct PIN was
what failed. Pairing there is the OS's to run: the firmware puts its UART
characteristic at ENC+MITM, the unbonded subscribe is answered with Insufficient
Authentication, and macOS responds by running Passkey Entry and asking for the
code itself. Withholding the PIN is what lets a protected companion bond. Windows
and Linux do expose explicit pairing and keep it.
Found by running the 0.3.6 arm64 build on a Mac. Detection over SSH is a separate
thing and stays impossible: an ssh session is a Background session, and macOS
refuses to prompt for Bluetooth in one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A discovered row was dropped when a read came back as anything that could not
be its value, on the reasoning that the node's answer was the row's whole
claim to exist. The reasoning holds and the cost does not.
A catalog row that misreads costs an n/a and nothing is lost: the catalog
still names the key, and the next read puts the value back. A discovered row's
key is recorded nowhere else. Deleting it throws away the one thing about this
node that only the reader could have found, and they may no longer remember
what it was -- for a reply that misread rather than a key that went away. A
truncated line, a node answering mid-reboot, a stray frame correlated to the
wrong command, and the row is gone with no way back but memory.
So a read now takes a discovered row to n/a like any other, and Del is the
only thing that removes one. The board-swap case the automatic drop was for is
still served: the row reads n/a, which is visible and says what happened, and
forgetting it is a keystroke.
A dropped packet was never the danger -- read_settings folds only a reply it
actually received, and a timeout leaves the row alone -- but the rows it can
delete were never worth the ones it tidied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third-party builds carry knobs the catalog has never heard of -- EasySkyMesh's
radio.rxps and reboot.interval among them -- and MeshTerm cannot know which
build a repeater is running without asking it. Asking is the whole problem:
every question is one paced round trip, so probing for a fork's keys would
spend a sweep on every node to benefit a few, and listing them outright would
leave dead n/a rows on everyone else's page.
So nothing is probed speculatively and the catalog does not grow. The command
line is already the only place those settings can be reached, so it is now
also where they are found: a get/set the reader ran, which this node answered,
earns a row on this node's page under Extra. The round trip was one the reader
was spending anyway, and no other node's page changes.
The two verbs are not equal evidence, which the firmware settles. handleSetCmd
compares each key with its trailing space -- "radio " -- so a longer key can
never land in a shorter one's branch, and an unknown one answers "unknown
config:". handleGetCmd compares a bare prefix, so stock firmware answers
"get radio.rxps" out of the get radio branch with a perfectly plausible
910.525,62.5,7,5. A catalog row survives that because its typing rejects it; a
discovered key has no typing, so a read is trusted only when nothing shadows
the key, or when the reply differs from what the shadow last said.
A discovered row's whole claim to exist is that the node answered it, so when
it stops answering the row goes rather than earning the n/a that only a
catalog key can -- which is what tidies up after a board swap onto a reused
identity. Del forgets one without waiting for a read, and asking the key again
brings it back. Its values are fitted to the lane the catalog's rows already
need, since the value lane is one width for the whole page.
prv.key is never stored, whichever way it was asked. The firmware answers it on
the serial console only, and a reply that reached us anyway must not be the
first place a repeater's private key lands in a file.
Two things fall out: get tx at the command line no longer leaves the TX power
row showing last week's value, since catalog keys now fold into the cache the
same way, and a discovered key completes at the prompt where it was learned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quarantine flag that stops an unsigned binary is set by the browser
that downloaded it, not by macOS. curl sets nothing, so the same file
fetched that way simply runs -- no dialog, no xattr, no signature. For a
program whose users are already at a terminal that is the normal way to
install it, so it leads the note now.
The old advice was also going stale: Sequoia removed the right-click ->
Open bypass, and since Catalina a quarantined binary started from Terminal
-- which is how the README says to start MeshTerm -- gets the same block
rather than escaping it. Both are named, xattr stays for anyone who already
used a browser, and pipx is pointed at as the route that downloads no
binary at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Windows greets an unsigned one-file PyInstaller build as a stranger every
release: SmartScreen warns, and antivirus engines flag it on sight. A
signature gives Windows a publisher to accumulate trust against instead.
SignPath Foundation issue the certificate free to open-source projects and
sign on their own infrastructure, so no key exists on the runner. Their
condition is that the artifact provably came from this repository's CI,
which is why the binary is handed over as a GitHub artifact id rather than
uploaded -- SignPath reads the workflow run behind it.
Signing sits between the build's smoke test and the naming step, so the
checksum describes the file people actually download. The unsigned draft
is the one artifact the release must not attach, so it is named outside
the meshterm-* pattern the release now downloads by.
Both steps skip wholesale until SIGNPATH_ORGANIZATION_ID is set, so the
Windows binary keeps building unsigned in the meantime and a fork with no
SignPath project of its own is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrolled down the purge preview and walked back up, the cursor rode off
the top of the box. Two things let it, and either was enough.
A select list under a prompt reported its highlighted row a prompt's
height too far down. The row's line is read off the body as it is laid
out, which already counts the prompt, and the prompt's height was then
added on top. The frame kept that lower line in view, so walking up left
the real row above the window's top edge for as long as the walk lasted.
And at the bottom of any list with a pinned column header or section
heading, the window slides down under the pins so they cost stale lines
at its top rather than the last ones. Walking up, the highlight reaches
the window's top line before the scroll has to move, and the slide
dropped exactly that line. The slide now stops at the highlighted row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The curated narrow and wide sets are gone, with the probe that decided when
to apply them and the add-narrow-emoji skill that fed them. They lined up
the rows somebody had already looked at and none of the rows nobody had,
and a contact's name arrives off the air carrying whatever its owner typed.
In their place, one structural rule: a glyph built from several codepoints
(a selector sequence, a ZWJ sequence, a keycap, a flag) or a lone
text-default emoji or Regional Indicator is reserved two cells, the most a
single glyph draws. Pinned into two cells, a glyph the font draws in two
fills them and one it draws in one leaves a blank beside it, so the text
after any emoji starts in the same column on every row. Pinning alone could
only hold the lanes after it.
Two exceptions keep their one cell: Rich's single-cell ranges, which its
fast path measures without asking, and the nine text-default emoji the app
draws itself as marks. That list is closed by a test over the package's
source, so a new mark cannot slip into the reserve unnoticed.
Rich measures in three places and prompt_toolkit in a fourth, and all four
are patched from the same rule at once: Rich's segment splitter walks a
string until a whole-string width meets its cut, and a character measured
differently from the string around it is a cut that walk never meets. Every
multi-codepoint glyph is also merged into one fragment before layout, so a
mark followed by a selector is not folded into a one-cell slot.
Installed only by the full-screen app on a platform that draws emoji. The
command line keeps the stock tables, because nothing pins its output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pin jumps over whatever part of a glyph's reservation the font did not
cover, and nothing wrote that cell. prompt_toolkit never writes the cell
after a wide glyph either, since it counts it as the glyph's own, so a
repaint that put an emoji where two letters had been left the second letter
standing beside it on a terminal that draws the emoji in one cell. On the
fast path the row is erased first, but only in the default colours, so the
same cell was a hole in a chip's fill.
Both pins now erase the reservation in the current colours before drawing
the glyph, with ECH, which blanks cells without moving the cursor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rows the fast renderer writes were already pinned. Anything
prompt_toolkit lays out itself — a floating dialog, and the backdrop it
repaints — still went out through its differential renderer, which steps a
cursor of its own by each glyph's measured width and moves relatively from
there, so one glyph drawn at another width threw off every move after it.
That renderer never knows a column absolutely, so the pin is spelled
relative to the glyph instead: save the cursor, draw the glyph, restore,
step forward exactly the width the renderer is about to add. The terminal's
cursor is back in step after every glyph, and nothing is tracked that could
drift from the renderer's own bookkeeping. PinnedOutput wraps the real
terminal; the width reclaim stays outermost.
A flag's two indicators are now merged into one cell before layout, like a
ZWJ sequence, so the pair is written as one glyph rather than with a cursor
move between its halves.
Nothing is pinned on a platform that draws no emoji: the PicoCalc only ever
draws glyphs its own font has verified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alignment stopped depending on the width tables being right about a glyph.
After anything whose drawn width is not certain, a row now carries an
absolute column address naming the column the app measured that glyph
into, so a mismeasured emoji costs one cell inside its own lane instead
of every lane after it and the frame's right border.
The curated narrow/wide sets keep their job — they are what makes the
reservation right, so a listed glyph gets neither a gap nor an overwrite
— but they are no longer what holds a row together. A name off the air,
which nobody can put on a list in advance, now frames flush too.
Pinning only where it is needed: the ranges the app draws in bulk and both
authorities agree are one cell (box drawing, braille, blocks, the node
marks, the chevrons) are listed, and a row holding nothing else is handed
back untouched — one scan, no walk. That list is a performance claim, not
what correctness rests on; getting an entry wrong costs what today costs.
Proved against a terminal that disagrees on purpose rather than against a
measurement, since the disagreement is the thing that cannot be probed.
Still limited to the rows the fast renderer writes itself: a frame
prompt_toolkit lays out — a dialog, and the backdrop it repaints — tracks
the cursor in its own arithmetic and is uncorrected. MESHTERM_COLUMN_SNAP=0
turns it off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map is the only networked part of MeshTerm, and every failure on that path
is logged at DEBUG while log_level defaults to WARNING: a reader whose tiles
never arrive gets a blank ground and no explanation anywhere. On macOS that is
routinely a Python with no certificate store at all, which no amount of looking
at the app can reveal.
scripts/basemap-doctor.py is the explanation. It reports the interpreter, the
trust store, the tile source and the cached-tile count, then fetches the
TileJSON and one real tile the way BasemapSource does, and turns the failure
into one verdict with its fix.
The verdict is an A/B rather than a reading: curl verifies against the OS trust
store and Python against its own, so curl succeeding where Python fails is the
app's store and nothing else, while both refusing the same certificate is TLS
interception or a genuinely bad server — one exception, opposite causes,
opposite fixes. Collapsing curl's exit codes into "failed" had it blaming the
store and the network in the same breath.
It also answers the other way a map looks wrong, since it costs nothing in the
same round trip: an unset COLORTERM is 256 colours, where the basemap's greens
and blues collide.
Started under the wrong Python it finds the installed meshterm command, reads
its shebang and hands itself over, so nobody has to know which of their three
Pythons the app went into. By subprocess, not execve, which on Windows is not a
real exec and segfaults under a redirect — and stdout is flushed first, or the
banner loses the report it introduces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tighter (0.035) left wedges a reader had to hunt for, looser (0.10) blurred pairs
whose point still read plainly. Set by eye on the same ladder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Set by eye on the same ladder: at 0.10 the solid point is found at one cell without
looking for it, and the safe side of the line is the blur, since a wrong blur still
draws a legible seam and a wrong interlock draws none. The shade stays at 0.10.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SEAM_BLUR drops from 0.10 to 0.035: on a ladder of the spectrum's own pairs drawn
both ways, the solid point stays findable at one cell down to a little under two
noticeable differences, so the interlock is the ordinary seam and the thin chevron
is for fills that are the same or as good as (a mirrored leg, a keyless run, the
greens' nearest neighbours). SEAM_SHADE drops from 0.15 to 0.10: a lightness step
is what a thin line is seen by, so it needs nothing like the blur distance to read,
and 0.10 is a clear line that is still plainly the chip's own colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The library arms one five-second future for the whole table and never re-arms
it, so a node holding four hundred contacts fails every read with "no event
received" however healthy the radio is. What means "the companion stopped
answering" is a gap between records, so that is what is timed now: each record
restarts the clock and the dump may take as long as it takes. An uncorrelated
ERROR mid-stream is somebody else's command and no longer kills the read.
A tool that failed is a caution rather than a loss, so it takes the amber tone
and the popup wraps it instead of handing a long sentence to the full-frame
result window.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thin chevron between two chips the reader can't tell apart is now drawn in the
previous chip's own fill stepped SEAM_SHADE darker (lighter for a dark fill) on the
fill ahead, so nothing on the ribbon is a colour that isn't a chip's. The step is
measured from whichever fill is further along, so the mark clears the fill it sits
on by the full shade however close the two are.
Whether two fills blur is now a perceptual distance, not a hue gap: ui/oklab.py is
the reference OKLab transform (from_hex, to_hex, distance, shaded), and the seam
interlocks only where the fills sit SEAM_BLUR (0.10) or more apart. The hue rule
mis-sized the wheel badly: sixteen first-byte steps measure 0.026 across the greens
and 0.165 across the cyans, so it blurred too little on one side and too much on the
other, and an sRGB distance would only have restated the hue gap, a fixed byte step
being a near-constant 55-60 of 8-bit RGB all the way round. 0.10 is five noticeable
differences, the typical 0x10 pair with a little to spare, erring toward the thin
chevron because a wrong blur still draws a legible seam and a wrong interlock draws
none. The greys need no exemption any more: the keyless grey and the faded slate sit
0.18 apart and keep their interlock on the metric alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The preview's rows led on a PCTL lane and followed it with WHY IT RANKS LOW, one
header over three lanes that held a contact's n-th weakest reason — so a message
count, an age, a packet tally and a hop count moved between columns from row to
row and nothing in a column meant the same thing twice. The lanes are fixed by
kind now: HEARD and PKTS first, in the Contacts list's own order and drawn in its
own grammar (the column age under recency heat, a tally muted, a faint dash for
none), then the two the sweep adds, MSGS and HOPS. Each is measured across the
victims and headed by a word right-aligned over its own values.
The percentile goes with the phrases. A rank is the score's reading of itself, so
a lane of them restated the order the list was already in; what an audit needs is
the measurements, which is what a reader can check against what they know. The
row pins its name out of the horizontal scroll instead — the evidence is what a
narrow terminal cuts, and the name is what says whose evidence it is. ScoredContact
drops the reasons it was the only consumer of.
A lane also stops trusting a name to be ordinary text. A name is written by whoever
owns the radio, so fit_cells — THE lane fitter, and every column's — now cuts
between glyphs rather than codepoints and folds what no terminal can draw: cutting
an emoji sequence in half stranded a joiner that swallowed the ellipsis appended
after it, leaving the lane a cell short of what was drawn and every column right of
the name starting late, and a newline or an escape in a name ended the row mid-lane
whatever it measured. Both are settled in emoji_width, which already owns what this
terminal really does with these codepoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where two consecutive chips share a fill or sit under SEAM_HUE_GAP apart, the seam
was the solid point drawn bare, so the page cut a wedge out of the ribbon: a run of
near hues read as dashes, and on a light terminal the wedge was a hole. It is now
the thin chevron (U+E0B1, core powerline set) in the chip's ink on the fill ahead,
powerline's own mark for a join inside one colour. The ribbon runs on unbroken, the
join is a line drawn on it, and it wears no fill of its own so it can't be taken
for a chip; on a faded leg it takes the dim ink and recedes with what it joins.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two consecutive chips with near-identical fills — first key bytes under 0x10 apart
on the hue wheel, measured circularly — used to interlock on a chevron the eye
couldn't find, one shade of green laid on the next. They now take the same exception
an exact match does: the point drops its background and the page cuts the wedge.
The wheel is recovered from the fill itself, so an override colour and a console
slot are placed on it too; the greys (keyless, dim, our own neutral) blend only with
their exact selves, since two distinct greys still read as two chips.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Calculinux bring-up, both console-font builds, the GPL-2.0 text that belongs
beside the 6x8 one, and the xiao-radio firmware kit move under scripts/picocalc/;
the SPI bridge is scripts/uconsole/meshterm-spi-bridge. The folder's own README is
gone: its per-file index is now a section of docs/hardware.md, next to the table
that says which manual is yours. Every path in the manuals, NOTICE, CLAUDE.md, the
tests, the package docstrings and the scripts' own headers follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new "Set clock on connect" preference (Device group, off by default) has the
interactive session write the host time to the radio once per connection — at
launch, on a deferred connect, and after a reconnect, since a link drop is exactly
when a board without a real-time clock may have rebooted and lost the time. The
set runs in a background task off the connect path and reports only in the log,
with the drift it corrected; a refused write costs a warning line, never the
session. Scripted CLI runs never start the service, so a one-shot command that
only reads the radio never writes its clock.
The device-side step is the one the Device config screen's Sync clock action
already ran inline in apply_ops; it is lifted into services/clock_sync.set_clock
and the action and the service both call it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/hardware.md shrinks to a router: which guide for which device, and the
two platform flavours. Everything the guides were still reaching into it for
moved home — the bridge's environment knobs into uconsole.md, and the
verified/unverified lists into each manual's closing section, split by
device (the PicoCalc's gains the MX1.25 pin order and the 5 V path, both
inferred rather than measured on a Lyra).
The PicoCalc manual gets the Wi-Fi kick back — it works and stays; only the
join script is going. The uConsole manual opens on the same kind of
disclaimer as the PicoCalc's, minus the hardware-handling parts: a LoRa
transmitter, a Linux shell, the guide may be wrong or stale, your call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/calculinux-console-font.sh is now calculinux-console-font-6x12.sh.
Every reference follows: the setup script's hand-off, the 6x8 script's
SHARED default and comments, the theme16 test that parses it, the module
docstrings, the two manuals, the scripts index and CLAUDE.md. The font files
it builds keep their names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A "Three ways to break it" chapter before Phase 1: the bare glass screen that
cracks when the mainboard flexes (do the Pico swap with the board out of the
case, on a padded surface, and tape the panel's edges with Kapton), the brass
screw posts that snap out of the shell when a screw is forced, and the charge
control that powering the core board's own USB-C bypasses (forum members
measured cells at 4.46 V and 4.74 V after powering the Pico's port). Steps 1
and 2 and the day-to-day section now follow it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/picocalc.md starts from a stock PicoCalc with its Pico and ends with a
Linux handheld running MeshTerm with a LoRa radio soldered inside: a priced
shopping list, the Lyra swap, writing Calculinux, the serial console, Wi-Fi,
the setup script phase by phase, then PlatformIO, the firmware build with the
patch explained hunk by hunk and a by-hand route for when it stops applying,
flashing, the four wires, the Lyra setup, day-to-day use and troubleshooting.
The README gains a Documentation table and points the handheld paragraph at
the two manuals; the script indexes and the changelog follow. hardware.md
stays as the reference behind both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/uconsole.md walks a uConsole with the AIO LoRa board from requirements
to a running bridge and a connected MeshTerm: the system prep, the radio
runtime (and the bookworm/trixie split), the preflight, a foreground run,
the user service, the profile, updating, troubleshooting, and what has been
tested. Plain language, one step at a time; docs/hardware.md stays as the
reference behind it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Support page's text and QR code and the repository's funding file all pointed at a
tinyurl redirect, which a reader could not audit; they now carry the hosted-button address
itself. The page still renders on both platforms.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two real contact names pulled the purge preview's reason lanes out of line, and the
Contacts list and chat picker would do the same, since all three pad names through one
measurement.
"Tech-Info 👨🏻💻" carries a skin tone. Rich measures the modifier zero, as the
terminal draws it; wcwidth gives it two, so prompt_toolkit reserved four cells for a
two-cell glyph. The cache now measures a modifier zero at either calibration, which lets
prompt_toolkit fold it into the cell before it the way it folds a variation selector,
and the cluster merge carries it inside a joined run.
"That's So Fetch 🏴" is a pirate flag cut at the name's byte limit, just past its
joiner. Both authorities folded whatever followed a joiner into the glyph, and here
that is the lane's padding space, so the name measured a cell short. A joiner now joins
only a pictograph. On the width-2 path Rich's own loop is wrapped for this one case; its
table is still left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The target ladder described every rung in a phrase, "keeps 45 · archives 12", so
comparing two rungs meant finding the numbers inside two sentences. The counts are
columns now, right-aligned under a pinned TARGET / KEEPS / ARCHIVES header like the
editors' lane header, one rung lane measured across both sections so the numbers run
straight down the whole ladder. Keeps still leads. A rung that archives nothing shows
a muted 0.
The preview joined its reasons with " · ", so the second and third began wherever the
first happened to end. They sit in three lanes measured against the victim list, and
remeasured when a spared row leaves, under the one WHY IT RANKS LOW header: a lane holds
a contact's n-th weakest reason, whose kind varies from row to row, so a word per lane
would claim a kind the column does not keep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rich draws a panel's corners rounded, and square only where it judges the console
legacy Windows, which a captured pytest run on Windows is. The credit tests looked for
the square corner, so they passed here and failed on every Linux and macOS runner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dashboard, map, trace and the Message paths dialog two by two, each with an alt text that
says what it shows, and the PicoCalc photo alone beneath them, downscaled to 1200 px wide so
the README does not load a 7 MB camera frame. The placeholder comment is gone with its job.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
github-release.yml gains a website job: once the release is published it rebuilds the meshterm.net site from the tagged tree and pushes it back with a deploy key scoped to that one repository. The release skill says what to do when only that job fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macOS installers of 0.3.3 never built: the notices step refused pyobjc-core and
pyobjc-framework-libdispatch, whose wheels ship no licence text. Their MIT text is vendored
now, and a test checks every vendored path on every platform.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The three rules the licensing pass settled, pinned where the other binding standards live so
a new file, a new map surface or a new NOTICE paragraph is held to them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The five installers bundled Cascadia's font licence and nothing else --
not MeshTerm's own LICENSE and NOTICE, not the MIT/BSD/PSF notices
Apache-2.0 and those licences all want kept with a redistributed copy.
packaging/notices.py walks the mesh-term distribution's actual runtime
dependency closure through importlib.metadata (markers evaluated for
this platform, the dev extra excluded), gathers each package's licence
text, and fails the build loudly if one is missing rather than shipping
a silent gap -- which is exactly what it found: pyserial and the nine
winrt-* packages bleak's Windows backend pulls in all declare a licence
but ship no licence file in their wheel at all. Their real text, pulled
from each project's own repository, is vendored under
packaging/vendored-licenses/ for those two documented, verified gaps;
anything else missing still stops the build.
The spec generates THIRD-PARTY-NOTICES.txt fresh into the build
directory on every run and bundles it at the archive root alongside
LICENSE and NOTICE. installers.yml uploads the same three files once,
from the linux-x64 leg, so they land beside the downloads on the
release page too, without extracting an installer just to read them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A silent skip would let an unmarked file through, which is the one thing the test exists to
catch. The two new changelog lines now read in the file's own voice, and a docstring in the
logging tests is wrapped under the line limit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The relicensing grant binds by conduct only if the contributor had a fair chance to see it, and the template mentioned CONTRIBUTING only inside an HTML comment the rendered PR never shows. One visible checkbox at the top of the checklist.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>