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>
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>
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>
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>
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>
Every paint handed prompt_toolkit a finished ANSI frame and let it parse
that back into fragments, write all ~1400 cells into a fresh Char grid,
diff the grid, and re-emit ANSI. Measured on the PicoCalc that round trip
is 70-125 ms, and it is paid in full whether the frame changed by one row,
by all of them, or not at all - the 2 s header tick recomposes an
identical frame and still pays it.
Since the frame is already rows of text, FastRenderer compares this
frame's rows against the last frame's and rewrites only the ones that
differ. Opt-in for now (MESHTERM_FASTRENDER=1) and deliberately narrow:
a floating dialog or the busy overlay is placed by pt's float containers,
so those paints fall straight through to the stock renderer.
The erase has to lead the row, never follow it. A row that exactly fills
53 columns leaves the cursor in the last column with wrap pending, and an
erase-to-end there clears the character just written - the last cell of
every row went missing until the clear moved in front.
Alongside it, three costs that were pure waste on this platform:
* The redraw was postponed up to max_render_postpone_time before it
even started - a flat 3 ms on the Lyra, 10 ms on desktop, on every
key. That default guards a UI whose own output floods it with
invalidations; here they are keystrokes and a 2 s tick.
* The splash rendered its body twice per paint - once to measure the
box, once to fill it - and ran the corner glow ungated, walking every
line to hand back the identical list on a console with no truecolor
to blend. compose_dialog had the same ungated glow.
* A tile's decoded layers were re-read from the SD card on every frame
(~96 ms each), so panning one dot re-read every tile that had not
moved. They are held in RAM now, bounded at a viewport plus its ring.
On the device, driven over the real radio: main menu 115 -> 40 ms,
Contacts 155 -> 73 ms, Dashboard 213 -> 117 ms, leaving a screen
375 -> 103 ms. Console readback confirms the frames are byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The offer flow end to end: a device reporting the 0/0 null island
(exactly what SELF_INFO carries for unset - signed microdegrees over
1e6, verified against the meshcore reader) gets its remembered fix
written back silently; a real conflicting fix still prompts; mixed
drift splits between the silent restore and the prompt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shift+Up/Down reach the app as PgUp/PgDn there; with Shift physically
held (the watcher knows) they fine-pan instead of zooming - no real
PgUp exists on that keyboard, so the code cannot mean anything else.
Shift+Left/Right the keymap eats outright; nothing arrives to rescue
until the console keymap maps them back to plain arrows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The right-gutter marks come back on the desktop (redundant but nice)
and the console keeps the bare tick - axis_chrome is the one number a
caller budgets, platform-bound like the rest of the chrome. The Rhythm
ladder widens to 1/5/10/15/20/30/60-minute slices, finest that fits:
the desktop keeps its 15-minute sweep at 72 cols and earns finer on a
wide terminal; the mesh page re-slices one minute-grid scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-item chips (node page F1 Info / F2 Routes, Time machine
F1-F3 spans) revert to the single next-thing toggle on F3, per JP's
call. The PicoCalc window ring still stops at 30 d; its F3 chip just
cycles the three spans that remain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rich honours NO_COLOR by stripping colour while keeping attributes, so
a shell that sets it (agent harnesses do) fails the suite spuriously.
The autouse fixture now deletes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the PicoCalc the query overlays the canvas last row - directly above
the F-key lane - so the ground never shifts when a find begins. You
(Ctrl+L) now clears the query as it comes home; its Shift half, You +,
also zooms in close, and Clear behind Frame drops the query outright.
Shifted arrows fine-pan on the handheld too: the Shift watcher covers a
console that strips the modifier off an arrow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 15-minute slices outran the PicoCalc; the chart now takes the
finest of 20/30/60-minute slices that fits (the mesh page re-slicing
one ten-minute base scan), and every section lays out for the single
left gutter. The window ring rides F1-F3 as direct chips - 24 h, 7 d,
30 d, the span on screen dimmed - and the handheld skips all time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marks now tick the left gutter alone and the right edge closes with a
bare border, handing its cells to the chart. Deep tallies compact to
k/M marks, and the gutter sizes to the widest mark actually printed -
a 1.5k peak reads 1k while a lower tick still lands three digits wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The F3 next-tab toggle gave way to one chip per view in strip order;
the tab on screen dims (its key would change nothing this paint), and
Tab/Shift+Tab still cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hash whose node nobody can name reads whole in node.unknown — path
lines, key lanes, the walk, the packet card, the arrivals — never in a
hue its bytes would derive, exactly as the path graph already drew it.
highlighted_hash gains a known flag; the two path widgets decide it from
their resolver, and the node page threads it per hop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F4/F5 keeps up on the right; an F1/F2 pair puts up on F1, so a select
list reads Sect ↑ then Sect ↓ — the hand anchors on the lane's edge and
rocks inward, whichever side the pair lives on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The map and the mesh walk echo the query you are typing in their footer
hint. The PicoCalc draws no footer hint -- the F-key lane took that row
-- so on the device both screens silently filtered themselves while the
reader had no way to see what they had typed.
The app already had an answer: the select list and the path composer
each draw the query as a warn-styled /query line above the rows it
narrows. That is now render.query_line, one definition, and the map and
the walk draw it too -- but only where the footer isn't, so the desktop
is untouched and no screen ever shows the query twice.
The row it costs comes from what the query narrows: the walk's link
list, the map's canvas. The map rebuilds its viewport a row shorter
through the ordinary resize path, so beginning a find loses a strip of
ground along the bottom rather than pushing the last row out of frame.
base_body_size was still mirroring only compose_base's bordered branch,
so a screen sizing itself against it left one row of a borderless frame
permanently blank -- which is the row the echo now wants back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home and End are on the PicoCalc's keyboard. Paging is not. So the
jump-to-either-end pair was never the unreachable thing that earned it
F1/F2 -- it only ever wanted a chip for consistency with the pager it
belongs to. Now it rides that pager: Shift+F5 is Top because F5 is
Page up, Shift+F4 is Bottom because F4 is Page down. One axis, one pair
of keys, the second-grade verb sitting behind the first.
That frees F1-F3 on every screen, and the lane is the only place the
device can advertise anything -- there is no hint line to read a chord
off. So the freed slots went to the affordances that were invisible
there:
select lists Sect down/up, the section jumps. Ctrl+PgUp on a
keyboard with no PgUp is the one binding in the app
that was physically unreachable. Plus Delete, lit on
exactly the rows the key acts on.
map Region (Home, renamed -- it frames the ground the mesh
covers, it does not "reset" anything), You (new: Ctrl+L,
recentre on our own node at the zoom you chose), and
Frame for the find matches.
node page the tab it would switch you to.
time machine the window span it would take you to -- w was
undiscoverable on the device.
The map is the one exception to the new rule: Region answers "where
should I be looking", not "how far down the body am I", so it keeps F1
beside the other two destinations rather than hiding behind a pager it
has nothing to do with.
Screens that never scroll (every dialog, the busy splash, the reorder
list) now say so with an empty lane instead of offering a pager that
does nothing. Screens whose Home/End mean something else say what:
Latest/Oldest in a chat, Newest/Oldest in the packet viewer, You on the
walk (where End is bound to nothing, so its slot stays blank).
The map's hint line finally fits 72: its basemap status was never a key
hint, so it moved to the title's status chain where the standards put
those -- which is what took the map off the gallery's pre-existing
overflow list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A public key is 64 hex digits; the Info tab's value lane is 63 cells at
the 72-column standard and 44 on the PicoCalc. So the key never fit, and
wrapping it spent a whole second line on the handful of digits that fell
off — a line the location preview then had to give up.
It holds one line now and scrolls with ←→, the same step the route
pathline one tab over uses. The marks that say "more this way" sit
beside the window rather than over its first cell, so every window the
lane can settle on starts on a byte boundary: the step is four whole
bytes, and a key read from a mid-byte cut spells it wrong. The shift
clamps against the width actually being drawn, to the first step that
brings the tail into the lane, so scrolling can never run off into empty
lane and a resize only pulls an out-of-range shift back in.
The lane is pinned chrome, not a cursor row — walking down to the action
rows leaves the reader's window where they put it; only leaving the tab
drops it. Both the keys and the footer atom naming them are gated on the
key actually overflowing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A contact's heard time came straight from the firmware's last_advert,
which the advertising node stamps with its own clock. That makes it
hearsay, and advert_time can only refuse a stamp from the future — a
node whose RTC runs days behind reports a plausible-looking time that
never catches up, so the lane sits at "4d ago" while we are receiving
from it right now. Our own history, stamped when we actually received
something, could only fill the gap when the device reported nothing at
all.
So merge instead of fill: take the later of the device's time and ours.
A device time still wins whenever the firmware caught an advert we never
recorded, and we only override it with proof of a later reception.
A direct message now counts too. In this app's lexicon "heard" means
received from, and a message is received from its sender — but a DM
never touches last_advert and is stored as a message, not an
observation, so a node we chat with daily could read stale and be swept
by the purge ladder's quiet rungs while talking to us. Read from the
messages table rather than recording an observation: a routed message's
SNR belongs to its last relay, which is why heard_nodes excludes packet
rows, and counting it would inflate a lane that means overheard traffic.
The merge runs on every contacts fetch now, so both halves are GROUP BY
queries that aggregate in SQLite and yield one row per node, instead of
heard_nodes streaming all history into Python to build stat objects.
Node detail takes the merged contact time, so it and the contact list
can no longer disagree about one node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Where two adjacent chips are opposite ends of one axis, the up/in/more end
now takes the right slot: the map reads Zoom - then Zoom +, the shared lane
Bottom/Top and Page down/Page up, and the chat's relabelled jump pair follows
with Latest/Oldest. The actions move with their labels, so one handedness
carries across every screen and F1 always means the near end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.
The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.
Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.
Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Street names got rarer the further you zoomed in, which is backwards.
A line label anchored to the midpoint of the feature as the *tile* drew
it -- a fixed geographic point, not a point in your view -- and the
tighter the view, the less likely that point was still on screen. Of the
265 named streets in the Montréal fixture, 37 anchors landed on a 53x26
canvas at zoom 14 and 6 at zoom 16, of which 2 survived collision.
The line is now clipped to the canvas first and the label goes on the
longest piece that survives, so the anchor is always somewhere you are
actually looking. Clipping rather than testing the endpoints matters:
at high zoom a street often crosses the whole view with both of its ends
outside it, and that is exactly the street you most want named. The
next-longest pieces are kept as alternates and tried in turn when the
first spot is already taken, so a street whose middle sits under a node
marker still gets named further along. A feature with nothing on screen
queues nothing at all.
That made a second bug visible: OpenStreetMap splits a long street into
several named features, and once each one anchored to its own visible
stretch, René-Lévesque was drawn twice on one screen. A name is now drawn
once per frame -- on a terminal's worth of columns the second copy only
ever takes space from a street that has none.
Zoom 16 goes 2 -> 6 street names at 53x26 and 2 -> 8 at 72x24; zoom 15
goes 10 -> 13. Waterway labels take the same path and improve with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map's decoded-tile dict was never evicted from, so it grew for the
life of the screen with every tile the view had ever touched. Measured on
the Lyra, a decoded tile is ~0.9 MB resident (62 bytes a point) against a
device with ~100 MB in total and, in practice, under 20 MB free -- so a
long enough pan was an out-of-memory kill waiting to happen. It predates
the caching work, but making tiles cheap to load makes it easier to reach.
The budget is relative to the view rather than a fixed number, so it
scales itself: twice what the current viewport needs, floored at 8. That
lands around 8-12 tiles on the PicoCalc's 53x26 and grows on a desktop
terminal, without either one needing to know about the other.
Two details worth keeping. Tiles on screen are moved to the end of the
order before the trim runs, so eviction starts at the far end and can
never take something being drawn. And only entries holding geometry are
counted or dropped: a None is the memory of having *asked* -- the tile was
absent, or the source never answered -- which costs a dict slot rather
than a megabyte, and dropping it would buy nothing back while costing a
re-request on the next repaint.
Getting this wrong is now cheap, which is what makes a tight budget the
right call: an evicted tile comes back from the decoded cache in ~25-50 ms
having already been parsed once, so the cache is trading a little memory
for instant back-panning, not for correctness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layer narrowing cut what a tile costs to parse; this stops us paying
it twice. Decoding is a pure function of the bytes and the layer set, but
the cache only held the bytes, so every session re-parsed the same tiles
from scratch -- and parsing is the single most expensive thing this app
does.
A decoded/ sidecar now sits beside the raw tiles holding the parsed
layers, via marshal: stdlib, C-speed, and it understands the plain
tuples and scalars a decoded tile reduces to. Deliberately not pickle --
this reads a file off disk, and marshal cannot be talked into importing a
module or calling a constructor.
The sidecar is derived data throughout, and the design leans on that. It
is never what makes a tile "known", so losing it costs only the decode it
was saving. Every way it can fail to be what this build expects -- bumped
wire version, a Python whose marshal differs, a truncated file -- reads
as a cache miss rather than an error. It carries a stamp of the layer set
it was decoded under, so widening DRAWN_LAYERS re-decodes instead of
serving a narrower blob. And because it runs ~2.4x the size of the bytes
it came from, it is the half of the cache that gets a budget: 64 MB, LRU
by mtime, swept once per 8 MB written rather than per tile, since the
walk is slow on the SD card the PicoCalc runs from.
Measured on the device over the 50 real cached tiles: a first session
costs 126 s and a later one 13 s, 9x, with all 50 tiles restoring
identically. Disk goes 7.8 MB -> 7.8 + 19.0 MB. Serialising adds ~13% to
the first decode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.
Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.
Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.
Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.
_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fetch failure — offline, DNS, timeout, reset, 5xx — collapsed into
None and then into b"", which the loader wrote to disk as "this tile is
legitimately empty". One 12-second Wi-Fi stall on the PicoCalc therefore
poisoned that tile for good: every later pan and every later session read
the zero-byte marker and drew a black square. A body cut short mid-link
did the same by another door, cached and undecodable ever after. The PC's
network simply never flaked long enough to trip it.
So the cache now holds only tiles that decode to real geometry. A GET
returns whether the source actually answered: a 2xx body or a 404 is an
answer, everything else is silence and is never written down, so the next
pan asks again. A body short of its Content-Length is truncation, not a
tile. A tile the source really calls empty is remembered in memory for
the session alone — no repeat requests while the map is open, no
permanent hole on disk.
Cached entries that no longer decode are pruned on read, so a cache
already poisoned this way repairs itself as you pan over it. Writes go
through a temp file and os.replace, since the map and a mini-map share
one source and can fetch the same tile from their own threads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JP drew a 53-cell MeshTerm at the original's height. It gets the same
colouring: the vertical ramp — white, yellow, light red, red, light
black — with every box-drawing character, shadow row and letter bevel
alike, in the light black.
The art moves to meshterm/assets/. It was sitting in meshterm/ui/ among
the modules, which is the wrong place for a file nothing imports; it
still ships inside the package, since the splash reads it at runtime and
an installed venv has no repo to read from. scripts/colour-logo.py paints
a .txt into its .ansi, so the palette is stated once in code instead of
retyped into escape sequences by hand, and re-styling stays a text edit.
Which mark is drawn is a question about the *screen*, not the platform: a
53-column console and a desktop terminal dragged narrow have the same
problem. load_logo(cols) answers it — widest that fits — and
compose_startup asks, since it is the only place that knows the width.
Below the smallest mark it returns nothing at all; no banner beats a torn
one. A test now draws the real wordmark at each platform's width, which
is the gate the splash never had (it is the one screen the gallery
doesn't cover, which is how a 71-cell mark shipped to a 53-cell console).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>