The quit confirm promised "Enter quit · Esc cancel", which held only while Quit
was the highlighted button — and on a bonded device there is an "Unpair & quit"
between Cancel and Quit, so Enter has three things it might do. The hint also
left out ←→, the one key that changes which. Both dialogs (the TX winner's
"Enter apply" is the same shape) drop back to the dialog default, which says the
mechanism instead of guessing the outcome: ←→ choose · Enter select · Esc cancel.
A row-picking list keeps its licence to name a committing verb, because every
row commits the same way. A lone-button acknowledgement keeps "Enter OK" too,
having nothing to choose between — that is the exception the new standards test
walks the package for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The footer line already carries the top screen's hint, so a floating dialog was
saying the same sentence twice on one frame: once in its own border, once at the
bottom of the terminal. On the desktop the border now says nothing and the
footer is the single place hints live; its clip arrows fall back to the base
frame's own "more" wording, so a silent border still says the one thing every
border here says.
The PicoCalc keeps the border hint, because it has no hint line to defer to —
the lane stands where the footer would be and speaks only for its five chips.
But what the lane does say it says already, one row below, so the border drops
every atom whose keys are all chips: the packet viewer's "PgUp/PgDn scroll" and
"Home/End ends" go, its arrows and Esc stay. An atom survives unless *every* key
it names is covered, so a half-covered pair never tells half a truth, and one
riding in the verb half (the map's Home/^U) keeps the atom whole.
Resolved per paint rather than folded into a screen's hint, since both halves
move: the viewer rewrites its hint as the list grows past one entry, and every
screen reads its lane fresh each frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skeleton card floated between screens carried a Knight-Rider scanning bar
under its caption: eighteen braille lamps, a red head and a persistence-of-vision
trail sweeping back and forth. It drew the eye harder than the words did, on a
card the reader is only ever passing through, so it and its colour ramp are gone
— the one-cell spinner beside the caption is the whole animation, and the card is
two lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app-wide Back row existed because it duplicated Esc — two of its call
sites said as much in a comment. On both platforms Esc is a labelled key
and every footer hint names it, so the row bought nothing for the blank
separator and line it took out of every screen; on the PicoCalc's 26 rows
that is a row in thirteen.
back_rows() is gone, and with it the four hand-drawn copies: ReorderScreen's
clean-state row, the live feed's pinned foot stop (the cursor now clamps on
the oldest packet), node detail's tail action, and the action-list rows on
the trace and TX-sweep screens.
Kept, because there leaving is a choice rather than an exit — the same rule
that keeps a dialog's Cancel button: exit_rows()' staged pair, where Apply
has no key of its own and ✗ Back names what the other way out costs;
ReorderScreen's dirty-state pair; the two Quit rows, which initiate the
app's terminal action behind a confirm; and the new-record dialog's Close,
which is its default, so dropping it would let a reflex Enter navigate.
With no Back row there is no Back value: the six spellings callers used to
test three at a time (_BACK, "__back__", _CANCEL, two tuple shapes, bare
None) collapse to `is CANCEL` / `is None`. The gallery now asserts the
absence of an exit row on every screen, on both platforms.
Resolves findings E and §10 of docs/survey-navigation.md. Finding D — Esc
peeling a filter on three screens and leaving on two — still stands, and is
now the only thing left telling a user what Esc does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every framed box was post-processed on the way to the screen: the top-left
corner blended toward white and the highlight decayed along the top and left
edges, so a box read as lit from that corner. It reads as a gradient laid over
the chrome rather than as the box being a box. The borders draw flat now.
The pass goes with it — ui/tui/glow.py, and the three composers that ran it
(base, startup, dialog), each of which also carried the platform gate that kept
it off the 16-slot console where it could only ever hand back the same lines.
Nothing else called it, and its memo went with the module. Text colour is
untouched: the pass only ever recoloured box-drawing glyphs, and titles and
subtitles were already skipped.
The four glow tests become one that pins the outcome instead of the mechanism —
every box glyph in a composed frame, nested panels and dialogs included, carries
the border's own colour and no other. Verified against the old pass: with it
back, that set holds sixty-five.
Platform.effects keeps its other two claims (the header blink, the braille
spinner); it just no longer mentions a pass that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block was laid out at its full height and whatever overran the screen was
cut off the bottom — which is the box: the device list, the PIN prompt, the
thing the user opened the app to use. The mark kept every row it had.
So a terminal too short for all of it now gives rows back, cheapest first: the
blank line under the mark, which is breathing room and nothing else, and then
the top of the mark itself, up to nine rows of it. Nine is the globe standing
over the lettering — decoration, where the lettering is what says whose splash
this is. Cropping from the top means the lettering and its copyright hold their
place while the mark thins above them, rather than the whole block sliding.
The bound is the art's own height too, so a mark drawn without a globe is never
sheared into its letters, and past the bound the old clip still applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the PicoCalc a repaint costs about 154ms, and these two loops waited 60ms
(the overlay) and 120ms (the splash) between them — rates hardcoded past
spinner_interval(), which every other animated wait in the app already sleeps
on. The animation asked for repaints faster than the console could finish them,
so it took the event loop from the device read it was covering for: the wait got
slower for being animated, and the spinner stuttered anyway. Both defer to the
platform now (0.5s there, 0.12s on the desktop).
Two calls were also blocking the loop outright, which stops the animation dead
however it is paced. Enumerating serial ports is a SetupAPI/sysfs walk — behind
the "Scanning for companion devices…" splash, and again on every poll of the
reconnect dialog — so both go to a thread. The UI itself cannot: prompt_toolkit
owns the loop for render and keys. Moving the blocking work off it is the same
fix from the other side.
Two tests: neither busy screen may carry a spin rate of its own, and the
spinner advances *during* an awaited read rather than only side of it.
Also drops the "· experimental" tag from the network-device row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mark's art signs itself now, so no splash adds a line under it — the
picker, the smoke-test spinner, the PIN prompt, the failure notices, the
scan and the settings offer all drop the footnote they were passing, and
nothing draws the notice twice. The written About pages keep their {copyright}
colophon: that is a page of prose, not a screen with the mark on it.
Moebius spells an untouched cell as a NUL, which a DOS console draws blank and
Rich measures as nothing at all — so every one swallowed a column and slid the
rest of its row leftwards, eighteen of them on the worst line. They are spaces
in the file now, and the loader translates any that come back (with codepage
437's hard space) so re-exporting the art can't reintroduce the drift.
Those NULs were also the only reason git guessed the file was binary. With them
gone, .gitattributes has to say so outright, before core.autocrlf starts
rewriting CRLFs that are part of the canvas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MeshCore.create_ble builds its client, calls connect() on it, and hands
it back only on success. A connect that *raises* therefore left that
client — and the bleak link it had already opened — orphaned inside the
library with no reference we could close. Our _mc was never assigned, so
disconnect() was a no-op and the probe's _safe_disconnect closed nothing.
That is not a corner: a PIN-protected companion answers the unbonded
notify-subscribe with a GATT authentication error, and the picker's first
attempt is always PIN-less. So selecting an unbonded companion leaked the
link, Windows held it for the life of the process, and the peripheral —
still believing it had a peer — stopped advertising. The PIN dialog then
asked for a code it could no longer deliver: the retry paired at the OS
level (the bond really is created) but could not form a GATT session, and
the user was told their correct PIN belonged to something that "isn't a
MeshCore companion". Since Unpair & quit is what leaves you unbonded, the
whole thing read as unpairing having changed the PIN.
Assemble the two objects here instead. _connect_owned_ble keeps the
handle, so both failure shapes close it: a raise (closed, then re-raised)
and a None handshake (closed, then reported as "not a companion"). The
teardown is shielded — the other way in is a probe whose wait_for expired
and cancelled the handshake, where a plain await would be cancelled the
moment it suspended and abandon the very close it was called to perform.
The picker's accepted PIN now also rides on the context, so a mid-session
reconnect doesn't re-run the whole "needs a PIN" dance against a companion
we already authenticated to. Session-lifetime only; nothing reaches disk.
Tests stop naming a real companion: the addresses and pairing code are
stand-ins now, and the fakes model the class we construct rather than the
create_ble we no longer call.
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>
←→ 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>
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>
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>
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>
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>
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>
A soldered SoC UART like the Luckfox Lyra's /dev/ttyS1 is never enumerated by
pyserial's comports(), so a serial [profiles.*] entry pointing at one could only
ever be reached via `meshterm --port` on the command line -- it never surfaced in
the startup picker, which listed only scanned devices (and TCP profiles). Fold
configured serial profiles into the picker too, mirroring the existing TCP-profile
folding, de-duplicated by port so a port pyserial *does* enumerate keeps its richer
scanned row.
Also fix the liveness poll for the same ports: serial_port_present() now treats an
existing /dev character device as present (platform UARTs aren't in comports() but
their node persists exactly as long as the port does; a USB node vanishes on unplug,
so this never masks a real disconnect). Without it the watcher would falsely report
a soldered radio unplugged mid-session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Choice.title accepts a width-taking callable (arity read once from the
signature; the zero-argument live form is untouched), resolved at the
row's content area each repaint while filtering, h-scroll measurement,
and dialog_width keep reading the natural unbounded form. The two
route-bearing lists use it: trophy rows and Explore-paths candidates
middle-elide through PathLine.ellipsized so both endpoints survive a
narrow terminal — a right truncation amputated the return leg — while
the highlighted row of an hscroll list keeps its natural length so ←→
still slide the full line (housekeeping H-007).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A full-frame rewrite belongs to incoming data or to something the user did — a
scroll, a keystroke, a resize. It has no business being on a metronome. The 1 Hz
refresh exists for the header alone (the activity pulse, the battery sweep, the
low-battery blink), and on an emoji-bearing screen it was dropping pt's cached
frame every tick, which erases the terminal and redraws all of it to move one
braille cell in the corner.
The requirement a mis-measured glyph imposes is narrower than the answer was.
prompt_toolkit's cursor is *relative*, and the drift is confined to a row: a
width-2 glyph the terminal draws in one cell leaves everything right of it on that
row a column left of pt's model, so a later paint that jumps into the row writes
one past the content it meant to overwrite. But stepping down a row is a carriage
return — pt writes "\r\n" and then counts forward from a true column 0 — so the
misalignment can never reach the row below. Rewriting a row whole, from column 0,
is the entire fix; erasing the screen was never part of it.
So the frame's changed rows are rewritten, and nothing else is: the background
composes one line per terminal row, so its own diff *is* the row list. A ticking
header repaints row 0 and leaves the board under it alone. An idle frame changes
no rows at all.
Two cases still fall back to the whole frame, both of them user-driven and
occasional rather than timed: a float, whose centred box the layout places (so its
lines map to no rows we know) — a dialog opening, changing, or closing — and a
frame whose height moved, where no row mapping holds at all.
Verified against pt's own renderer, and on a composed Trophy case: 24 lines for 24
rows, and a pulse tick reports exactly row 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Trophy case flickered about once a second, and so did every other
emoji-bearing screen — the more the frame held, the more obvious it was.
The app repaints on a 1 Hz timer to keep the header's pulse ticking. Every one of
those repaints re-composed the same frame, and _emit read the same verdict off it:
this frame carries a glyph the terminal may draw narrower than prompt_toolkit
reserves for it, so drop pt's cached frame and let the next paint be a full
erase_down + redraw. That upgrade is right — it is what keeps a row with an emoji
in it from stranding stale cells — but it was being applied to a frame that had
not changed at all. Erasing the whole terminal and rewriting it identically, once
a second, is precisely a flicker.
So a layer that composes byte-identically to its last frame is exempt. Nothing
needs rewriting when nothing changed: pt writes no cells, so its cursor cannot
have drifted, and the previous paint already left the terminal aligned.
What decides is the frame, not the layer that changed: a plain dialog moving over
a base row that carries an emoji is rewritten from a model of that row the
terminal disagrees with, so any change upgrades the paint while a wide glyph is
drawn anywhere. And a layer *leaving* is a change too — a float is hidden by
dropping its window, so a closing dialog simply stops rendering, and the cells it
hands back to the base would otherwise be rewritten piecemeal. The background
composes first, so the paint reconciles the drawn layers there.
An animating header (a charging battery, a low-battery blink, a busy pulse) does
change the frame every second, and still repaints in full. If that reads as
flicker too, the next step is to make the full repaint rewrite in place rather
than erase first — pt only erases when the cached frame is missing entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things a grouped list has to keep in view while it scrolls, and neither one
was staying: the lane names that read every row, and the heading that says which
group a row belongs to.
The lanes come first. A column header means the same thing in every section, so
it is not a section landmark at all — it pins for the *whole* list, above any
heading, once it scrolls off (`Separator(pinned=True)`). It is drawn outside the
body slice, so it stays exactly one row: too wide for the terminal it ellipsizes
rather than wrapping and stealing a second reserved row from the content. And it
can now size itself — a Separator title may be a callable taking the render
width — so `column_header`/`Lane` lay each label over its own lane and hand back
the shorter form (DESCRIPTION → DESC) only for the lanes that actually overrun,
from the right, where the trailing lane is the only one with a cell to give.
`lane_header` is that header for `lane_row`'s lanes, shared by the two editors so
they head identical lanes identically.
The headings were the other half, and they were pinning the wrong row. Every
non-pinned separator was a candidate, and the rule takes the last one at or above
the scroll — so what pinned was whatever muted line sat lowest under the heading.
In the Trophy case that meant a discipline's *description* stuck to the top and
the heading itself never did; at the foot of any list, the blank spacer above
Back pinned a blank row. So heading-ness is explicit now (`Separator.heading`,
conferred by `section_heading`, which is why every hand-rolled `── … ──` in the
app — the main menu's categories, the chat picker's Channels/Direct, Trace path's
candidate and ranked lists — now goes through it, and why three lanes headers
that genuinely *are* their block's only landmark say so).
A landmark is also no longer one line but a block: a heading plus the separators
written immediately under it, before the section's first row. Prose written there
is the section's preamble — the Trophy case's "what this discipline scores" — and
it belongs overhead with the heading rather than scrolling away from the rows it
explains. The block hands its rows over one at a time as they leave, so the
heading pins while its description is still the top content row and the two pin
together once both are gone: what is overhead always continues seamlessly into
the body. It never takes more than half the viewport, giving rows up from the
end, so the row that says *which* section this is is the last one dropped.
The frame settles the scroll against however many rows come back rather than
assuming one, and re-asks after sliding the window at the bottom — a heading
among the dropped lines would otherwise vanish instead of pinning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The right-Ctrl rescue existed because some layouts claim that key and deliver
the chord as a bare letter, but it only covered what the *screens* answer: the
letter table listed ^R and ^P, while ^C and ^V were bound straight to their own
handlers above the dispatcher. So on the affected layouts right Ctrl-V typed a
`v` into the compose line and right Ctrl-C did nothing — the two chords a user
is most likely to reach for with the right hand.
Both now funnel through _dispatch like every other key, and the letter table is
the single source of truth for both halves of a chord's life: the Keys.Control*
bindings are generated from it, and the rescue reads it. Adding a chord is one
line, and a chord can no longer be bound without its rescue — or rescued into an
action nothing binds. `quit` and `paste_clipboard` are answered in the
dispatcher and never forwarded, so no screen sees them.
m/i/h stay out of the table on purpose: prompt_toolkit spells Enter, Tab and
Backspace as ControlM/ControlI/ControlH, so claiming them would rebind those.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hop with no label now draws as its seam alone — the arrow, no chip, no
word. The trace screens' route lane uses it (bare_self) for both ends of
every walk: a trace leaves us and comes back to us, so naming ourselves
twice only pushed the hops that *are* news out of the lane. Arrow mode
drops the padding the vacated label sat in ("→ 3d → f2 →"); chip mode
lets the seam point into the page.
Wrapped chip lines now open on the female point (U+E0B2, core set) from
the second line down, so a continuation reads as one — only the very
first chip of a path keeps its square left edge. The packer pays for that
cell, so lines still fit their column.
Also carries the round already sitting in the tree: the wrap evens hops
across the fewest lines it can take and folds at the route's turn, the
trace/node-detail/select surfaces route their rows through the widget.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A [profiles.*] TCP entry in config.toml was reachable only via
`meshterm -p <alias>` — the interactive picker built its list from the
live scan plus TCP devices remembered in the DeviceStore, never from the
user's own config. So a hand-authored network companion stayed invisible
until after it had been connected to once.
Fold configured TCP profiles into the picker the same way remembered ones
are: _profile_tcp_devices() rebuilds each into a DiscoveredDevice carrying
its alias as the DEVICE-column name, deduped by stable_id so an endpoint
that's also scanned or remembered keeps its richer row. An unconfirmed
profile shows the muted "network companion" tag and turns "MeshCore
device" once its smoke test passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every type-to-filter surface — the shared select list, the map's node
find, the atlas find, and the path composer's hop entry — now refuses a
leading space (the query never begins with whitespace) and strips the
query when matching (so a trailing space is ignored). Spaces still count
mid-query, so a multi-word name like "Homestead R&D" filters fine; only
" Homestead" and "Homestead " are prevented from mattering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pasted text never reached a screen: the key dispatch forwarded only single
printable characters, so a bracketed paste (what a terminal sends for
Ctrl-V) was dropped whole. Route a multi-character run to a new "paste"
action instead, and add a Ctrl-V binding that reads the OS clipboard for
terminals that deliver the key literally rather than as a bracketed paste.
In chat, a paste is confirmed before it lands: a chat message goes out over
the air, so an amber Cancel/Paste dialog ("Paste N characters into your
message?") guards it — the disruptive (danger) tier, not data loss. On
confirm the run drops into the compose line, newlines folded to spaces
(a message is one line) and the byte gauge flagging an over-budget result
exactly as typing past the limit already does. Plain text prompts gain
silent multi-character paste through the shared line editor; only chat adds
the confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opt-in hscroll shifted every row (and separator) sideways together, so
reading one long line dragged the whole screen with it and ←/→ advertised
themselves even when nothing overflowed. Make the shift per-row: only the
highlighted row slides, and only as far as its own tail, under a pinned
pointer; every other row and each column header stays put. A row that fits
the width can't shift at all, and moving the highlight to another row (or
editing the filter) drops the scroll back to the start — each row scrolls
on its own.
Surface the ←→ atom from the list itself, inserted right after the move
atom only while the highlighted row actually overflows, so the key shows
up exactly when it would do something. Watchtower's alert log — the one
hscroll user — drops the hardcoded "←→ scroll" from its footer and lets
the list add it on demand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The physical-key probe promoted only the eight navigation chords, so a layout-claimed right Ctrl (Canadian Multilingual Standard, which strips the ctrl flag) still could not reach the letter shortcuts: ^R (chat retry, atlas rebuild) and ^P (chat paths). A bare r/p typed while the right Ctrl is physically held now promotes to retry/paths via _CTRL_LETTER_CHORDS. It is safe because a genuine third-level character arrives as a different glyph and is never in the map; a layout that swallows the key makes this a harmless no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Watchlist rows now lead with the shared node-type marker in its own
colour (the type is stored at star time; legacy entries resolve
through the contact table) with the name in its key-derived hue —
silence keeps its ⚠ tail instead of tinting the glyph. Unacked alert
labels resolve back to a key for their hue (contacts first, then the
watchlist's own names, so a starred node colours even offline); acked
rows recede to muted whole. The star-a-node picker draws the same
glyph-and-hue rows. Long rows can now be read to the end: SelectScreen
grows an opt-in hscroll flag — ←/→ slide every row under the pinned
pointer via crop_cells, clamped at the widest row, surviving ↑↓ and
resetting on filter edits — enabled only here, with the footer
advertising it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layouts that claim right Ctrl as a character-group modifier (the
Canadian Multilingual Standard) hand the console a right-Ctrl'd arrow
with no ctrl flag, so only the left Ctrl ever steered a sortable list.
A GetAsyncKeyState probe at dispatch promotes bare navigation actions
to their Ctrl chords while the physical right Ctrl is down - a no-op
whenever the console already reported the chord.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The composer's box hugged its content, so every added hop, new suggestion
set, or filter keystroke resized it. It now opts into grow_only like the
packet viewer, and the frame ratchets a grow-only dialog's natural width
alongside its height, so the box holds its largest extent for the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The erase_down workaround stopped the lingering but left the real fault in
place, which showed as the leftover artifact: a bordered row with an emoji
rendered one cell short, so its right border sat a column in with a blank
beside it.
Root cause is a three-party disagreement about one glyph. Rich lays out every
row (and places the right border), prompt_toolkit diffs against the last frame
stepping a *relative* cursor by its own width model, and the terminal draws the
result. Rich and pt both measure an emoji as two cells (wcwidth); the terminal
draws it in one. That one-cell gap is both symptoms: pt's cursor drifts a column
past the terminal and strands stale cells (the smear), and Rich sizes the row
one short so the border lands early (the ragged edge). "Drawing the border last"
can't reach it — pt diffs per row across all columns, and the terminal has
already eaten the cell no matter the compositing order.
Fix it at the source: teach both width authorities the terminal's truth. New
glyphwidth.align_emoji_cell_width() patches pt's _CHAR_SIZES_CACHE and Rich's
get_character_cell_size so an emoji measures one cell — while CJK/fullwidth
glyphs (which terminals do draw two wide) stay two, and the width-1 marks and
braille are untouched. With all three parties agreeing, ordinary differential
painting stays synced (no lingering) and Rich pads rows to a flush border (no
notch) — so the erase_down/_has_wide_glyph machinery is gone entirely. Applied
at TuiSession construction, before anything renders; gated by MESHTERM_NARROW_EMOJI
(default on), the sibling of the MESHTERM_FULL_WIDTH column-reclaim shim.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lingering characters are an emoji-width desync, and the earlier fix aimed
at the wrong glyph and the wrong screens.
prompt_toolkit paints differentially with a *relative* cursor, advancing by its
own width model. That is sound only while every glyph is one cell. An emoji is
width-2 in both Rich and pt, but the terminal draws it in a single cell, so from
the emoji onward the row's cursor model is one column off. On the next paint, if
the emoji itself didn't change it is skipped, and any cell pt repositions to on
its right lands a column early — stranding the stale cell it meant to overwrite.
That is the lingering, and it needs an emoji on the line (confirmed: 👋).
The node-type marks (▲●■◉○★) and chart braille the previous attempt chased are
width-1 in Rich, pt, and the terminal — they never desync. That is why scoping
the repaint to floating route-graph dialogs did nothing: the real trigger is
emoji, which live in chat content and menu icons on ordinary full-frame screens.
Fix the invariant instead of the screen list: differential paint is only sound
when every glyph is one cell, so when a composed frame carries a width-2 glyph,
drop pt's cached frame (_emit → _invalidate_last_frame) and let the next paint be
a full erase_down + redraw — every cell rewritten contiguously, the terminal's
own cursor advance keeping the row aligned, nothing stale left behind. Checked at
compose time, so it covers a scroll, resize, or timer tick, on any screen. Pure
width-1 frames keep the fast differential paint. Drops the _SCROLL_ACTIONS /
_has_float dispatch hook the float-only theory needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real scroll-smear cause, and why the previous fix could not work.
A floating dialog draws a small box over a static background: the frame
around it and the box's own border are identical frame-to-frame, so
prompt_toolkit's differential renderer never rewrites them. Its paint is
relative-positioned (cursor_forward/backward from pt's own assumed
column, advanced by pt's own width model). A glyph the terminal renders
wider than pt models — a route graph's braille lanes, node-type marks, a
chart's braille — desyncs the terminal cursor and pushes a stray cell
onto that static frame, where it lingers because pt believes the cell is
unchanged. Full-frame screens masked this: their body redraws wholesale
on scroll, healing the smear itself (the map scrubs only its static edge).
The prior attempt (822db52) filled pt's cached frame with a sentinel to
force an in-place rewrite. But that keeps previous_screen non-null, so pt
never takes the erase_down + cursor-reset-to-origin branch — it repaints
the same desynced garbage. Only dropping the cached frame triggers the
full erase_down that blanks the terminal and re-syncs the cursor. It also
only covered three route-graph screens, missing scrolled result windows.
Fix, structural rather than per-screen: when a scroll/movement action
reaches a screen that is floating over a backdrop, drop pt's remembered
frame so the next paint clears the whole terminal and redraws clean. No
smears flag, no geometry — any floating dialog, any wide glyph. This is
the "clear the screen before redraw" the symptom calls for; the cost is
one full repaint per dialog scroll, negligible for a modal box.
Removes the smears flag and _reflush_frame; the map's own edge scrub is
untouched. Full suite: 807 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dialog that draws a route graph (message paths, the trophy case, the packet
viewer) paints braille and node-type marks. On a terminal whose font lacks a
glyph, the double-width fallback shoves that row right and smears a static cell
— a box border — that prompt_toolkit's differential renderer never rewrites (it
only redraws each row up to its new content and only erases a shortened tail),
so the fragment lingers once a scroll slides the offending row away.
The map fixes its own case by scrubbing two right-edge columns after a pan, but
that only works for a full-frame panel whose border sits at the terminal edge;
a route-graph dialog is a centred box (or, on an empty stack, a full-frame base)
whose smear can land anywhere, so edge geometry can't target it reliably.
Instead: a screen opts in with `smears = True`, and on a scroll key the session
reflushes — fills pt's remembered frame with a sentinel so the next diff rewrites
every cell *in place* and erase-to-end-of-lines each tail, scrubbing the leftover
wherever it sits. Because the cached frame is kept (not dropped), pt overwrites
rather than erasing first, so there's no blank-flash flicker. Plain dialogs keep
the efficient differential paint untouched; the map/atlas keep their own edge
scrub. Functionality is unchanged — only the smear is cleaned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lift the chat compose bar's byte budget into a reusable gauge and reuse it
wherever a field feeds a size-capped LoRa packet.
- prompt.py gains byte_counter/byte_style and the DM/CHANNEL byte limits, plus
a byte_limit option on TextScreen: a field so limited rides the shared
used/limit gauge (right-pinned, green→yellow→orange→red) and blocks Enter
once the text tops the cap, with a "Too long by n bytes" note. Ui.text and
session.text thread byte_limit through.
- chat.py drops its private copies and calls the shared byte_counter; the
limits now come from prompt.py.
- The courier's "Message for …" entry passes byte_limit=DM_BYTE_LIMIT so a
queued message is gauged and capped the same way a live chat message is.
Also fixes a courier scheduling bug uncovered here: the "When it's next heard"
row carried the value None, indistinguishable from session.select's own cancel
sentinel — backing out of the picker silently queued the message. It now
carries its own WHEN_HEARD sentinel, so Esc cancels and the row means no
schedule.
Full suite green (806).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enforce the UX standard "modal popups over the pushed backdrop" across three
flows that were drawing a full-frame replacement instead of a floating popup:
- Trophy case: keep the record browser pushed as a static backdrop while its
delete-record / delete-discipline / delete-all confirms float over it, and
run any trace hand-off after popping.
- Device picker: pass the picker's rows through to the removal confirm so it
floats over a redraw of the list (backdrop_items/backdrop_default on
confirm_startup), with the row being removed left highlighted.
- Remote-admin password: add an opt-in floating flag to Ui.text so a mid-flow
password prompt draws as a centered popup over a blank base instead of the
full frame; apply it to repeater_admin and tx_optimize logins. Tool-entry
text prompts (Trace/tx target fallbacks) stay full-frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three independent consistency passes:
1. **Message paths: unknown relays as grey full-hash**
The message-paths dialog showed unnamed relay hops as a single lit byte (e.g.
`e8`). Now they render at the device's path-hash-mode width, muted grey, and
annotated with their addressed byte — `e839f2 (e8)` — so the rows stay
readable and cross-reference the route graph's byte labels (matching named
hops' trace presentation). Added `hash_as_name` flag to path_text widget.
2. **Dialogs: multi-layer compositor + backdrop for empty stack**
Opening a confirm over a floating popup used to stretch that popup to
full-frame (the single-float compositor's bug). Now the compositor maintains
a pool of _MAX_DIALOG_LAYERS=8 centered-box floats, each rendering one layer
above the background — so a popup and its confirm both stay their own size.
Also: button_dialog/typed_confirm/autocomplete now float over a blank base
when the stack is empty (via _run_dialog_screen), guaranteeing popups render
as small boxes on an empty stack, not full-frame chrome. Simplified
message_dialog to delegate its backdrop logic to button_dialog.
3. **Data-loss dialogs: red `destructive` tier**
Added a second caution tier to the dialog API: danger=True stays amber
(disruptive: discard edits, reboot), destructive=True is red (irreversible
data loss). Updated: single-record delete, channel "clear slot", courier
"clear finished" (new confirm), and startup "remove device" (splash confirm).
Per UX spec: bulk/irreversible deletions gate behind typed_confirm (also red);
single-record delete is a red Cancel/[verb] button dialog.
All changes are backward-compatible. Full suite: 793 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add TCP alongside serial and BLE as a first-class companion transport, end to
end: discovery/connection/selection learn a tcp: stable_id and host:port target,
--tcp and TCP config profiles reach a WiFi board or network proxy, and a network
companion -- which isn't discoverable -- is rebuilt into the picker from its
remembered endpoint.
Device picker gains the network-device affordances:
- "Add a network device" prompts for host:port, smoke-tests it, and remembers it.
The row is marked "- experimental" in the cautionary hue.
- Delete on a network row opens a chromeless Cancel/Remove confirm (Cancel left,
Remove right and default: Enter removes, Esc cancels), then DeviceStore.forget
prunes it and re-draws the list. Serial/BLE rows stay inert.
- The "Del remove" footer atom now follows the highlight, surfacing on the box's
bottom border only while the cursor sits on a deletable row; the box is sized
to the fullest footer so it never widens mid-navigation.
Folds in the accompanying courier, channels, and config refinements built along
the way. Screens stay readable at 72 cols; full suite green (745 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate the copyright into a single meshterm.copyright_notice() beside
__version__ (was defined twice, disagreeing on wording, holder, and year
span). Every startup splash draws from it, so it can never drift.
Place it immediately under the wordmark, right-justified to the logo edge, so
the two read as one signed block. Simplify the display logic: it now rides
every splash that shows the logo (picker, spinner, PIN, notices) rather than
being dropped after the first selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the between-screens ring spinner with a titled skeleton card: a
heading, the one-cell working chip beside a "reading from <device>…"
caption, and a two-row braille LED bar in place of the old ring.
The bar is a Knight-Rider scanner — a red head sweeps left↔right and
bounces off both ends over a dark-gray track, dragging a persistence-of-
vision comet trail (per-tick decay) with a white-hot core. It rides the
same tick that spins the chip and fades in from black with the card, so
the wait reads as work rather than a frozen bar.
Thread an optional title/caption through busy_overlay so each gap names
the screen being fetched and the companion it's reading from. Give the
CLI progress dialog the same liveness: a spinning chip on every row and a
slim braille meter, with no dead track on indeterminate tasks. Drop the
now-unused RingSpinner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Carried over from the last punch-list pass: the packet viewer opts into
a grow-only floating dialog (the box ratchets to its tallest body and
blank-pads shorter ones, so paging never re-centres it); raw payload
field names are shown whole by widening the label lane instead of
clipping at eight chars; the Time Machine node rhythm matches the mesh
page — 96 fifteen-minute slices sharing the Volume gutter — and its
time axis shortens marks to bare dates on multi-day windows; Courier
and Watchtower gain a visible Back exit beside Esc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enter is now the sole grab/drop key (Space is ignored), the rows drop
the openness/hash lanes since reordering is about position, and the
screen gains a dialog_width sized for its fullest state so the popup
fits its content without resizing mid-interaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow the device-config editor's exit-group pattern: once the order
has actually changed, an ok-tinted Apply joins an err-tinted
"Back — discard changes"; untouched, a lone Back. Enter on a list row
now grabs/drops like Space (matching Enter-activates-the-row), and
committing happens on the Apply row instead of Enter-anywhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Esc used to be the only way out of the channel-reorder screen and it
always committed, so there was no way to back out of an accidental
shuffle. The screen now follows the other dialogs' Enter-accepts/
Esc-cancels contract: Space grabs and drops the highlighted row
(arriving as printable text from the session, with the normalized
"space" action accepted for symmetry), Enter resolves the final order
— dropping a still-carried row where it sits — and Esc resolves CANCEL,
which the session already maps back to the identity order so the
caller treats it as "no change". Footer hints re-phrase per state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new glow pass post-processes composed ANSI lines: it finds each
box's top-left corner glyph, lifts it toward white, and fades the
highlight quadratically along the top and left edges into the
border's own colour. It reads colours from the SGR stream itself, so
accent, muted, and warn frames each glow in their own hue and nested
tool panels are lit individually with no per-callsite wiring.
String surgery instead of a Rich parse/re-render round-trip keeps the
pass ~2ms per repaint. Replaces render_to_ansi_dialog, whose only
caller now composes through the glow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A one-line result like "✓ flood advertisement sent" used to open the
full scrollable result window — a huge frame for a single sentence.
TuiUi.present() now collapses small, text-only output (at most 3 lines,
none wider than 76 cells) into a centered popup with an OK button,
floating over whatever screen is beneath; tables, long errors, and
multi-line output keep the window. Fixed centrally in present(), the
upgrade covers every such site at once: adverts, clock sync, backups,
key import, "applied N changes", tool failures, and future ones.
ButtonDialog learns to render a pre-styled (multi-line) Text prompt so
note markup survives into the popup, and the border echoes the message's
strongest tone (err > warn > accent). When the popup would sit on an
empty stack (a tool run from the popped main menu), a blank base frame
is pushed beneath it first — the same trick as the reconnect dialog —
so it draws as a floating box, not as the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Dialog footer hints take the border's hue one shade darker (hint.*
theme styles), mirroring the brighter title tinting.
- Dialogs get a blank breathing row above and below the body whenever
it doesn't cost visible content.
- The chat conversation picker gains column headers over its lanes.
- The map screen titles itself "Map", matching its menu item.
- The config editor lays settings out in aligned SETTING / VALUE /
DESCRIPTION columns under a header line, and with changes staged the
Apply and Back rows sit together below the blank separator.
- Device actions move out of the config editor into their own screen
behind a new menu-only device-actions tool (CLI equivalents stay
under the config group).
- Type-to-filter keeps section headers (and column headers) in place,
and section jumps now work over the filtered rows too.
- The main menu aligns tool names and muted descriptions into two
columns, headerless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enforce one voice across every screen: column and section headers are
UPPERCASE, menu items are Capitalized buttons, and short descriptions
drop their trailing period (with real ellipses instead of "...").
Tools gain a display `title` separate from their kebab-case CLI name,
so a menu item now reads exactly like the screen it opens ("Device
configuration" opens Device configuration) and result windows inherit
the same name. Every sub-screen backs out via a "Back" row below a
blank separator (the config editor was missing both; its Close is now
Back, spelling out the discard when changes are staged), while the main
menu and device picker keep "Quit". Panel and dialog titles now wear a
brighter tint of their border colour via new title.* theme styles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>