The first cut pushed TRACE_DATA for any received trace packet, so every
trace read as an instant success: the dispatcher is promiscuous and fires
the packet-received callback for strangers' traces and for our own
outbound echoed back by the first repeater (same tag, still in flight).
Two corrections:
- Correlate against traces we originated. Wrap send_trace_path_raw (the one
send seam the frame server calls) to remember each tag and its route, and
push only a received trace whose tag we sent. Entries expire after 90s so a
never-answered trace can't match a recycled tag.
- Read the SNRs from the right place. A TRACE keeps its route (hop hashes)
in the payload unchanged; the per-hop SNRs ride in the packet *header* path
field, one signed byte (SNR*4) per hop. The earlier code waited for SNRs to
appear in the payload, so it never fired. Now completeness is "the header
has accumulated an SNR for every route hop", and that's the only thing we
push — the finished reply, with genuine per-hop SNRs.
Pending on-air verification (RF conditions poor at commit time); leaves
verbose per-trace INFO logging in place for that test, to be trimmed once a
clean multi-hop trace is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pymc_core never forwards a received trace to the companion client: its
frame server registers every push callback except on_trace_received, and
nothing in the library ever fires the trace_received event, so the whole
path is dead code — the only push_trace_data call is the self-hop
shortcut in _cmd_send_trace_path. A trace sent from MeshTerm therefore
returns RESP_CODE_SENT and then times out, because the node parses the
reply and drops it.
Shim it at the dispatcher's packet-received callback (where CompanionRadio
already watches every RX packet with its measured SNR): wrap it, and for a
TRACE payload re-slice the on-air bytes into the hash/SNR blocks
push_trace_data wants, emitting the standard 0x89 frame the meshcore client
decodes. Verified byte-exact end to end. Handles both the SNR-per-hop
layout (final SNR from our reception) and firmware that trails the final
SNR in the payload.
Upstream fix (no local pymc_core checkout to carry it): mirror every other
push — fire trace_received from _on_packet_received and register
on_trace_received in frame_server._setup_push_callbacks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits on a field: the node's own hue as ink on the neutral dark grey
pathline already fills our ★ chip with, closed at both ends by the rounded
caps a path line closes on, or by half blocks where the font has none.
Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.
The widget is ui/widgets.name_chip — the ink arrives as whatever style the
caller would have drawn the name in and is *added* to the fill as a Style,
because Rich silently drops "node.unknown on #3f3f46" and renders the name
bare. The console has no field to offer (its one dark grey is slot 8 and
backgrounds stop at 7), so the name draws unframed there, bound on
truecolor like the heat gradient. The specimen card grows a chips row so
both dresses are visible on the acceptance screen.
And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rows renamed, and the rename carried into the screens they open —
the config editor's table title and its staged-count header, the support
page's own title (it comes from the tool, so it followed on its own).
"Support this project" never said which project; the menu is MeshTerm's
and the section above it is already About MeshTerm, so the name is the
useful half of that phrase. "Device configuration" was the longest label
in the list and set the width of the whole name column: at 19 cells
instead of 23, every description now gets 51 and the widest row is 68.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The label column is the widest tool title plus two cells of gap, which
leaves the muted description 47 to work in. All 22 of them ran past it —
"Act on the device itself — clock, backup, identity key, reboot, reset"
needed 94 columns of terminal to say what 67 could. The row scrolled
under ←→ rather than lying, but a description you have to scroll is a
description nobody reads.
Rewritten to fit: the subject moves into the title's shadow (the row
already says Device actions, so the description just lists them), the
parenthetical asides go, and the trailing clause that repeated the
obvious goes with them. Widest row is now exactly 72.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New file laying out the ground rules for outside contributions ahead
of opening the repo up: dev setup, the test/lint gates a PR needs to
pass, and where the style guide lives (CLAUDE.md is that guide, not
just an AI aid).
The licensing section goes one step past what Apache-2.0's inbound
license clause alone gives — a PR also grants the right to relicense
the contribution later, so the project doesn't end up needing every
past contributor's sign-off if the license ever changes. Framed as
automatic (opening the PR is the agreement), no signature bot.
README's Development section now points to it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MeshTerm goes out under the Apache License, Version 2.0 — free to use,
modify, and redistribute, commercially or otherwise. The patent grant
and the built-in trademark clause (§6) are why Apache over MIT.
The "MeshTerm" name and any logo are reserved as trademarks outside
the code license (NOTICE), and the app's donate link is called out
as supporting this project specifically — a fork can take the code,
but not the name or the donations that come with it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The three sections were placeholders holding a shape. This writes the
first one: what MeshTerm is (one screen for what the radio can tell you,
the five menu sections as a list, and the standing promise that nothing
transmits unasked), where it came from (June 2026, a few serial scripts,
grown by the recording; the mesh's scarce airtime is why it listens more
than it speaks; later the PicoCalc), and the licence — MIT, as the
package metadata declares, with the credit MeshCore and OpenStreetMap
are owed, the ODbL by name.
The layout is unchanged: title, standfirst, three sections, rule,
colophon. It just runs longer than a screenful now, which is what the
sticky headings and the section jumps were for.
Link targets also drop a leading www., so the OSM copyright URL reads as
the host it is rather than a subdomain nobody types.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three About pages were scaffolding built out of Text() calls, which
made writing them a Python edit and capped what they could ever say. They
are markdown now — meshterm/assets/pages/*.md, drawn by ui/markdown.py
into the app's own language rather than markdown's: # the page's name in
brand, ## a section in the body accent (with the muted · aside), ###
indented with its prose, paragraphs and lists hanging as blocks, quotes
and fences behind a rail that survives wrapping, tables through Rich, and
links that say where they go — nothing is clickable on a framebuffer
console. Two paragraphs are page frame rather than body and sit flush and
muted: the standfirst under the title, the colophon under the closing
rule, which is where the copyright lands.
The pages stay scaffolded on purpose — the placeholders are unchanged —
but filling one in is now editing its .md, and the live package facts
arrive as {version}/{author}/{copyright} so a page still can't drift from
the build it describes.
Being a document earns the screen two things a block never had: its ##
headings are landmarks, so a heading pins while its own prose scrolls
under it and ^PgUp/^PgDn step by them, and the console lane claims
Sect ↑/Sect ↓ on the free F1/F2 pair once a page has more than one
section — dimmed while the whole page fits, since the lane never
advertises a dead key.
Parsing is markdown-it-py (already under Rich, now declared): a real
CommonMark parser, so an author writes ordinary markdown. The md.* styles
are defined in both themes; the new test renders the whole vocabulary at
53 cells and asserts it stays inside the console font and the 16 slots.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu asks "What would you like to do?" and its five sections each
name a doing. "What is this thing, and who made it" is not one, and had
nowhere to go — so it gets the sixth section, the one that names a
subject, sorted last where a reader who has run out of things to do finds
it. Three pages, in the order a stranger asks:
About MeshTerm what this is, where it came from, its terms
About the author the person behind it, on the mesh and off it
Support this project what keeps it going, and the ways to help
Every page is scaffolding: the headings are the settled part, and each
section carries one lowercase muted "placeholder — …" line where its
prose will go. That is the app's own empty-state voice, so an unwritten
page reads as deliberately unwritten rather than as a screen that failed
to load, and filling one in means replacing its placeholder — the screen,
the menu rows and the CLI faces all follow unchanged. What is not a
placeholder is the version, the author and the copyright span: they come
from the package itself, so the About page can never describe a build it
isn't running inside.
The pages act on nothing — no radio, no database, no device — so each
opens straight to its screen with no prompt first, and each keeps a CLI
face (meshterm about / about-author / support) printing the same page to
a shell.
Two new icons, both funnelled for the console: 📖 read/about becomes ¶,
the page-of-prose mark, and 💰 support/donate becomes $.
ScrollScreen earns a derived footer hint on the way through. A read-only
body has only two keys to name, the pager and Esc, so passing "↑↓ PgUp/
PgDn scroll · Esc close" at every call site was both repetition and a lie
on a body that fits — the standing rule is that a footer never advertises
a key that would do nothing. Left unset, it now names the pager only
while the content actually overflows, and takes its Esc verb from the
surface: a floating view closes, a full screen goes back. Every existing
caller passes its hint explicitly and is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A trace subscribed to its tag only after send_trace returned. That send
waits on the companion's MSG_SENT, which the meshcore library correlates
by event type alone — so any other command in flight (a scheduled advert,
a telemetry poll, the courier) can consume ours and leave the send
blocked on its own 15-second default. Every reply that landed inside that
window was dispatched to no listener and dropped, and the walk recorded
as "no reply" though the mesh had answered it. Because the traffic that
steals the acknowledgement is periodic, the condition outlives the trace
that hit it: traces keep failing for as long as it lasts. Subscribe
first, stamp the reply's own arrival so a stalled send inflates no round
trip, and log every trace and every reply heard so a silent walk can be
told apart from a deaf one.
The other half is pacing. run_traces sleeps between its own samples and
never after the last one, so at one trace per candidate — the path
probe's default — it paced nothing at all, and the sweep walked every
candidate back to back. Same hole across the TX optimizer's level
boundaries, and on the trace screen, where aborting a walk and hitting
Enter again transmitted immediately. Repeaters penalize a node that
bursts, and a penalized node's every later trace comes home empty. Pace
between probe candidates and TX levels, and measure the screen's cooldown
from its last transmission rather than only from the previous sample.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu asks "What would you like to do?" and then grouped its rows by
subject, which is not an answer to that. Mesh had grown to seven of the
nineteen rows — live watching, passive browsing, and one transmit action
in the same bucket — and Tools said nothing at all, since everything in
the menu is a tool.
So each section now names a doing, and holds what shares it:
Message Chat · Channels · Courier · Contacts
Watch Dashboard · Live feed · Watchtower · Time machine
Explore Map · Mesh walk · Trace target · Trace path · Trophy case
This node Device info · Device configuration · Device actions · Send advert
Other nodes Repeater admin · TX optimize
Four rows moved for a reason. Contacts is the address book the three
features above it pick a recipient from, not a mesh view. Time machine is
the past tense of the Dashboard, and was stranded in the grab-bag. Send
advert is your own node announcing itself — a device action, filed with
the mesh views only because it was about the mesh. And Repeater admin and
TX optimize are the same job — someone else's radio, over the mesh, with
credentials — which the old Device-management/Tools split hid.
The two walks join the topology views they walk over, with the boards
that score them underneath: see a node, walk to it, get scored. Nothing
is renamed (a menu row still names the screen it opens) and no section is
now larger than five rows or smaller than two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tool's description is written to say what the tool is for, not to fit
whatever column is left after the longest name — so it was read to the
right edge and no further. The list screen has had per-row h-scrolling
for a while (the Trophy case, the Watchtower log); the label+description
lists just had no way to ask for it, because the builders that lay them
out never see the screen they will be opened in.
So the row asks. menu_rows and the main menu pin their label lane as each
row's hscroll_from, and a list turns its scrolling on when any row
declares one — a head block is only meaningful as the part that stays
put, so a row that pins one is a row built to scroll. The name holds its
place, the description slides under it, and a list whose rows all fit
still ignores ←→ and says nothing about them in its footer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every icon on the console is a one-glyph stand-in for an emoji, and the
menu is where that pinches hardest: the main menu drew @ for both Map and
Mesh walk, … for both Live feed and Time machine, and ?? for Trace path,
in a lane costing three of fifty-three columns on every row (JP,
on-device).
So drop the lane there, mark and emoji alike. A command row's icon is
decoration — it names the action's family while the label names the
action — so where it can only be approximated it is worth less than the
cells it takes, and the label and its description get them back. This is
a platform choice (Platform.menu_icons), not a per-emoji one: a glyph
that carries *data* is not an icon in this sense and never goes, which is
why the Channels page keeps its #/🌐/🔒 openness mark, the feed keeps the
class icon it shows in place of the class word at 53 columns, and node
types, status marks and the mute mark in the channel lane all stay put.
Three funnels do the work, so a screen never asks the platform itself:
command_label for a label carrying its own icon, command_icon for a row
that builds its own lane, and marked_label where the icon carried a tint
— a delete row's red moves onto its words rather than leaving with the 🗑.
Section headings go through the same cut (a stand-in beside the ── rule
is pure noise), which also retires the ?? that No revisits was drawing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console's compact glyphs for REQ and RESPONSE read them as inbound
and outbound mail — ↓ for the ask, ↑ for the answer. That is the wrong
axis: what a reader is tracking is the exchange, not the mailbox, and in
an exchange the question goes out and the answer comes back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six disciplines measured the whole walk — how far it travelled, how far
out it reached, how many nodes it touched. None of them measured a single
link, and that is its own game: a node reached over a chain of short hops
scores far, not long, while one huge jump between two relays scores
nothing today.
Longest leg (🏹) takes the greatest distance the walk crossed in one hop,
both ends positioned. The measurement rides the loop that already sums
the circuit, so it costs nothing extra, and it carries the ids of the two
ends it spanned — our own end left null — so the record card can name the
link on THE path widget, ★ at home like every other route we draw. A
record set before the discipline existed simply has no leg lane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the PicoCalc the viewer's reception row folded the bare word "rssi"
onto a line of its own. Two things were spending the cells it needed.
The dialog gutter: every floating box left six columns to the backdrop it
sits over. Three a side reads as floating at 72; at 53 the same gutter is
a twelfth of the whole display, and it costs content. How wide the gutter
is worth being is now the platform's call (Platform.dialog_margin), and
the console's is four — still floating, two columns back.
The separator: the row chained its two atoms on the roomy " · " no
matter the lane. That is the header's problem too, and the header already
solved it — roomy by default, compact when the cells run out — so the row
now takes the same trade, sizing itself at render time like the via path
beside it. Two cells alone would have left a two-digit SNR beside a
three-digit RSSI still folding; together they fit with a cell to spare.
The roomy/compact pair moves to ui/menus.py, one definition for both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every action row led with its mark and a hard space, which only aligns
while every mark is one cell wide. ⚡ is an emoji — two — so Explore's
label sat a column right of Compose's, Trace's, and the rest.
The marks now go through a measured icon column: each pads out to the
widest of them, so the labels share one column whatever they lead with.
Measured rather than fixed at two because the marks change width with the
platform — on PicoCalc every icon funnels through theme.glyph to a single
console-font character, so the column tightens to one cell and the labels
move left with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tightest rung was a week, which is no help to a device whose contact
table filled up in one busy afternoon. Nothing else moves: the rung sweeps
only contacts actually heard and since gone quiet, the never-heard bucket
still stands apart, and the typed `delete` gate still names the span.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep is one companion command per victim over a link that can be slow,
so its end is a count away — but it ran under the busy overlay, which only
ever says "working" and is drawn only in the gaps *between* screens. Over the
pushed contacts list it drew nothing at all: a hundred-contact purge looked
like a screen that had stopped answering, while the arrow keys still moved a
cursor nothing was being done with. And what it removed went to a note the
reader would only meet on their way off the screen.
Now the app's progress dialog counts the sweep down against a real total and
swallows every key while it runs, and the outcome lands in a dialog dismissed
in front of the list it just purged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Use this path" was respelling the composed spec as raw hex, two rows under
the live preview that *is* the path — the same thing said worse, growing by a
hop every time the route did. The row now says what Enter does and lets the
picture above it say what to. Only the unarmed case still needs words.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
←→ was sliding the whole alert row, so reading a long message to its end cost
the marker, age, kind, glyph and node name off the left edge — the lanes that
say *which* alert this is, and the part that already fitted. Each row now
declares them (`Choice.hscroll_from`, the seam the Trophy case cut), keeping
the `—` with the head so the message still arrives introduced.
The ←→ footer atom is gated on the run rather than the whole label, so a row
whose pinned lanes make it wide but whose message fits no longer advertises a
key that would do nothing.
The section jumps were already there: two `section_heading` rows earn the
list its ^PgUp/^PgDn and their F1/F2 chips automatically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the path rows owed their readers.
The opens-further `…` was being reserved out of the lane, so a route that
fitted exactly cracked on its own last chip — the row claiming the walk ran
on when it had arrived, the crack being the closing cap with half of it taken
away. It now rides outside the budget (`with_action_mark`, and `cut_to(...,
action=True)`): the path is fitted to the whole lane and the mark takes the
leftovers, or goes unshown. That is the widget's answer everywhere a path row
carries it, not the node page's local trade.
Every stats line hanging under a path now leads with the hop count
(`hops_atom`) — the figure the picture above encodes but never states, and
the first thing two routes are compared on. `direct` where there are none,
which is the app's own word for it; the Explore list's direct-shot provenance
tag was that same word for the same reason, so the atom absorbs it.
And Explore paths cuts its unselected candidates instead of middle-eliding
them, so walking the cursor no longer rewrites the row under it. The `⋯`
rescue saves a route's two endpoints; here they are the same two on every row
by construction, so it spent cells on what the reader knows and took them off
the candidates' front. The probe-results rows pin their ranking lanes out of
the ←→ scroll while they are at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unselected rows middle-elided to the width while the highlighted one kept its
natural line and cracked at the lane, so moving the cursor rewrote the row
under it — two renderings of the same walk, differing in more than the shift.
The `⋯` rescue exists to save a route's two endpoints from a right truncation.
On this board both endpoints are the same `★` on every row, by construction, so
it spent three cells and a hop to arrive back at the one thing the reader
already knew, while the walk's *front* — the part that actually differs from
row to row — paid for it. Every row now carries the whole line and is cut by
the list, so selected and unselected differ in exactly one thing: the shift.
The title is a finished Text rather than a width-aware callable now (nothing
fits itself to the terminal any more), which retires the per-width memo — a
frozen record composes once per open instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A record row is fixed lanes then one long run — rank, date, score, then the
walk — and ←→ was sliding the whole line, so reading a route's tail cost the
reader their place in the board and bought back cells the walk was already
being given.
A Choice may now pin a head block out of the scroll (`hscroll_from`); the
trophy row sets it to its lanes' own width, measured from the composed lanes
rather than guessed, which is why `browser_row` splits in two. While a row is
scrolled, each edge its run continues past wears a `cut_mark` — a chip broken
off in its own fill, the faint `…` where there is no fill to shear — closing
the gap where a shifted select row drew no mark at either end. The clamp goes
to the first whole step that brings the tail inside the lane, so the right
mark never promises a remainder the keys can no longer reach; that is what the
node page's routes and the Message paths lanes already do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a585a74 listed 🛣 (U+1F6E3) in the wide set on the reading that the font drew
a two-cell colour glyph where both authorities measured one. It had the
direction backwards, and the entry is what JP is looking at now: the Trophy
case's Longest-distance heading reserves a cell the terminal never draws, so
its row is 24 characters where its neighbours are 25 and the right border
lands a column in.
The arithmetic is unambiguous — that heading emits one character fewer than
the rows around it while measuring the same — and the control was one board
down the whole time. 🕸 (U+1F578) is the identical Unicode class, used bare,
never listed, and framed flush throughout.
The rule the mistake missed: outside Emoji_Presentation, a *bare* codepoint
has no variation selector asking for emoji presentation, so the font draws a
one-cell text glyph — which is exactly what Rich and wcwidth said. Both
authorities already agreeing is not a bug to correct. The wide set is for the
one thing it was built for, a VS16 sequence the narrow verdict gets wrong
(🛩), and 🛣 needed no entry at all.
Pinned by a test naming both glyphs, and the skill that would have
recommended the same mistake an hour ago now argues against it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The width machinery gained a *wide* set — glyphs the font paints in two cells
where Rich and wcwidth both say one, so they overrun a padded row instead of
notching a border — but the skill never learned about it. classify.py still
called _make_cell_len(narrow) and died on the signature; its "lone narrow
codepoint" verdict said "no change needed", which is exactly wrong for the
glyph that motivated the wide set (🛣 U+1F6E3); and nothing reported that a
glyph was already listed, which is the commonest answer to "please add this
one".
Both sets are now applied, both directions documented, and every branch says
whether the decision has already been made. The frontmatter advertises the
overrun symptom too, so the skill is findable from the half of the problem it
could not previously see.
No production change: 🛣 has been in _DEFAULT_WIDE_BASE since the set existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the pads around our own star saved two cells and cost the chip its
shape — squeezed between two chevrons it read as a glyph wedged into the
seam rather than as a segment of the route. The pads are not room for a long
word, they are what makes a chip a chip.
The dark grey fill and the map's yellow ink stay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three cuts to what a chip line spends its cells on.
The seam was two: the previous chip tapering onto the page, the next
notching back out of its own fill, a sliver of bare page between. That gap
was insurance against two neighbours landing on one colour and fusing into a
single block — but the hue wheel is 256 distinct values wide (measured: zero
collisions across all 256 first bytes), so it was insurance against
something that does not happen. One interlocked chevron now, the chip behind
as foreground and the chip ahead as background.
Where two fills really are equal the interlock cannot draw a chevron at all,
and that case is not bad luck: a mirrored return leg is a run of identically
faded hops by construction, and so is a stretch of keyless greys. There the
point drops its background and the page cuts the wedge — still one cell, the
old separation without the old width.
The elision was a chip: pads, a fill, and a seam on each side, seven cells to
say "some hops are missing" on a line that is short of room by definition. It
is not a node called ⋯. It now breaks the ribbon instead — the chip before
closes onto the page, the mark sits there bare, the chip after opens on its
notch. Three cells, and elision_hop() is the one definition the widget and
the mesh walk's trail share. Arrow mode needed no change: a bare label
between two arrows already was a gap.
The you chip was ` ★ ` in dark ink on pure white — five cells shouting the
one node the reader never has to be told. Now the map's own yellow star
(taken from marks.SELF_MARK, so the star in a route and the star on the map
cannot drift) on a neutral dark grey, and padless, because the pads exist to
keep a *word* off the chevrons and a single mark centred in its cell needs no
such room. Three cells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes to what a path line shows at its edges and at its ends.
The edges: the crack only reached the two surfaces that drew their own marks.
Everywhere else a path still met render_to_ansi(no_wrap=True) or a plain
truncate, both of which ellipsize — so the Message paths rows you were not
on trailed off in dots while the one you were on cracked. Those cuts now go
through cut_to, which reads the line and decides: a chip breaks off on the
crack, prose keeps its ellipsis. That covers the unselected arrival rows,
every select-list row (the trophy walks and probe candidates), the TX sweep
action rows, and the composer's suggestion rows. The generic surfaces are
safe to convert precisely because the fallback is the old behaviour.
The ends: a path's ends are the nodes it went between, so a line that opens
on its first *relay* is naming a node that only passed the message on. The
Message paths rows now run origin → relays → us — the same two endpoints the
graph one row up already drew between, so the row and the picture finally
cross-read. A hopless arrival stops collapsing to the word "direct":
Alice ▶ ★ is what direct delivery looks like, on the same rails as every
relayed row.
And our own end is the ★ everywhere, not our name — it was already so on the
trace lane, the trophy card and the composer, and now also on the node
detail Routes rows, the TX sweep route, the mesh walk trail and the live
feed's subject lane. It is the one node the reader never has to be told, and
in an 18-cell feed lane or a width-starved trail the cells it gives back are
hops that stay in view.
Left alone deliberately: the packet viewer's `via` row (the dialog has a
`from` row naming the origin, so `via` really is just the relay chain), the
verbatim wire spec, and widgets._link_text — a table of FROM → TO *links*,
still on the legacy path_text builder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ellipsis at a path line's edge says a word was shortened. What actually
ran out is the lane — the route continues, and in chip mode there is a
segment right there to say so with. A cut chip now breaks off on a half
block drawn in that chip's own fill and no background, so the cell is half
segment and half bare page: the crack reads as a chip that carries on.
CRACK_TAIL (▌) closes a line the path runs past, CRACK_HEAD (▐) opens one
whose start is off to the left.
Only chips can crack, which is also the whole mode test: cut_mark reads the
fill from the nearest cell still drawn — scanning back for a tail cut,
forward for a head cut, so a cut landing on a seam still takes the colour
the reader can see — and falls back to the muted ellipsis when it finds
none. An arrow line has no fill to shear, so it keeps cutting exactly as
before and no caller has to know which mode drew the line.
Applied at the four places a path line is cut rather than folded: the
ellipsized() last resort, the over-wide lone hop in wrapped(), and the two
h-scroll surfaces whose edge marks were literal "…" — the Message paths
selected row and the node detail Routes row (whose unselected rows now go
through cut_to as well). Untouched: the ⋯ that marks whole hops elided out
of a path's middle, which is a different claim entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desync JP saw. Hiding the came-from node fixed _rows, and the canvas
reads _rows -- but the list did not. It rendered straight out of the link
table while the cursor indexed the rows, so from the came-from's position
onward every row named one link and drew another: the highlight sat one link
away from the one the canvas was lighting, and the came-from was still
sitting there in the list besides. Rows are now the authority on which links
the list holds and in what order; the table is only ever looked up through
them. Onward counts still gather over the whole table -- that memo is per
focus, not per paint, and a narrowed view must not be what fills it.
Sizing the whole fan to its longest name was the wrong correction. One
29-cell repeater dragged every marker west with it. The fan reaches for a
tip sized to an ordinary name now, and only a marker whose own name wants
more steps west from there, by exactly the difference -- so a long name
costs its own row some easting and leaves the rest of the fan where it was.
And the collapsed slot is laid out for the label it wears at rest, never for
the selection: a stand-in's name would otherwise shuffle the whole fan as
the cursor walked the collapsed nodes. A name too long for the room that
leaves simply truncates.
The stand-in's name goes back to the selection white. Colouring it by
identity was mine to get wrong: a collapsed node is only ever drawn while
selected, so white is not a highlight painted over its colour -- it is
simply what that label is, exactly as it would be with a marker of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The margin the fan kept clear of the east edge was a constant, and the
constant was sized for a name of twenty cells. Anything longer arrived at
the tightest (due-east) marker already ellipsized -- so a repeater called
Sensor-Rooftop-Longueuil-East read as Sensor-Rooftop-Longu... on a canvas
with room to spare, because nothing had asked how wide the names were. The
widest label about to be drawn now sets the margin and the fan pulls west by
exactly that much: short names spread to the edge, a long one buys its
spelling with reach. The floor is a third of the width -- past that the
markers pile onto the focus and the graph stops reading as a fan at all, so
a canvas too narrow for both goes back to clipping. The fan's label cap goes
to 32, the protocol's own limit on a name: a name that fits the mesh now
fits the graph.
And the collapsed marker's rank moves west of the mark. Trailing the name it
read as part of what the node is called and left that one name ending in a
different place from every other name on the fan; ahead of the glyph it is
plainly an annotation on the marker: (2/6) * Dave.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marks. The canvas coloured every marker by the node's key hue and left the
glyph shape to carry the type alone. That put this one screen at odds with
every other place a typed node is pinned -- the map, the route graph, a
contact row -- where the mark's colour *is* the type and is picked
deliberately per platform through the theme's type.* (a naive downsample
greys the repeater's violet). Identity had its own lane here all along: the
name beside the marker. Shape and colour now agree, room and sensor marks
arrive with them, and the legend keys both because it reads its pairs from
the same places the canvas does.
The way back. The node the walk came in from was drawn ducking in west of
the focus and listed as a link with a "back" tag. It is not a way onward:
walking it just undid the last step, and it took the list's top row, which
is where the cursor lands on every reset -- so the cursor opened on the
node you had that moment left rather than on the strongest link out. It is
gone from both; backspace was always the way back. _fan_nodes is now the
single definition of what the east of this screen is, and the list and the
canvas both read it, find included.
Spacing. The fan spread by angle, so its rows fell out of a sine and
crowded the rim: five neighbours read as a clump. It is a row grid now --
one marker per row, a blank row between each, a blank row above and below
-- with only the easting still an arc, so the fan bows without standing as
a flat column. The outer rows are what gives when the canvas is short: air
is worth a row until it costs a node. The focus sits level with the block's
middle rather than the canvas's, so its edges leave symmetrically.
And the collapsed marker stands in for a selected weak link now, instead of
whiting out an ellipsis: that node's own mark, its own name in its own hue,
and a grey (2/17) for where it sits among the collapsed -- which is also
what keeps the count the old "+n weaker" label carried from being lost the
moment you selected into it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Map. Enter framed the matches, which is only ever half of what finishing a
find means. Typing a query dims everything it doesn't match, and the common
finish is "yes, that one -- now let me look around it": the query has done
its job and only the dimming is in the way. So Enter drops the query and
leaves the view where the reader put it, and ^Enter is the other finish --
take me to them -- keeping the query, because a frame is a place to arrive
and framing tighter from there is one more press.
^Enter is not a keycode any terminal has to offer: Enter *is* ^M, so a
console without modifyOtherKeys has nothing left to modify. What most do
send is LF, which prompt_toolkit spells ControlJ and nothing here was using,
so it is bound; the right-Ctrl rescue covers the rest; and where a console
spells neither, the F3 Frame chip is the affordance -- which is the platform
where that was already the whole story.
Mesh walk. The breadcrumb elided its head and snapped right, so a long walk
had a beginning that could not be read at all. Left and right now scroll it
a hop at a time, and a scrolled line grows its own trailing elision: the
mark means the same thing on either side, more walk that way. It stops where
the head comes into view rather than running the line off the edge, and any
change to the trail rewinds it -- the focus is what the next move is read
from. The hint trades "type to find" for the arrows exactly while the trail
overflows: typing announces itself the moment a letter lands, the scroll had
no way to.
And the find now narrows the canvas fan, not just the list. The east of the
picture is the ways onward, which is what the query is asking about; the
focus and the node we came from are the walk itself, so they hold through
whatever is typed and the graph keeps its bearings while the choices thin.
Chat. The transcript's sections are days and its section jump was already
bound, so it takes the free left pair the same way a grouped list's does --
Day up outside Day down, the lane's handedness, ctrl_pageup/ctrl_pagedown --
lit only when there is a second day to step to. Read off the two end
messages rather than by re-dating the whole transcript every paint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mesh walk's You had been living behind Page up, in the pager's Shift
bank, because that bank is where a screen's Home lands by default. But You
is not an end of the link list -- it drops the whole trail and refocuses our
own node -- so it had to pretend to be Top, and Bottom had to stay blank to
keep the pretence, and the walk was the one scrolling screen whose Home
didn't scroll.
So You takes a slot of the screen's own (F3, which is what F1-F3 are for),
and the pager's Shift bank goes back to meaning what it means everywhere
else: F10 Top, F9 Bottom, the two ends of the list. Home and End on a
keyboard follow the same correction and now move the highlight.
Its chord moves with it: locate is ^U, for you, on the map as well as here.
The letter is the mnemonic of the action rather than one screen's word for
it -- ^L only ever read as "locate" from inside the map's code, and the
action is the same one on both screens, so it should not have been two
mnemonics waiting to happen.
You dims once the focus is already us and no find is narrowing the list --
there is nowhere to come back from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the A/B for all six remaining items — device-reverted between
runs, same database, same radio — and what the console readback proved:
the composited quit confirm is byte-identical to prompt_toolkit's own
output across all three captures, and so is the mesh walk's settled graph
at 339 nodes and 1155 links. The rework changed the time and nothing
else.
Also the one thing that got slower. Paging into rows never drawn before
is 154 ms against 84 ms, because a cold row costs 3.9 ms and 3.4 ms of
that is Rich's console.print — measured on the device, so there is
nothing to shave short of not using Rich for rows. That work did not
grow; it moved off the open, where it used to cost ~585 ms of the second
Contacts took to appear, onto the page you actually visit, once.
The harness gains the tour that did the A/B, and the note that reverting
the device beats rebuilding it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of the same paint.
A screen renders its body whole and the frame then slices a viewport out
of it, so a 141-contact list rasterized 150 rows to show the twenty that
fit — on every keystroke and on every idle tick. A select screen now
plans its body and hands over one callable per row; the count, the
cursor, and the sticky landmarks are all still exact (they are layout,
not drawing), but only the sliced rows are ever built. A row's live
callable title is resolved only while it is on screen. 3.2x on the body.
And a floating dialog dropped the whole frame onto prompt_toolkit's
renderer, paying its full grid rebuild and diff — 70-125 ms per keystroke
in every confirm, picker and viewer in the app. There is nothing in that
placement we cannot do: an unanchored, unsized float is centred. So the
frame source composites the boxes itself, cell-accurately, memoized per
row; the rows a box does not reach come back as the same strings, which
is what lets the row diff skip them. The busy overlay, a content-sized
window the float container measures, is still prompt_toolkit's to place.
The row writer also stops writing entirely when the frame is unchanged —
an empty write leaves the panel's damage region empty, so the display
never flushes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mark_rgb was reached ~30 000 times a frame and Feature.get ~32 000, both
answering the same handful of questions: a tile's colours are a handful
of constants and a handful of feature classes, shared by tens of
thousands of features. The memo behind mark_rgb makes each call a dict
hit, but at that count a dict hit behind a function call is real time on
a Cortex-A7.
Resolve the fixed colours once per tile and memoize the per-class styles
(roads, waterways) into small local dicts; read tags directly rather than
through the accessor. Only the ground raster's catch-up, not the
keystroke — that moved off the paint path already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screen repaints once a second to keep its rates, ages and clock
honest. Both of its expensive stages are pure functions of data that
mostly does not move between those frames: the window digest re-walked up
to four thousand observations to reach the numbers it already had, and
the braille activity chart re-rasterized an identical picture.
The window changes in exactly two places — an arriving observation, an
expiring one — so both bump a revision the digest compares against. The
chart memoizes on the numbers it draws from. An idle repaint: 8.4 -> 3.8 ms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prefix settle walked every link in the graph three times per merge —
to derive the node set, to build the neighbour table the corroboration
vote reads, and to hunt down the links naming the id being folded. On the
device's mesh that is ~1150 links, three times over, some eighty times:
the largest remaining cost of opening the walk.
All three questions are one table's to answer, and a merge's effect on it
is knowable where it happens. So build it once and keep it: its keys are
the live node set, a fold relabels its own neighbours, and a stub whose
every link ran to its owner takes the owner out with it. Both passes now
walk only the under-specified ids, since a full-width one extends
nothing.
Ties in the shortest-first order break lexicographically too. Length
alone left equal-width ids in whatever order the node set iterated, and
CPython randomizes string hashing per process — so two runs over the same
evidence could take their merges in different orders and, where one fold
changes what a later one sees, settle to different graphs.
Verified byte-identical against the previous algorithm over twelve
randomized graphs, and 6.3x on the settle at that scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prewarm at connect filled contacts and the two slot probes, so the
first Contacts or Trace open still queued a path-hash-mode round trip
behind whichever probe was walking the slot table at the time — and the
slot walks are measured in seconds.
Warm every cached fact, cheapest first: self-info and the routing mode
(single round trips, and what every list screen needs), then contacts,
then the two probes only the channel manager waits on. The mode gets a
lock like its siblings, so a screen opening beside the warm awaits the
read in flight instead of issuing a second one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 53-column mark's fourth row is the only row in either wordmark whose
colour lands on the dim bank (185;28;28 -> slot 1), and on the console it
came out part red, part light red: the M's right leg, E, S, H and the R's
right leg promoted, the M's left leg, T, E and the R's left leg not.
The split follows the bevels. The mark alternates letter blocks with slate
bevel spans, the bevel is slot 8 -- spelled 90, which on the VT sets the
intensity bit -- and the quantizer handed back a bare 31 for the blocks
after it. Bare, it keeps the bit and renders 91. Every leg that happened to
follow a bevel went light red; every leg that followed one of the row's few
resets stayed red. The .ansi art is one colour throughout; the promotion is
entirely on the way out.
So the fold's quantizer now states its intent the way the theme's dim-slot
styles already must: a dim slot leaves as 22;3N, never a bare 3N. Adjacent
art spans reset nothing between them, so this is the only place that can
know. It fixes the same latent promotion in a map raster, where two
neighbouring cells are exactly this pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rasterizing a view is most of a second, so the paint that answers a pan
drew the nodes on an empty canvas and waited — the map going black on
every keypress and flashing back when the frame landed. Each finished
frame now keeps its braille layer, and the next moved view stands on it:
the two viewports differ by an affine scale-and-shift in Web Mercator,
which collapses to two cell-index tables the canvas pastes by. So the
streets slide with the view — dimmed, and short of the edge you are
panning onto — until their own raster replaces them.
Cell granularity, not dot-exact: the stand-in lands within half a cell
of true and is corrected a moment later, for +0.5 ms on the paint path
instead of eight times the work. Only the ground travels; labels and
markers are redrawn where they really are. The fade is off on the
16-slot console, where dimming a colour is a recolouring, and the whole
ghost is dropped once it says nothing — panned clear off, or more than
two zoom steps away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Routes tab had it the other way round: names in the graph, hashes
in the row list. Names in the fan read well on one route and fall
apart on several - each label is set above or below its own marker,
so a long one spills across the lanes either side of it, and the very
graph that earns its lanes is the one that can least afford them.
So take the split the Message paths dialog already settled on. In the
fan, every relay is its first hash byte alone - two cells, whatever
the node is called; only the two endpoints stay named. In the rows,
every hop the resolver can place reads as its contact name, in that
node's own hue, and only a hop with no name to show stands in its
hash at the device's path-hash width. Nothing spells the same node
twice: the two bands cross-reference by colour.
Names cost cells a hash chain doesn't, which is what the row's own
horizontal scroll is for - so it now draws the faint edge marks the
key lane and the Message paths rows draw, and clamps where the tail
is actually readable rather than one step short of it, under a right
mark promising a remainder the keys could not reach.
self_key falls out of the route line and its view: the far end is our
name now, not our hash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the PicoCalc the route graph opened under a blank row and closed
under a caption, neither of which draws anything the picture doesn't
already say. The blank is the renderer's own end padding: it centres
the lane band inside two cells of air while the topmost label wants
only one, so that first canvas row is reliably empty. The stage now
asks for one row over its ceiling and peels that row off, which pays
for the grant, and drops the caption outright — the white line and the
you star make the same point.
Both rows go back into the ceiling, so a ceiling-bound fan draws two
lanes taller on the console than the captioned desktop stage affords
it from the same viewport. The desktop keeps its air and its caption:
this rides frame_border, the same frugality signal the tab strip's air
already binds to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
instrument.py wrapped Renderer.render, which FastRenderer overrides - so
once the row writer became the default paint the trace showed only the
handful of frames that still fall back to prompt_toolkit, and read as a
plausible-looking session rather than an obviously broken one. Hooked at
Application._redraw instead, which is renderer-agnostic.
With it seeing again, two corrections. Leaving a screen is not slow: the
escape paint is ~10 ms and the menu repaint that follows lands ~46 ms
later as an untriggered tick, ~90 ms in all - the 520-570 ms in the tour
tables was console-quiescence overshoot. And the idle tick still
recomposes: 34 ms on the menu, 70 ms on Contacts, every 2 s, where
Contacts spends 151 render_to_ansi calls filling a ~20-row viewport.
Both go in the plan, along with the reading rule that found them: judge an
action by the rows *after* it too, since anything the menu loop does when
a screen pops is asynchronous and lands unattributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opening the mesh walk took 4.9 s, and 4.2 s of it was build_topology.
coalesce_prefixes folds a short hop hash onto the longer id it abbreviates.
It restarts the whole pass after each merge; each pass asks _extensions
once per node; and _extensions scanned every node in the graph. On this
device - 3053 stored packet paths, 1145 links, ~80 merges to settle - that
came to 24 million len calls and 7.6 million startswith per build.
Ids sharing a prefix are contiguous once sorted, so the extensions of a
short id are a range, not a search: bisect to its start and stop at the
first id that no longer carries the prefix. build_topology 4.22 -> 1.23 s
with real contacts, and the graph is byte-identical - 1133 links, 345
nodes, both dumped and diffed. On the console the open is 4.94 -> 1.95 s.
Contacts' 1.3 s open turns out not to be ours at all: heard_nodes 23 ms,
the screen 131 ms, first render 43 ms. The rest is the first devstate read
over the UART, already cached for every later open - a warm one is 230 ms.
Warming devstate at connect is the fix, and it is now first in the plan.
The harness needed two things to measure any of this, both folded back into
the skill. An open is not a repaint - it paints the menu closing, falls
silent while it loads, then paints the new screen - so quiescence stopped
the clock on the first burst and reported the walk opening in 124 ms.
Widening the silence window does not help either: past a second the 2 s
header tick lands inside every measurement. `expect TEXT` watches
/dev/vcs1 until the screen is actually there. Its needle has to be unique
to the destination, because every main-menu row carries its own screen's
name - `expect Mesh walk` matched the menu and made a 2.3x win read as no
change at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Packages the profiling pass as a repeatable skill - the pty driver that
runs MeshTerm on /dev/tty1 and times each key, the in-app stage-timing
wrapper, the ablation bench, and three tours. Screens are verified by
reading /dev/vcs1, the console's own memory, because the panel's text is
the only sound record of what a rendering change did.
Most of the value is the traps, all of which cost real time to find:
cProfile's per-call overhead misattributes this workload badly (it blamed
two prompt_toolkit functions whose removal turned out to be a wash - use
ablation); get_app() outside a running app builds a throwaway Application
and reloads every vi/emacs binding, ~150 ms a call; output must be drained
before each key or the previous key's tail reads as a 0.3 ms response; an
exception inside Renderer.render's finally is swallowed by asyncio and
silently stops all logging; and the app boots to a device picker, so a
tour that forgets to connect measures the splash under every label.
The plan's map section was wrong and is rewritten. Tile loading was never
on the paint path, and a decode does not starve the loop (47 ms worst-case
heartbeat gap during a 1223 ms decode). Rasterizing the ground was the
cost, which the first bench missed because its viewport had no tiles
resident. Numbers and remaining work updated to match what shipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.
Two changes, in that order.
The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).
The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.
Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.
On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.
Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>