35 Commits
Author SHA1 Message Date
Jean-Pierre Martineau 8875a45070 MeshTerm 0.3.1
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau d41111bd5f The missing link refs are cosmetic, and the check now says so 2026-09-09 02:32:47 -04:00
Jean-Pierre Martineau c2fb49a001 Skill: cutting a release, and the two workflows it must never confuse 2026-09-09 02:23:45 -04:00
Jean-Pierre MartineauandClaude Opus 5 398a184eed The splash says bye, and keeps the reader's place when a row goes
Three passes over what landed yesterday.

The hidden-count line stops standing in the list. With devices still
listed the footer already names ⇧H on every row, so the line was a row
of the box spent saying it twice; it stays only for a list hiding has
emptied, where there is no footer atom to read it off and "no
companion devices detected" would be a lie about a machine with a
radio plugged into it.

Hiding now leaves the highlight where the row was - the next device
down, or the one above at the end, never rolling round to the top - so
a run of adapters clears with a run of presses instead of the cursor
jumping back to the remembered default after each one.

And Esc says bye here rather than quit: the splash is the door, and
the reader leaving it has not started anything to quit out of.

One consequence, on the console: the splash border is 47 cells and the
sentence now sheds the hide key too when it must. Between the two
shortcuts the survivor is the way back - ⇧H only appears once
something is hidden, which is exactly when a reader needs to be told
how to undo it, and by then they have found h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 4109c73522 A full pack should not rescale the pulse it sits beside
The charger cuts out at full, the pack settles back to 99, the charger
restarts - and the companion reports that flip every poll for as long
as the thing is plugged in. The gauge is pinned to the header's right
edge and the pulse takes whatever is left, so the fourth cell 100%
needed came off the sparkline and redrew the whole activity history at
a new scale, twice a minute, on a device sitting still.

A full pack reads `100` now, with no sign: three cells, exactly as the
`99%` it keeps flipping back to. It is the one reading whose `%` can
be inferred - a bare 100 beside a full block is a percentage without
being told - and the sign goes on doing its work at every charge that
could be mistaken for something else.

Only that pair is answered, deliberately. The other width change in
the range, 9 to 10, is a boundary a discharging pack crosses once and
never comes back over: one repaint, no jitter, nothing to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:45:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 3d3506be4f A route that has finished does not end on the mark for "there is more"
The chevron is the ribbon's continuation cue: it is what a wrapped line
closes with to say the path runs on past the edge, and it is the same
shape as every seam between two hops. So the last chip of a finished
route wearing one read as a route cut off mid-walk. Where the font has
the rounded caps nothing changed - they close the lozenge and always
did. Where it hasn't, the widget already squared the *opening* off and
documented that it squares both; only the close never followed. It now
appends nothing at all and the last chip's own pad is the edge.

The cell that frees up goes back to the route. `_measure` used to
report one closing figure for every line, which was true while the
close was always a point; it reports two now - what a line the path
runs on past closes with, and what the line that *ends* it closes with
- so a wrapped path measures its own last line honestly instead of
holding a cell back for a glyph that is no longer drawn. The one caller
that flows a leg of a route rather than a whole one says so, because
its final line is a continuation and still pays the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 b2bf305a31 Hide the devices on the splash that are never the one you want
A machine with a debug probe, a programmer and a USB adapter soldered
into it shows all three on every start, in front of the one radio the
user actually came for. h drops the highlighted device from the list
and remembers it in the device registry; ⇧H brings every hidden one
back. Both keys are named in the footer only where they would act -
h on a device row, ⇧H only while something is hidden - which is the
rule Del remove already followed.

Hiding is a listing choice and nothing else: a hidden device is still
remembered, still resolvable by --port, and shows itself again the
moment it is connected to. A row that is simply gone would let the
list misrepresent the machine, so a muted line says how many are
hidden and which key returns them - and that line is also where ⇧H
survives on a console whose splash border is 47 cells.

The mechanics are shared rather than local: a non-filterable list can
declare bare-key shortcuts (a filtering one cannot - every letter
there belongs to the query) and name them per highlighted row, and a
chromeless splash whose hint outgrows its box now drops atoms in front
of Esc rather than being cut off at the end, where Esc is.

Two things the narrow console showed while this was being fitted: the
splash header was a hand-built string that wrapped at 53 columns, and
the DEVICE lane sized itself to the longest name - which for a USB
adapter is its forty-character product string, pushing HARDWARE off
the edge entirely. The header goes through column_header, and the name
lane now yields to keep HARDWARE on screen. What is left of a long
model string scrolls: ←→ read the highlighted row's tail to its end,
with the lanes in front of it pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:26:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 3991c723ab Two of the seven disciplines wear a one-cell mark
`🛣` and `🕸` sit outside Emoji_Presentation, so Rich and wcwidth both
measure them at one cell where the other five marks measure two — and
they are right, which is why the width machinery must keep leaving them
alone. But the Trophy case wrote `f"{icon} {title}"`, so those two
headings started their titles a column left of their siblings, and on a
terminal that paints the road wider than it advances the single space
after it was overpainted and the gap looked closed.

The marks now pad out to the widest of the seven, so every heading — and
every row of the delete picker — starts its title in one column. It is
the rule menus.icon_lane already writes down for a command row's icon
column, and deliberately not that function: a command row drops its icon
where the platform draws no icon lane, and a board's mark is part of its
name on both platforms. So the lane measures what theme.glyph will
actually draw here and the marks stay.

Measured rather than written down, which is what makes it survive the
PicoCalc's substitution map still being open: `👣` is the one discipline
mark that has no console-font glyph today and folds to two cells, so the
lane there is two as well. Give it a one-cell substitution later and all
seven headings tighten by a cell with nothing else moving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:05:06 -04:00
Jean-Pierre MartineauandClaude Opus 5 18064c44de A rename is not a reason to close the page it renamed
The channel is still there and that is still its page, so the new name
and key are read back into the title, the summary and the rows and the
reader stays put. Only a clear closes it now, which is the one case
where what the page was about is gone. It used to close on both,
because the slot it was holding was a snapshot the rename made stale.

Two other things a slow action left open. The standard Public channel
has one well-known key, so a second copy is a duplicate row rather
than a channel - the menu drops the row once a slot holds it, but the
row dispatches by sentinel, so a press already on its way while the
first write ran added it twice. It is refused where it runs now.

And a reorder is a sequence of writes with no transaction under it, so
a link that drops partway leaves the slots between the two orders. It
says so, at once, because the re-read that follows means the reader is
about to be looking straight at the half-applied layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:53:47 -04:00
Jean-Pierre MartineauandClaude Opus 5 d19e2ec712 Channels goes through the shared row builders it was working around
It was never in the dual-platform gallery, which is why none of this
was caught. Adding it first, then fixing what it showed.

The column header was a hand-built f-string with a five-cell indent
and no idea of the render width: at 53 columns it ran to 54 and
wrapped, costing a content row out of twenty-six and drawing the
pinned landmark twice, and its indent assumed a two-cell channel glyph
where the console draws one. It goes through column_header now, which
measures the glyph and sheds ACTIVITY before it wraps.

The command rows hand-padded their icons, so the one-cell marks (↕ ✎
🗑) started their labels a column left of the two-cell ones - the
Organize row had a two-space hack and a four-line comment about
exactly this. They share a measured icon lane now, and the clear row
tints its mark rather than its words.

The empty message count was ·, which in that same row is also the
status-atom separator and what the console folds 🔕 to: a muted
channel with no messages drew it twice, two cells apart, meaning
different things. It is ○, the app's empty mark.

Also: the detail title was a 42-cell parenthesised blob that filled
the PicoCalc's whole title bar, leaving nowhere to say Esc leaves - it
is "Channel — name", with the openness and hash now leading a
vital-signs line that sheds atoms rather than wrapping. The manager
says "Enter open", which is what its rows do. The slot count chains
with · rather than an em dash. And the four Add rows are replaced by
one empty state when there is no free slot to add into, instead of all
four dead-ending in the same refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:48:07 -04:00
Jean-Pierre MartineauandClaude Opus 5 4c01e1da7e A short list is not a layout, and a free slot is asked before it is used
The slot probe stops for two very different reasons and returned the
same short list either way. Off the end of the configured slots it is
the truth; a read that simply failed leaves whatever it had, and that
was cached for the session - an empty one reading exactly like a
device with no channels, a truncated one making the next free slot
look free with a channel sitting in it, which the add flows then wrote
straight over. The two endings raise different things, so the probe
now says whether it finished, an unfinished one is answered but not
kept, and every add confirms the slot is empty before anything is
written to it.

The chat service's slot map was also walking the radio itself, and
unbounded - it skipped empty slots all the way to the 64-slot cap, so
on firmware that never rejects an index every edit paid 64 round-trips
before the screen could redraw, on top of the manager's own re-read.
It now builds from the probe the manager was making anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:41:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 4ba8da7f03 Channels shows the wait instead of announcing the result
Three things, all the same story - the manager was silent while it
worked and talkative once the work was already visible.

The acknowledgement goes. It reported changes the reader had just
watched land in the list in front of them, after the screen showing
them had closed. The count stays in summary, for the run log.

Every device action now reports while it runs, under the modal busy
card: write_channel draws its own (it is the one boundary every
mutation crosses), a reorder draws one card for the batch and retitles
it per move, and the re-read that follows a change draws one rather
than three. Modal is the point - the list underneath stays armed the
whole time it waits, so keys tapped during a slow write were landing
in its filter and coming back as a list showing nothing.

And "a write happened" moves off the change count onto the cache the
write drops. A write that landed and then raised - a QR view unwound
with ^W, a link lost mid-reorder - never reached changes += 1, so the
stale slot list and the chat service's slot map outlived a layout that
had already moved. write_channel now invalidates as it writes, which
the four CLI subcommands were never doing at all, and the manager
compares the epoch across a round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:32:19 -04:00
Jean-Pierre MartineauandClaude Opus 5 0a0dc977e6 A busy card that is actually there, and actually takes the keys
The overlay could not report a hub's own slow work: it paints only on
an empty stack, so over a screen kept up with stay() it draws nothing,
and it is not a screen at all, so it takes no keys either. A visited
hub stays armed the whole time it waits, which means every press
during a slow device write still reaches it - letters into its live
filter, Esc resolving a round nobody is waiting for yet, handed back
the instant the work lands.

BusyDialog is BusyScreen drawn as a box over what it interrupts:
modal, so the press dies on the card instead. Nesting keeps the
outermost one, so a batch of writes is one wait rather than a box per
write, and its caption can change to say which step it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:23:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 217625dcf7 The channel manager kept a receipt nobody could read
Every action banked a "created X" note that only surfaced once the
manager closed - by which time the refreshed list had already shown
the result. Worse, the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup's line budget and
came back as the full-frame result window instead, so the same visit
reported itself two different ways depending on how much had been done
in it. The visit now acknowledges once, in the summary the tool
already returned. An error is the exception and is shown when it
happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:50:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 9341c4c1d9 Twenty-two cells reserved for a name most people spell in five
The chat picker's conversation lane was a flat 22 cells, and every cell
of it past the longest name in the list was padding in front of a short
one — taken straight out of the last-message preview, which is the only
run on the row with something to say. On the PicoCalc's 53 columns that
left the message 12 cells: enough for a sender's name and a colon.

It is measured now, against the names the list actually holds, floored
at the header word and capped at the shape of a repeater-style name.
The preview keeps what it saves: 31 cells to 37 on a 72-column
terminal, 12 to 18 on the console.

What the cap costs is the tail of a long name, so the row gained the way
to read past its own edge that every other lane-and-run list in the app
already had: ←→ slide the message under the pinned columns, one row at a
time (Choice.hscroll_from). The preview had to stop pre-truncating
itself at 40 cells to make that worth pressing — a cut made before the
terminal's own leaves nothing to reveal.

The marker lane was measured too, and was wrong. A contact's dot was
padded to "a channel's double-cell glyph", which the channel glyph is on
regular and is not in the console font: on the PicoCalc every channel
row sat a cell to the left of every direct row, header over neither. It
now pads to what the glyph reports.

The footer had to give a cell back for the scroll atom, since it can
coincide with the delete atom on any thread with history — the same
arithmetic the purge preview does in its own comment. Enter opens a
screen here, so its verb is `open` rather than the default `select`,
which pays for most of it; `Del delete history` spends the rest on an
object the highlighted row is already naming.

The picker joins the gallery, which is what would have caught the marker
lane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:34:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 c008aa5f01 Windows got sixteen million colours, everyone else got 256
prompt_toolkit picks the colour depth per output class, and its two classes
disagree: Windows10_Output returns TRUE_COLOR outright, Vt100_Output returns
DEPTH_8_BIT for every TERM but linux. Nothing ever passed color_depth, so the
same build drawing the same theme was 24-bit on Windows and quantized to the
216-colour cube on macOS and Linux — silently, which is why it went unnoticed:
the app looks fine, just flatter.

It costs exactly what this palette is made of. The seven heat.* steps and the
per-node hue wheel are close pastels chosen to be told apart; snapped to the
cube, neighbours collide and an identity stops being distinguishable by hue.
COLORTERM is not consulted by prompt_toolkit at all — only its own
PROMPT_TOOLKIT_COLOR_DEPTH is — so a terminal announcing truecolor the
conventional way was still handed 256.

The resolver only ever raises the verdict. A terminal that cannot be shown to
do better returns None and keeps precisely the depth it had, so a host this has
never heard of cannot be made worse by it: guessing 24-bit at a terminal
without it costs not a duller palette but the colour entirely. A 16-slot
console is never promoted — the theme addresses its palette by index, and RGB
has nowhere to land there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:35:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 4d8c67358c Release 0.3.0
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The command line was rebuilt around the fact that it has two readers.
`--json` is the machine contract and now covers all 49 subcommands
instead of two; the plain face is free to be read by a person, which is
what it was always for.

The changelog entry had been written for the intermediate design — it
promised quoted names and absolute timestamps, both of which the finished
work deliberately reverses — so it describes what shipped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:07:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 b76a93808f Three more arguments that were reported as the radio's fault
`4` promises a caller that the node answered and a retry is worth
trying. An unknown admin node, an unknown courier contact and an `--at`
that is not a clock time are all decided before a word goes over the
air, so a caller retrying on `4` would retransmit nothing, forever.
`tx-optimize` had already answered the identical question the identical
way for a missing password; these had not caught up.

A refused admin login stays `4`. The node was reached and answered no,
the password may simply have changed, and that is exactly the case a
retry exists for — the line is whether anything was transmitted, not
whether the command worked.

The manual documented the old behaviour and called the inconsistency
out; it now documents the rule instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:03:17 -04:00
Jean-Pierre MartineauandClaude Opus 5 c920f50584 The manual promised awk; now it says which reader it is talking to
docs/cli.md led with "the CLI is meant to be read by awk" and made every rule
follow from it. That promise moved to --json, so the manual now opens on the
split — the plain face for a person at a prompt, the JSON face for a program —
and documents both per command: what each prints, and what shape and fields come
back. Every sample is captured from a real --mock run in a throwaway
MESHTERM_HOME, not written.

Three sections that were missing:

  - Where MeshTerm keeps its state. --db moves the database and nothing else;
    the outbox, the caches, the stored passwords and the remembered device stay
    in the config directory. $MESHTERM_HOME is what actually isolates a run and
    was documented nowhere.
  - About the examples. They come from the simulator, which opens a fresh radio
    per invocation, never fills PKTS, and invents hop names on a device-routed
    trace. And --mock records into the real history unless given its own home.
  - When it goes wrong. The exit scheme was explained and never once shown.

Corrected: quoting is gone from listings, times are ages with --absolute behind
the cli_time_format preference, routes take arrows where paths keep commas,
contacts gained HASH and LOCATION, preferences regained DESCRIPTION, ack and
closing messages now reach stderr rather than being dropped, and the written
pages wrap. Exit 3 now covers a --port that will not open, and the usage class
covers a missing --yes, a bad --sort or --category, an unknown --profile and a
bad courier id. The awk -F'"' recipe split on quotes that no longer exist, so
the recipes go through jq; the cron entry flooded the mesh every 30 minutes,
six times tighter than the cadence picker's own floor, and is now
config advert-cadence with an absolute path for what still belongs in cron.

README: chat is not interactive, trace-path is its own command, and the exit
table is prose plus a link instead of a third copy to keep in sync.

about.md: everything-in-the-TUI-is-in-the-CLI was never true — the map,
dashboard, live feed, watchtower and mesh walk have no command — so it says
nearly, names them, and points at the JSON the claim is now worth making about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:58:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 360c0c131e The keystroke raced the screen that reads it
`_run_live` pushes the picker itself, so the Enter could not be queued
before the call — it was sent blind and lost about one full-suite run in
six, on a machine busy enough to change the timing. `_screen_at` exists
for exactly this and says so in its docstring: wait until the stack is
the depth the flow reached, then press.

Fifteen consecutive runs of the test, and three of the whole suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:30:40 -04:00
Jean-Pierre MartineauandClaude Opus 5 b42b0a473e Take the flag where it is typed, and stop the heading from lying
`meshterm contacts --json` is what a person types, and it failed with a
bare usage error: Click binds an option to whatever command declares it,
so a global on the callback is a parse error one word later. For an
output format that is the wrong way round — `-o json` goes after the verb
in every tool that has one, and --json is the first thing anyone reaches
for. The group's own options are now lifted to the front before Click
parses, so both orders mean the same run. `--` stops the lifting and
--help is never lifted, since `contacts --help` must stay the contacts
help rather than quietly becoming the program's.

And a column headed AGE held `2026-09-08T04:18:25-04:00` under
--absolute. Every other time column already names the fact — HEARD,
RECORDED, FINISHED — which stays true whichever form the run asked for;
only chat's named the form. `when()` now says so where the header is
declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:21:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a71284d7d A bare answer with nothing in it prints nothing, not a dash
repeater-admin on a node that never answered, channels share on an empty slot:
the bare form prints the value, and there is no value. A lone `-` there is a
line of output where the exit status is the whole report — and 5 already means
"ran fine, found nothing", which is the branch a script is waiting on.

The document keeps the shape either way, with null where the answer would have
been, because a consumer branching on it still needs the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:10:02 -04:00
Jean-Pierre MartineauandClaude Opus 5 8d2a7b8281 --absolute asks for another form of time, not for one fewer fact
A never-heard node has no instant to print, so the timestamp language dropped
"never" back to the plain absent token and a contacts listing under --absolute
could no longer tell "we have not heard from it" from "this row has no heard
time". The absent word survives the switch; only the form of a real time changes.

Also drops a dead guard in the preferences writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:07:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 de0f60cbe0 Rewrite the CLI standard for the two faces it now has
The section described a command line "read by awk", which was the premise that
changed: the plain face is read by a person and --json is read by the program.
It stated the quoting, the absolute times and the comma-joined path line as
rules, and all three now say the opposite.

What replaces it is the seam first — a tool states, a renderer draws, the
boundary picks — then each face with the rules an implementer follows and the
reason behind each one, then the exit statuses, with the two classifications
that were wrong somewhere stated out loud: nothing transmitted is not a device
failure, and a rendering flag never moves the status.

docs/cli.md is untouched and now stale in three places: the --json row still
says only two commands honour it, --absolute is undocumented, and every console
sample carries quoted names and ISO timestamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 04:02:29 -04:00
Jean-Pierre MartineauandClaude Opus 5 5215a1b76c Two things the sweep could not see
A block that renders to nothing still counted toward the separator, so
`config restore --dry-run` opened with a blank line it did not have — the plan
is a listing under an outcome that says everything it has to say in the exit
status. The separator now counts what was drawn.

And the device inventory declared `port` and `address` in its rows and never in
its columns, so both fell out of the document: a caller had to parse the target
for a colon to find out which transport it was holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:59:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 e3ae668f4a Give the person at the prompt back what a redirect never wanted
Five small things the seam left standing, each one a rule applied a step too
far or a question asked of the wrong flag.

ack and a tool's closing message go to stderr instead of being dropped. The
reason they were dropped was sound as far as it went — an acknowledgement is not
the answer, and `meshterm contacts > f` must catch only the answer — but stdout
is not the only stream. Someone watching a five-minute `config restore` gets the
setting-by-setting ✓ back, and the redirect still catches nothing extra.

tx-optimize asked --json whether it could prompt. That is a question about
output format, and it answered this one wrong in both directions: with the flag
a piped run failed cleanly, without it a scheduled run on a terminal sat waiting
for a password nobody was going to type. It now asks stdin — and asks it
properly on Windows, where NUL is a character device that isatty() calls a
terminal and getpass then reads the console directly and blocks forever. A
missing password is a usage error, since nothing was transmitted.

courier send on an id nobody has raised a device failure, which told a scheduled
sender to retry a typo. It is a bad argument: the outbox is a local file.

--mock alongside --port/--ble/--tcp used to let --mock win silently, so a
scheduled `--port COM7 --mock info` reported on a simulator while reading as
though it had reached the radio.

And stderr stops folding at 80 columns off a terminal, which is what made
`grep 'not already connected' errors.log` come back empty — the sentence was
there, with a newline through the middle of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:56:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 109fe7645d Move every command onto the seam, and give the plain face its own premise
Forty-nine leaf commands now state a Report instead of printing one, so --json
works on all of them at once rather than on the two that grew a branch for it.
The two hand-rolled JSON paths are gone: devices no longer builds a payload dict
of its own, and its empty scan exits 5 like the plain path always did rather than
0 — a rendering flag has no business moving the report.

The plain face changes premise along with it. It used to be a serialisation
format a person could squint at, and several of its rules existed only to make
splitting safe. Splitting is the machine face's job now, so:

- Names are bare. Alignment is the delimiter; the escaping under the quoting
  stays, because nothing else stops a name a node broadcast from ending its own
  record.
- Times are ages. "Heard recently?" is the question a contacts listing is opened
  to ask, and an ISO instant makes the reader do arithmetic to answer it.
  --absolute puts the instants back; an appointment, the device clock and a live
  capture's own column were never ages and still aren't.
- A route takes arrows and a path keeps its commas. The lexicon has always split
  these and the output never did, so a route line looked pasteable into --path,
  which it is not.
- The listings gain what was cut for parser hygiene: contacts gets the HASH
  --path takes and a LOCATION the model has always carried, preferences gets the
  DESCRIPTION already written in its registry, and the two live streams get
  pinned lanes — which is what let them have columns at all, and with them the
  last call site for the quoting.
- The four written pages wrap at 72 instead of printing 600-cell paragraphs. It
  is the one exception to "no wrapping", and the rule's own reason says why: a
  paragraph has no fields to land under the wrong headings.

info keeps its keys and gains a gloss on the four readings nobody can hold in
their head (93784 (1d 2h)); it is the one key/value block with no set behind it,
so there is no round trip to protect. config show is untouched, deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:43:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:16:57 -04:00
Jean-Pierre MartineauandClaude Opus 5 59f10f904c The escape table was a list where it needed to be a category
Turning markup off on the scripted console stopped a node name being read
as a style. It also stopped every tag MeshTerm writes *itself* from being
consumed — so `config export-key > key.hex` wrote a file with
`[muted]` and `[/muted]` around the private key that was supposed to be
its whole content. `PlainUi.note` is documented to take markup and now
resolves it to text, which fixes the leak wherever it is written rather
than only where it was found; the key itself is bare on both faces.

Escaping covered the newline, the tab and the return, and let ESC
through — a live colour run written into the caller's file, which is the
one thing "not a single escape sequence" exists to prevent. BEL was
worse: dropped in silence, so the printed name was not the advertised
one, and U+2028 ended the record like a newline. It is now every C0 and
C1 control plus the two Unicode separators, named escapes where one
reads and numeric everywhere else. Legitimate non-ASCII is untouched.

The covered-command list is now walked out of the Typer app instead of
kept by hand. It was six commands of forty-nine, and both violations
above sat in the forty-three — the `---` in `about` drawn as a rule the
width of a 16384-cell console, and the tags above. Verified by putting
each bug back and watching the sweep name it.

Three assertions could not fail. `len(set(MEANINGS)) == len(MEANINGS)`
is true of every dict, so "no two outcomes share a status" was unenforced
— renumbering NO_DEVICE to 4 left all three green while status 3 vanished
from --help. `" " * script.GUTTER in record` reads the constant it
checks, narrowing to `" " in record` exactly when the gutter narrows.
Both are literals now.

The four rule tests read `result.output`, which is stdout and stderr
mixed, while the rules they enforce are about stdout — so a heading that
only ever appeared on stderr would have satisfied them.

And $MESHTERM_HOME is isolated for every test, not just the CLI ones:
--db moves the history and nothing else, so any test touching a store,
a preference or the logger was reading the developer's own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:52:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 98c635a62e Refuse the wrong answer instead of quietly giving it
Four places where the CLI answered a question the caller had not asked.

`--profile yagi` naming no profile fell through to ordinary discovery, so
a typo in a scheduled advert transmitted from whichever radio happened to
be attached. Naming a profile is a claim about which device; an
unkeepable claim is a bad argument, not a default.

`--sort bogus` sorted by the default and `--category long_hual` selected
every discipline — which then came back as exit 5, the code that means a
real empty result. A caller branching on 5 could not tell "no records"
from "you misspelled the discipline". `ContactsSort.from_name` still
forgives an unknown name, because a saved preference from an older build
must not wedge the menu's list; the command line refuses instead.

A `--port` that would not open reported exit 4 and "the connection to the
device was lost", describing a connection there had never been. Every
failure while acquiring the device is the other status: nothing was
transmitted. It now says which port, and why.

A message body could hold a newline, which ended its record early and
left the remainder indented under the other columns as a second record
with an empty TIME — the one field a stranger fills in, breaking the one
rule the format has. script.oneline folds it flat.

And an unnamed node read as `""` in one listing and `-` in the next:
seven of the ten places that print a name were claiming "called nothing"
where they meant "never said". script.name draws the distinction once,
which is also the one the JSON rendering will need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:31:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in replaced every
empty setting with a pair of quote marks — and the next dump looked
identical, so nothing ever said so. It also masked an *unreported* PIN
behind six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00
Jean-Pierre MartineauandClaude Opus 5 4a91845651 A manual for the command line, and two rules it caught us breaking
docs/cli.md documents every command and option, what each one prints, and
what it returns -- with the output conventions and the exit-status table
up front, since those are the two things a script is written against.
Every command, option and flag in it was checked against the running app;
the sample output is captured from the simulator rather than typed out.

Writing it turned up two places the CLI still did the thing the last
commit was about. A destructive command refused for want of --yes printed
its refusal in red on *stdout* and exited 1: a colour and a sentence that
is not the command's answer, in whatever was reading the command's answer.
A missing confirmation is a bad invocation, so it now goes through the
parser like any other -- nothing on stdout, the reason on stderr, exit 2.

The README keeps its cookbook and its summary of the conventions, and
points at the manual for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:17:26 -04:00
Jean-Pierre MartineauandClaude Opus 5 da05dd402f Give the CLI its own voice, meant for a pipe rather than an eye
The CLI had grown the menu's manners -- colour, boxes, section headings,
glyph columns, a closing check mark -- and every one of them is in the way
of the thing it is actually for. It now prints like a standard Unix
utility: plain text, no escape sequences at all, no frames, one record per
line, and nothing wrapped, so a long line runs off the right instead of
folding its fields onto a second one.

meshterm/ui/script.py is the whole vocabulary. Listings are an uppercase
header and space-aligned records; key/value blocks are sysctl's shape, and
a `get` prints the bare value alone. Names are quoted wherever they share a
line, because a name can hold a space or a comma and a bare one is not a
field. A route reads "Origin" (3d),"Relay" (f2),"Us" (a1) -- the hash
outside the quotes, our own node a hop like any other rather than a star,
since the hash is what carries the identity that colour carries on screen.
Times are absolute local ISO-8601 and "-" is the one token for absent.

The return value is now half the report: 0 ok, 1 failure, 2 usage, 3 no
device, 4 the device was reached and the operation failed, 5 nothing to
report. That last one is the point -- a command that ran fine and found
nothing prints nothing and says so in $?, so a script never has to count
output lines to tell an empty mesh from a full one. Errors and progress
moved to stderr, and an expected failure no longer arrives as a traceback.

Two things lose their scripted face rather than get a degraded one. The map
is a picture whose nodes are told apart by colour: monochrome it would be
unreadable, coloured it could not be piped anywhere useful, and the nodes
themselves are still in `contacts`. The QR codes are a second rendering of
a link the output already prints.

ui.ack joins ui.note to draw the line the change needed: a note is output,
an acknowledgement is reassurance a person wants and a script must filter
out, so the menu prints it and the CLI drops it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:52:48 -04:00
Jean-Pierre MartineauandClaude Opus 5 2790159d04 Ask for a window MeshTerm fits in, rather than taking whatever is open
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
JP saw the PicoCalc's wordmark on a desktop session. The splash picks the
widest mark that fits, the desktop one is 71 columns, and below that it swaps
to the 53 drawn for the handheld -- so the app was announcing itself as the
handheld and truncating every menu description onto the pager underneath.

The window was 64 columns. reopen() passed no window argument, so wt.exe put
the session in a *tab* of whatever window happened to be open and it inherited
that size; with no window open it would instead have taken the reader's global
launch size, which is a setting about their shell and nothing to do with this
app. Either way the app could land somewhere too small for it, and the failure
was quiet and cosmetic rather than anything that announced itself.

It now asks for a window of its own at the platform's stated minimum plus a
margin -- 80x30 on the desktop, derived from readable_cols/readable_rows so it
follows them. Windows Terminal clamps to what the display can show, so a small
screen degrades exactly as it did before.

The tab is titled MeshTerm too. It was showing the executable's full path,
which on a downloaded build is a line of Downloads folder. The icon is not ours
to set -- Windows Terminal takes that from a profile and a bare command line is
not one.

Release 0.2.8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 20:28:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 690807370b Take them to the better terminal instead of asking permission to
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The classic Windows console cannot draw a single MeshTerm icon whatever font
it is given, so "reopen in Windows Terminal?" had exactly one sensible answer
-- and was being put to someone who had not seen the app yet and so had nothing
to weigh it against, at the one moment nothing had been drawn.

So it moves. It still says what it is doing and still names the way to stop it,
because a program that opens a window you did not ask for should say so.

Installing a font keeps its consent, and the split is deliberate: moving window
costs nothing and undoes itself by closing, while a font is a file written to
someone's machine that Windows will not let go of until their next logon.

The console_setup preference reads auto/off now rather than ask/off, and its
help says what it does. An older stored "ask" is not "off", so it keeps working
and simply means auto.

JP's call, and it is the right one -- the reference experience should be what
an end user gets, not what they think to ask for.

Release 0.2.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:42:46 -04:00
73 changed files with 11491 additions and 1246 deletions

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
---
name: release
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
---
# Cutting a MeshTerm release
A release is a **rename and a tag**, not a week of git archaeology — that is the whole
point of keeping an `[Unreleased]` section. The one part that takes real work is writing
the entry, because the changelog is prose someone reads, not a list of commit subjects.
Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml),
which calls `installers.yml`, builds all five platforms, attaches them with a combined
`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the
changelog is not documentation of the release; it *is* the release notes. An empty section
publishes "No changelog entry for X.Y.Z."
## Two things this skill never does
- **Never runs `release.yml`.** That is the **PyPI** publish, and it is deliberately hard
to fire: no automatic trigger, a typed version that must match the build, and a `pypi`
environment gate. PyPI has no rename and no true delete. It runs only when JP says so in
that moment, from the Actions tab, by hand. The name `meshterm` is on PyPI's prohibited
list anyway — the project publishes as `mesh-term`.
- **Never picks the version silently when it isn't a patch.** Default is a **patch** bump
(`0.3.0` → `0.3.1`). Anything else — minor, major — is JP's call, and he says which part.
Bumping one part zeroes the ones after it. While the major is `0`, a **minor** bump is
allowed to change behaviour, not just add to it.
## 1. Preflight
```
python .claude/skills/release/release_check.py
```
It prints the current version, the last tag, every commit since it, the state of the
`[Unreleased]` section, any changelog link refs that are missing, and whether the tree is
clean and in sync with `origin/main`. Read it before touching anything — it is also the
answer to "what would go into the next release?" on its own.
Then the gates `CONTRIBUTING.md` asks contributors to pass:
```
python -m pytest -q && ruff check . && ruff format --check .
```
A release does not go out on a red suite. If something fails, stop and say so.
## 2. Decide the version
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
so a wrong assumption costs a sentence instead of a tag.
## 3. Write the entry
This is the work. Read the commits since the last tag (`git log --stat v<last>..HEAD`) and
write **what changed for someone using MeshTerm**, in the changelog's existing voice:
- **Categories are Keep a Changelog's** — `### Added`, `### Changed`, `### Fixed`,
`### Removed` — in that order, and only the ones that have entries.
- **A bullet opens with a bolded sentence stating the user-visible fact**, then a paragraph
saying what was actually wrong and what it now does. Read the `0.2.6` and `0.2.8` entries
for the register: the failure is described from the reader's side first, the mechanism
second, and the lesson last if there is one.
- **Small entries are one line and no bold.** Not every commit earns a paragraph, and
several commits often collapse into one bullet — group by what the reader experienced,
not by what the diff touched.
- **A commit that changes nothing a user can see does not appear.** Refactors that move
code behind an unchanged surface, test-only commits, skill and tooling commits: leave
them out. The changelog is not a shadow git log.
- Wrap at the file's width (~95 columns) and match its em-dash-and-clause rhythm.
Insert the section directly under the HTML comment at the top of `CHANGELOG.md`:
```markdown
## [Unreleased]
## [X.Y.Z] — YYYY-MM-DD
```
A fresh empty `[Unreleased]` heading goes back in above it — that is what makes the *next*
release a rename. Then add the link refs at the bottom, repointing `[Unreleased]` at the
new tag:
```
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/jpmartineau/MeshTerm/releases/tag/vX.Y.Z
```
The date is the real date — check it rather than copying the one above.
## 4. Bump the version
`__version__` in [meshterm/__init__.py](../../../meshterm/__init__.py) is **the only place a
version is written**. `[project] version` in `pyproject.toml` is `dynamic` and hatchling
reads the attribute, so the number in a built wheel cannot drift from the number in the
source. Do not add a second one.
## 5. Commit, tag, push
```
git commit -am "MeshTerm X.Y.Z"
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
```
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
the push is the act that builds and publishes the release:
```
git push origin main && git push origin vX.Y.Z
```
This is the one workflow where pushing is expected. It is still asked for, once, in that
moment — the standing rule against unasked pushes is not suspended by the skill, it is
satisfied by the answer.
## 6. Watch it land
```
gh run watch --exit-status
gh release view vX.Y.Z
```
Five platform builds plus the release job, so it takes a few minutes. Report the release
URL and whether the notes came out of the changelog or fell back to "No changelog entry".
If a build fails, the tag is already public: fix forward with a new patch version rather
than deleting and re-pushing a tag people may have fetched.
## At 0.9.0 the changelog resets
The first public release collapses everything below it into a single entry reading "first
public release" with the headline features under it. Every version under 0.9.0 was released
nowhere, and its entries are notes to ourselves about getting ready — nobody arriving on
launch day wants a changelog of the fortnight before it. **Keep the dates and the tags;
replace the prose.** The same note is an HTML comment at the top of `CHANGELOG.md`, where
whoever cuts it will be looking.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Preflight for a MeshTerm release, and the answer to "what's in the next one?".
Reports the six things worth knowing before a version number is written down, and says
nothing else: the current version and the tag it belongs to, the commits since, whether
there is an `[Unreleased]` section to rename, which changelog link refs are missing, and
whether the working tree is clean and level with `origin/main`.
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
INIT = ROOT / "meshterm" / "__init__.py"
CHANGELOG = ROOT / "CHANGELOG.md"
OK, WARN, INFO = "ok ", "warn", " "
def git(*args: str) -> str:
"""Run a git command in the repo, returning stripped stdout ('' on failure)."""
done = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True)
return done.stdout.strip() if done.returncode == 0 else ""
def say(mark: str, line: str) -> None:
"""Print one finding, indented under its heading; a blank mark is a plain note."""
print(f" {mark} {line}" if mark.strip() else f" {line}")
def current_version() -> str:
"""The version `meshterm/__init__.py` declares - the only place one is written."""
found = re.search(r'^__version__\s*=\s*"([^"]+)"', INIT.read_text(encoding="utf-8"), re.M)
return found.group(1) if found else "?"
def latest_tag() -> str:
"""The highest v-tag by version order, not by date — a re-tag must not reorder this."""
tags = [t for t in git("tag", "-l", "v*").splitlines() if t]
if not tags:
return ""
return sorted(tags, key=lambda t: [int(p) for p in re.findall(r"\d+", t)])[-1]
def next_patch(version: str) -> str:
"""The default bump. Any other part is JP's call, and he says which."""
parts = version.split(".")
if len(parts) != 3 or not parts[2].isdigit():
return "?"
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
def main() -> int:
"""Print the report. Nothing here blocks; every line is for a person to act on."""
version, tag = current_version(), latest_tag()
text = CHANGELOG.read_text(encoding="utf-8")
print(f"\nMeshTerm {version} | last tag {tag or '(none)'}\n")
print("Version")
if tag and tag != f"v{version}":
say(WARN, f"__version__ is {version} but the last tag is {tag} - already bumped?")
else:
say(OK, f"__version__ {version} matches the last tag")
say(INFO, f"next patch would be {next_patch(version)} (JP names anything else)")
print("\nCommits since the tag")
log = git("log", "--oneline", f"{tag}..HEAD") if tag else git("log", "--oneline")
lines = [ln for ln in log.splitlines() if ln]
if not lines:
say(WARN, "none - there is nothing to release")
else:
say(INFO, f"{len(lines)} commit(s):")
for ln in lines:
print(f" {ln}")
print("\nChangelog")
if re.search(r"^## \[Unreleased\]", text, re.M):
body = text.split("## [Unreleased]", 1)[1].split("\n## [", 1)[0].strip()
if body:
say(OK, "an [Unreleased] section with entries - rename it to the new version")
else:
say(WARN, "an [Unreleased] heading with nothing under it - the entry is unwritten")
else:
say(WARN, "no [Unreleased] heading - write the section from the commits above")
versions = set(re.findall(r"^## \[(\d[^\]]*)\]", text, re.M))
refs = set(re.findall(r"^\[(\d[^\]]*)\]:", text, re.M))
missing = sorted(versions - refs, key=lambda v: [int(p) for p in re.findall(r"\d+", v)])
if missing:
# Cosmetic only: the heading renders as literal brackets instead of a link. The
# release notes come from the section body, so a published release is unaffected.
say(WARN, f"link refs missing at the bottom for: {', '.join(missing)}")
say(INFO, "(cosmetic - the heading loses its link; release notes are unaffected)")
else:
say(OK, "every version heading has its link ref")
print("\nTree")
dirty = git("status", "--porcelain")
say(
OK if not dirty else WARN,
"clean" if not dirty else f"{len(dirty.splitlines())} uncommitted change(s)",
)
branch = git("rev-parse", "--abbrev-ref", "HEAD")
say(OK if branch == "main" else WARN, f"on {branch}")
git("fetch", "--quiet", "origin")
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD")
if counts:
behind, ahead = counts.split()
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
print("\nRun the gates before tagging:")
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+290 -1
View File
@@ -17,6 +17,289 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.3.1] — 2026-09-09
### Added
- **Devices you never want to connect to can be hidden from the splash.** A machine with a
debug probe, a programmer and a USB adapter soldered into it listed all three on every
start, in front of the one radio you came for. `h` drops the highlighted device and
remembers it, `⇧H` brings every hidden one back, and both keys are named in the footer
only where they would act.
Hiding is a listing choice and nothing else — a hidden device is still remembered, still
resolvable by `--port`, and shows itself again the moment it is connected to. The
highlight stays where the row was rather than jumping back to the remembered default, so
a run of adapters clears with a run of presses.
### Changed
- **The chat picker's conversation lane is measured against the names the list actually
holds**, instead of a flat 22 cells. Every cell past the longest name was padding in
front of a short one, taken straight out of the last-message preview — the only run on
the row with something to say, and on the PicoCalc's 53 columns it left the message 12
cells. The preview keeps what it saves: 31 cells to 37 on a 72-column terminal, 12 to 18
on the console. What the cap costs is the tail of a long name, so `←→` now slides the
message under the pinned columns to read one past its own edge.
- **Channels shows the wait instead of announcing the result.** Every action banked a
"created X" note that surfaced only once the manager closed — by which time the refreshed
list had already shown the result — and the notes accumulated: at three changes in one
visit the outcome outgrew the acknowledgement popup and came back as the full-frame
result window instead, so the same visit reported itself two different ways depending on
how much had been done in it. Every device action now reports while it runs, under a
modal busy card, and the count stays in the run log.
- Renaming a channel no longer closes the page it renamed. The channel is still there and
that is still its page, so the new name and key are read back into the title and the
rows, and the reader stays put. Only clearing it closes it, which is the one case where
what the page was about is gone.
- `Esc` on the device splash says **bye** rather than quit — the splash is the door, and
nothing has been started there to quit out of.
### Fixed
- **macOS and Linux were drawing the whole app in 256 colours.** prompt_toolkit picks the
colour depth per output class, and its two classes disagree: the Windows one returns
truecolor outright, the VT100 one returns 8-bit for every `TERM` but `linux`. Nothing
ever passed a depth, so the same build drawing the same theme was 24-bit on Windows and
quantized to the 216-colour cube everywhere else — silently, which is why it went
unnoticed. The app looked fine, just flatter.
It costs exactly what this palette is made of. The seven heat steps and the per-node hue
wheel are close pastels chosen to be told apart; snapped to the cube, neighbours collide
and an identity stops being distinguishable by hue. `COLORTERM` is not consulted by
prompt_toolkit at all, so a terminal announcing truecolor the conventional way was still
handed 256.
The resolver only ever raises the verdict. A terminal that cannot be shown to do better
keeps precisely the depth it had, because guessing 24-bit at a terminal without it costs
not a duller palette but the colour entirely. A 16-slot console is never promoted — the
theme addresses its palette by index, and RGB has nowhere to land there.
- **Adding a channel could write straight over one that was already there.** The free-slot
probe stops for two very different reasons and returned the same short list either way:
off the end of the configured slots it is the truth, but a read that simply failed left
whatever it had — and that was cached for the session, an empty one reading exactly like
a device with no channels, a truncated one making the next free slot look free with a
channel sitting in it. The probe now says whether it finished, an unfinished one is
answered but not kept, and every add confirms the slot is empty before anything is
written to it.
- Keys tapped during a slow channel write were landing in the filter of the list
underneath, which then came back showing nothing. The busy card is modal, so the press
dies on the card instead.
- **Channels at 53 columns.** The column header was a hand-built string with no idea of the
render width: it ran to 54 cells and wrapped, costing a content row out of twenty-six and
drawing the pinned landmark twice. The command rows hand-padded their icons, so the
one-cell marks started their labels a column left of the two-cell ones. The empty-message
count was `·`, which in that same row is also the status separator and what the console
folds 🔕 to — a muted channel with no messages drew it twice, two cells apart, meaning
different things; it is `○` now, the app's empty mark. And the detail title was a 42-cell
parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc
leaves.
- Every channel edit paid up to 64 device round-trips before the screen could redraw. The
chat service's slot map was walking the radio itself, unbounded, skipping empty slots all
the way to the 64-slot cap on firmware that never rejects an index — on top of the
manager's own re-read. It builds from the probe that was being made anyway.
- A channel write that landed and then raised never invalidated what it had made stale, so
a slot list and a chat slot map outlived a layout that had already moved. A reorder that
drops partway now says so at once, since the reader is about to be looking straight at
the half-applied layout, and the standard Public channel can no longer be added twice by
a second press already on its way.
- Two of the Trophy case's seven discipline marks started their titles a column left of
their siblings: 🛣 and 🕸 sit outside Emoji_Presentation, so Rich and wcwidth both measure
them at one cell where the other five measure two. The marks now pad out to the widest of
the seven, measured against what the platform will actually draw rather than written
down.
- A route that has finished no longer ends on the chevron that means "the path runs on past
the edge" — it ends square, and the cell that frees up goes back to the route.
- **A fully charged pack redrew the activity sparkline twice a minute.** The charger cuts
out at full, the pack settles back to 99, the charger restarts, and the companion reports
that flip every poll for as long as the thing is plugged in. The gauge is pinned to the
header's right edge and the pulse takes whatever is left, so the fourth cell `100%`
needed came off the sparkline and redrew the whole activity history at a new scale, on a
device sitting still. A full pack reads `100` now, with no sign: three cells, exactly as
the `99%` it keeps flipping back to, and the one reading whose `%` can be inferred.
- On the PicoCalc every channel row in the chat picker sat a cell to the left of every
direct row, with the header over neither — the marker lane padded to a two-cell channel
glyph that the console font draws in one.
- The device splash's own header wrapped at 53 columns, and its DEVICE lane sized itself to
the longest name — which for a USB adapter is a forty-character product string, pushing
HARDWARE off the edge entirely. The name lane yields now, and `←→` reads the rest of a
long model string with the lanes in front of it pinned.
## [0.3.0] — 2026-09-08
The command line was rebuilt around the fact that it has **two readers**, and that trying
to serve both with one stream had been making it worse at each.
### Added
- **`--json`, on every command.** It used to be honoured by two of twenty and silently
ignored by the rest, so `meshterm --json contacts | jq` failed while MeshTerm reported
success. It now covers all 49 subcommands: an array for a listing, an object for a set of
facts, one compact line per document, and no envelope — `contacts --json | jq '.[].node.name'`
reads the way it looks.
Values are typed (`20`, not `"20"`), absent is `null` and never an omitted key,
timestamps are UTC RFC 3339 to the second so string comparison is time comparison, and a
numeric field carries its unit in its key (`snr_db`, `uptime_s`, `rtt_ms`). A key is never
truncated — a short id is a `hash`, because a truncated key cannot go back into `--to`.
`monitor` and `chat listen` emit one document per record as they arrive, all the same
shape.
`--json` changes the rendering and never the report: the same exit status, the same
records. An empty result prints its empty document **and still exits `5`**.
- **A documented exit status per outcome**, listed under every `--help`: `0` success, `1`
failure, `2` usage error, `3` no device, `4` the device was reached but the operation
failed, `5` nothing to report.
`5` is the one worth knowing about. A command that ran fine and found nothing — an empty
contact list, a trace that never came home — returns it, so a script can tell "found
nothing" from "worked" without counting lines.
- **`--absolute`**, restoring ISO-8601 timestamps for a run, over a new `cli_time_format`
preference. JSON is always absolute UTC regardless: a local offset is a fact about the
machine that ran the command, not about the event.
- **A global option may be typed anywhere.** `meshterm contacts --json` and
`meshterm --json contacts` are the same run. The first is what everyone types and it used
to fail with a bare usage error.
- **`contacts` gains `HASH` and `LOCATION`.** The hash is the token `--path` takes; you used
to slice it out of `KEY` by hand. `preferences show` gains `DESCRIPTION` back, and `info`
glosses its second-valued readings (`uptime_s 93784 (1d 2h)`).
- **[`docs/cli.md`](docs/cli.md)**, a manual for the whole thing: both faces of every
command, where MeshTerm keeps its state, what each failure looks like, and recipes. Every
sample in it is captured from a real run rather than written.
### Changed
- **The plain face is for a person now, and says so.** It had grown the menu's manners —
colour, boxes, section headings, glyph columns, a closing ✓ — and then briefly overcorrected
into something only `awk` could love. Machine-readability is `--json`'s job, so the plain
face is free to be read: no colour and no frames, still, but **alignment is the delimiter**
and names are bare rather than `"quoted"`.
- **A time is an age** — `now`, `5m`, `3h`, `never` — because "recently?" is the question you
typed the command to ask. An absolute instant survives where the instant *is* the fact: the
device clock, an appointment, a live capture's own `TIME`.
- **A route is drawn and a path is typed.** A route reads
`Yagi-Repeater (a1) → Alice (d4) → MockCompanion (00)`; a `--path` spec stays the
comma-joined hex it has to be, because it is the one line that round-trips. Our own node is
a hop like any other — the menu's `★` says "you already know who this is", which is true of
the reader and false of whoever opens the file later.
- **A value read out of `show` can be typed back into `set`.** The settings dump printed an
enum as `0 (off)` and an unset string as `(not set)`, neither of which `config set` would
take back.
- **`ui.ack` and a tool's closing message go to stderr** rather than being dropped. stderr
keeps the promise the dropping was made to keep — a redirect catches only the answer —
while giving the person at the prompt back their ✓ and their count.
- **`--help` is Click's own plain help**, with no boxed panels and no markup to strip out of
a pipe.
### Fixed
- **A node name could not be printed safely.** A node broadcasts its own name, and Rich read
`[...]` in one as console markup: a node called `[bold]Loud` printed as `Loud` — silently
no longer the string that identifies it — and one called `[/]Bob` raised `MarkupError` and
took the command down, then took the error handler down with it while reporting the crash.
- **A control character in a name reached stdout.** Escaping covered the newline, the tab and
the return and let **ESC** through, which put a live colour run into the caller's file — the
one thing "no escape sequences" exists to prevent. BEL was dropped in silence, so the
printed name was not the advertised one, and U+2028 ended the record like a newline. It is
now every C0 and C1 control plus the Unicode separators.
- **Numeric columns never right-aligned.** Rich's `Text.wrap` returns early on
`overflow="ignore"`, above the justify step, so asking for one threw the other away — while
the standard, the code's own docstring and the manual all promised alignment.
- **`config show` printed an empty string as `""`**, which `config set` read as those two
characters. Feeding a dump back replaced every empty setting with a pair of quote marks, and
the next dump looked identical, so nothing ever said so. It also masked an **unreported**
PIN behind bullets, telling the reader the radio was PIN-locked when it was not.
- **`meshterm about` drew the rule above its colophon at console width** — one 48KB line in a
4KB page.
- **`config export-key > key.hex`** wrote a file with `[muted]` tags around the key that was
supposed to be its whole content.
- **Failures were classified wrong in six places.** Nothing transmitted is not a device
failure: an unopenable `--port` is `3` rather than `4` with a message about a connection
there had never been, and a missing `--yes`, an unknown `--sort`/`--category`/`--profile`,
a missing admin password, an unknown contact and a bad `--at` are all usage errors. A
`--category` typo used to come back as `5` — indistinguishable from a real empty result —
and an unknown `--profile` fell through to whichever radio was attached, which is the one
wrong answer that puts a packet on the air.
- **Errors go to stderr**, in the `meshterm: what went wrong` shape, and stop folding at 80
columns — a sentence broken across three lines is a sentence `grep` cannot find. Progress
bars moved there too and draw nothing off a terminal.
- **An expected failure no longer dumps a traceback.** A lost serial link, or an argument a
command rejected on its own, reads as one line and the right exit status.
### Removed
- **`meshterm map`.** A map is a picture — braille cells whose meaning is their position, and
whose nodes are told apart by colour. Stripped of colour it would be unreadable; left
coloured it was the one command whose output could not be piped anywhere useful. The map
stays in the menu, and the located nodes are scriptable through `contacts`, which now
carries their coordinates.
- **The QR codes** from `channels share`, `config share` and the About pages' scripted face.
A QR is a second rendering of a link the output already prints, drawn for a phone pointed at
a screen; redirected into a file it was a block of block characters wrapped around the one
thing that was actually the answer. The menu still draws them.
## [0.2.8] — 2026-09-07
### Fixed
- **The window MeshTerm opened for itself was too small, and it showed.** The startup
wordmark is 71 columns wide; below that the splash quietly swaps to the narrow one drawn
for the PicoCalc — so a desktop session came up wearing the handheld's mark, with every
menu description truncated onto the pager underneath it.
MeshTerm was opening a *tab* in whatever Windows Terminal window happened to be around,
inheriting its size (64 columns on the machine this was found on). It now asks for a
window of its own, sized from the platform's own minimum plus a margin. Windows Terminal
clamps that to what the display can show, so a small screen degrades exactly as before.
- The tab it opens is titled **MeshTerm**, not the executable's full path — which on a
downloaded build was a line of Downloads folder.
## [0.2.7] — 2026-09-07
### Changed
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
still tells you how to stop it, but it doesn't put the question. The classic console
can't draw a single icon whatever font it's given, so there was only one sensible
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
to judge it by.
Installing a **font** still asks, because that writes a file to your machine. Moving
window doesn't. Preferences → Display → Console setup stops both.
## [0.2.6] — 2026-09-07
### Fixed
@@ -235,7 +518,13 @@ deliberately not reconstructed here.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.1
[0.3.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.3.0
[0.2.8]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.8
[0.2.7]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.7
[0.2.6]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.6
[0.2.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.5
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
+147 -2
View File
@@ -187,8 +187,15 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
picker unchanged · `Esc quit` only at the main menu. While a find-as-you-type filter
is standing the verb becomes `Esc clear`, because that is what the press does then.
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
splash, which is the door rather than a screen — nothing has been started there to
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
because that is what the press does then.
- A hint that outgrows the surface drawing it **sheds atoms rather than being cut off**,
because the cut takes the end and the end is Esc (`frame.fit_hint`, the chromeless
splash). They go from the right, in front of Esc; a screen may name the ones it can
spare first (`spare_hint_atoms`) — a key the reader would find anyway, like the ←→ its
move atom already named, before one that is unguessable and advertised nowhere else.
- Enter verb by action: `Enter open` when the row pushes a screen or dialog ·
`Enter select` when it picks a value or action row · `Enter set` in a value picker.
A more specific committing verb (`Enter adopt path`, `Enter add`) is fine; a synonym
@@ -257,6 +264,12 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
same fill are the exception the interlock can't draw: the point drops its background so
the page cuts the wedge. An elision breaks the ribbon rather than joining it — bare `⋯`
on the page between a closing point and the next chip's notch, no fill, no padding.
- The ribbon's **outer ends**: the chevron means *the route continues*, so a path that has
finished never wears one. It ends **square** — nothing appended, the last chip's own pad
is the edge — and opens square the same way; a full Nerd Font rounds both ends into a
lozenge instead (`POWERLINE_ROUND_*`, drawn only where `termfont.powerline_full`). A
*wrapped* line the path outruns still closes on the point: there the route really does
go on, which is the cue arrow mode spells with a trailing `→`.
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret · 🗑 clear/delete · ✎ compose/edit ·
⚙ parameter · `#` count · ▶ run · ⚡ explore/probe · ★ best/winner · ⭐ watch ·
@@ -323,6 +336,138 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Radio traffic: single transmissions or a user-chosen sample count with cooldown pacing
— never bursts.
### The command line — one answer, two faces
Everything above describes the **menu**, which is read by a person sitting in front of it.
The command line has two readers and it no longer pretends they are the same one: the
**plain face** is for somebody at a prompt who typed a command to find something out, and
the **JSON face** (`--json`) is for a program, often on another machine and often later.
The seam that makes two faces possible is the rule everything else here hangs off:
> **A tool states its answer as data. A renderer turns data into bytes. The CLI boundary
> picks the renderer.**
`exit_code` already worked this way — the tool states it, the boundary turns it into a
process status, no tool calls `sys.exit`. The answer travels the same way now. A tool that
*prints* its answer has given it away: it is rendered and gone by the time anything could
offer it in another format, which is why `--json` reached two commands out of twenty and
stopped, each one growing an `if ctx.json_output:` branch above the rendering that restated
the whole answer in a dialect nobody else could reuse.
- `ui/report.py` — `Listing` (records) and `Facts` (one thing, key by key). **A row holds
the typed value** (`6.0`, `None`, a `datetime`), never a formatted cell; each `Column`
carries both projections, a plain `Lane` and a JSON function. A `Facts` block also covers
the one-scalar answer, through `shape=BARE` (`config get` prints its value alone because
the caller named the key) and the acknowledgement, through `shape=SILENT`.
- `ui/fields.py` — **the shared shapes, one constructor per concept**: `node`, `channel`,
`position`, `route`, `spec`, `when`, `instant`, `snr`, `flag`, `free`. A listing that
mentions a node asks for `fields.node`, so every document speaks the same five-key object
and every listing names its lanes the same way — by construction, not by review.
`fields.node` is the one that earns the design: **one machine key expands to as many
plain columns as the surface has room for**, four in `contacts` and one in a hop table.
- `ui/renderers.py` — `PlainRenderer` and `JsonRenderer`, chosen by `ctx.output`.
**A third format is a new renderer and nothing else**: no tool is touched, no report
changes. `ctx.output` is an `OutputFormat`, never a boolean, because the boolean was
already being asked questions it could not answer.
- `ToolResult.report` is `None` for the menu path and for a feature with no scripted face
(the map, the dashboard). It is **not** `summary`: that is the run log's record of what an
invocation did, written to the `runs` table on every execution, and folding a five-hour
`monitor` capture into it would be the price of one field fewer.
- **`ui/surface.py` is untouched by all of this.** `PlainUi`/`TuiUi` is the *menu-vs-terminal*
split and stays exactly what it is; a report is **returned**, not shown, so `ctx.ui` never
grows a `report()` method and `TuiUi` never sees one.
- **Streaming is a second, narrower seam** and deliberately not the general path:
`monitor` and `chat listen` run until a window closes, so their answer cannot be a value
handed back at the end. They open `renderers.stream(...)` and emit one typed row per
record. Two commands use it; anything that can build a whole report builds one.
#### The plain face — for a person at a prompt
It is still a Unix utility's output — one record per line, no colour, no frames, stdout is
the answer, the exit status is half the report — but every rule that existed *only* to make
splitting safe has gone, because splitting is the machine face's job now.
`meshterm/ui/script.py` is the whole vocabulary and carries the reasoning; the rules:
- **Alignment is the delimiter.** A name is bare (`script.name`). The *escaping* under the
quoting stays and always will (`script._escaped`): a node broadcasts its own name and a
stranger fills in a message body, and neither may end the record it sits in.
- **A time is an age** — `now`, `5m`, `3h`, `never` (`script.age`, delegating to
`widgets._format_age` so the two faces cannot drift). An absolute instant survives where
the instant *is* the fact (`script.stamp`, `fields.instant`): the device clock, an
appointment set with `--at`, a live capture's own `TIME`, every column under `--absolute`.
`--absolute` is an override of the `cli_time_format` **preference**, not a switch beside
it — behaviour belongs in the registry.
- **A route is drawn and a path is typed.** `script.route` joins hops with ` → `; a hop is
`Name (hash)`, or whichever half is known, never an empty `()`. `script.spec` is the
comma-joined hex `--path` takes back, and it is the one line on either face that
round-trips, so nothing creeps into it. **Our own node is a hop like any other** — the
menu's `★` says "you already know who this is", which is true of the reader and false of
whoever opens the file afterwards.
- **`unknown` is a word and `-` is an absence.** `script.NONE` is the one token for absent;
`never` is a *value* (a node not yet heard is a fact). A node type stays the word
`repeater` — a monochrome `▲` would need a legend and the CLI has no legends.
- **A live stream pins its lanes** (`script.stream`), because a capture cannot measure
columns it has not seen. A value wider than its lane overruns and pushes the row right;
nothing is elided, and the one unbounded field goes **last** so it can push nothing.
- **A value that round-trips is untouchable.** `config show` prints what `config set` takes
— an enum's number, `""` for an empty string — and gains nothing cosmetic. `fields.rendered`
carries the typed value beside the text its own spec produced, so neither face parses the
other's output.
- **No wrapping, with one exception**: the four written pages (`about`, `about-author`,
`discord`, `support`) draw on a `script.PAGE_WIDTH` console. The no-wrap rule exists so a
*record* is never split with its fields under the wrong headings; a paragraph has no
fields, and unwrapped it is a 600-cell line no terminal can read. **Never wrap a listing.**
- **stdout is the answer; everything else is stderr** — errors (`meshterm: what went
wrong`), progress bars, log records, `ui.ack`, and a `ToolResult.message`. The last two
used to be dropped; stderr keeps the promise the dropping was made to keep (a redirect
catches only the answer) while giving the person at the prompt back their ✓ and their
count. `script.stderr_console()` stops wrapping off a terminal, because a sentence folded
at 80 columns is a sentence `grep` cannot find.
#### The JSON face — the machine contract
`--json` prints **the answer itself**, and the report a caller needs is still `$?`.
- **No envelope.** An array for a listing, an object for a set of facts, so
`contacts --json | jq '.[].node.name'` reads what it looks like. A multi-block report is
one object: each `Listing` under its own key, each `Facts` merged at the top level.
- **One compact line, `\n`-terminated**, UTF-8 with no ASCII escaping, keys in the order the
report declares them. A stream is one document per record, **all the same shape** — no
discriminator, because every reader would pay for it and only a stream would use it.
- **Absent is `null`, never an omitted key**, and typed values throughout: `9` not `"9"`,
`false` not `"false"`, a body raw and unescaped. `unknown` becomes `null`; a consumer
already has one spelling for "nothing here".
- **Timestamps are UTC, RFC 3339, `Z`, to the second** — twenty characters, so string
comparison is time comparison. `--absolute` does not touch this: a local offset is a fact
about the machine that ran the command, not about the event.
- **A numeric field carries its unit in its key** (`snr_db`, `uptime_s`, `rtt_ms`), never in
its value, and a key/hash is lowercase hex. **A key is never truncated** — a truncated key
cannot go back into `--to` or `--path`, so a short id is a `hash`.
- **`--json` changes the rendering, never the report.** Same exit status, same records. An
empty result prints its empty document (`[]`, or the object with its `null`s) **and still
exits 5**; a failure prints **nothing on stdout** and keeps its sentence on stderr.
- **"Prints nothing" is never a legitimate JSON answer.** A command whose plain face is
silent (`config advert`, `channels join`, `courier cancel`) still emits what it did.
- **A command with no data face refuses, loudly.** `--json specimen` is a usage error (exit
2): its output *is* the colour, and a document of it would be a lie or an empty gesture.
So is `--json` with no subcommand. The map, the dashboard, the live feed, the watchtower
and the mesh walk have no subcommand to refuse from, and that stays the honest answer
rather than a degraded one.
#### The exit status is the report
`core/exitcodes.py`, and the `--help` epilog: 0 ok · 1 failure · 2 usage · 3 no device ·
4 device failed · 5 nothing to report. A tool that ran fine and found nothing returns
`NO_RESULT`; a failure is *raised*, never returned. Two classifications worth restating
because both were wrong somewhere: **nothing transmitted is not a device failure** (a bad
argument is exit 2), and **a rendering flag never moves the status**.
`tests/test_report.py` is the enforcement point for the seam, `tests/test_script_output.py`
for the plain vocabulary, and `tests/test_cli_contract.py` for the commands — including a
derived sweep that runs **every** registered command twice, once plain and once under
`--json`, and pins that the two agree about the exit status.
### Platforms
One codebase, two flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji) and
+84 -23
View File
@@ -68,14 +68,14 @@ if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
```powershell
.\meshterm-0.2.6-windows-x64.exe
.\meshterm-0.2.8-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.2.6-*
./meshterm-0.2.6-*
chmod +x meshterm-0.2.8-*
./meshterm-0.2.8-*
```
### A word about terminals, on Windows
@@ -91,20 +91,19 @@ doesn't borrow. It draws what its one font holds and empty boxes for everything
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it offers to move: **reopen in Windows Terminal**,
one keypress, nothing installed and nothing changed. That's the whole app, exactly as the
screenshots show it. Windows Terminal is already on every Windows 11 machine and is a free
install on Windows 10.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Stay in the classic console and MeshTerm offers the next best thing instead — the charts
and marks, without the icons. It ships
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
— the charts and marks, without the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
it simply switches to it.
You can decline both and MeshTerm still runs. Preferences → Display → Console setup is
where you change your mind.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
becomes just `meshterm` from anywhere.
@@ -114,7 +113,7 @@ becomes just `meshterm` from anywhere.
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.2.6-*` and run it again.
> `xattr -d com.apple.quarantine meshterm-0.2.8-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -124,10 +123,10 @@ becomes just `meshterm` from anywhere.
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.6-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.8-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.6-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.8-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -199,7 +198,7 @@ here with its scripted equivalent, where one exists.
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. | `meshterm channels list / add / join / import / share / clear` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. | `meshterm contacts` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
### 📊 Watch — what the mesh is doing, and what it did
@@ -215,10 +214,10 @@ here with its scripted equivalent, where one exists.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | — (a map is a picture; `meshterm contacts` lists the same nodes) |
| **🌐 Mesh walk** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the walk answers *how it hangs together*. | — |
| **🎯 Trace target** | A live trace screen: pick a target and watch each trace stream in hop by hop, with running per-hop medians and reliability. Compose or force a route through specific repeaters. A trace transmits **exactly once** (repeaters can blacklist nodes that burst) — sample more by running it again. | `meshterm trace --target …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace --path …` |
| **👣 Trace path** | The other half of tracing: compose the whole circuit by hand — out and back whichever way you choose — and walk it. The path composer suggests each next hop from the links actually observed, strongest first, and can fetch a repeater's neighbour table over the mesh when you hold its admin password. | `meshterm trace-path --path …` |
| **🏆 Trophy case** | Every trace that comes home is scored, on seven boards: longest distance, farthest node, longest single leg, most nodes (with and without revisits), weakest surviving link, and biggest enclosed loop. Records are kept per hash width, and a record walk must be a *trail* — no link crossed twice the same way. | `meshterm records` |
### 🔧 This node — the radio in your hand
@@ -240,7 +239,11 @@ here with its scripted equivalent, where one exists.
## CLI cookbook
Every menu option is also a Typer subcommand — ideal for scripting, cron, and bots.
Nearly every menu option is also a subcommand — ideal for scripting, cron, and bots. The
live pictures stay in the menu, where their meaning is (the map, dashboard, live feed,
watchtower and mesh walk); everything else has a command, and every command speaks
`--json`. **[`docs/cli.md`](docs/cli.md) is the full manual**: every command and option,
what each one prints on both faces, and the exit statuses. A taste:
```bash
# Trace — a trace transmits exactly once; run it again to sample more.
@@ -251,11 +254,13 @@ meshterm trace --target Alice --profile yagi
meshterm trace --target Alice --path "3d,f2,3d"
meshterm trace --target Alice --path "3d,Bravo-Repeater,f2"
# Walk a composed circuit with no target at all — out and back your own way
meshterm trace-path --path "3d,f2,3d"
# Sweep and apply a remote node's TX power
meshterm tx-optimize --path "Bravo-Repeater,Alice" --samples 6 --step 3 --apply
# Messaging: live chat in the menu, or scripted from the CLI
meshterm chat # interactive: pick a conversation, chat live
# Messaging: the live transcript is a menu screen; the CLI takes a subcommand
meshterm chat send --to Alice "on my way" # direct message
meshterm chat send --channel 0 "net in 5" # channel broadcast
meshterm chat history --to Alice
@@ -270,7 +275,8 @@ meshterm courier send 3 # force one delivery attempt now
meshterm repeater-admin Bravo-Repeater "get name"
# Device configuration: view, set, back up, restore
meshterm config # show all current settings
meshterm config # show all current settings, one `key value` per line
meshterm config get name # just the value, ready for $(...)
meshterm config set radio_sf 9 # change one setting
meshterm config backup node.toml # archive every setting to TOML
meshterm config restore node.toml --dry-run
@@ -281,8 +287,56 @@ meshterm config advert-cadence 24 --flood # flood the wider mesh daily
meshterm monitor --seconds 60
```
Global options (before the subcommand): `--profile/-p`, `--port`, `--ble`, `--ble-pin`,
`--tcp`, `--mock`, `--db`, `--json`, `--quiet/-q`.
Global options — `--profile/-p`, `--port`, `--ble`, `--ble-pin`, `--tcp`, `--mock`,
`--db`, `--json`, `--absolute`, `--quiet/-q` — may be typed **before or after** the
subcommand: `meshterm contacts --json` and `meshterm --json contacts` are the same run.
### Two output faces
*(The short version — [`docs/cli.md`](docs/cli.md) has the whole of it.)*
The **plain face** is for a person at a prompt. It prints like a standard Unix utility —
no colour, no borders, one record per line, nothing wrapped — and it is allowed to be
comfortable about it: listings are `ps`-style aligned records with **bare names**
(alignment is the delimiter), times are **relative ages** (`now`, `5m`, `never`), and a
route is drawn with arrows — `MockCompanion (00) → Yagi-Repeater (a1) → Alice (d4)`.
`--absolute` swaps every age back for an ISO-8601 instant. A *path*, the spec `--path`
takes back, stays comma-separated hex. `-` is the one token for absent. Errors,
acknowledgements and progress all go to stderr, so a redirect catches only the answer.
The **JSON face** is the machine contract, and `--json` works on **every** command:
```console
$ meshterm contacts --json | jq -r '.[] | select(.node.type == "repeater") | .node.key'
b2c3d4e500000000000000000000000000000000000000000000000000000000
a1b2c3d400000000000000000000000000000000000000000000000000000000
```
No envelope — an array for a listing, an object for a set of facts. One compact line;
`monitor` and `chat listen` stream one document per record. Values are typed, absent is
`null` and never an omitted key, and timestamps are always UTC to the second regardless of
`--absolute`. `--json` changes the rendering, never the report: same records, same exit
status.
Anything structural should go through `--json` and `jq`. The plain face is for looking at.
### Exit status
`0` success · `1` failure · `2` usage error · `3` no device found (nothing was
transmitted) · `4` the device was reached but the operation failed · `5` nothing to
report. The table with its full wording is printed under `meshterm --help` and explained
in [`docs/cli.md`](docs/cli.md#exit-status).
`5` is the one worth knowing about: it lets a script tell "found nothing" from "worked"
without counting output lines.
```bash
if meshterm contacts > contacts.txt; then
echo "$(wc -l < contacts.txt) contacts"
elif [ $? -eq 5 ]; then
echo "no contacts yet"
fi
```
## Configuration
@@ -332,6 +386,13 @@ host = "192.168.1.50"
description = "Basestation over WiFi"
```
Both live in `~/.meshterm`, along with everything else MeshTerm remembers: the SQLite
history, the outbox, the contact and channel caches, stored admin passwords, and the log.
**`$MESHTERM_HOME` moves the whole directory**, which is the way to run a second radio —
or a `--mock` session — without touching the one you use every day. `--db` moves the
database alone; the rest stays where it was. [`docs/cli.md`](docs/cli.md#where-meshterm-keeps-its-state)
lists every file.
Timestamps are stored as UTC and rendered in your local time. History older than the
`history_days` preference is pruned once at session start.
+1635
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.2.6"
__version__ = "0.3.1"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+1 -1
View File
@@ -6,7 +6,7 @@
**MeshTerm** is a full-featured **MeshCore** client for the terminal. It talks to companion devices running the stock firmware over serial, Bluetooth, or TCP.
Everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks!
Nearly everything MeshTerm can do thru its interactive Text-based User Interface *(TUI)*, it can also do from the Command-Line Interface *(CLI)*. The live pictures stay in the TUI, where they belong — the map, the dashboard, the live feed, the watchtower, the mesh walk — and everything else has a command. The TUI runs the same code as the CLI, so you get the best of both worlds: a great user experience, and the ability to script or schedule tasks! Every command prints for a person by default, and speaks JSON with `--json` when a program is reading!
Everything the radio overhears is recorded to a local database from the moment it's turned on — adverts, telemetry, signal quality, positions, etc. Features like maps, dashboard, and route graphs read back from it, so the longer it runs, the more it know about the mesh!
+311 -98
View File
@@ -15,9 +15,10 @@ from pathlib import Path
import typer
from rich.console import Console
from typer.core import TyperGroup
from .context import AppContext
from .core import win32dll
from .core import exitcodes, win32dll
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.connection import DeviceCommandError, is_connection_lost
@@ -31,48 +32,90 @@ from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform, without_emoji
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui import renderers, script
from .ui.renderers import OutputFormat
from .ui.termfont import emoji_support
from .ui.theme import make_console
#: The exit-status table, printed under every ``--help``. A documented return value is
#: half of what makes the CLI scriptable (the other half is :mod:`meshterm.ui.script`),
#: and a caller should not have to find it in a README.
EXIT_STATUS_EPILOG = "Exit status: " + " · ".join(
f"{code} {meaning}" for code, meaning in exitcodes.MEANINGS.items()
)
def _unframe_help_panels() -> None:
"""Render Typer's help/error sections as coloured headings instead of boxed panels.
Typer's rich help gives us the colour we want — yellow usage, cyan options, green
switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`,
the boxed framing scripted output is meant to be free of. There's no constant to drop
that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that
keeps the colour and the section title (as a bold heading, indigo like the app accent,
red for errors) but no border — the same heading-over-content shape tool results use
(see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less.
class _GlobalOptionsAnywhere(TyperGroup):
"""A group whose own options may be typed after the subcommand as well as before.
Click binds an option to whatever command declares it, so a global declared on the
callback is a parse error one word later: ``meshterm contacts --json`` failed with a
bare usage error while ``meshterm --json contacts`` worked. That is the wrong way
round for an output-format flag — ``-o json`` goes *after* the verb in every tool that
has one, and ``--json`` is the first thing anyone will reach for here.
So the group's own options are lifted to the front before Click sees them, and both
orders mean the same run. ``--`` stops the lifting, which is the standard way to say
the rest is data; ``--help`` is deliberately never lifted, since ``contacts --help``
must stay the *contacts* help rather than silently becoming the program's.
"""
import typer.rich_utils as rich_utils
from rich.console import Group
from rich.text import Text
def _bare_panel(
renderable: object,
*,
title: object = None,
border_style: str = "",
**_: object,
) -> Group:
style = "bold red" if border_style == "red" else "bold #818cf8"
heading = Text(str(title), style=style) if title else Text("")
return Group(Text(""), heading, renderable) # type: ignore[list-item]
rich_utils.Panel = _bare_panel # type: ignore[assignment,misc]
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
"""Lift this group's options out of ``args``, then parse as Click normally would."""
return super().parse_args(ctx, _globals_first(self, args))
_unframe_help_panels()
def _globals_first(group: TyperGroup, args: list[str]) -> list[str]:
"""``args`` with ``group``'s own options moved ahead of the subcommand.
An option is told from an argument by carrying ``is_flag`` — Typer vendors Click, so
there is no importable ``click.Option`` to test against, and the attribute is the same
question asked without reaching into a private package.
Args:
group: The command whose options count as global.
args: The argument list as typed.
Returns:
A reordered list. The relative order of the options, and of everything else, is
preserved, so a repeated flag resolves exactly as Click would resolve it.
"""
takes_value: dict[str, bool] = {}
for param in group.params:
if getattr(param, "is_flag", None) is None or param.name == "help":
continue
for opt in (*param.opts, *param.secondary_opts):
takes_value[opt] = not param.is_flag
lifted: list[str] = []
kept: list[str] = []
index = 0
while index < len(args):
token = args[index]
if token == "--":
kept.extend(args[index:])
break
name = token.split("=", 1)[0]
if name not in takes_value:
kept.append(token)
index += 1
elif "=" in token or not takes_value[name] or index + 1 >= len(args):
lifted.append(token)
index += 1
else:
lifted.extend(args[index : index + 2])
index += 2
return lifted + kept
app = typer.Typer(
cls=_GlobalOptionsAnywhere,
add_completion=False,
no_args_is_help=False,
# Rich help for its colour (usage, option flags, metavars), but with the boxed
# section panels flattened to plain headings by _unframe_help_panels() above — a
# splash of colour, none of the framing.
rich_markup_mode="rich",
# Click's own plain help, not Typer's rich one: no boxed Options/Commands panels, no
# colour, no markup to strip out of a piped `--help`. The CLI is read by scripts, and
# its help is read the way every other utility's is (see meshterm.ui.script).
rich_markup_mode=None,
# The one description, same as the package summary and the PyPI page. See
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
help=(
@@ -80,6 +123,7 @@ app = typer.Typer(
"Supports USB, Bluetooth and TCP companion connection. "
"For Windows, macOS and Linux."
),
epilog=EXIT_STATUS_EPILOG,
)
# The context built by the callback and consumed by subcommands within one process.
@@ -109,6 +153,9 @@ def main_callback(
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
absolute: bool = typer.Option(
False, "--absolute", help="Print absolute timestamps instead of relative ages"
),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
platform: str | None = typer.Option(
None,
@@ -127,7 +174,8 @@ def main_callback(
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
json_output: Request machine-readable output from tools.
json_output: Print the answer as JSON instead of aligned text.
absolute: Print times as ISO-8601 instants rather than relative ages, for this run.
quiet: Suppress console logging (file logging continues).
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
"""
@@ -148,22 +196,55 @@ def main_callback(
active = without_emoji(active)
set_platform(active)
if mock and (port or ble or tcp):
# One flag says "pretend", the other names one exact radio, and `--mock` used to
# win silently — so a scheduled `--port COM7 --mock info` reported on a simulator
# while reading as though it had reached the radio. Two contradictory claims about
# which device to talk to is a bad command line, not a precedence question.
named = "--port" if port else ("--ble" if ble else "--tcp")
raise typer.BadParameter(f"--mock and {named} name different devices; pass one")
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
console = make_console()
# Two front ends, two consoles, and which one this process gets is settled here — a
# named subcommand is a scripted run, so it prints on the plain, colourless,
# never-wrapping console the CLI's output language is written for (see
# meshterm.ui.script); no subcommand means the menu, which needs the themed one.
scripted = ctx.invoked_subcommand is not None
console = script.console() if scripted else make_console()
# Loaded before logging is configured, because how much goes in the file is one of
# them — and handed to the context afterwards so the file is not read twice.
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
if absolute:
# An override of the preference rather than a second switch beside it: *how
# MeshTerm behaves* is a preference by this project's own rule, and a flag that
# bypassed the registry would be a behaviour a reader could not find. Set, never
# saved — it is a claim about this run.
prefs.set("cli_time_format", "absolute")
configure_logging(
console,
# A scripted run's stdout carries its answer and nothing else, so log records go
# to stderr instead of interleaving with the data a caller is parsing.
script.stderr_console() if scripted else console,
settings.config_dir,
level=logging.INFO,
# And it only hears about faults. Every *expected* failure is already reported on
# stderr in one line and in the exit status (see `_reported`), so echoing the same
# thing again as a WARNING record would say it twice; the file keeps both either
# way. The menu stays at INFO, where the log pane is the only place these show.
level=logging.ERROR if scripted else logging.INFO,
file_level=level_from_name(prefs.log_level),
quiet=quiet or json_output,
)
if profile is not None and settings.resolve_profile(profile) is None:
# Falling through to ordinary discovery would pick whichever radio is attached, so
# a typo in a scheduled `--profile yagi config advert` transmits from the wrong
# node rather than failing. Naming a profile is a claim about *which* device, and
# an unkeepable claim is a bad argument.
known = ", ".join(sorted(settings.profiles)) or "none are defined"
raise typer.BadParameter(f"no device profile named {profile!r} (known: {known})")
app_ctx = AppContext(
preferences=prefs,
console=console,
@@ -177,7 +258,10 @@ def main_callback(
ble_override=ble,
tcp_override=tcp,
ble_pin=ble_pin,
json_output=json_output,
output=OutputFormat.JSON if json_output else OutputFormat.PLAIN,
# Whether a tool may stop and ask, which is a different question from what its
# output looks like. A pipe on stdin means nobody is there to answer.
interactive=_stdin_is_a_person(),
explicit_selection=(
profile is not None or port is not None or ble is not None or tcp is not None
),
@@ -186,6 +270,12 @@ def main_callback(
ctx.call_on_close(app_ctx.repo.close)
if ctx.invoked_subcommand is None:
if json_output:
# There is no document an interactive session can emit, and launching the
# full-screen menu for a caller that asked for JSON would hang whatever was
# waiting to parse it.
raise typer.BadParameter("--json needs a subcommand; the menu has no document")
from .ui.menu import run_menu
# Before prompt_toolkit takes the screen: this can change the console's own font,
@@ -222,16 +312,21 @@ def main_callback(
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
"""On the classic Windows console, offer the reader something better than it.
"""On the classic Windows console, get the reader somewhere better than it.
Two offers, in the order of how much they buy. **Windows Terminal** first, because it
is the only one that gets the whole app: emoji icons included, which no font can put
on the classic console — the only Windows fonts carrying emoji are proportional, and
a console will not take one. Then, for a reader who stays, **a font** that can at
least draw the charts and the marks.
Two remedies, and they are not asked the same way.
**Windows Terminal** first, and simply done. It is the only thing that gets the whole
app — emoji icons included, which no font can put on that console, the only Windows
fonts carrying any being proportional and a console taking none of them. Moving there
installs nothing and changes nothing, so there is one sensible answer to a question
that would be put to someone who has not seen the app yet and cannot judge it.
**A font** second, only where the move was not possible, and that one *is* asked —
it writes a file onto someone's machine, which is theirs to agree to.
Args:
console: The console to ask on.
console: The console to report and ask on.
prefs: The preference set, read for a previous refusal and written on a new one.
Returns:
@@ -241,24 +336,30 @@ def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences
if not classic_console() or prefs.get("console_setup") == "off":
return False
if _offer_windows_terminal(console):
if _move_to_windows_terminal(console):
return True
_offer_a_font_this_console_can_draw(console, prefs)
return False
def _offer_windows_terminal(console: Console) -> bool:
"""Offer to reopen the session in Windows Terminal, where the app looks like itself.
def _move_to_windows_terminal(console: Console) -> bool:
"""Reopen the session in Windows Terminal, where the app can draw all of itself.
Asked with yes as the default, which is not how the font offer is asked: this one
changes nothing on the machine — no install, no setting, one new window — and it is
the only answer that gets the reader the app as it was drawn.
Done rather than offered. The classic console cannot show a single icon whatever it is
given, so "reopen there?" is a question with one sensible answer, asked of someone who
has not seen the app yet and so cannot judge it — and asked at the worst moment, before
anything has been drawn. Moving costs nothing and changes nothing: no install, no
setting, one window instead of another.
It is announced rather than silent, and the announcement names the way to stop it,
because a program that opens a window you did not ask for should at least say so.
``console_setup`` set to ``off`` keeps the session here for good.
Args:
console: The console to ask on.
console: The console to report on.
Returns:
Whether Windows Terminal was started and this session should stand down.
Whether Windows Terminal took over and this session should stand down.
"""
from .core import winterminal
@@ -266,17 +367,12 @@ def _offer_windows_terminal(console: Console) -> bool:
return False
console.print()
console.print("[accent]•[/accent] This is the classic Windows console, and it can only")
console.print(" draw part of MeshTerm — no icons, and no charts.")
console.print("[accent]•[/accent] Opening in [accent]Windows Terminal[/accent] — this console")
console.print(" can't draw MeshTerm's icons or charts.")
console.print("[muted] Preferences → Display → Console setup keeps it here instead.[/muted]")
console.print()
console.print(" [accent]Windows Terminal[/accent] draws all of it, and you have it.")
console.print(" MeshTerm can reopen itself there now. Nothing is installed")
console.print(" and nothing is changed; it is just a better window.")
console.print()
if not _asks_yes(console, " Reopen in Windows Terminal? [Y/n] ", default=True):
return False
if winterminal.reopen():
console.print("[ok]✓[/ok] Reopening in Windows Terminal.")
get_logger().info("reopened the session in Windows Terminal")
return True
console.print("[warn]⚠[/warn] Windows Terminal would not start. Staying here.")
@@ -446,23 +542,38 @@ def platform_command() -> None:
the flavour and the usual reason a Windows session looks plainer than the screenshots
(see :func:`meshterm.ui.termfont.emoji_support`).
"""
assert _platform_resolution is not None # set by the callback that always runs first
r = _platform_resolution
console = make_console()
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
console.print(f" --platform: {r.flag or '(not passed)'}")
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
from .ui import fields
from .ui.report import Facts
assert _platform_resolution is not None # set by the callback that always runs first
assert _state is not None # ditto
r = _platform_resolution
emoji = emoji_support()
verdict = "yes" if emoji.supported else "no — icons use the compact glyphs"
console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})")
if not emoji.supported and emoji.source == "console":
console.print(
" this is the classic Windows console, which draws no emoji whatever font\n"
" it is set to. Windows Terminal and VS Code's terminal both do — running\n"
" MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this."
renderers.for_format(_state.output, _state.console).render(
(
Facts(
key="platform",
fields=(
fields.word("platform", "platform"),
fields.word("platform_source", "platform_source"),
fields.word("flag", "flag"),
fields.word("env", "env"),
fields.word("device_tree_model", "device_tree_model"),
fields.flag("icons", "icons"),
fields.word("icons_source", "icons_source"),
),
values={
"platform": r.platform.name,
"platform_source": r.source,
"flag": r.flag or None,
"env": r.env or None,
"device_tree_model": r.detected_model or None,
"icons": emoji.supported,
"icons_source": emoji.source,
},
),
)
)
@app.command(name="specimen")
@@ -473,9 +584,26 @@ def specimen_command() -> None:
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
previews that flavour. See :mod:`meshterm.ui.specimen`.
The one command that keeps its colour, and it builds its own themed console to do it
(rather than the plain one every other subcommand prints on — see
:mod:`meshterm.ui.script`). This is not scripted output that happens to be pretty: the
colour *is* the output. A monochrome specimen would test nothing.
It is also the one command that *refuses* ``--json``, loudly and as a usage error.
There is no data behind a colour card, so a document of it would be either a lie or an
empty gesture — and a loud refusal is the same answer this project already gives for
the map, the dashboard and the live feed, which simply have no subcommand to refuse
from.
"""
from .ui.specimen import specimen_lines
assert _state is not None # set by the callback that always runs first
if _state.output is not OutputFormat.PLAIN:
raise typer.BadParameter(
"specimen has no machine-readable output — its output is the colour"
)
console = make_console()
for line in specimen_lines():
console.print(line)
@@ -493,44 +621,119 @@ def run_tool_command(tool: Tool, params: dict) -> None:
"""
assert _state is not None # set by the callback before any subcommand runs
try:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
# Expected user-facing error (ambiguous/absent device, a bad config or preference
# value, or a transient command failure): show the message, not a traceback. Logged
# at warning because it is a thing that went wrong, even though it is an ordinary
# one — a log that only holds crashes cannot answer "what happened before it".
get_logger().warning("%s failed: %s", tool.name, exc)
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
result = asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except DeviceSelectionError as exc:
# No companion could be picked at all: nothing was transmitted, and no retry is
# going to help until the caller attaches a device or names one.
raise _reported(tool, exc, exitcodes.NO_DEVICE) from exc
except DeviceCommandError as exc:
# A device was reached and the operation failed. Worth retrying.
raise _reported(tool, exc, exitcodes.DEVICE) from exc
except (DeviceConfigError, PreferenceError) as exc:
# A value we were given is wrong — a bad setting key, an out-of-range preference.
# Retrying changes nothing; the caller has to change what it asked for.
raise _reported(tool, exc, exitcodes.FAILURE) from exc
except (typer.BadParameter, typer.Abort, typer.Exit):
# A tool that rejected one of its own arguments (an unresolvable `--to`, a
# malformed path) raised the same thing the parser would have. It is a usage
# error, Click already knows how to print it and what to exit with, and it is not
# a fault — so it passes through without a traceback or a pointer to the log.
raise
except Exception as exc:
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
# from in a one-shot scripted run the way the interactive menu offers — but it should
# still read as a clean message, not a traceback.
# A dropped serial link (device unplugged/powered off mid-command) can't be
# recovered from in a one-shot scripted run the way the interactive menu offers —
# but it reads as a clean message, not a traceback, and as a device failure.
if is_connection_lost(exc):
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
raise _reported(
tool,
"the connection to the device was lost (it may have been unplugged or powered off)",
exitcodes.DEVICE,
) from exc
# Anything else is a real fault. It still reaches the terminal as a traceback,
# because a person looking at one wants to see it — but it also lands in the log,
# which is the copy that survives the terminal being closed and is the one thing
# worth attaching to a bug report.
get_logger().exception("%s raised an unhandled error", tool.name)
_report_log_location(_state.console, _state.settings.config_dir)
_report_log_location(script.stderr_console(), _state.settings.config_dir)
raise
if result.exit_code != exitcodes.OK:
raise typer.Exit(result.exit_code)
async def _drive(coro, ctx: AppContext) -> None:
def _stdin_is_a_person() -> bool:
"""Whether somebody is there to answer a prompt.
The test a tool actually wants before it stops and asks: a redirected or piped stdin
means nobody is watching, and a command that blocks there has hung rather than failed.
``tx-optimize`` used to ask ``--json`` instead — a flag about output format, which
answers a different question and answered this one wrong in both directions.
``isatty`` alone is not enough on Windows, and the gap is exactly the case this exists
for. ``NUL`` is a *character device*, so a scheduled task's empty stdin answers yes —
and :func:`getpass.getpass` on Windows then reads the console directly through
``msvcrt`` rather than through stdin, and waits forever for a keypress nobody is there
to make. ``GetConsoleMode`` succeeds only on a real console handle, which is the
question being asked.
Returns:
``True`` only where stdin is a terminal somebody could type into.
"""
try:
if not sys.stdin.isatty():
return False
except (AttributeError, ValueError): # pragma: no cover - stdin closed or replaced
return False
if sys.platform != "win32":
return True
try: # pragma: no cover - the branch is Windows-only and needs a real handle
import ctypes
import msvcrt
mode = ctypes.c_uint()
handle = msvcrt.get_osfhandle(sys.stdin.fileno())
return bool(win32dll.kernel32().GetConsoleMode(handle, ctypes.byref(mode)))
except Exception: # pragma: no cover - no console, or a stubbed kernel32
return False
def _reported(tool: Tool, problem: object, code: int) -> typer.Exit:
"""Log a failure, say so on stderr, and build the exit it should end on.
The message goes to stderr in the ``program: what went wrong`` shape every utility
uses, so a caller redirecting stdout still sees it and a caller parsing stdout never
has to filter it out. It carries no mark and no colour — the exit status is what says
this failed, which is why it is classified (see :mod:`meshterm.core.exitcodes`).
It is logged at warning rather than error because these are the *ordinary* failures —
an absent device, a wrong password — and a log holding only crashes cannot answer
"what happened just before it".
Args:
tool: The tool that failed, for the log line.
problem: The exception or message to report.
code: The status to exit with.
Returns:
The :class:`typer.Exit` for the caller to raise.
"""
get_logger().warning("%s failed: %s", tool.name, problem)
script.stderr_console().print(f"meshterm: {problem}", style="err", highlight=False)
return typer.Exit(code)
async def _drive(coro, ctx: AppContext): # noqa: ANN201 - passes the awaited value through
"""Await a coroutine then disconnect the device (but keep the repo open).
Args:
coro: The coroutine to run (a tool execution or the menu loop).
ctx: The application context whose device should be closed afterward.
Returns:
Whatever ``coro`` returned — a :class:`~meshterm.tools.base.ToolResult` on the
scripted path, whose ``exit_code`` the caller turns into the process status.
"""
try:
await coro
return await coro
finally:
if ctx._device is not None:
try:
@@ -541,7 +744,18 @@ async def _drive(coro, ctx: AppContext) -> None:
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
"""Run a tool and print its closing message and artifact list.
"""Run a tool, render the answer it stated, and close with what it has to say.
The one place a report becomes output. The tool says what happened as data and this
hands it to whichever renderer ``--json`` selected, exactly as ``exit_code`` is stated
here and turned into a process status by the caller — which is what lets a third format
be a new renderer rather than an edit to twenty tools.
A tool's ``message`` — "✓ applied 3 changes", "✓ traced Alice" — goes to **stderr**,
like every other acknowledgement (see :meth:`~meshterm.ui.surface.PlainUi.ack`). It is
the closing line rather than the answer, so it must stay out of a redirect; it is also
the count a person at a prompt actually wanted after a command that scrolled, so
dropping it was throwing it away to protect a stream it was never going to reach.
Args:
tool: The tool to execute.
@@ -552,10 +766,9 @@ async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> Tool
The tool's :class:`ToolResult`.
"""
result = await tool.execute(ctx, params)
renderers.for_format(ctx.output, ctx.console).render(result.report)
if result.message:
ctx.console.print(result.message)
for artifact in result.artifacts:
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
ctx.ui.ack(result.message)
return result
+42 -6
View File
@@ -30,6 +30,7 @@ from .core.settings_store import SettingsStore
from .core.watch_store import WatchStore
from .persistence.logging import get_logger
from .persistence.repository import Repository
from .ui.renderers import OutputFormat
if TYPE_CHECKING:
from .services.advert_scheduler import AdvertScheduler
@@ -91,7 +92,16 @@ class AppContext:
interactive picker), selecting the TCP transport. Takes precedence over
``ble_override`` and ``port_override``.
ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device.
json_output: Whether tools should emit machine-readable output.
output: Which format a scripted run prints its answer in (see
:class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to
decide *what* to say — it states its answer as a
:class:`~meshterm.ui.report.Report` and the CLI boundary picks the renderer —
and the two streaming commands read it only to open the right kind of stream.
interactive: Whether this run may stop and ask a person something. False for every
scripted run whose stdin is not a terminal, which is the question a tool
actually wants answered before it prompts. ``tx-optimize`` used to ask
``--json`` instead, and a flag about *output format* cannot answer it: without
the flag, a scheduled run on a terminal would sit waiting for a password.
selected_device: The discovered device chosen for this session, when known, so it
can be remembered after a successful connection.
explicit_selection: Whether ``--port``/``--ble``/``--profile`` was passed explicitly
@@ -118,7 +128,8 @@ class AppContext:
ble_override: str | None = None
tcp_override: str | None = None
ble_pin: str | None = None
json_output: bool = False
output: OutputFormat = OutputFormat.PLAIN
interactive: bool = True
selected_device: DiscoveredDevice | None = None
explicit_selection: bool = False
_device: Device | None = field(default=None, init=False, repr=False)
@@ -439,6 +450,31 @@ class AppContext:
getattr(device, "endpoint", None) if (self._active_transport == "tcp") else None
)
async def _open(self, what: str) -> None:
"""Open :attr:`_device`, reporting a failure to open as a *selection* failure.
The two device exit statuses answer different questions — ``NO_DEVICE`` says
nothing was transmitted and the caller should look at what is plugged in;
``DEVICE`` says the radio was reached and the operation failed, so retrying is
reasonable. Every failure on this path is the first kind: the connection does not
exist yet, so nothing has been sent over it. It used to come back as ``DEVICE``
with the message "the connection to the device was lost", which describes a
connection there had never been — and ``--port NOSUCHPORT`` is the most common way
to reach this line.
Args:
what: The thing being opened, for the message (a port, an address, a host).
Raises:
DeviceSelectionError: If the connection cannot be established.
"""
from .core.selection import DeviceSelectionError
try:
await self._device.connect()
except Exception as exc: # noqa: BLE001 - reclassified, then re-raised
raise DeviceSelectionError(f"could not open {what}: {exc}") from exc
async def device(self) -> Device:
"""Return a connected :class:`Device`, opening the connection on first use.
@@ -462,7 +498,7 @@ class AppContext:
self._active_port = None
self._active_address = None
self._active_endpoint = None
await self._device.connect()
await self._open("the simulator")
return self._device
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
@@ -477,7 +513,7 @@ class AppContext:
self._active_endpoint = f"{tcp_host}:{tcp_port}"
self._active_port = None
self._active_address = None
await self._device.connect()
await self._open(f"TCP companion {tcp_host}:{tcp_port}")
await self._remember_connected()
await self._reconcile_channels()
return self._device
@@ -511,7 +547,7 @@ class AppContext:
self._active_address = ble_address
self._active_port = None
self._active_endpoint = None
await self._device.connect()
await self._open(f"BLE companion {ble_address}")
await self._remember_connected()
await self._reconcile_channels()
return self._device
@@ -530,7 +566,7 @@ class AppContext:
self._active_port = resolution.port
self._active_address = None
self._active_endpoint = None
await self._device.connect()
await self._open(f"serial port {resolution.port}")
await self._remember_connected()
await self._reconcile_channels()
return self._device
+8
View File
@@ -96,6 +96,14 @@ def parse_value(spec: SettingSpec, raw: Any, snapshot: dict | None = None) -> An
"""
text = str(raw).strip()
if spec.value_type == "str":
# `config show` prints an empty string as the two characters `""`, because a key
# with nothing after it reads as a truncated line rather than as an empty value.
# A value the dump prints has to be one `set` takes back (CLAUDE.md, "A value must
# round-trip"), so the quotes come off here — otherwise feeding a dump back in
# silently replaces every empty setting with a pair of quote marks, and the next
# dump looks identical, so nothing ever tells you.
if text == '""':
text = ""
if spec.max_length is not None and len(text) > spec.max_length:
raise DeviceConfigError(
f"{spec.key}: must be at most {spec.max_length} characters, got {len(text)}"
+74 -16
View File
@@ -11,6 +11,15 @@ The registry also tracks which device was most recently connected (``last``), us
preselect and star a default on the startup splash. Membership in the registry is what marks
a device as a confirmed MeshCore companion — the splash reserves its "MeshCore device" tag
for these, rather than guessing from the USB vendor ID.
Alongside those it keeps ``hidden``: the stable ids the splash has been told to stop
listing. It lives here rather than in the preferences because it is a fact about *this
machine's* hardware — the USB adapters and dev boards permanently plugged into it that are
not companions — and because the ids it holds are the registry's own. Hiding is a **splash**
concern only: nothing else consults it, so ``--port`` and every scripted path still reach a
hidden device by name. A hidden id need not be a remembered device (usually it is the
opposite: a serial adapter nobody wants to see), so the set stands on its own rather than
being a flag on a record.
"""
from __future__ import annotations
@@ -101,8 +110,8 @@ class DeviceStore:
"""
self._path = path
def _read(self) -> tuple[dict[str, RememberedDevice], str | None]:
"""Return the parsed ``(registry, last_id)``; empty on a missing/corrupt file.
def _read(self) -> tuple[dict[str, RememberedDevice], str | None, set[str]]:
"""Return the parsed ``(registry, last_id, hidden_ids)``; empty on a missing file.
A missing or corrupt file is treated as "nothing remembered" rather than an error,
so a stray edit never blocks startup. An old flat-format file (a single record at
@@ -112,16 +121,16 @@ class DeviceStore:
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, None
return {}, None, set()
if not isinstance(data, dict):
return {}, None
return {}, None, set()
# Old flat shape: a single record with ``stable_id`` at the top level.
if "devices" not in data and "stable_id" in data:
record = self._record_from(data)
if record is None:
return {}, None
return {record.stable_id: record}, record.stable_id
return {}, None, set()
return {record.stable_id: record}, record.stable_id, set()
registry: dict[str, RememberedDevice] = {}
for entry in (data.get("devices") or {}).values():
@@ -131,7 +140,9 @@ class DeviceStore:
last = data.get("last")
if last not in registry:
last = None
return registry, last
raw_hidden = data.get("hidden")
hidden = {str(x) for x in raw_hidden} if isinstance(raw_hidden, list) else set()
return registry, last, hidden
@staticmethod
def _record_from(entry: object) -> RememberedDevice | None:
@@ -163,17 +174,17 @@ class DeviceStore:
Returns:
The last-connected :class:`RememberedDevice`, or ``None``.
"""
registry, last = self._read()
registry, last, _hidden = self._read()
return registry.get(last) if last is not None else None
def load_all(self) -> dict[str, RememberedDevice]:
"""Return the full registry of confirmed devices, keyed by ``stable_id``."""
registry, _ = self._read()
registry, _last, _hidden = self._read()
return registry
def is_known(self, device: DiscoveredDevice) -> bool:
"""Return whether ``device`` has ever been confirmed as a MeshCore companion."""
registry, _ = self._read()
registry, _last, _hidden = self._read()
return device.stable_id in registry
def remember(
@@ -193,7 +204,7 @@ class DeviceStore:
a reconnect on firmware that couldn't answer the query doesn't erase a model
learned earlier.
"""
registry, _ = self._read()
registry, _last, hidden = self._read()
existing = registry.get(device.stable_id)
if not node_name and existing is not None:
node_name = existing.node_name
@@ -211,7 +222,11 @@ class DeviceStore:
host=device.host or "",
tcp_port=device.tcp_port or 0,
)
self._write(registry, device.stable_id)
# Connecting to a device is the plainest statement that it should be listed, so a
# confirmed one stops being hidden — otherwise it would vanish from the splash the
# moment it proved itself, which is the opposite of what hiding is for.
hidden.discard(device.stable_id)
self._write(registry, device.stable_id, hidden)
def forget(self, stable_id: str) -> bool:
"""Drop a remembered device from the registry.
@@ -230,17 +245,58 @@ class DeviceStore:
Returns:
``True`` if a record was removed, ``False`` if none matched.
"""
registry, last = self._read()
registry, last, hidden = self._read()
if stable_id not in registry:
return False
del registry[stable_id]
if last == stable_id:
last = max(registry, key=lambda k: registry[k].last_connected, default=None)
self._write(registry, last)
hidden.discard(stable_id) # nothing left to hide it from
self._write(registry, last, hidden)
return True
def _write(self, registry: dict[str, RememberedDevice], last: str | None) -> None:
"""Persist the registry, marking ``last`` as the most recently connected device."""
def hidden_ids(self) -> set[str]:
"""Return the stable ids the startup splash has been told not to list."""
_registry, _last, hidden = self._read()
return hidden
def hide(self, stable_id: str) -> None:
"""Stop listing ``stable_id`` on the startup splash, from now until it is shown again.
Kept forever, like the registry itself — the splash is a list of what is plugged into
this machine, and the adapters that are always plugged in and never companions are
the same ones every time. Hiding an id that is not remembered is normal and fine:
those are exactly the rows worth hiding.
Args:
stable_id: The device's :attr:`DiscoveredDevice.stable_id`.
"""
registry, last, hidden = self._read()
if stable_id in hidden:
return
hidden.add(stable_id)
self._write(registry, last, hidden)
def show_all(self) -> int:
"""Un-hide every hidden device; return how many were brought back.
The single way back, deliberately: hiding is per-row and un-hiding is not, because a
hidden row is not on screen to press a key on. Returning the count lets the caller
say what it just did.
"""
registry, last, hidden = self._read()
if not hidden:
return 0
self._write(registry, last, set())
return len(hidden)
def _write(
self,
registry: dict[str, RememberedDevice],
last: str | None,
hidden: set[str],
) -> None:
"""Persist the registry, its ``last`` pointer, and the splash's hidden ids."""
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"devices": {
@@ -259,5 +315,7 @@ class DeviceStore:
for stable_id, record in registry.items()
},
"last": last,
# Sorted so the file does not churn between writes over set ordering alone.
"hidden": sorted(hidden),
}
write_atomically(self._path, json.dumps(payload, indent=2))
+73
View File
@@ -0,0 +1,73 @@
"""The exit statuses ``meshterm`` returns, and what each one means.
A scripted run's *return value is its report*: the CLI says what happened in plain
text on stdout and says whether it worked in ``$?``. The codes are classified rather
than a bare 0/1 so a caller can tell the failures apart without reading prose — a
timeout is worth retrying, a bad key never is.
The set is small and closed. Anything that does not map onto one of the specific
causes below is :data:`FAILURE`; nothing invents a code of its own.
+------+--------------+--------------------------------------------------------+
| Code | Name | Meaning |
+======+==============+========================================================+
| 0 | ``OK`` | The command did what it was asked. |
+------+--------------+--------------------------------------------------------+
| 1 | ``FAILURE`` | It failed for a reason with no more specific code — |
| | | a bad configuration or preference value, an |
| | | unreadable file, an unhandled fault. |
+------+--------------+--------------------------------------------------------+
| 2 | ``USAGE`` | The command line itself was wrong: an unknown flag, a |
| | | missing argument, a value the parser rejected. Click |
| | | returns this on its own; it is named here so the |
| | | table is complete. |
+------+--------------+--------------------------------------------------------+
| 3 | ``NO_DEVICE``| No companion could be selected: none attached, none |
| | | matching ``--port``/``--ble``/``--tcp``, or the |
| | | choice was ambiguous. Nothing was transmitted. |
+------+--------------+--------------------------------------------------------+
| 4 | ``DEVICE`` | A device was reached but the operation failed: a |
| | | command error, a timeout, a link lost mid-run. |
| | | Retrying is reasonable. |
+------+--------------+--------------------------------------------------------+
| 5 | ``NO_RESULT``| The command ran and completed, and there was nothing |
| | | to report: an empty list, a target that never |
| | | answered, a conversation with no messages. stdout is |
| | | empty; this is not an error. |
+------+--------------+--------------------------------------------------------+
:data:`NO_RESULT` is the one that needs saying out loud, because it is the one a
script most wants and no other tool offers: ``grep`` conflates "found nothing" with
"worked", and a caller that has to count output lines to tell an empty mesh from a
full one is parsing when it could be branching.
"""
from __future__ import annotations
#: The command did what it was asked.
OK = 0
#: A failure with no more specific code — a bad value, an unreadable file, a fault.
FAILURE = 1
#: The command line was wrong. Click returns this itself; named here for completeness.
USAGE = 2
#: No companion device could be selected. Nothing was transmitted.
NO_DEVICE = 3
#: A device was reached but the operation failed. Retrying is reasonable.
DEVICE = 4
#: The command completed with nothing to report. Not an error; stdout is empty.
NO_RESULT = 5
#: Every code, by name, for the ``--help`` epilog and the documentation.
MEANINGS: dict[int, str] = {
OK: "success",
FAILURE: "failure",
USAGE: "usage error",
NO_DEVICE: "no device found, or the selection was ambiguous",
DEVICE: "the device was reached but the operation failed",
NO_RESULT: "nothing to report (empty result)",
}
+55 -8
View File
@@ -109,6 +109,10 @@ class PrefSpec:
return f"{self.help} (next launch)" if self.relaunch else self.help
#: The two languages the plain CLI can print a time in. Named for what the reader sees
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
_SILENCE_CHOICES: dict[int, str] = {
**{h: f"{h} h" for h in SILENCE_CHOICES_H},
@@ -122,6 +126,20 @@ _SILENCE_CHOICES: dict[int, str] = {
#: need to disagree with its platform.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: How many colours to send the terminal. ``auto`` is the honest default and the only
#: value that is not itself a depth: it means "take prompt_toolkit's verdict unless we can
#: positively establish better", which is the difference between fixing a host and breaking
#: one. The rest are named for the count a reader can see rather than for the bit depth,
#: because "24-bit" is a fact about the wire and "16 million" is a fact about the screen.
#: See :func:`meshterm.ui.tui.session._color_depth` for why auto is not simply "the most
#: this terminal can do".
_COLOR_DEPTH_CHOICES: dict[str, str] = {
"auto": "auto",
"truecolor": "16 million",
"256": "256",
"16": "16",
}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
@@ -131,7 +149,7 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
"ask": "Ask",
"auto": "Automatic",
"off": "Leave it alone",
}
@@ -284,6 +302,19 @@ PREFERENCES: tuple[PrefSpec, ...] = (
maximum=5000,
),
# --- Display -----------------------------------------------------------------
PrefSpec(
key="cli_time_format",
label="Command-line times",
help="Whether the command line prints ages or timestamps",
group="Display",
value_type="enum",
# Relative, because a person at a prompt asking "heard recently?" should not have
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
# for one run, and the JSON face is UTC either way — it is read somewhere else and
# often later, where a relative age has nothing to be relative to.
default="relative",
choices=_CLI_TIME_CHOICES,
),
PrefSpec(
key="fast_render",
label="Fast redraw",
@@ -303,15 +334,31 @@ PREFERENCES: tuple[PrefSpec, ...] = (
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="console_setup",
label="Console setup",
help="Offer a better terminal or font when this console can't draw MeshTerm",
key="color_depth",
label="Colours",
help="How many colours to send this terminal",
group="Display",
value_type="enum",
# "ask", not "always": changing the font of a window someone else opened is not
# ours to do unasked. Declining twice writes "off" here, which is how the offer
# stops being made rather than being made every launch.
default="ask",
# Auto, because the count a terminal accepts is a fact about the terminal and the
# reader should not have to know it. It is only ever consulted to *raise* the
# verdict prompt_toolkit already reached, never to lower it — a terminal that
# cannot be shown to do better keeps exactly what it had.
default="auto",
choices=_COLOR_DEPTH_CHOICES,
relaunch=True,
),
PrefSpec(
key="console_setup",
label="Console setup",
help="Move to a better terminal when this console can't draw MeshTerm",
group="Display",
value_type="enum",
# Reopening in Windows Terminal is done, not asked: the classic console cannot
# draw a single icon whatever font it is given, so there is only one sensible
# answer and the reader has not seen the app yet to judge it. Installing a *font*
# still asks, because that writes to their machine. Declining that twice writes
# "off" here, which turns both off for good.
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
+39 -1
View File
@@ -119,10 +119,48 @@ def reopen() -> bool:
# arrive intact). `-d` keeps the working directory, which relative paths given
# on the command line depend on.
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
[terminal, "-d", os.getcwd(), "--", *own_command()],
[
terminal,
"-w",
"new",
"--size",
_wanted_size(),
# Otherwise the tab is titled with the executable's full path, which on a
# downloaded build is a line of Downloads folder. The icon is not ours to
# set: Windows Terminal takes that from a profile, and a bare command line
# is not one, so it gets the generic console glyph.
"--title",
"MeshTerm",
"-d",
os.getcwd(),
"--",
*own_command(),
],
env=child_environment(),
close_fds=True,
)
return True
except OSError:
return False
def _wanted_size() -> str:
"""The window to ask Windows Terminal for, as ``cols,rows``.
Asked for, because a window MeshTerm opens should be one MeshTerm fits in. Without
``-w new`` the session lands as a *tab* in whatever window happens to be open and
inherits its size; without ``--size`` a new window takes the reader's global launch
size, which is a setting about their shell and not about this app. Either way the app
can arrive in a window too small for it, and the failure is quiet and cosmetic: the
startup wordmark is 71 columns, so at 70 it silently swaps to the narrow one drawn for
the PicoCalc, and a short window truncates every description onto the pager.
The numbers come from the platform's own stated minimum plus a margin, so they follow
it rather than repeating it. Windows Terminal clamps to what the display can show, so
this is a request and not a demand — on a small screen the app degrades exactly as it
would have anyway.
"""
from ..platforms import get_platform
platform = get_platform()
return f"{platform.readable_cols + 8},{platform.readable_rows + 6}"
+7 -1
View File
@@ -101,8 +101,14 @@ def configure_logging(
logger.propagate = False
if not quiet:
# markup=False: a log line carries whatever the device said — a node name, a
# firmware error — and Rich would read `[...]` in it as a style tag. That made the
# *error path* the one most likely to fail: reporting a fault on a node called
# `[/]Bob` raised MarkupError from inside the handler, replacing the diagnostic
# with a traceback about the diagnostic. No log call in the package marks up its
# message, so parsing them buys nothing.
rich_handler = RichHandler(
console=console, rich_tracebacks=True, show_path=False, markup=True
console=console, rich_tracebacks=True, show_path=False, markup=False
)
rich_handler.setLevel(level)
logger.addHandler(rich_handler)
+10 -14
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from ..core.channels import CHANNEL_SLOT_PROBE_CAP, channel_identity
from ..core.channels import channel_identity
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import (
@@ -128,20 +128,16 @@ class ChatService:
The map is what lets an inbound message (which carries only a slot index) be recorded
against its channel's intrinsic identity. It is rebuilt wholesale so a reordered,
renamed, re-keyed, or cleared slot is reflected accurately.
Read through the session cache, which is the same slot probe the channel manager is
about to make anyway — so a channel edit costs *one* walk of the device rather than
this one plus the manager's. Its own walk was also unbounded where the shared probe
is not: it skipped empty slots and kept going to the 64-slot cap, so on firmware that
never rejects an index every single edit paid 64 round-trips before the screen could
redraw. That is most of what "applying a change takes a while" was.
"""
device = await self._ctx.device()
ids: dict[int, str] = {}
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if not payload:
continue # an empty slot; keep scanning (slots can be non-contiguous)
name = str(payload.get("channel_name") or "")
secret = bytes(payload.get("channel_secret") or b"\x00" * 16)
ids[idx] = channel_identity(name, secret)
self._channel_ids = ids
slots = await self._ctx.devstate.channel_slots()
self._channel_ids = {slot.idx: channel_identity(slot.name, slot.secret) for slot in slots}
async def channel_id_for(self, idx: int) -> str:
"""Resolve a channel slot index to its intrinsic identity, reading the slot fresh.
+24 -3
View File
@@ -94,6 +94,7 @@ class DeviceState:
self._self_info: dict | None = None
self._path_hash_mode: int | None = None
self._channels: list[ChannelSlot] | None = None
self._channels_epoch = 0
self._channel_capacity: int | None = None
# One lock per slow fetch so overlapping first-access callers (two screens opened in
# quick succession) collapse onto a single round-trip instead of each firing their own.
@@ -276,7 +277,9 @@ class DeviceState:
The underlying probe (:func:`~meshterm.ui.channels.read_channel_slots`) walks every
slot index on the firmware, which is one of the slowest reads on a screen open — so it
is well worth reading once. It is best-effort itself (an unsupported firmware yields an
empty list rather than raising), and that result is cached as-is.
empty list rather than raising), but a probe that ended early because a *read failed*
is answered and then dropped rather than cached — see :func:`~meshterm.ui.channels.
probe_channel_slots` for why a short list is not a layout.
Returns:
One :class:`~meshterm.ui.channels.ChannelSlot` per configured slot, in index order.
@@ -284,10 +287,16 @@ class DeviceState:
if self._channels is None:
async with self._channels_lock:
if self._channels is None:
from ..ui.channels import read_channel_slots
from ..ui.channels import probe_channel_slots
device = await self._ctx.device()
self._channels = await read_channel_slots(device)
slots, complete = await probe_channel_slots(device)
if complete:
self._channels = slots
# An incomplete probe is answered but not kept: one timed-out read used
# to poison the whole session, and the shortfall does not announce
# itself — an empty result reads exactly like a device with no channels.
return slots
return self._channels
async def channel_capacity(self) -> int:
@@ -373,9 +382,21 @@ class DeviceState:
"""Drop the cached path-hash mode — call after writing it in the config editor."""
self._path_hash_mode = None
@property
def channels_epoch(self) -> int:
"""How many times the channel layout has been dropped this session.
A caller that wants to know whether *anything* rewrote a slot while it was busy
compares this across the gap rather than trusting a return value: a write that
landed and then raised on its way back moved the device just as much as one that
returned cleanly, and the count it never got to increment cannot say so.
"""
return self._channels_epoch
def invalidate_channels(self) -> None:
"""Drop the cached channel slots — call after the channel editor saves or clears one."""
self._channels = None
self._channels_epoch += 1
def invalidate_config(self) -> None:
"""Drop everything the config editor can change in one call (self-info + routing mode).
+91 -3
View File
@@ -26,6 +26,7 @@ from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..ui.markdown import MarkdownDoc
from ..ui.report import Facts
class _AboutTool(Tool):
@@ -67,7 +68,11 @@ class _AboutTool(Tool):
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print the page — only reachable from the CLI (the menu opens the screen).
"""State the page — only reachable from the CLI (the menu opens the screen).
Without its scannable codes: a QR is a second rendering of a link the page already
prints, drawn for a phone pointed at a screen, and redirected into a file it is a
block of block characters around nothing new.
Args:
ctx: Shared application context.
@@ -76,8 +81,10 @@ class _AboutTool(Tool):
Returns:
A :class:`ToolResult` naming the page that was shown.
"""
ctx.ui.show(self.page())
return ToolResult(summary={"page": self.name})
return ToolResult(
summary={"page": self.name},
report=(_written_page(self.name, self.title, self.page().for_script()),),
)
@register
@@ -150,3 +157,84 @@ class SupportProjectTool(_AboutTool):
from ..ui.about import support_project
return support_project()
def _written_page(name: str, title: str, doc: MarkdownDoc) -> Facts:
"""One prose page, stated once for both faces.
**Wrapped**, and that is the one deliberate exception to "no line wrapping". The rule
exists so a *record* is never split across two lines with its fields landing under the
wrong headings; a paragraph has no records and no headings, and left unwrapped these
four pages print as single six-hundred-cell lines no terminal can read. So the page is
drawn on a console :data:`~meshterm.ui.script.PAGE_WIDTH` cells wide — a fixed width,
not the terminal's, so the same paragraph reads the same piped, redirected, or shipped
as the document's ``text``.
``links`` is the one machine-actionable thing on a page of prose, and the one thing the
menu bothers to draw a QR of. ``text`` is the page's plain rendering rather than a fold
of the markdown into nested JSON: a structured markdown tree would be a second
rendering, not data, and nothing would consume it.
"""
from .. import __version__
from ..ui import fields
from ..ui.report import BARE, Column, Facts
text = _rendered_page(doc)
return Facts(
key="page",
fields=(
fields.word("page", "page"),
fields.word("title", "title"),
fields.word("version", "version"),
fields.word("text", "text"),
Column(key="links"),
),
values={
"page": name,
"title": title,
# Duplicated from the package on purpose: a page's own facts are part of its
# content, and this is the version its `{version}` placeholder resolved to.
"version": __version__,
"text": text,
"links": _links(text),
},
shape=BARE,
bare="text",
)
def _rendered_page(doc: MarkdownDoc) -> str:
"""The page as plain text, folded to the fixed page width."""
import io
from rich.console import Console
from ..ui import script
buffer = io.StringIO()
Console(
file=buffer,
width=script.PAGE_WIDTH,
color_system=None,
highlight=False,
emoji=False,
markup=False,
).print(doc)
return "\n".join(line.rstrip() for line in buffer.getvalue().splitlines()).strip("\n")
def _links(text: str) -> list[str]:
"""Every URL the page carries, in document order, de-duplicated.
Read off the rendered page rather than the markdown source, so a link written as a
reference and a link written inline both count once and both read as what the reader
sees. Trailing punctuation is not part of a URL and is trimmed.
"""
import re
seen: list[str] = []
for match in re.findall(r"https?://[^\s<>()\[\]]+", text):
url = match.rstrip(".,;:*_")
if url not in seen:
seen.append(url)
return seen
+45 -5
View File
@@ -12,10 +12,13 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from ..core import exitcodes
if TYPE_CHECKING: # avoid importing typer/context at module load for fast startup
import typer
from ..context import AppContext
from ..ui.report import Report
@dataclass(slots=True)
@@ -24,13 +27,37 @@ class ToolResult:
Attributes:
summary: JSON-serializable summary persisted to the run record.
message: Optional human-readable closing message.
message: Optional human-readable closing message. The *menu's* closing line — a
scripted run does not print it (see
:func:`~meshterm.cli._execute_and_render`), because on the command line it
restates the output above it and the exit status below it.
artifacts: Paths to any files produced (e.g. generated visualizations).
exit_code: The status a scripted run should exit with — see
:mod:`meshterm.core.exitcodes`. Left at ``OK`` by everything that worked;
set to ``NO_RESULT`` by a tool that ran fine and found nothing to report, so
a caller can tell an empty mesh from a full one without counting lines. A
*failure* is raised, not returned, so this never carries one.
report: **The answer**, as data (see :mod:`meshterm.ui.report`). A scripted run
states it here and the CLI boundary hands it to a renderer, exactly as it
already does with ``exit_code`` — the tool says what happened, and something
else turns that into bytes or into a process status. It used to be printed
instead, which meant the answer was gone by the time anything could ask for it
in another format, which is why ``--json`` reached two commands and stopped.
``None`` for the menu path and for a feature with no scripted face at all (the
map, the dashboard), which keep working unchanged.
Not folded into ``summary``: that is the *run log's* record of what this
invocation did, written to the ``runs`` table on every execution including a
menu run. Putting a five-hour ``monitor`` capture in SQLite would be the price
of one field fewer.
"""
summary: dict[str, Any] = field(default_factory=dict)
message: str | None = None
artifacts: list[str] = field(default_factory=list)
exit_code: int = exitcodes.OK
report: Report | None = None
class Tool(ABC):
@@ -123,7 +150,9 @@ class Tool(ABC):
Raises:
Exception: Re-raises any error from :meth:`run` after recording it.
"""
from ..core.connection import DeviceCommandError
import typer
from ..core.connection import DeviceCommandError, is_connection_lost
from ..core.device_config import DeviceConfigError
from ..core.preferences import PreferenceError
from ..core.selection import DeviceSelectionError
@@ -137,16 +166,27 @@ class Tool(ABC):
DeviceConfigError,
DeviceCommandError,
PreferenceError,
# A tool rejecting one of its own arguments (an unresolvable `--to`) raises
# what the parser would have. It is a usage error, not a fault.
typer.BadParameter,
typer.Abort,
typer.Exit,
) as exc:
# Expected user-facing condition (no/ambiguous device, a bad config or
# preference value, or a transient command failure): record it but don't dump
# a traceback; callers print the message cleanly.
# preference value, a bad argument, or a transient command failure): record it
# but don't dump a traceback; callers print the message cleanly.
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
ctx.log.debug("run %s aborted: %s", run_id, exc)
raise
except Exception as exc: # noqa: BLE001 - we record then re-raise
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
ctx.log.exception("run %s failed: %s", run_id, exc)
if is_connection_lost(exc):
# The radio was unplugged or powered off mid-command. Every layer above
# already knows how to say that in one line; a stack of serial internals
# describes how the driver found out, which is not the same question.
ctx.log.warning("run %s lost the device connection: %s", run_id, exc)
else:
ctx.log.exception("run %s failed: %s", run_id, exc)
raise
ctx.repo.finish_run(run_id, "ok", result.summary)
ctx.log.debug("run %s ok", run_id)
+159 -55
View File
@@ -12,16 +12,18 @@ everything to do with configuring the slots themselves.
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from rich.console import Group
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core.channels import (
channel_hash,
derive_secret,
is_public_channel,
normalize_secret,
parse_share_url,
random_secret,
@@ -29,6 +31,9 @@ from ..core.channels import (
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Facts, Listing
@register
class ChannelsTool(Tool):
@@ -49,7 +54,7 @@ class ChannelsTool(Tool):
params: A ``cli_action`` with its arguments (CLI), or empty for the menu.
Returns:
A :class:`ToolResult` summarizing what happened.
A :class:`ToolResult` recording what happened (the menu path shows nothing).
"""
action = params.get("cli_action")
if action is not None:
@@ -57,14 +62,11 @@ class ChannelsTool(Tool):
from ..ui.channels import manage_channels
changes = await manage_channels(ctx)
message = (
f"[ok]✓[/ok] applied [brand]{changes}[/brand] "
f"channel change{'' if changes == 1 else 's'}"
if changes
else None
)
return ToolResult(summary={"changes": changes}, message=message)
# No message: the manager acknowledged its work on the way out, which meant telling
# the reader that changes they had just watched land were applied. The count still
# goes to the run log through ``summary``, which is the record of what an invocation
# did rather than anything shown.
return ToolResult(summary={"changes": await manage_channels(ctx)})
# -- CLI --------------------------------------------------------------------
@@ -88,23 +90,11 @@ class ChannelsTool(Tool):
device = await ctx.device()
slots = await read_channel_slots(device)
if not slots:
ctx.ui.note("[muted]no channels configured[/muted]")
return ToolResult(summary={"channels": 0})
table = Table(title="Channels", border_style="muted", expand=False)
table.add_column("SLOT", justify="right")
table.add_column("NAME")
table.add_column("TYPE")
table.add_column("HASH")
for slot in slots:
table.add_row(
str(slot.idx),
Text(slot.name, style="brand"),
"public" if slot.is_public else "private",
slot.hash,
)
ctx.ui.show(table)
return ToolResult(summary={"channels": len(slots)})
return ToolResult(
summary={"channels": len(slots)},
report=(_channel_listing(slots),),
exit_code=exitcodes.OK if slots else exitcodes.NO_RESULT,
)
async def _cli_add(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Add a public, private-random, or explicitly-keyed channel on a slot."""
@@ -119,9 +109,11 @@ class ChannelsTool(Tool):
else:
secret = normalize_secret(params["secret"]) if params.get("secret") else random_secret()
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] channel [brand]{name}[/brand] set on slot {idx}")
self._print_share(ctx, name, secret)
return ToolResult(summary={"index": idx, "name": name})
self._show_qr(ctx, share_url(name, secret))
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret),),
)
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Join a channel from a name and an explicit key."""
@@ -132,8 +124,10 @@ class ChannelsTool(Tool):
name = str(params["name"])
secret = normalize_secret(str(params["secret"]))
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name}[/brand] on slot {idx}")
return ToolResult(summary={"index": idx, "name": name})
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret, shown=False),),
)
async def _cli_import(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Import a channel from a ``meshcore://channel/add`` link."""
@@ -146,21 +140,34 @@ class ChannelsTool(Tool):
device = await ctx.device()
idx = int(params["index"])
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand] on slot {idx}")
return ToolResult(summary={"index": idx, "name": name})
return ToolResult(
summary={"index": idx, "name": name},
report=(_written(idx, name, secret, shown=False),),
)
async def _cli_share(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print a channel's share link and QR code."""
"""Print a channel's share link, with its QR code in the menu.
The one command that names a channel's key deliberately, so it is the one place
the secret and the share URL leave the app at all — every other channel document
carries the identity and stops there.
"""
from ..ui.channels import read_channel_slots
device = await ctx.device()
idx = int(params["index"])
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
if slot is None:
ctx.ui.note(f"[warn]slot {idx} is empty[/warn]")
return ToolResult(summary={"index": idx, "shared": False})
self._print_share(ctx, slot.name, slot.secret)
return ToolResult(summary={"index": idx, "shared": True})
return ToolResult(
summary={"index": idx, "shared": False},
report=(_written(idx, None, None, shown=False),),
exit_code=exitcodes.NO_RESULT,
)
self._show_qr(ctx, share_url(slot.name, slot.secret))
return ToolResult(
summary={"index": idx, "shared": True},
report=(_written(idx, slot.name, slot.secret),),
)
async def _cli_clear(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Clear a channel slot, removing the channel from the device.
@@ -176,19 +183,30 @@ class ChannelsTool(Tool):
idx = int(params["index"])
slot = next((s for s in await read_channel_slots(device) if s.idx == idx), None)
if slot is None:
ctx.ui.note(f"[muted]slot {idx} is already empty[/muted]")
return ToolResult(summary={"index": idx, "cleared": False})
return ToolResult(
summary={"index": idx, "cleared": False},
report=(_cleared(idx, False),),
exit_code=exitcodes.NO_RESULT,
)
await write_channel(ctx, device, idx, "", None) # empty name => the slot reads as unused
ctx.ui.note(f"[warn]cleared channel {slot.name} on slot {idx}[/warn]")
return ToolResult(summary={"index": idx, "cleared": True})
return ToolResult(
summary={"index": idx, "cleared": True},
report=(_cleared(idx, True),),
)
@staticmethod
def _print_share(ctx: AppContext, name: str, secret: bytes) -> None:
"""Render a channel's QR code and share URL into the output surface."""
from ..ui.qr import qr_text
def _show_qr(ctx: AppContext, url: str) -> None:
"""Draw a channel's share link as a QR code — in the menu, and only there.
url = share_url(name, secret)
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
The QR is for a phone pointed at the screen. Piped into a file it is a block of
block characters wrapped around the one thing that is actually the answer, so a
scripted run states the link and nothing else.
"""
from ..ui.qr import qr_text
from ..ui.surface import TuiUi
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``channels`` subcommand group.
@@ -198,7 +216,7 @@ class ChannelsTool(Tool):
"""
from ..cli import run_tool_command
channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
channels_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@channels_app.command("list", help="List the configured channel slots")
def _list_cmd() -> None:
@@ -247,12 +265,98 @@ class ChannelsTool(Tool):
),
) -> None:
if not yes:
typer.secho(
"Refusing: clearing a slot removes the channel (a private channel's "
"key is lost unless you have it saved). Re-run with --yes to confirm.",
fg="red",
# A usage error, in the parser's own words and under its own status — not a
# red line printed onto whatever is reading this command's output.
raise typer.BadParameter(
"clearing a slot removes the channel (a private channel's key is lost "
"unless you have it saved). Re-run with --yes to confirm."
)
raise typer.Exit(1)
run_tool_command(self, {"cli_action": "clear", "index": index})
app.add_typer(channels_app, name=self.name)
def _channel_listing(slots: list) -> Listing:
"""The configured slots, one record each.
The one listing whose record *is* a shared shape rather than carrying one: a row that
nested its only field under a ``channel`` key would make ``jq '.[].channel.name'``
out of a listing whose every column is already the channel.
"""
from ..ui import fields
from ..ui.report import Listing
return Listing(
key="channels",
columns=(
fields.integer("slot", "SLOT"),
fields.name("name", "NAME"),
fields.word("type", "TYPE"),
fields.hexid("hash", "HASH"),
),
rows=[
{
"slot": slot.idx,
"name": slot.name,
"type": "public" if slot.is_public else "private",
"hash": slot.hash,
}
for slot in slots
],
)
def _written(idx: int, name: str | None, secret: bytes | None, *, shown: bool = True) -> Facts:
"""What a slot now holds, after ``add``, ``join``, ``import`` or ``share``.
All four write or read the same thing and all four know the key they wrote, so all
four state the same shape. The plain face prints the share URL alone where the caller
asked to be given something to pass on (``add``, ``share``) and nothing at all where
it did not (``join``, ``import``): those two were handed the key, and reading it back
to them is not an answer.
Args:
idx: The slot index.
name: The channel name, or ``None`` for a slot that turned out to be empty.
secret: The 16-byte key, or ``None`` for an empty slot.
shown: Whether the plain face prints the URL.
Returns:
The facts block.
"""
from ..ui import fields
from ..ui.fields import ChannelRef
from ..ui.report import BARE, SILENT, Facts
channel = None
if name is not None and secret is not None:
public = is_public_channel(name, secret)
channel = ChannelRef(slot=idx, name=name, public=public, hash=channel_hash(secret))
return Facts(
key="channel",
fields=(
fields.channel("channel"),
fields.hexid("secret", "secret"),
fields.word("url", "url"),
),
values={
"channel": channel,
"secret": secret.hex() if secret else None,
"url": share_url(name, secret) if name is not None and secret is not None else None,
},
shape=BARE if shown else SILENT,
bare="url",
)
def _cleared(idx: int, cleared: bool) -> Facts:
"""What ``channels clear`` did — nothing plain, since the status already said it."""
from ..ui import fields
from ..ui.report import SILENT, Facts
return Facts(
key="cleared",
fields=(fields.integer("slot", "slot"), fields.flag("cleared", "cleared")),
values={"slot": idx, "cleared": cleared},
shape=SILENT,
)
+412 -99
View File
@@ -15,29 +15,36 @@ from __future__ import annotations
import asyncio
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import typer
from rich.table import Table
from rich.cells import cell_len
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
channel_hash,
channel_identity,
is_public_channel,
)
from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
from ..core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_LABELS,
ChatMessage,
Contact,
Conversation,
is_direct_messageable,
)
from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
from ..ui import renderers
from ..ui.chat import _MENTION, _split_channel_sender
from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.theme import name_style
from ..ui.tui import Choice, DeleteRequest, Separator
@@ -46,6 +53,7 @@ from .base import Tool, ToolResult, register
if TYPE_CHECKING:
from ..ui.channels import ChannelSlot
from ..ui.report import Facts, Listing
#: How many recent messages ``chat history`` prints by default.
_HISTORY_LIMIT = 50
@@ -121,7 +129,16 @@ class ChatTool(Tool):
picker = SelectScreen(
"Chat — pick a conversation",
await self._picker_items(ctx),
delete_hint="Del delete history",
# Enter pushes the chat screen, so the committing verb is `open`. Neither the
# scroll nor the erase atom is written into the base: the select list splices
# each in exactly when its key would act — ←→ only while the highlighted row
# overflows, Del only on a thread that has history to lose — and the three of
# them together are what has to stay inside 72 cells (the purge preview's list
# is the same arithmetic). `Del erase` is that budget's doing: the row already
# names the conversation, so the atom spends its cells on the verb, and `erase`
# is the word the app uses elsewhere for taking data away.
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
delete_hint="Del erase",
)
opened = 0
shown = 0
@@ -173,35 +190,43 @@ class ChatTool(Tool):
# colour rule); a name no contact or stored advert carries stays muted.
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
# List contacts by recency — those with messages first, newest exchange at the top —
# then the never-contacted ones alphabetically (see _recency_key).
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
# One measurement for the whole list, headings and rows alike, taken before either is
# built: the name lane is only as wide as the names actually in it, and every cell it
# gives back goes to the message preview (see _lanes).
lanes = _lanes(channels + direct)
# The lane names pin for the whole picker (they mean the same in both groups),
# so scrolling into Direct keeps them overhead with that group's heading under
# them, instead of the header vanishing one row in — see Screen.sticky_rows.
items: list = [
Separator(_picker_header, pinned=True),
Separator(lambda w: _picker_header(lanes, w), pinned=True),
section_heading("📡 Channels"),
]
for conversation in channels:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
title=_row_title(ctx, conversation, live, key_of, lanes),
value=conversation,
# ←→ slide the message alone; the lanes in front of it hold (_Lanes.head).
hscroll_from=lanes.head,
)
)
items.append(section_heading("👤 Direct"))
if companions:
# List contacts by recency — those with messages first, newest exchange at
# the top — then the never-contacted ones alphabetically (see _recency_key).
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
for conversation in direct:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
title=_row_title(ctx, conversation, live, key_of, lanes),
value=conversation,
# Del offers to delete this thread's stored history —
# only where there is history to delete.
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
)
)
else:
@@ -252,20 +277,30 @@ class ChatTool(Tool):
return await self._cli_list(ctx)
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Send a channel or direct message and report the outcome."""
"""Send a channel or direct message and report whether it was acknowledged.
A channel broadcast has nothing to report — there is no acknowledgement on a
channel — so it prints nothing and lets the exit status say it went out. A direct
message prints its ``acked`` state, which is the one thing the send does not
already tell the caller: the radio accepted it either way, and whether the peer
answered is a separate fact.
"""
device = await ctx.device()
text = str(params["text"])
channel = params.get("channel")
if channel is not None:
await ctx.chat.send_channel(int(channel), text, label=f"#{channel}")
ctx.ui.note(f"[ok]✓[/ok] sent to channel [brand]{channel}[/brand]")
return ToolResult(summary={"channel": channel, "sent": True})
return ToolResult(
summary={"channel": channel, "sent": True},
report=(_sent(channel=int(channel)),),
)
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
message = await ctx.chat.send_direct(contact, text)
state = "[ok]✓ delivered[/ok]" if message.acked else "[warn]? no ack[/warn]"
ctx.ui.note(f"[ok]✓[/ok] sent to [brand]{contact.name}[/brand] — {state}")
return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)})
return ToolResult(
summary={"to": contact.name, "acked": bool(message.acked)},
report=(_sent(contact=contact, acked=bool(message.acked)),),
)
async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print a conversation's stored transcript."""
@@ -288,11 +323,11 @@ class ChatTool(Tool):
)
label = contact.name
if not messages:
ctx.ui.note(f"[muted]no messages with {label} yet[/muted]")
else:
ctx.ui.show(_history_table(label, messages))
return ToolResult(summary={"conversation": label, "messages": len(messages)})
return ToolResult(
summary={"conversation": label, "messages": len(messages)},
report=(_transcript(messages),),
exit_code=exitcodes.OK if messages else exitcodes.NO_RESULT,
)
async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Tail inbound messages live in the console (and record them to history).
@@ -309,12 +344,18 @@ class ChatTool(Tool):
Returns:
A :class:`ToolResult` with how many messages were seen.
"""
from ..ui import script
seconds = params.get("seconds") or 0
await ctx.device()
await ctx.chat.start() # begins recording inbound to history too
ctx.console.print(
"[muted]listening for messages"
f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]"
# About the run, not part of its answer: stderr, so a redirected stdout holds
# nothing but messages.
script.stderr_console().print(
"listening for messages"
+ (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"),
style="muted",
highlight=False,
)
seen = 0
@@ -324,27 +365,39 @@ class ChatTool(Tool):
if message is None:
return
seen += 1
stamp = message.received_at.astimezone().strftime("%H:%M:%S")
if message.is_channel:
who = f"#{message.channel}"
else:
who = message.sender or "?"
snr = f" [muted]({message.snr:+.0f} dB)[/muted]" if message.snr is not None else ""
ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]: {message.text}{snr}")
channel = (
_channel_ref(message.channel, f"#{message.channel}", None)
if message.is_channel
else None
)
emit(
{
"received_at": message.received_at,
"conversation": channel.name if channel else (message.sender or None),
"direction": "in",
"node": None if message.is_channel else NodeRef(hash=message.sender),
"channel": channel,
"snr_db": message.snr,
"text": message.text,
"acked": None,
}
)
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C / cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
with renderers.stream(ctx, _live_messages()) as emit:
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C / cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
return ToolResult(
summary={"messages": seen},
message=f"[muted]stopped — heard {seen} message{'' if seen == 1 else 's'}[/muted]",
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
@@ -354,23 +407,14 @@ class ChatTool(Tool):
contacts = await device.get_contacts()
lasts = ctx.repo.last_chat_messages()
table = Table(title="Conversations", border_style="muted", expand=False)
table.add_column("CONVERSATION")
table.add_column("UNREAD", justify="right")
table.add_column("LAST MESSAGE")
rows = [*channels] + [
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
]
for conversation in rows:
last = lasts.get(conversation.key)
snippet = ""
if last is not None:
who = "you: " if last.outbound else ""
snippet = f"{who}{last.text[:40]}"
unread = ctx.chat.unread(conversation.key)
table.add_row(conversation.label, str(unread) if unread else "·", snippet)
ctx.ui.show(table)
return ToolResult(summary={"conversations": len(rows)})
return ToolResult(
summary={"conversations": len(rows)},
report=(_conversations(ctx, rows, lasts),),
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``chat`` subcommand group.
@@ -380,7 +424,7 @@ class ChatTool(Tool):
"""
from ..cli import run_tool_command
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@chat_app.command("send", help="Send a message to a contact or channel")
def _send_cmd(
@@ -605,25 +649,87 @@ class _LiveLasts:
return self._cache.get(key)
#: Column width (display cells) the conversation label is padded/ellipsized to, so the unread
#: badge and message preview line up in fixed lanes down the picker.
_LABEL_WIDTH = 22
#: Width of the unread-badge lane between the label and the age (fits ``● 999``).
_BADGE_WIDTH = 5
#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now``
#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column.
_AGE_WIDTH = 3
#: Longest message preview shown before it is ellipsized.
_PREVIEW_WIDTH = 40
#: Floor for the conversation lane. The header word is ``CONVERSATION`` (12 cells) and the
#: lane carries a two-cell gap after it, so anything narrower than this runs the heading
#: straight into ``UNREAD`` on a mesh whose every name is ``Bob``.
_LABEL_MIN = 12
#: Ceiling for it. The lane is sized to the longest name it actually holds (see
#: :func:`_lanes`), and this is where that stops paying: one very long name would otherwise
#: buy padding in front of every *short* one. It holds the long-but-ordinary shape of a
#: repeater-style name (``YUL-Cartierville``), a name past it ellipsizes — the row is a way
#: in to the conversation, whose own screen is titled with the whole name — and every cell
#: the ceiling saves goes to the preview.
_LABEL_MAX = 16
def _picker_header(width: int) -> str:
@dataclass(frozen=True)
class _Lanes:
"""The picker's fixed lane widths, measured once per list build.
Both the column header and every row are laid out from one of these, so the headings
cannot drift off the columns they name. Only the first two vary: the marker is the
platform's own glyph width (a channel glyph is double-cell on regular and single on the
PicoCalc console) and the label lane is sized to the longest name the list actually
holds, within :data:`_LABEL_MIN` / :data:`_LABEL_MAX`.
Attributes:
marker: The leading glyph lane — a channel glyph or contact dot plus its space.
label: Cells the conversation name is padded/ellipsized to.
"""
marker: int
label: int
@property
def head(self) -> int:
"""Cells before the preview: everything ←→ leave pinned (:attr:`Choice.hscroll_from`).
Marker, name, unread badge and age, with the two-cell gap that follows each — the
columns that say *which conversation this row is*. The preview is the only run that
slides, which is the point: the lanes are the reader's place in the list and they
already fit.
"""
return self.marker + self.label + 2 + _BADGE_WIDTH + 2 + _AGE_WIDTH + 2
def _lanes(conversations: list[Conversation]) -> _Lanes:
"""Measure the picker's lanes against the conversations it is about to draw.
The name lane used to be a flat 22 cells, which is a wide claim on a 72-column terminal
and a very wide one on the PicoCalc's 53: every cell it holds past the longest name in
the list is padding taken straight out of the last-message preview. Sized to the content
instead, a mesh of ``Alice`` and ``Bob`` keeps its names inside the header word's own
lane and gives everything else to the messages.
Args:
conversations: Every row the list will hold, channels and direct alike.
Returns:
The lane widths for both the header and the rows.
"""
longest = max((cell_len(c.label) for c in conversations), default=0)
return _Lanes(
# The marker is "glyph + space", and the glyph is the platform's: double-cell on
# regular, single on the console font. Read from the widget rather than assumed, so
# the two groups' lanes line up on both platforms — a contact's dot is padded to
# whatever a channel's glyph measures here (see _append_marker).
marker=cell_len(channel_glyph("Public", None)) + 1,
label=max(_LABEL_MIN, min(_LABEL_MAX, longest)),
)
def _picker_header(lanes: _Lanes, width: int) -> str:
"""Column headers over the picker's fixed lanes (see :func:`_title` for the layout).
The five-cell indent covers the select screen's pointer column (2 cells, drawn on choice
rows but not separators) plus the marker lane (3 cells), so each header lands exactly
over its column. UNREAD borrows its lane's trailing gap — the badge lane itself is one
cell too narrow for the word — which still leaves a space before the age column.
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
not separators) plus the marker lane, so each header lands exactly over its column.
UNREAD borrows its lane's trailing gap — the badge lane itself is one cell too narrow
for the word — which still leaves a space before the age column.
Resolved against the render width (the header row is pinned, so it must stay one row):
on a terminal too narrow for the whole line, ``LAST MESSAGE`` gives its cells back a
@@ -632,13 +738,13 @@ def _picker_header(width: int) -> str:
"""
return column_header(
[
Lane("CONVERSATION", _LABEL_WIDTH + 2),
Lane("CONVERSATION", lanes.label + 2),
Lane("UNREAD", _BADGE_WIDTH + 2),
Lane("AGE", _AGE_WIDTH + 2),
Lane("TIME", _AGE_WIDTH + 2),
Lane(("LAST MESSAGE", "LAST MSG", "LAST")),
],
width,
indent=5,
indent=2 + lanes.marker,
)
@@ -647,6 +753,7 @@ def _row_title(
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
) -> Callable[[], str | Text]:
"""Return a picker-row title *callable* the select screen re-renders on each repaint.
@@ -659,11 +766,12 @@ def _row_title(
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view feeding the preview.
key_of: Maps a sender name back to its node's key, for the preview hues.
lanes: The list's measured lane widths.
Returns:
A zero-argument callable producing the current row title.
"""
return lambda: _title(ctx, conversation, lasts, key_of)
return lambda: _title(ctx, conversation, lasts, key_of, lanes)
def _title(
@@ -671,6 +779,7 @@ def _title(
conversation: Conversation,
lasts: _LiveLasts,
key_of: NameKeyResolver,
lanes: _Lanes,
) -> str | Text:
"""Build a picker row as fixed-width, colour-coded lanes.
@@ -688,6 +797,7 @@ def _title(
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view.
key_of: Maps a preview sender/mention name back to its node's key.
lanes: The list's measured lane widths.
Returns:
The row title as a styled :class:`~rich.text.Text`.
@@ -695,12 +805,12 @@ def _title(
unread = ctx.chat.unread(conversation.key)
last = lasts.get(conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last)
_append_marker(text, conversation, last, lanes)
label_style = ""
if not conversation.is_channel and conversation.contact is not None:
contact = conversation.contact
label_style = name_style(conversation.label, contact.public_key or contact.key_prefix)
text.append(fit_cells(conversation.label, _LABEL_WIDTH), style=label_style or None)
text.append(fit_cells(conversation.label, lanes.label), style=label_style or None)
text.append(" ")
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
# the following lanes still line up on rows with nothing unread.
@@ -726,21 +836,28 @@ def _title(
_COMPANION_DOT_STYLE = _NODE_GLYPHS[NODE_TYPE_CHAT][1]
def _append_marker(text: Text, conversation: Conversation, last: ChatMessage | None) -> None:
"""Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot.
def _append_marker(
text: Text, conversation: Conversation, last: ChatMessage | None, lanes: _Lanes
) -> None:
"""Prepend the row's leading marker — a channel glyph or a contact dot — in its own lane.
A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle in the
standard companion pink — filled (``●``) once we've exchanged messages, a hollow ring
(``○``) before any — so the hollow-vs-filled shape marks whether there's history while the
name itself carries the person's key-derived hue. The contact dot is padded to the same
width as a channel's double-cell glyph so the labels line up across both sections.
name itself carries the person's key-derived hue.
The dot is padded out to :attr:`_Lanes.marker`, which is measured from the channel glyph
itself rather than assumed: that glyph is double-cell on regular and single-cell on the
PicoCalc console font, so a hard-coded pad lined the two sections up on one platform and
left every channel row a cell to the left of every direct row on the other.
"""
if conversation.is_channel:
text.append(f"{channel_glyph(conversation.label, conversation.secret)} ")
glyph = channel_glyph(conversation.label, conversation.secret)
text.append(glyph + " " * max(1, lanes.marker - cell_len(glyph)))
else:
dot = "●" if last is not None else "○"
text.append(dot, style=_COMPANION_DOT_STYLE)
text.append(" ")
text.append(" " * max(1, lanes.marker - cell_len(dot)))
def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
@@ -749,8 +866,12 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel
message's inline ``Name:`` sender is coloured in its key-derived hue (muted when no known
node carries the name), and every ``@[Name]`` mention reads as a bare ``@Name`` the same
way — so the list and the chat speak the same colour language. The result is clipped to
:data:`_PREVIEW_WIDTH` cells.
way — so the list and the chat speak the same colour language.
The preview is built *whole*, however long the message ran. It used to be clipped to a
fixed 40 cells, which is a cut nothing could undo: the row is what ←→ scroll now, and a
preview pre-truncated to roughly the visible lane would have had nothing left to reveal.
The list cuts it at the right edge like any other row; the scroll walks past that.
"""
body_raw = last.text.replace("\n", " ")
text = Text()
@@ -767,7 +888,6 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text:
_append_body(text, body_raw, key_of)
else:
_append_body(text, body_raw, key_of)
text.truncate(_PREVIEW_WIDTH, overflow="ellipsis")
return text
@@ -800,25 +920,218 @@ def _ago(when: Any) -> str:
return _format_age(_age_seconds(when))
def _history_table(label: str, messages: list[ChatMessage]) -> Table:
"""Render a conversation's stored messages as a table.
def _channel_ref(idx: int | None, label: str, secret: bytes | None) -> ChannelRef:
"""One channel as the shared shape, from whatever the surface knows about it.
Args:
label: The conversation's display name.
messages: The messages to show, oldest-first.
Returns:
A Rich :class:`Table` of time, sender, and text.
A transcript row and a live message know a slot and a label but not the key, so
``public`` is read off the name where the secret is not in hand — which is what the
leading ``#`` means and how a public channel is written everywhere else in the app.
"""
table = Table(title=f"History — {label}", border_style="muted", expand=False)
table.add_column("TIME")
table.add_column("FROM")
table.add_column("MESSAGE")
return ChannelRef(
slot=int(idx or 0),
name=label,
public=is_public_channel(label, secret) if secret else label.startswith("#"),
hash=channel_hash(secret) if secret else None,
)
def _message_columns() -> tuple:
"""One transcript row's columns, shared by ``history`` and the ``listen`` stream.
``DIR`` carries what the menu's transcript writes into its sender lane as the word
"you": which way a message went is a fact of its own, and spending the name lane on it
made our own name a value that lane could not otherwise hold. ``PEER`` is therefore
always the *other* party.
``TEXT`` is last and escaped: it is the rest of the line, and "the rest of the line"
stops being true the moment a body holds a newline — after which the remainder reads
as a second record with an empty time. The document carries the body raw, a JSON string
holding a newline natively.
"""
from ..ui import fields
return (
fields.when("created_at", "TIME"),
fields.word("direction", "DIR"),
# One column for whichever end the conversation had; the document keeps the two
# apart, because a parser cannot tell a channel from a contact by its label.
fields.plain_only(fields.name("conversation", "PEER")),
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
fields.snr("snr_db", "SNR_DB"),
fields.free("text", "TEXT"),
fields.hidden("acked"),
)
def _transcript(messages: list[ChatMessage]) -> Listing:
"""A conversation's stored messages, oldest first, one record each."""
from ..ui.report import Listing
rows = []
for message in messages:
stamp = message.created_at.astimezone().strftime("%m-%d %H:%M")
if message.outbound:
who = Text("you", style="accent")
else:
who = Text(message.peer_name or message.peer or "?", style="brand")
table.add_row(stamp, who, message.text)
return table
channel = (
_channel_ref(message.channel_idx, message.peer_name or f"#{message.channel_idx}", None)
if message.is_channel
else None
)
rows.append(
{
"created_at": message.created_at,
"direction": "out" if message.outbound else "in",
"conversation": message.peer_name or message.peer or None,
"node": None
if message.is_channel
else NodeRef(name=message.peer_name, hash=message.peer),
"channel": channel,
"snr_db": message.snr,
"text": message.text,
"acked": message.acked,
}
)
return Listing(
key="messages",
columns=_message_columns(),
rows=rows,
order=("TIME", "DIR", "PEER", "SNR_DB", "TEXT"),
)
def _live_messages() -> Listing:
"""The shape ``chat listen`` streams: what arrived, as it arrived.
``TIME`` is absolute here where the transcript's is an age, and for the reason the
whole time rule turns on: every row of a live tail would read ``now``, which is no
information at all. There is no ``DIR`` lane — everything a tail hears came in — and
the body goes last, so the one field with no width can never push a lane.
"""
from dataclasses import replace
from ..ui import fields
from ..ui.report import Listing
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
return Listing(
key="messages",
columns=(
pin(fields.instant("received_at", "TIME"), 25),
pin(fields.plain_only(fields.name("conversation", "PEER")), 16),
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
pin(fields.snr("snr_db", "SNR_DB"), 6),
fields.free("text", "TEXT"),
fields.hidden("direction"),
fields.hidden("acked"),
),
order=("TIME", "PEER", "SNR_DB", "TEXT"),
)
def _conversations(ctx: AppContext, rows: list, lasts: dict) -> Listing:
"""Every channel and contact, with the last thing said in each.
``LAST`` is an age rather than an instant: a conversation list is scanned for what is
warm, and ``never`` is a real answer — the conversation exists and nothing has been
said in it.
"""
from ..ui import fields
from ..ui.report import Listing
records = []
for conversation in rows:
last = lasts.get(conversation.key)
channel = (
_channel_ref(conversation.channel_idx, conversation.label, conversation.secret)
if conversation.is_channel
else None
)
contact = getattr(conversation, "contact", None)
records.append(
{
"kind": "channel" if conversation.is_channel else "direct",
"conversation": conversation.label,
"channel": channel,
"node": None
if conversation.is_channel
else NodeRef(
name=conversation.label,
key=(getattr(contact, "public_key", "") or "").lower() or None,
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
),
"unread": ctx.chat.unread(conversation.key),
"last_message_at": last.created_at if last is not None else None,
# Not truncated to 40 cells the way the picker's preview is: nothing here
# wraps, so there is no reason to cut a message short — but a body that
# holds a newline would still end the record, so it is folded flat.
"last_text": last.text if last is not None else None,
}
)
return Listing(
key="conversations",
columns=(
fields.word("kind", "KIND"),
fields.plain_only(fields.name("conversation", "CONVERSATION")),
fields.channel("channel", lanes=()),
fields.node("node", lanes=()),
fields.integer("unread", "UNREAD"),
fields.when("last_message_at", "LAST", absent="never"),
fields.free("last_text", "LAST_TEXT"),
),
rows=records,
order=("CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"),
)
def _sent(
*,
contact: object | None = None,
channel: int | None = None,
acked: bool | None = None,
) -> Facts:
"""What one ``chat send`` did.
A direct message prints its ``acked`` state, which is the one thing the send does not
already tell the caller: the radio accepted it either way, and whether the peer
answered is a separate fact. A channel broadcast prints nothing, because there *is* no
acknowledgement on a channel — and the document says that in the one way the plain
face never could, with ``null`` rather than ``false``.
"""
from ..ui import fields
from ..ui.report import PAIRS, SILENT, Facts
node = (
NodeRef(
name=contact.name,
key=(getattr(contact, "public_key", "") or "").lower() or None,
hash=(getattr(contact, "key_prefix", "") or "").lower() or None,
type=NODE_TYPE_LABELS.get(getattr(contact, "node_type", None)),
)
if contact is not None
else None
)
return Facts(
key="sent",
fields=(
# `acked` is the only lane: the caller named the recipient and the radio
# accepting the message is what a zero exit already says, so the one fact
# left to report is whether the peer answered.
fields.hidden("kind"),
fields.node("node", lanes=()),
fields.channel("channel", lanes=()),
fields.hidden("sent"),
fields.flag("acked", "acked"),
),
values={
"kind": "channel" if channel is not None else "direct",
"node": node,
"channel": (
_channel_ref(channel, f"#{channel}", None) if channel is not None else None
),
"sent": True,
"acked": acked,
},
shape=SILENT if channel is not None else PAIRS,
)
+435 -70
View File
@@ -12,23 +12,27 @@ management, factory reset) through the same executor.
from __future__ import annotations
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from rich.table import Table
from ..context import AppContext
from ..core.channels import CHANNEL_SLOT_PROBE_CAP
from ..core.config_io import backup_config, plan_restore, read_backup
from ..core.connection import Device
from ..core.connection import Device, DeviceCommandError
from ..core.device_config import (
SettingSpec,
build_snapshot,
format_value,
get_spec,
parse_value,
settings_by_category,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Block, Facts, Listing, Report
@register
class ConfigTool(Tool):
@@ -70,11 +74,16 @@ class ConfigTool(Tool):
device = await ctx.device()
ops: list[tuple] = list(params.get("ops") or [])
snapshot = await build_snapshot(device)
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
changes, artifacts, report = await apply_ops(ctx, device, snapshot, ops)
plural = "" if changes == 1 else "s"
message = f"[ok]✓[/ok] applied [brand]{changes}[/brand] change{plural}" if changes else None
return ToolResult(summary={"changes": changes}, message=message, artifacts=artifacts)
return ToolResult(
summary={"changes": changes},
message=message,
artifacts=artifacts,
report=report or None,
)
# -- CLI --------------------------------------------------------------------
@@ -86,7 +95,7 @@ class ConfigTool(Tool):
"""
from ..cli import run_tool_command
config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich")
config_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None)
@config_app.callback(invoke_without_command=True)
def _root(ctx: typer.Context) -> None:
@@ -201,7 +210,7 @@ class ConfigTool(Tool):
async def apply_ops(
ctx: AppContext, device: Device, snapshot: dict, ops: list[tuple]
) -> tuple[int, list[str]]:
) -> tuple[int, list[str], Report]:
"""Execute a list of configuration operation tuples against ``device``.
This is the single executor for every config operation, used both by
@@ -217,32 +226,51 @@ async def apply_ops(
ops: Operation tuples (see the module docstring).
Returns:
A ``(changes, artifacts)`` pair: the number of value changes applied and any
file paths produced.
A ``(changes, artifacts, report)`` triple: the number of value changes applied,
any file paths produced, and the blocks stating what happened (empty for the
menu, which shows its own acknowledgements).
"""
changes = 0
artifacts: list[str] = []
report: list[Any] = []
applied: list[dict[str, Any]] = []
for op in ops:
kind = op[0]
if kind == "show":
await _show(ctx, device, snapshot)
block = await _show(ctx, device, snapshot)
if block is not None:
report.append(block)
elif kind == "get":
# The bare value, nothing else: the caller named the key, so repeating it back
# is one more thing for `$(meshterm config get name)` to strip off. The same
# shape `sysctl -n` and `git config --get` print. The document keeps the key,
# the type and an enum's label, none of which the bare form has room for.
spec = get_spec(op[1])
ctx.ui.note(f"[muted]{spec.key}[/muted] = {format_value(spec, spec.getter(snapshot))}")
report.append(_one_setting(spec, spec.getter(snapshot)))
elif kind == "set":
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
change = await _apply_setting(ctx, device, op[1], op[2], snapshot)
if change is not None:
applied.append(change)
changes += 1
elif kind == "set_custom":
previous = (await device.get_custom_vars()).get(op[1])
await device.set_custom_var(op[1], op[2])
ctx.ui.note(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
ctx.ui.ack(f"[ok]✓[/ok] custom [brand]{op[1]}[/brand] = {op[2]}")
applied.append({"key": f"custom.{op[1]}", "previous": previous, "value": op[2]})
changes += 1
elif kind == "set_channel":
await device.set_channel(op[1], op[2], op[3])
ctx.ui.note(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
ctx.ui.ack(f"[ok]✓[/ok] channel {op[1]} = [brand]{op[2]}[/brand]")
changes += 1
report.append(_channel_written(op[1], op[2], op[3]))
elif kind == "backup":
artifacts.append(str(await _backup(device, snapshot, op[1])))
written, counts = await _backup(device, snapshot, op[1])
artifacts.append(str(written))
report.append(_backed_up(written, counts))
elif kind == "restore":
changes += await _restore(ctx, device, snapshot, op[1], op[2])
done, blocks = await _restore(ctx, device, snapshot, op[1], op[2])
changes += done
report.extend(blocks)
elif kind == "advert":
flood = len(op) > 1 and bool(op[1])
await device.send_advert(flood)
@@ -252,7 +280,8 @@ async def apply_ops(
if public_key:
ctx.advert_store.mark_sent(public_key, flood=flood)
kind_label = "flood" if flood else "zero-hop"
ctx.ui.note(f"[ok]✓[/ok] {kind_label} advertisement sent")
ctx.ui.ack(f"[ok]✓[/ok] {kind_label} advertisement sent")
report.append(_acted("advert", sent=True, flood=flood))
elif kind == "advert_cadence":
from ..core.advert_store import cadence_label
@@ -261,38 +290,57 @@ async def apply_ops(
if public_key:
ctx.advert_store.set_cadence(public_key, flood=flood, hours=hours)
kind_label = "flood" if flood else "zero-hop"
ctx.ui.note(
ctx.ui.ack(
f"[ok]✓[/ok] background {kind_label} advert: "
f"[brand]{cadence_label(hours)}[/brand]"
)
changes += 1
report.append(_acted("cadence", changes=1, flood=flood, hours=hours))
else: # pragma: no cover - SELF_INFO always carries the key on real firmware
ctx.ui.note("[err]device reported no public key — cadence not saved[/err]")
raise DeviceCommandError(
"the device reported no public key — the advert cadence was not saved"
)
elif kind == "share":
_share_contact(ctx, snapshot)
report.append(_share_contact(ctx, snapshot))
elif kind == "sync_clock":
import time as _time
from datetime import datetime
epoch = int(_time.time())
drift = await _try_clock(device)
await device.set_time(epoch)
stamp = datetime.fromtimestamp(epoch).astimezone().strftime("%Y-%m-%d %H:%M:%S")
ctx.ui.note(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
ctx.ui.ack(f"[ok]✓[/ok] device clock set to [brand]{stamp}[/brand]")
changes += 1
report.append(
_acted(
"clock",
changes=1,
set_at=datetime.fromtimestamp(epoch).astimezone(),
# What the clock was off by *before* the set: the fact worth logging,
# and the one this command destroys by succeeding.
drift_s=None if drift is None else drift - epoch,
)
)
elif kind == "reboot":
await device.reboot()
ctx.ui.note("[warn]device rebooting[/warn]")
ctx.ui.ack("[warn]device rebooting[/warn]")
report.append(_acted("reboot", rebooted=True))
elif kind == "export_key":
await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts)
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
elif kind == "import_key":
await device.import_private_key(op[1])
ctx.ui.note("[ok]✓[/ok] private key imported")
ctx.ui.ack("[ok]✓[/ok] private key imported")
changes += 1
report.append(_acted("imported", changes=1, imported=True))
elif kind == "factory_reset":
await device.factory_reset()
ctx.ui.note("[err]device factory-reset[/err]")
ctx.ui.ack("[err]device factory-reset[/err]")
report.append(_acted("factory_reset", factory_reset=True))
else: # pragma: no cover - guarded by the call sites that build ops
ctx.ui.note(f"[err]unknown config op: {kind}[/err]")
raise DeviceCommandError(f"unknown config operation: {kind}")
if applied:
report.append(_applied(applied))
# Drop any session-cached facts these ops may have changed, so the next screen re-reads
# the truth rather than a stale copy (see meshterm.services.device_state.DeviceState). A
# reboot/reconnect resets the whole cache on its own; these cover the in-place edits.
@@ -305,22 +353,25 @@ async def apply_ops(
ctx.devstate.invalidate_config() # self-info fields + path-hash mode
if "set_channel" in kinds:
ctx.devstate.invalidate_channels()
return changes, artifacts
return changes, artifacts, tuple(report)
async def _apply_setting(
ctx: AppContext, device: Device, key: str, raw: Any, snapshot: dict
) -> int:
) -> dict[str, Any]:
"""Parse, apply and record one setting; keep ``snapshot`` consistent.
The local snapshot is updated with the new value so a later coupled change in the
same batch (e.g. another radio field) is rebuilt from current values.
Returns:
``1`` (a change was applied).
The change, as ``{"key", "previous", "value"}``. The *previous* value is read off
the snapshot before it is overwritten: a caller managing a machine's configuration
wants to know what moved, and it is gone the moment this returns.
"""
spec = get_spec(key)
value = parse_value(spec, raw, snapshot)
previous = spec.getter(snapshot)
await spec.apply(device, value, snapshot)
snapshot[key] = value
# Remember what we set, keyed by the device's own public key, so a forgetful device (a
@@ -328,94 +379,268 @@ async def _apply_setting(
# meshterm.core.settings_store). Provenance-gated: only values changed through MeshTerm.
if ctx.settings_store is not None:
ctx.settings_store.remember(str(snapshot.get("public_key") or ""), key, value)
ctx.ui.note(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
return 1
ctx.ui.ack(f"[ok]✓[/ok] [brand]{key}[/brand] = {format_value(spec, value)}")
return {"key": key, "previous": previous, "value": value}
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> None:
"""Render the full configuration table plus custom variables."""
from ..ui.config_editor import config_table
async def _show(ctx: AppContext, device: Device, snapshot: dict) -> Listing | None:
"""Print every current setting, plus any custom variables.
The menu shows the annotated table; a scripted run gets one ``key value`` line per
setting, keyed exactly as ``config get`` and ``config set`` name them, so a line read
out of ``show`` can be typed straight back in. The labels and descriptions the table
carries are for a reader choosing a setting, and this reader has already chosen.
The pairing PIN stays concealed here, as it is in the table: this is the whole-device
dump, the thing that gets redirected into a file and pasted into a bug report, and the
PIN is the one value on it that lets someone else's phone onto the radio.
``config get device_pin`` names it deliberately.
"""
from ..ui import fields, script
from ..ui.config_editor import PIN_KEY, conceal, config_table
from ..ui.report import Listing
from ..ui.surface import TuiUi
custom = await device.get_custom_vars()
ctx.ui.show(config_table(snapshot, custom))
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(config_table(snapshot, custom))
return None
rows: list[dict[str, Any]] = []
for _category, specs in settings_by_category():
for spec in specs:
value = spec.getter(snapshot)
printed = _script_value(spec, value)
redacted = spec.key == PIN_KEY and printed != script.NONE
masked = conceal(printed, absent=script.NONE) if spec.key == PIN_KEY else printed
rows.append(
{
"key": spec.key,
# A masked PIN is *withheld*, which is not a value: the document says
# so with `redacted` and writes `null`, rather than shipping six
# bullets a consumer would have to recognise as a mask.
"value": fields.Rendered(None if redacted else value, masked),
"type": spec.value_type,
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
"redacted": redacted,
}
)
# Custom variables are experimental firmware fields with no spec, so they are namespaced
# rather than mixed in — a reader can tell which lines `config set` will take.
rows.extend(
{
"key": f"custom.{key}",
"value": fields.Rendered(value, value),
"type": "str",
"label": None,
"redacted": False,
}
for key, value in sorted(custom.items())
)
return Listing(
key="settings",
columns=_setting_columns(),
rows=rows,
# An array to a parser and a `sysctl -a` block to a reader: a header line over two
# columns whose keys *are* the answer would be furniture.
headed=False,
)
def _share_contact(ctx: AppContext, snapshot: dict) -> None:
"""Render this node's contact card — a scannable QR code plus the raw URI."""
def _script_value(spec: SettingSpec, value: Any) -> str:
"""A setting's value in the form ``config set`` accepts back.
:func:`~meshterm.core.device_config.format_value` writes for a reader choosing a
setting: an enum reads ``0 (off)``, an unset string reads ``(not set)``, an unreported
one reads ``?``. None of those three survive a round trip —
:func:`~meshterm.core.device_config.parse_value` wants a bare integer for an enum, and
would take the words ``(not set)`` as the literal value of a string. So the scripted
dump prints what can be typed back: the enum's number, the empty string as ``""``, and
:data:`~meshterm.ui.script.NONE` for a value the firmware never reported (which is an
absence, not a value, and is the one line ``config set`` should not be handed back).
Args:
spec: The setting the value belongs to.
value: Its current value, or ``None`` when the device did not report it.
Returns:
The scripted rendering.
"""
from ..ui import script
if value is None:
return script.NONE
if spec.value_type == "bool":
return "true" if value else "false"
if spec.value_type == "str" and value == "":
return '""'
return str(value)
def _share_contact(ctx: AppContext, snapshot: dict) -> Facts:
"""State this node's contact card — with the QR code drawn in the menu.
The QR is for a phone pointed at the screen. Redirected into a file it is a block of
block characters around the one thing that is the answer, so a scripted run states the
link by itself. There is no machine face for the code either, and never will be: it is
a second rendering of ``url``.
"""
from rich.console import Group
from rich.text import Text
from ..ui import fields
from ..ui.config_editor import contact_share_url
from ..ui.qr import qr_text
from ..ui.report import BARE, Facts
from ..ui.surface import TuiUi
public_key = str(snapshot.get("public_key") or "")
if not public_key:
ctx.ui.note("[err]the device did not report a public key — nothing to share[/err]")
return
raise DeviceCommandError("the device did not report a public key — nothing to share")
name = str(snapshot.get("name") or "this node")
url = contact_share_url(name, public_key, int(snapshot.get("adv_type") or 1))
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
adv_type = int(snapshot.get("adv_type") or 1)
url = contact_share_url(name, public_key, adv_type)
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(Group(qr_text(url), Text(""), Text(url, style="accent")))
return Facts(
key="share",
fields=(
fields.word("url", "url"),
fields.word("name", "name"),
fields.hexid("public_key", "public_key"),
fields.integer("type", "type"),
),
values={"url": url, "name": name, "public_key": public_key.lower(), "type": adv_type},
shape=BARE,
bare="url",
)
async def _backup(device: Device, snapshot: dict, path: Path) -> Path:
"""Write a TOML backup of the current configuration."""
async def _backup(device: Device, snapshot: dict, path: Path) -> tuple[Path, dict[str, int]]:
"""Write a TOML backup of the current configuration.
Returns:
The path written and how much went into it, counted the way the file counts —
a setting the firmware never reported is not in the backup and is not in the tally.
"""
from ..core.device_config import DEVICE_SETTINGS
custom = await device.get_custom_vars()
channels = await _read_channels(device)
return backup_config(Path(path), snapshot, custom, channels)
written = backup_config(Path(path), snapshot, custom, channels)
counts = {
"settings": sum(1 for spec in DEVICE_SETTINGS if spec.getter(snapshot) is not None),
"channels": len(channels),
"custom": len(custom),
}
return written, counts
async def _restore(
ctx: AppContext, device: Device, snapshot: dict, path: Path, dry_run: bool
) -> int:
) -> tuple[int, list[Block]]:
"""Apply (or preview) a backup file against the current configuration.
The plan is the answer on a dry run and a record on a real one, so the same listing
serves both — drawn for a reader only when nothing was changed, since a run that *did*
change things already said so, setting by setting, on stderr.
Returns:
The number of changes applied (always ``0`` for a dry run).
The number of changes applied (always ``0`` for a dry run), and the blocks stating
what was planned or done.
"""
from ..ui import fields
from ..ui.report import SILENT, Facts, Listing
backup = read_backup(Path(path))
custom = await device.get_custom_vars()
ops = plan_restore(backup, snapshot, custom)
def plan(applied: list[tuple], *, drawn: bool) -> list[Block]:
listing = Listing(
key="operations",
columns=(
fields.word("operation", "OPERATION"),
fields.word("target", "TARGET"),
fields.word("value", "VALUE"),
),
rows=[
{
"operation": op[0],
"target": str(op[1]),
"value": str(op[2]) if len(op) > 2 else None,
}
for op in applied
],
plain_only=not drawn,
)
facts = Facts(
key="restore",
fields=(fields.flag("dry_run", "dry_run"), fields.integer("changes", "changes")),
values={"dry_run": dry_run, "changes": 0 if dry_run else len(applied)},
shape=SILENT,
)
return [facts, listing]
if not ops:
ctx.ui.note("[muted]restore: device already matches the backup.[/muted]")
return 0
ctx.ui.ack("[muted]restore: device already matches the backup.[/muted]")
return 0, plan([], drawn=False)
if dry_run:
table = Table(title="Restore preview", border_style="warn", expand=False)
table.add_column("OPERATION")
table.add_column("TARGET")
table.add_column("VALUE")
for op in ops:
table.add_row(op[0], str(op[1]), str(op[2] if len(op) > 2 else ""))
ctx.ui.show(table)
ctx.ui.note("[muted]dry run — nothing was changed.[/muted]")
return 0
ctx.ui.ack("[muted]dry run — nothing was changed.[/muted]")
return 0, plan(ops, drawn=True)
changes = 0
for op in ops:
if op[0] == "set":
changes += await _apply_setting(ctx, device, op[1], op[2], snapshot)
await _apply_setting(ctx, device, op[1], op[2], snapshot)
changes += 1
elif op[0] == "set_custom":
await device.set_custom_var(op[1], op[2])
changes += 1
elif op[0] == "set_channel":
await device.set_channel(op[1], op[2], op[3])
changes += 1
return changes
return changes, plan(ops, drawn=False)
async def _export_key(
ctx: AppContext, device: Device, out: Path | None, artifacts: list[str]
) -> None:
"""Export the private key, to a file if ``out`` is given, else to the console."""
) -> Facts:
"""Export the private key, to a file if ``out`` is given, else as the answer itself.
Either way the plain face prints **one bare line** — the key, or the path it was
written to — because ``config export-key > key.hex`` is expected to hold the key and
nothing else, and a caller that asked for a file wants the name of it. The warning
that comes with either belongs beside it on screen, not in the file, so it goes to
stderr with every other acknowledgement.
This document carries a private key when one was asked for. That is what the command
is, exactly as on the plain face, and no extra gate is introduced here.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
key_hex = await device.export_private_key()
written: Path | None = None
if out is not None:
path = Path(out)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(key_hex, encoding="utf-8")
artifacts.append(str(path))
ctx.ui.note("[warn]private key written — keep this file secret.[/warn]")
written = Path(out)
written.parent.mkdir(parents=True, exist_ok=True)
written.write_text(key_hex, encoding="utf-8")
artifacts.append(str(written))
ctx.ui.ack("[warn]private key written — keep this file secret.[/warn]")
else:
ctx.ui.note("[warn]private key (keep secret):[/warn] [muted]" + key_hex + "[/muted]")
ctx.ui.ack("[warn]private key (keep secret):[/warn]")
return Facts(
key="key",
fields=(fields.hexid("private_key", "private_key"), fields.word("path", "path")),
values={
"private_key": None if written else key_hex,
"path": str(written) if written else None,
},
shape=BARE,
bare="path" if written else "private_key",
)
async def _read_channels(device: Device) -> list[dict]:
@@ -434,13 +659,153 @@ async def _read_channels(device: Device) -> list[dict]:
def _require_yes(yes: bool, what: str) -> None:
"""Abort a destructive CLI command unless ``--yes`` was passed.
Raised as a :class:`typer.BadParameter` rather than printed and exited: a missing
confirmation *is* a usage error, so it belongs on stderr in the parser's own words and
under the parser's own status (see :mod:`meshterm.core.exitcodes`). Printing it in red
on stdout put a colour, and a sentence that is not the command's answer, into whatever
was reading the command's answer.
Args:
yes: Whether the user passed ``--yes``.
what: Human-readable description of the consequence.
Raises:
typer.Exit: With code 1 if confirmation was not given.
typer.BadParameter: If confirmation was not given.
"""
if not yes:
typer.secho(f"Refusing: {what}. Re-run with --yes to confirm.", fg="red")
raise typer.Exit(1)
raise typer.BadParameter(f"{what}. Re-run with --yes to confirm.")
def _setting_columns() -> tuple:
"""One setting row's columns, shared by ``config show`` and ``config get``."""
from ..ui import fields
return (
fields.word("key", "key"),
fields.rendered("value", "value"),
fields.hidden("type"),
fields.hidden("label"),
fields.hidden("redacted"),
)
def _one_setting(spec: SettingSpec, value: Any) -> Facts:
"""One setting: the bare value plain, the whole object in the document.
``config get device_pin`` is the one place the PIN is named deliberately, so it is
**not** redacted here — the caller asked for that key by name, which is a different
act from dumping every setting into a file.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="setting",
fields=_setting_columns(),
values={
"key": spec.key,
"value": fields.Rendered(value, _script_value(spec, value)),
"type": spec.value_type,
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
"redacted": False,
},
shape=BARE,
bare="value",
)
def _applied(applied: list[dict[str, Any]]) -> Facts:
"""What a ``set`` changed — nothing plain, a record for whoever is logging it.
The exit status is the plain answer and the acknowledgement on stderr is the reassurance;
neither is something a script can read back later to find out *what moved*.
"""
from ..ui import fields
from ..ui.report import SILENT, Column, Facts
return Facts(
key="applied",
fields=(fields.integer("changes", "changes"), Column(key="applied")),
values={"changes": len(applied), "applied": applied},
shape=SILENT,
)
def _channel_written(idx: int, name: str, secret: bytes | None) -> Facts:
"""What ``config channel`` wrote into a slot."""
from ..core.channels import channel_hash, derive_secret, is_public_channel
from ..ui import fields
from ..ui.fields import ChannelRef
from ..ui.report import SILENT, Facts
key = secret or derive_secret(name)
return Facts(
key="channel",
fields=(fields.integer("changes", "changes"), fields.channel("channel", lanes=())),
values={
"changes": 1,
"channel": ChannelRef(
slot=idx,
name=name,
public=is_public_channel(name, key),
hash=channel_hash(key),
),
},
shape=SILENT,
)
def _backed_up(written: Path, counts: dict[str, int]) -> Facts:
"""What ``config backup`` wrote, and how much of it.
The plain face prints the path bare — it is what the caller keeps, and what the thing
that ran the command reads next.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="backup",
fields=(
fields.word("path", "path"),
fields.integer("settings", "settings"),
fields.integer("channels", "channels"),
fields.integer("custom", "custom"),
),
values={"path": str(written), **counts},
shape=BARE,
bare="path",
)
def _acted(key: str, **facts: Any) -> Facts:
"""An action, stated as data: nothing plain, a document for whoever wanted a record.
Every one of these prints nothing on the plain face today and should keep doing so —
``config advert`` says all it has to say in its exit status. But "prints nothing" is an
answer a person can act on and a program cannot, so the machine face gets the fact.
"""
from ..ui import fields
from ..ui.report import SILENT, Column, Facts
def column(name: str, value: Any) -> Column:
if isinstance(value, bool):
return fields.flag(name, name)
if isinstance(value, int) or value is None:
return fields.integer(name, name)
return Column(key=name)
return Facts(
key=key,
fields=tuple(column(name, value) for name, value in facts.items()),
values=dict(facts),
shape=SILENT,
)
async def _try_clock(device: Device) -> int | None:
"""The device's current clock, or ``None`` where the firmware will not answer."""
try:
return await device.get_time()
except Exception: # noqa: BLE001 - optional read; the drift is a nicety
return None
+91 -8
View File
@@ -1,14 +1,25 @@
"""The ``contacts`` tool: list this node and the contacts it knows about."""
"""The ``contacts`` tool: list the contacts this node knows about.
The menu opens the sortable Contacts screen, which leads with our own node; the CLI
prints the contact list alone, because our own node is not a contact — ``meshterm info``
reports it, in far more detail than a row could hold.
"""
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.models import NODE_TYPE_LABELS
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..core.models import Contact
from ..ui.report import Listing
@register
class ContactsTool(Tool):
@@ -32,7 +43,7 @@ class ContactsTool(Tool):
A :class:`ToolResult` summarizing the number of known contacts.
"""
from ..ui.surface import TuiUi
from ..ui.widgets import ContactsSort, contacts_table
from ..ui.widgets import ContactsSort, ordered_contacts
# Read through the session cache: on a busy node the contacts table is a slow
# round-trip, and re-fetching it (plus self-info) on every menu visit is a chief cause
@@ -63,18 +74,22 @@ class ContactsTool(Tool):
# In the menu, hand the list to the interactive screen so the Ctrl+arrows re-sort it
# live — its ring spans the shared contact list's four columns (hash included); on the
# scripted CLI, render the static table once in the requested order.
# scripted CLI, print the plain listing once in the requested order.
if isinstance(ctx.ui, TuiUi):
from ..ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
from ..ui.contacts_screen import open_contacts
sort = ContactsSort.from_name(sort_name, SORT_COLUMNS, SORT_OPENS_ASCENDING)
await open_contacts(ctx, self_name, self_key, contacts, prefix_bytes, counts, sort)
else:
sort = ContactsSort.from_name(sort_name)
ctx.ui.show(contacts_table(self_name, self_key, contacts, prefix_bytes, counts, sort))
return ToolResult(summary={"contacts": len(contacts)})
return ToolResult(summary={"contacts": len(contacts)})
sort = ContactsSort.from_name(sort_name)
listing = _listing(ordered_contacts(contacts, counts, sort), counts, prefix_bytes)
return ToolResult(
summary={"contacts": len(contacts)},
report=(listing,),
exit_code=exitcodes.OK if contacts else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``contacts`` subcommand.
@@ -83,6 +98,7 @@ class ContactsTool(Tool):
app: The Typer application.
"""
from ..cli import run_tool_command
from ..ui.widgets import ContactsSort
@app.command(name=self.name, help=self.help)
def _contacts(
@@ -90,4 +106,71 @@ class ContactsTool(Tool):
"heard", "--sort", "-s", help="Order contacts by: heard, name, packets"
),
) -> None:
# `ContactsSort.from_name` falls back to the first column for an unknown name,
# deliberately, so a saved preference written by an older build cannot wedge
# the menu's list. On the command line the same silence hides a typo: the
# caller asked for one order and got another, with nothing said.
if sort not in ContactsSort.names():
choices = ", ".join(ContactsSort.names())
raise typer.BadParameter(f"--sort must be one of: {choices} (got {sort!r})")
run_tool_command(self, {"sort": sort})
def _listing(contacts: list[Contact], counts: dict[str, int], prefix_bytes: int) -> Listing:
"""The contact list, stated once for both faces.
``TYPE`` is the node's advertised role in words, where the menu draws a coloured glyph:
a monochrome ``▲`` would need a legend and the CLI has no legends. ``HEARD`` is a
relative age, because "recently?" is the question this listing is opened to answer.
``HASH`` is the token ``--path`` and ``--to`` take — it used to have to be sliced out
of ``KEY`` by hand — and ``LOCATION`` is a fact the model has always carried and no
column had room for. ``KEY`` stays full and last, so it runs off the right harmlessly
and is never elided: a truncated key is not something a caller can hand back.
Args:
contacts: The contacts, already in the requested order.
counts: Overheard-packet tallies keyed by node id (from passive monitoring).
prefix_bytes: The device's path-hash width, which is what makes ``HASH`` the
token a forced path addresses this node by rather than an arbitrary slice.
Returns:
The listing.
"""
from ..ui import fields
from ..ui.report import Listing
from ..ui.widgets import _contact_pkts
rows = []
for contact in contacts:
key = (contact.public_key or "").lower()
rows.append(
{
"node": fields.NodeRef(
name=contact.name,
key=key or None,
hash=(key or contact.key_prefix.lower())[: prefix_bytes * 2] or None,
type=NODE_TYPE_LABELS.get(contact.node_type),
),
"heard_at": contact.last_seen,
"packets": _contact_pkts(contact, counts),
"position": (
fields.Position(contact.lat, contact.lon) if contact.has_location else None
),
}
)
return Listing(
key="contacts",
columns=(
fields.node(
"node",
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
),
fields.when("heard_at", "HEARD", absent="never"),
fields.integer("packets", "PKTS"),
fields.position(),
),
rows=rows,
# The node's four lanes are not contiguous: what a reader scans for — who, what,
# how recently, how much — comes first, and the two long hex fields go right.
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"),
)
+175 -60
View File
@@ -17,14 +17,18 @@ of the outbox screen's own Send-now, Cancel, and Clear-finished actions.
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.connection import DeviceCommandError
from ..core import exitcodes
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..core.courier_store import QueuedMessage
from ..ui.report import Facts, Listing
@register
class CourierTool(Tool):
@@ -97,17 +101,19 @@ class CourierTool(Tool):
name = str(params["contact"])
needle = name.casefold()
contact = next((c for c in contacts if c.name.casefold() == needle), None)
# The named contact, not the radio: nothing is queued and nothing is sent, so
# these are bad arguments (2) rather than device failures (4).
if contact is None:
raise DeviceCommandError(f"unknown contact: {name!r}")
raise typer.BadParameter(f"unknown contact: {name!r}")
key = contact_watch_key(contact)
if key is None:
raise DeviceCommandError(f"{name!r} has no usable key to address")
raise typer.BadParameter(f"{name!r} has no usable key to address")
not_before = None
if params.get("at"):
not_before = parse_clock(str(params["at"]))
if not_before is None:
raise DeviceCommandError(
raise typer.BadParameter(
f"--at wants a clock time like 07:00, not {params['at']!r}"
)
message = ctx.courier_store.queue(
@@ -118,62 +124,27 @@ class CourierTool(Tool):
if not_before is not None
else "when the contact is next heard"
)
ctx.ui.note(
ctx.ui.ack(
f"[ok]queued[/ok] #{message.ident} for [brand]{contact.name}[/brand] — "
f"delivers {when} (an interactive session's courier does the sending)"
)
return ToolResult(
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")}
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")},
report=(_queued(message, contact.name, not_before),),
)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""Print the outbox — waiting entries then finished ones — as a table.
"""State the outbox — waiting entries then finished ones.
A read-only view over the stored outbox: it needs no device, so it works the same
whether or not a radio is attached (the scriptable face of the outbox screen).
"""
from rich.table import Table
from rich.text import Text
from ..core.courier_store import DELIVERED, QUEUED
from ..core.models import utcnow
from ..ui.courier_screen import _local_stamp, _shorten
from ..ui.widgets import _age_seconds, format_ago
entries = ctx.courier_store.entries()
if not entries:
ctx.ui.note("[muted]the outbox is empty[/muted]")
return ToolResult(summary={"entries": 0})
table = Table(title="Courier outbox", border_style="muted", expand=False)
table.add_column("ID", justify="right")
table.add_column("STATE")
table.add_column("NODE")
table.add_column("MESSAGE")
table.add_column("WHEN")
now = utcnow()
for m in entries:
if m.status == QUEUED:
state = Text("⏳ waiting", style="warn")
if m.not_before is not None and now < m.not_before:
when = f"scheduled {_local_stamp(m.not_before)}"
else:
when = "when next heard"
elif m.status == DELIVERED:
state = Text("✓ delivered", style="ok")
when = f"delivered {format_ago(_age_seconds(m.finished or m.created))}"
else:
state = Text("✗ gave up", style="err")
when = f"gave up {format_ago(_age_seconds(m.finished or m.created))}"
table.add_row(
str(m.ident),
state,
Text(m.node_name, style="brand"),
_shorten(m.text),
Text(when, style="muted"),
)
ctx.ui.show(table)
return ToolResult(summary={"entries": len(entries)})
return ToolResult(
summary={"entries": len(entries)},
report=(_outbox(entries),),
exit_code=exitcodes.OK if entries else exitcodes.NO_RESULT,
)
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Force one delivery attempt for a waiting entry, right now.
@@ -183,7 +154,10 @@ class CourierTool(Tool):
"""
ident = int(params["id"])
if ctx.courier_store.get(ident) is None:
raise DeviceCommandError(f"no outbox entry #{ident}")
# A bad argument, not a device failure: the outbox is a local file, nothing was
# transmitted, and no retry will make an id exist. Exit 4 told a caller to try
# again; exit 2 tells it to fix the command, which is the truth.
raise typer.BadParameter(f"no outbox entry #{ident} (see `courier list`)")
outcome = await ctx.courier.attempt_now(ident)
notes = {
"delivered": "[ok]✓ delivered[/ok] — acknowledged by the contact",
@@ -195,17 +169,27 @@ class CourierTool(Tool):
"busy": "[muted]another delivery is in flight — try again in a moment[/muted]",
"gone": "[muted]that entry is no longer waiting[/muted]",
}
ctx.ui.note(notes.get(outcome, outcome))
return ToolResult(summary={"id": ident, "outcome": outcome})
ctx.ui.ack(notes.get(outcome, outcome))
return ToolResult(
summary={"id": ident, "outcome": outcome},
report=(_attempted(ident, outcome),),
)
async def _cli_cancel(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Remove a waiting entry from the outbox (finished ones use ``clear``)."""
ident = int(params["id"])
if ctx.courier_store.cancel(ident):
ctx.ui.note(f"[warn]cancelled outbox entry #{ident}[/warn]")
return ToolResult(summary={"id": ident, "cancelled": True})
ctx.ui.note(f"[muted]no waiting entry #{ident} to cancel[/muted]")
return ToolResult(summary={"id": ident, "cancelled": False})
ctx.ui.ack(f"[warn]cancelled outbox entry #{ident}[/warn]")
return ToolResult(
summary={"id": ident, "cancelled": True},
report=(_did(("id", ident), ("cancelled", True)),),
)
ctx.ui.ack(f"[muted]no waiting entry #{ident} to cancel[/muted]")
return ToolResult(
summary={"id": ident, "cancelled": False},
report=(_did(("id", ident), ("cancelled", False)),),
exit_code=exitcodes.NO_RESULT,
)
async def _cli_clear(self, ctx: AppContext) -> ToolResult:
"""Drop every finished (delivered / given-up) entry, leaving the queue untouched."""
@@ -213,10 +197,14 @@ class CourierTool(Tool):
ctx.courier_store.clear_done()
cleared = before - len(ctx.courier_store.entries())
if cleared:
ctx.ui.note(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]")
ctx.ui.ack(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]")
else:
ctx.ui.note("[muted]no finished entries to clear[/muted]")
return ToolResult(summary={"cleared": cleared})
ctx.ui.ack("[muted]no finished entries to clear[/muted]")
return ToolResult(
summary={"cleared": cleared},
report=(_did(("cleared", cleared)),),
exit_code=exitcodes.OK if cleared else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``courier`` subcommand group.
@@ -226,7 +214,7 @@ class CourierTool(Tool):
"""
from ..cli import run_tool_command
courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@courier_app.command(
"queue", help="Queue a message for delivery when the contact is next heard"
@@ -268,3 +256,130 @@ class CourierTool(Tool):
run_tool_command(self, {"cli_action": "clear"})
app.add_typer(courier_app, name=self.name)
def _recipient(name: str | None, node_key: str | None) -> object:
"""The outbox's addressee as the shared node shape.
The outbox stores a name and the 12-hex id it addresses, so three of the five fields
are honestly ``null`` — and the id goes in ``hash`` rather than ``key``, because that
is what it is. A key is the full value and is never truncated by this app's choosing;
calling a six-byte prefix one would hand a caller something ``--to`` cannot take.
"""
from ..ui.fields import NodeRef
return NodeRef(name=name, hash=(node_key or "").lower() or None)
def _queued(message: QueuedMessage, name: str, not_before: object) -> Facts:
"""What ``courier queue`` put in the outbox.
The plain face prints the **id and nothing else**, because the id is the one fact a
caller has to keep — it is what ``send`` and ``cancel`` take — and everything else on
the line is what they just typed. The document carries the rest, because a caller
logging the queue has no other record of it.
"""
from ..ui import fields
from ..ui.report import Facts
return Facts(
key="queued",
fields=(
fields.integer("id", "id"),
fields.node("node", lanes=()),
fields.hidden("scheduled_at"),
fields.hidden("text"),
),
values={
"id": message.ident,
"node": _recipient(name, message.node_key),
"scheduled_at": not_before,
"text": message.text,
},
)
def _outbox(entries: list) -> Listing:
"""The outbox, waiting entries then finished ones.
The deliberate asymmetry between the two time columns: ``SCHEDULED`` is an
*appointment* the caller set with ``--at``, so it stays a wall-clock instant, and
``FINISHED`` is an age, because "how long ago did it go" is what anyone reading a
delivered row wants. The mixed widths are the price of both columns being right, and
``--absolute`` makes them uniform for anyone who dislikes it.
"""
from ..core.courier_store import DELIVERED, QUEUED
from ..ui import fields
from ..ui.report import Listing
rows = []
for m in entries:
if m.status == QUEUED:
state = "waiting"
elif m.status == DELIVERED:
state = "delivered"
else:
state = "gave up"
rows.append(
{
"id": m.ident,
"state": state,
"node": _recipient(m.node_name, m.node_key),
# An entry with no scheduled time goes as soon as the contact is heard,
# which is not a time and so is not one here either.
"scheduled_at": m.not_before,
"finished_at": m.finished,
# Not shortened the way the outbox screen shortens it: nothing wraps here.
"text": m.text,
}
)
return Listing(
key="outbox",
columns=(
fields.integer("id", "ID"),
fields.word("state", "STATE"),
fields.node("node", lanes=(("name", "NODE"),)),
fields.instant("scheduled_at", "SCHEDULED"),
fields.when("finished_at", "FINISHED"),
fields.free("text", "TEXT"),
),
rows=rows,
)
def _attempted(ident: int, outcome: str) -> Facts:
"""What one forced delivery attempt came to.
``outcome`` is one of the closed set the help lists, kept verbatim — spaces and all —
so both faces say the same word and a script matching ``no ack`` matches what a person
reads.
"""
from ..ui import fields
from ..ui.report import Facts
return Facts(
key="attempt",
fields=(fields.hidden("id"), fields.word("outcome", "outcome")),
values={"id": ident, "outcome": outcome},
)
def _did(*facts: tuple[str, object]) -> Facts:
"""An acknowledgement as data: nothing plain, a document for whoever is logging.
``cancel`` and ``clear`` say everything they have to say in their exit status, which
is the right plain answer — but a caller that wants to record *what* was cancelled has
nowhere to read it, and that is what the document is for.
"""
from ..ui import fields
from ..ui.report import SILENT, Facts
def column(key: str, value: object): # noqa: ANN202 - one of two shapes
return fields.flag(key, key) if isinstance(value, bool) else fields.integer(key, key)
return Facts(
key="outcome",
fields=tuple(column(key, value) for key, value in facts),
values=dict(facts),
shape=SILENT,
)
+103 -78
View File
@@ -11,27 +11,23 @@ the prompt shown when the menu launches (which smoke-tests the choice before con
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from rich.table import Table
from ..context import AppContext
from ..core import exitcodes
from ..core.discovery import discover_all, discover_devices
from .base import Tool, ToolResult, register
#: "MeshCore?" table cell per discovery confidence tier, for devices we have *not* yet
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Listing
#: The ``MESHCORE`` verdict per discovery confidence tier, for devices we have *not* yet
#: confirmed. The USB vendor ID is only a hint — a native-USB board or a bare bridge chip
#: is a "maybe", never a "yes" — so nothing is billed as MeshCore until a connection proves
#: it (see ``_confirmed_cell``).
_MAYBE_CELL: dict[str, str] = {
"board": "[warn]maybe[/warn]",
"bridge": "[warn]maybe[/warn]",
"unknown": "[muted]—[/muted]",
}
#: "MeshCore?" cell for a device already confirmed to speak the protocol.
_CONFIRMED_CELL = "[ok]yes[/ok]"
#: it.
_MAYBE: dict[str, str] = {"board": "maybe", "bridge": "maybe", "unknown": "no"}
@register
@@ -46,7 +42,7 @@ class DevicesTool(Tool):
menu_visible = False # CLI-only: a startup diagnostic with no place in a connected session
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Enumerate serial devices and render them as a table.
"""Enumerate attached and advertising devices, and state them as a listing.
Args:
ctx: Shared application context.
@@ -62,74 +58,25 @@ class DevicesTool(Tool):
active = ctx.selected_device
active_target = ctx.ble_override or ctx.port_override or (active.target if active else None)
if ctx.json_output:
import json
payload = [
{
"transport": d.transport,
"port": d.port,
"address": d.address,
"target": d.target,
"label": d.label,
"vendor": d.vendor_label,
"model": (known[d.stable_id].hardware_model if d.stable_id in known else ""),
"likely_lora": d.is_likely_lora,
"confidence": d.confidence,
"confirmed": d.stable_id in known,
"serial_number": d.serial_number,
"stable_id": d.stable_id,
"remembered": remembered is not None and remembered.matches(d),
"active": d.target == active_target,
}
for d in devices
]
ctx.console.print_json(json.dumps(payload))
return ToolResult(summary={"count": len(devices)})
if not devices:
ctx.ui.note(
"[warn]No companion devices detected.[/warn] "
"Connect one over USB or power on a Bluetooth companion nearby, or use "
"[accent]--mock[/accent] for the simulator."
)
return ToolResult(summary={"count": 0})
# Not an error: the scan ran and found nothing. Said on stderr so a caller
# redirecting stdout still hears it, and reported as NO_RESULT so a script can
# branch on it without matching prose. The empty listing still reaches the
# machine face as `[]`, which is a document a consumer can read — where an
# empty stdout is a parse error it would have to tell apart from a real one.
from ..ui import script
table = Table(title="Companion devices", border_style="muted", expand=False)
table.add_column("", style="ok", no_wrap=True) # active/confirmed markers
table.add_column("PORT / ADDRESS", style="brand")
table.add_column("DEVICE")
# "Hardware" (not "Vendor"): for a confirmed device this holds the firmware's own model
# string ("Seeed Tracker T1000-E") — the only reliable source of what the box is — and
# for a merely-attached serial port it falls back to the USB vendor name ("Espressif").
# One column spans both because a maker name and a model name are the same question:
# "what hardware is this?". A BLE device that's never connected has neither, so it's "?".
table.add_column("HARDWARE", style="muted")
table.add_column("MESHCORE?", justify="center")
table.add_column("SERIAL", style="muted")
for d in devices:
confirmed = known.get(d.stable_id) # the remembered record, if ever confirmed
is_active = d.target == active_target
marker = ("●" if is_active else "") + ("★" if confirmed else "")
device_name = d.name or d.product or d.description or "[muted]?[/muted]"
if confirmed and confirmed.node_name: # the mesh name learned on connect
device_name = f"{confirmed.node_name} [muted]({device_name})[/muted]"
# Prefer the connected-time model; fall back to the USB vendor for unconnected ports.
hardware = (confirmed.hardware_model if confirmed else "") or d.vendor_label
table.add_row(
marker,
d.target,
device_name,
hardware or "[muted]?[/muted]",
_CONFIRMED_CELL if confirmed else _MAYBE_CELL[d.confidence],
d.serial_number or "[muted]—[/muted]",
script.stderr_console().print(
"meshterm: no companion devices detected", style="warn", highlight=False
)
ctx.ui.show(table)
ctx.ui.note(
"[muted]● active ★ confirmed MeshCore device. "
"Pass --port <PORT> or --ble <ADDRESS> to select on the CLI.[/muted]"
return ToolResult(
summary={"count": len(devices)},
report=(_listing(devices, known, remembered, active_target),),
# Changed from the 0 this used to return under --json: a rendering flag has no
# business changing the report, and the plain path has always said 5 here.
exit_code=exitcodes.OK if devices else exitcodes.NO_RESULT,
)
return ToolResult(summary={"count": len(devices)})
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``devices`` subcommand (inventory only; no connection).
@@ -139,10 +86,88 @@ class DevicesTool(Tool):
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
@app.command(
name=self.name,
help=self.help,
# The listing used to close with a line explaining its own markers and how to
# act on a row. That is help, and this is where help goes.
epilog="Select a device with --port TARGET or --ble TARGET, using the "
"TARGET column verbatim.",
)
def _devices(
ble: bool = typer.Option(
True, "--ble/--no-ble", help="Include a Bluetooth LE scan (adds a few seconds)"
),
) -> None:
run_tool_command(self, {"ble": ble})
def _listing(devices: list, known: dict, remembered: object, active_target: str | None) -> Listing:
"""The device inventory, stated once for both faces.
``TARGET`` leads because it is the field a caller acts on — it is what ``--port`` and
``--ble`` take, verbatim. The menu's two markers become columns of their own
(``ACTIVE``, and ``MESHCORE`` for the confirmed star), because a glyph in a margin is
something to look at rather than something to test.
``MESHCORE`` is three-valued and stays that way: ``yes`` only once a connection has
proved the device speaks the protocol, ``maybe`` for a USB vendor ID that suggests a
LoRa board or a bridge chip, ``no`` for anything else. A vendor ID is a hint, and the
column would be lying if it rounded one up — which is why the machine face carries the
same three words rather than the boolean it used to.
Args:
devices: The discovered devices.
known: Remembered device records, keyed by stable id.
remembered: The remembered default device, if there is one.
active_target: The target this invocation is (or would be) using.
Returns:
The listing.
"""
from ..ui import fields
from ..ui.report import Listing
rows = []
for device in devices:
confirmed = known.get(device.stable_id)
rows.append(
{
"target": device.target,
"transport": device.transport,
"port": device.port,
"address": device.address,
"label": (confirmed.node_name if confirmed else "") or device.label,
"hardware": (confirmed.hardware_model if confirmed else "") or device.vendor_label,
"meshcore": "yes" if confirmed else _MAYBE.get(device.confidence, "no"),
"confidence": device.confidence,
"serial_number": device.serial_number,
"stable_id": device.stable_id,
"confirmed": confirmed is not None,
"remembered": remembered is not None and remembered.matches(device),
"active": device.target == active_target,
}
)
return Listing(
key="devices",
columns=(
fields.word("target", "TARGET"),
fields.word("transport", "TRANSPORT"),
# The two halves `target` is one of, kept apart for a caller that has to know
# which transport it is holding without parsing the target for a colon.
fields.hidden("port"),
fields.hidden("address"),
fields.name("label", "NAME"),
fields.name("hardware", "HARDWARE"),
fields.word("meshcore", "MESHCORE"),
# What the "maybe" was derived from. A person reading the column has the
# vendor label beside it and can see for themselves; a program cannot.
fields.hidden("confidence"),
fields.word("serial_number", "SERIAL"),
fields.hidden("stable_id"),
fields.hidden("confirmed"),
fields.hidden("remembered"),
fields.flag("active", "ACTIVE"),
),
rows=rows,
)
+178 -23
View File
@@ -3,7 +3,9 @@
from __future__ import annotations
import time
from typing import Any
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import typer
from rich.panel import Panel
@@ -13,6 +15,9 @@ from ..context import AppContext
from ..core.connection import Device
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Column, Facts
#: MeshCore advert-type byte -> human-readable node role.
_ROLES = {1: "companion", 2: "repeater", 3: "room server", 4: "sensor"}
@@ -47,8 +52,21 @@ class InfoTool(Tool):
device = await ctx.device()
snapshot = await cached_snapshot(ctx, device)
custom = await device.get_custom_vars()
# On the CLI this is the *status* report and nothing else: how the radio is doing
# right now, one fact per line. What it is set to is `config show`'s answer, in
# the very keys `config get`/`config set` take, so printing the settings here as
# well would be the same table twice under two different spellings.
session = getattr(ctx.ui, "session", None)
if session is None:
return ToolResult(
summary={"name": snapshot.get("name")},
report=(await status_facts(device, snapshot),),
)
from ..ui.device_info_screen import DeviceInfoScreen
custom = await device.get_custom_vars()
# A live status panel (role, firmware, battery, clock, radio/packet statistics)
# above every current setting with a short explanation of each. The panel is read
# once; only the table differs between the two states, and it is cheap to rebuild.
@@ -57,20 +75,11 @@ class InfoTool(Tool):
def page(reveal_pin: bool) -> Group:
return Group(panel, Text(""), config_table(snapshot, custom, reveal_pin=reveal_pin))
# In the menu the page is its own screen, because it has a key of its own: the
# pairing PIN is masked until ``p`` uncovers it. On the CLI there is no keyboard to
# press, so the dump prints as it always did — with the PIN masked there too, and
# ``meshterm config get device_pin`` as the way to name it deliberately.
session = getattr(ctx.ui, "session", None)
if session is None:
ctx.ui.show(page(False))
else:
from ..ui.device_info_screen import DeviceInfoScreen
await session.run_screen(
DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot))
)
# The page is its own screen because it has a key of its own: the pairing PIN is
# masked until ``p`` uncovers it.
await session.run_screen(
DeviceInfoScreen(session, page, title=self.title, conceals=has_pin(snapshot))
)
return ToolResult(summary={"name": snapshot.get("name")})
def register_cli(self, app: typer.Typer) -> None:
@@ -89,11 +98,34 @@ class InfoTool(Tool):
# -- the status panel -----------------------------------------------------------
async def _read_status(device: Device) -> dict[str, Any]:
"""Read every live status value the firmware will answer for, best-effort.
The one place the device is asked. Both faces build from what it returns: the menu's
panel composes prose rows out of these values, the CLI prints them one fact per line
(see :func:`_status_panel` and :func:`status_facts`). Every read is optional —
firmware predating a query simply contributes nothing — so this works on any device.
Args:
device: The connected device to query.
Returns:
The merged raw readings: ``info``, ``battery``, ``stats`` and ``clock``.
"""
return {
"info": await _try(device.get_device_info) or {},
"battery": await _try(device.get_battery) or {},
"stats": await _try(device.get_stats) or {},
"clock": await _try(device.get_time),
}
async def _status_panel(device: Device, snapshot: dict) -> Panel:
"""Build the live status panel: role, firmware, battery, clock, and statistics.
Every read beyond the snapshot is best-effort — firmware predating a query (battery,
clock, stats) simply contributes no row — so the panel renders on any device.
The menu's face of :func:`_read_status` — each row a short phrase rather than an
atomic value, because a person reads "-110 dBm noise floor · last RSSI -62 dBm" in one
go where a script wants the three numbers apart.
Args:
device: The connected device to query.
@@ -103,32 +135,31 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
A Rich :class:`Panel` of label/value status rows.
"""
rows: list[tuple[str, Text]] = []
readings = await _read_status(device)
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
adv_type = snapshot.get("adv_type")
if adv_type is not None:
role = _ROLES.get(int(adv_type), f"type {adv_type}")
rows.append(("role", Text(role, style="brand")))
info = await _try(device.get_device_info) or {}
if info.get("model"):
rows.append(("model", Text(str(info["model"]))))
firmware = " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
firmware = _firmware(info)
if firmware:
rows.append(("firmware", Text(firmware)))
battery = await _try(device.get_battery) or {}
level = battery.get("level")
if level:
rows.append(("battery", Text(f"{int(level) / 1000:.2f} V")))
if battery.get("total_kb"):
rows.append(("storage", Text(f"{battery.get('used_kb', 0)} / {battery['total_kb']} kB")))
clock = await _try(device.get_time)
clock = readings["clock"]
if clock:
stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clock))
rows.append(("clock", Text.assemble(stamp, (f" {_drift(clock)}", "muted"))))
stats = await _try(device.get_stats) or {}
if stats.get("uptime_secs") is not None:
rows.append(("uptime", Text(_uptime(int(stats["uptime_secs"])))))
if stats.get("noise_floor") is not None:
@@ -160,6 +191,130 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False)
def _firmware(info: dict) -> str:
"""The firmware version and build joined, or ``""`` when neither was reported."""
return " ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k))
async def status_facts(device: Device, snapshot: dict) -> Facts:
"""The device's live status as one set of facts.
The CLI's face of :func:`_read_status`. Where the panel writes a phrase per row, this
writes one value per key and puts the unit *in the key* — ``battery_v``, ``uptime_s``,
``noise_floor_dbm`` — so nothing has to be pulled back out of prose. A number never
carries a unit in its value, on either face.
The three second-valued readings and the clock drift carry a **gloss** beside the
figure: ``uptime_s 93784 (1d 2h)``. This is the one key/value block that may, and
the reason is that it is the one with no ``get`` and no ``set`` behind it — there is no
round trip to protect, the key still says what the number counts, and ``93784`` is a
reading nobody can hold in their head. The document carries the number alone, having
nowhere useful to put a phrase.
A reading the firmware did not answer for is **left out** of the plain block rather
than dashed: these are optional queries, and a missing key says "this device does not
report it" where a dash would claim it reported nothing. The document keeps the key and
writes ``null`` — see :attr:`~meshterm.ui.report.Facts.omit_absent` for why the two
faces disagree here on purpose.
Args:
device: The connected device to query.
snapshot: The already-built settings snapshot (for role and identity).
Returns:
The facts block.
"""
from ..ui import fields
from ..ui.report import Facts
readings = await _read_status(device)
info, battery, stats = readings["info"], readings["battery"], readings["stats"]
level = battery.get("level")
adv_type = snapshot.get("adv_type")
clock = readings["clock"]
values: dict[str, Any] = {
# Identity first: which radio this is. Everything it *can be set to* is
# `config show`'s answer, not this one.
"name": snapshot.get("name"),
"public_key": str(snapshot.get("public_key") or "").lower() or None,
"role": _ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None,
"model": info.get("model"),
"firmware": _firmware(info) or None,
"battery_v": round(int(level) / 1000, 2) if level else None,
"storage_used_kb": battery.get("used_kb", 0) if battery.get("total_kb") else None,
"storage_total_kb": battery.get("total_kb"),
"clock_at": (
datetime.fromtimestamp(clock, tz=timezone.utc).astimezone() if clock else None
),
"clock_drift_s": (clock - int(time.time())) if clock else None,
"uptime_s": stats.get("uptime_secs"),
"noise_floor_dbm": stats.get("noise_floor"),
"last_rssi_dbm": stats.get("last_rssi"),
"last_snr_db": stats.get("last_snr"),
"tx_air_s": stats.get("tx_air_secs"),
"rx_air_s": stats.get("rx_air_secs"),
"packets_sent": stats.get("sent"),
"packets_received": stats.get("recv"),
"receive_errors": stats.get("recv_errors"),
}
return Facts(
key="info",
fields=(
fields.word("name", "name"),
fields.hexid("public_key", "public_key"),
fields.word("role", "role"),
fields.word("model", "model"),
fields.word("firmware", "firmware"),
fields.decimal("battery_v", "battery_v", ".2f"),
fields.integer("storage_used_kb", "storage_used_kb"),
fields.integer("storage_total_kb", "storage_total_kb"),
fields.instant("clock_at", "clock_at"),
_glossed("clock_drift_s", _drift_gloss),
_glossed("uptime_s", _span_gloss),
fields.integer("noise_floor_dbm", "noise_floor_dbm"),
fields.integer("last_rssi_dbm", "last_rssi_dbm"),
fields.snr("last_snr_db", "last_snr_db"),
_glossed("tx_air_s", _span_gloss),
_glossed("rx_air_s", _span_gloss),
fields.integer("packets_sent", "packets_sent"),
fields.integer("packets_received", "packets_received"),
fields.integer("receive_errors", "receive_errors"),
),
values=values,
omit_absent=True,
)
def _glossed(key: str, gloss: Callable[[int], str]) -> Column:
"""A seconds reading printed as its own number with a readable span beside it."""
from ..ui import script
from ..ui.report import Column, Lane
def cell(value: Any) -> str:
if value is None:
return script.NONE
return f"{value} ({gloss(int(value))})"
return Column(key=key, lanes=(Lane(header=key, render=cell),))
def _span_gloss(seconds: int) -> str:
"""``93784`` → ``1d 2h``: the figure a person can hold in their head."""
from ..ui import script
return script.duration(seconds)
def _drift_gloss(seconds: int) -> str:
"""The device clock's error, in the direction a reader would say it."""
from ..ui import script
if abs(seconds) < 2:
return "in sync"
return f"{script.duration(seconds)} {'fast' if seconds > 0 else 'slow'}"
async def _try(read) -> Any | None:
"""Await a device read, returning ``None`` when the firmware doesn't support it."""
try:
+24 -134
View File
@@ -5,11 +5,12 @@ Nodes that share their location in adverts are collected from the passive-monito
its position is known, and everything is drawn over a real street basemap rendered as Unicode
braille (streets, rivers, place names — the same terminal-map idea as ``mapscii``).
In the menu it opens a full-screen, pannable/zoomable map (see
:mod:`meshterm.ui.map_screen`). On the CLI it prints a one-shot render fitted to the nodes.
Repeaters are prioritised over ordinary nodes: a distinct marker, drawn on top, listed first.
The basemap is best-effort — with no network (and no cached tiles) the nodes are plotted on a
blank grid instead, and the tool still works offline.
Menu-only: it opens a full-screen, pannable/zoomable map (see
:mod:`meshterm.ui.map_screen`) and registers no CLI subcommand, because a map is a picture
and the scripted CLI deals in facts (see :meth:`MapTool.register_cli`). Repeaters are
prioritised over ordinary nodes: a distinct marker, drawn on top. The basemap is
best-effort — with no network (and no cached tiles) the nodes are plotted on a blank grid
instead, and the tool still works offline.
"""
from __future__ import annotations
@@ -17,9 +18,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from rich.console import Group
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core.geo import usable_fix
@@ -61,17 +59,18 @@ class MapTool(Tool):
order = 10
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Gather located nodes and open the interactive map, or render a static one.
"""Gather the located nodes and open the interactive map.
Menu-only (see :meth:`register_cli`), so there is one path through here.
Args:
ctx: Shared application context.
params: Optional ``static`` (force one-shot render), ``width``, ``zoom``,
``basemap`` (set ``False`` to skip fetching tiles).
params: Optional ``fraction`` — how much of the mesh to open framed on.
Returns:
A :class:`ToolResult` summarizing how many nodes were plotted.
"""
from ..ui.surface import TuiUi
from ..ui.map_screen import open_map
markers = await self._gather(ctx)
if not markers:
@@ -81,13 +80,7 @@ class MapTool(Tool):
)
return ToolResult(summary={"located": 0})
interactive = isinstance(ctx.ui, TuiUi) and not params.get("static")
if interactive:
from ..ui.map_screen import open_map
await open_map(ctx, markers, fraction=_fraction(ctx, params))
else:
await self._render_static(ctx, markers, params)
await open_map(ctx, markers, fraction=_fraction(ctx, params))
repeaters = sum(1 for m in markers if m.is_repeater and not m.is_self)
self_located = any(m.is_self for m in markers)
@@ -98,12 +91,6 @@ class MapTool(Tool):
"nodes": len(markers) - repeaters - (1 if self_located else 0),
"self_located": self_located,
},
message=None
if interactive
else (
f"[ok]✓[/ok] mapped [brand]{len(markers)}[/brand] located nodes "
f"([accent]{repeaters}[/accent] repeaters)"
),
)
# -- marker gathering -------------------------------------------------------
@@ -112,90 +99,22 @@ class MapTool(Tool):
"""Collect every located node to plot (see :func:`gather_markers`)."""
return await gather_markers(ctx)
# -- static (CLI) render ----------------------------------------------------
async def _render_static(
self, ctx: AppContext, markers: list[MapMarker], params: dict[str, Any]
) -> None:
"""Fetch tiles synchronously and print a one-shot map fitted to the nodes."""
import asyncio
from ..core.geo import Viewport
from ..ui.map_render import render_map
from ..ui.map_screen import basemap_source
cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160))
cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect
dot_w, dot_h = cell_w * 2, cell_h * 4
source = basemap_source(ctx)
max_zoom = await asyncio.to_thread(lambda: source.max_zoom)
coords = [(m.lat, m.lon) for m in markers]
if params.get("zoom") is not None:
from ..core.geo import BBox
center = BBox.around(coords).center
vp = Viewport(center[0], center[1], int(params["zoom"]), dot_w, dot_h)
else:
vp = Viewport.fit(
coords,
dot_w,
dot_h,
max_zoom=max_zoom,
fraction=_fraction(ctx, params),
)
tiles = {}
if params.get("basemap", True):
for tile in vp.tiles(max_zoom):
tiles[tile] = await asyncio.to_thread(source.load_tile, *tile)
lines = render_map(vp, tiles, markers)
body = Text.from_ansi("\n".join(lines))
ctx.ui.show(Group(body, Text(""), _legend(markers)))
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``map`` subcommand.
"""Register no CLI command — a map is a picture, and pictures are menu-only.
Every other feature has a scripted face because its answer is a set of facts a
script can act on. A map's answer is a *drawing*: braille cells whose meaning is
their position on a grid and whose nodes are told apart by colour, which is
exactly what the scripted CLI does not have (see :mod:`meshterm.ui.script`). A
one-shot render there would be an unparseable block of glyphs, and stripping its
colour to match the rest of the CLI would make it unreadable as well.
The located nodes themselves are still scriptable — ``meshterm contacts`` lists
every one of them, coordinates included.
Args:
app: The Typer application.
app: The Typer application (untouched).
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _map(
width: int | None = typer.Option(
None, "--width", "-w", help="Map width in character cells"
),
zoom: int | None = typer.Option(
None, "--zoom", "-z", help="Fixed zoom level (omit to fit the nodes)"
),
fraction: float | None = typer.Option(
None,
"--fraction",
"-f",
min=0.0,
max=1.0,
help="Fraction of nodes to frame: the densest that many, so distant "
"outliers don't zoom the view out. 1.0 fits every node. Ignored with "
"--zoom. Defaults to the 'Opening frame' preference",
),
basemap: bool = typer.Option(
True, "--basemap/--no-basemap", help="Draw the OpenStreetMap street basemap"
),
) -> None:
if fraction is not None and not 0.0 < fraction <= 1.0:
raise typer.BadParameter("--fraction must be greater than 0 and at most 1")
params: dict[str, Any] = {
"static": True,
"basemap": basemap,
"fraction": fraction,
}
if width is not None:
params["width"] = width
if zoom is not None:
params["zoom"] = zoom
run_tool_command(self, params)
async def gather_markers(ctx: AppContext) -> list[MapMarker]:
@@ -285,35 +204,6 @@ async def _self_marker(ctx: AppContext) -> MapMarker | None:
)
def _legend(markers: list[MapMarker]) -> Table:
"""A compact legend: self → repeaters → leaf nodes, with coordinates and detail.
Names take their key-derived hue (our own the pure-white ``you``), matching the
interactive map's labels; the glyph keeps the marker's type colour.
"""
from ..ui.map_render import _NODE, _REPEATER, _SELF
from ..ui.theme import name_style
table = Table(box=None, padding=(0, 2, 0, 0), expand=False)
table.add_column("")
table.add_column("NODE")
table.add_column("TYPE")
table.add_column("COORDS", justify="right")
table.add_column("HEARD")
ordered = sorted(markers, key=lambda m: -m._rank())
for m in ordered:
glyph, color = _SELF if m.is_self else (_REPEATER if m.is_repeater else _NODE)
kind = "you" if m.is_self else ("repeater" if m.is_repeater else "node")
table.add_row(
Text(glyph, style=color),
Text(m.label, style="you" if m.is_self else name_style(m.label, m.key)),
Text(kind, style="muted"),
Text(f"{m.lat:.4f}, {m.lon:.4f}", style="muted"),
Text(m.detail, style="muted"),
)
return table
def _signal_detail(node: object) -> str:
"""A compact 'N pkts · +x.x dB' reception note for a heard node, or '' if unheard."""
if node is None:
+166 -55
View File
@@ -18,17 +18,21 @@ summarizes the window when it ends.
from __future__ import annotations
import asyncio
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core.events import EventKind, MeshEvent
from ..core.models import HeardNode, Observation
from ..core.models import NODE_TYPE_LABELS, HeardNode, Observation
from ..ui import renderers
from ..ui.fields import NodeRef, Position
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Listing
@register
class MonitorTool(Tool):
@@ -74,13 +78,18 @@ class MonitorTool(Tool):
Returns:
A :class:`ToolResult` with the window's packet and node counts.
"""
from ..ui import script
seconds = int(params.get("seconds") or 0)
await ctx.device() # surface connection problems before announcing the capture
await ctx.monitor.start() # register history recording before the hub pumps
await ctx.events.start()
ctx.console.print(
"[muted]monitoring"
f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]"
# The announcement is about the run, not part of its answer, so it goes to stderr
# and stays out of `meshterm monitor -s 60 > packets.txt`.
script.stderr_console().print(
"monitoring" + (f" for {seconds}s" if seconds else " — press Ctrl-C to stop"),
style="muted",
highlight=False,
)
seen: list[Observation] = []
@@ -89,39 +98,54 @@ class MonitorTool(Tool):
if obs is None:
return
seen.append(obs)
stamp = obs.observed_at.astimezone().strftime("%H:%M:%S")
who = obs.name or obs.node or "?"
snr = f" [muted]{obs.snr:+.1f} dB[/muted]" if obs.snr is not None else ""
rssi = f" [muted]{obs.rssi:.0f} dBm[/muted]" if obs.rssi is not None else ""
loc = " [ok]●[/ok]" if obs.lat is not None else ""
ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]{snr}{rssi}{loc}")
emit(
{
"observed_at": obs.observed_at,
"node": NodeRef(
name=obs.name,
key=(obs.public_key or "").lower() or None,
hash=obs.node,
type=NODE_TYPE_LABELS.get(obs.node_type),
),
"kind": obs.kind,
"snr_db": obs.snr,
"rssi_dbm": obs.rssi,
"position": _position(obs.lat, obs.lon),
"path": _relays(obs.path),
}
)
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C / cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
await ctx.monitor.stop() # close the run row so the capture is a full record
with renderers.stream(ctx, _live_packets()) as emit:
unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C / cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
await ctx.monitor.stop() # close the run row so the capture is a full record
by_node: dict[str, list[Observation]] = {}
for obs in seen:
by_node.setdefault(obs.node, []).append(obs)
nodes = [HeardNode.from_observations(node, group) for node, group in by_node.items()]
if nodes:
nodes.sort(key=lambda n: n.last_seen, reverse=True)
ctx.ui.show(_heard_table(nodes))
nodes.sort(key=lambda n: n.last_seen, reverse=True)
# The window is over, so its facts are worth saying — on stderr, where everything
# about a run goes, keeping the packets a caller redirected the run for on their own.
script.stderr_console().print(
f"captured {len(seen)} packet{'' if len(seen) == 1 else 's'} "
f"from {len(nodes)} node{'' if len(nodes) == 1 else 's'}"
+ (f" in {seconds}s" if seconds else ""),
style="muted",
highlight=False,
)
return ToolResult(
summary={"seconds": seconds, "packets": len(seen), "nodes": len(nodes)},
message=(
f"[ok]✓[/ok] heard [brand]{len(seen)}[/brand] "
f"packet{'' if len(seen) == 1 else 's'} from "
f"[brand]{len(nodes)}[/brand] node{'' if len(nodes) == 1 else 's'}"
),
report=(_heard(nodes),),
exit_code=exitcodes.OK if seen else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
@@ -141,33 +165,120 @@ class MonitorTool(Tool):
run_tool_command(self, {"seconds": seconds})
def _heard_table(heard: list[HeardNode]) -> Table:
"""Render the capture window's heard-node summary table.
def _position(lat: float | None, lon: float | None) -> Position | None:
"""A reception's shared position, or ``None`` where the packet carried none."""
return Position(lat, lon) if lat is not None and lon is not None else None
def _relays(path: str | None) -> list[str] | None:
"""A packet's relay chain as hashes, in propagation order.
Hashes rather than the shared node shape, and that is the honest answer: nothing
resolves a relay to a node at reception time, so a node object per hop would be four
``null``s wrapped round a hash. ``[]`` is a direct reception (zero hops); ``None``
means the packet class carries no path at all, which is a different fact.
"""
if path is None:
return None
return [hop for hop in path.split(",") if hop]
def _live_packets() -> Listing:
"""The shape the live capture streams: one record per packet, as it lands.
``TIME`` is absolute where a listing's times are ages, and for the reason the whole
rule turns on: every row of a live tail would read ``now``. The **name goes last**, so
the one field with no width cannot push a lane — which is what finally let the two
streams have columns at all, and with them the quoting could go.
"""
from dataclasses import replace
from ..ui import fields
from ..ui.report import Listing
def pin(column, width: int): # noqa: ANN001, ANN202 - one column in, one column out
return replace(column, lanes=tuple(replace(lane, width=width) for lane in column.lanes))
node = fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME")))
node = replace(
node,
lanes=(replace(node.lanes[0], width=8), node.lanes[1]),
)
return Listing(
key="observations",
columns=(
pin(fields.instant("observed_at", "TIME"), 25),
node,
# The packet class, which the plain stream has never had room for. New
# surface: the simulator does not exercise every class, so a consumer should
# treat an unfamiliar word as a word rather than an error.
fields.hidden("kind"),
pin(fields.snr("snr_db", "SNR_DB"), 6),
pin(fields.decimal("rssi_dbm", "RSSI_DBM", ".0f"), 8),
pin(fields.position(), 19),
fields.hidden("path"),
),
order=("TIME", "NODE", "SNR_DB", "RSSI_DBM", "LOCATION", "NAME"),
)
def _heard(heard: list[HeardNode]) -> Listing:
"""The capture window's per-node summary: what each node did over the whole window.
The aggregate the live stream cannot give — a count, a median, a best — one record per
node heard, most recently heard first.
Drawn **for a person only**. Every figure in it is computable from the records that
scrolled past above it, and emitting it as a second *shape* of line in the middle of an
otherwise homogeneous NDJSON stream would cost every consumer a discriminator it would
never otherwise need.
Args:
heard: Aggregated per-node statistics for the window.
Returns:
A Rich :class:`Table` of node, packet count, SNR, RSSI, and location.
The listing.
"""
from ..ui.theme import snr_style
from ..ui import fields
from ..ui.report import Listing
table = Table(title=f"Heard nodes ({len(heard)})", border_style="muted", expand=False)
table.add_column("NODE")
table.add_column("PKTS", justify="right")
table.add_column("MEDIAN SNR", justify="right")
table.add_column("BEST SNR", justify="right")
table.add_column("RSSI", justify="right")
table.add_column("LOC", justify="center")
for node in heard:
label = node.name or node.node or "?"
median = node.median_snr
best = node.best_snr
median_cell = (
Text(f"{median:+.1f}", style=snr_style(median)) if median is not None else Text("—")
)
best_cell = Text(f"{best:+.1f}", style=snr_style(best)) if best is not None else Text("—")
rssi_cell = f"{node.last_rssi:.0f}" if node.last_rssi is not None else "—"
loc_cell = Text("●", style="ok") if node.has_location else Text("·", style="muted")
table.add_row(str(label), str(node.count), median_cell, best_cell, rssi_cell, loc_cell)
return table
return Listing(
key="heard",
columns=(
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
fields.integer("packets", "PKTS"),
fields.snr("median_snr_db", "MEDIAN_SNR_DB"),
fields.snr("best_snr_db", "BEST_SNR_DB"),
fields.decimal("last_rssi_dbm", "RSSI_DBM", ".0f"),
fields.when("last_heard_at", "HEARD"),
fields.position(),
),
rows=[
{
"node": NodeRef(
name=node.name,
key=(node.public_key or "").lower() or None,
hash=node.node,
type=NODE_TYPE_LABELS.get(node.node_type),
),
"packets": node.count,
"median_snr_db": node.median_snr,
"best_snr_db": node.best_snr,
"last_rssi_dbm": node.last_rssi,
"last_heard_at": node.last_seen,
"position": _position(node.lat, node.lon),
}
for node in heard
],
order=(
"NODE",
"NAME",
"PKTS",
"MEDIAN_SNR_DB",
"BEST_SNR_DB",
"RSSI_DBM",
"HEARD",
"LOCATION",
),
plain_only=True,
)
+168 -10
View File
@@ -17,14 +17,24 @@ than sitting beside Device config.
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.preferences import PreferenceError, format_value, get_spec
from ..core.preferences import (
PREFERENCES,
PreferenceError,
Preferences,
PrefSpec,
format_value,
get_spec,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Block, Facts, Listing
@register
class PreferencesTool(Tool):
@@ -73,31 +83,50 @@ class PreferencesTool(Tool):
cleanly by the CLI rather than as a traceback.
"""
from ..ui.preferences import preferences_table
from ..ui.surface import TuiUi
prefs = ctx.preferences
scripted = not isinstance(ctx.ui, TuiUi)
ops: list[tuple] = list(params.get("ops") or [])
blocks: list[Block] = []
applied: list[dict[str, Any]] = []
changes = 0
for op in ops:
action = op[0]
if action == "show":
ctx.ui.show(preferences_table(prefs, ctx.console.width))
if scripted:
blocks.append(_listing(prefs))
else:
ctx.ui.show(preferences_table(prefs, ctx.console.width))
elif action == "get":
# The bare value and nothing else — the caller named the key, and
# whether it is overridden is what `show`'s DEFAULT lane answers. The
# document keeps both, since a parser has no lane to read them off.
spec = get_spec(op[1])
where = "changed" if prefs.is_overridden(spec.key) else "default"
ctx.ui.show(
f"[accent]{spec.key}[/accent] = "
f"[brand]{format_value(spec, prefs.get(spec.key))}[/brand] ({where})"
)
if scripted:
blocks.append(_one(prefs, spec))
else:
where = "changed" if prefs.is_overridden(spec.key) else "default"
shown = format_value(spec, prefs.get(spec.key))
ctx.ui.note(f"[accent]{spec.key}[/accent] = [brand]{shown}[/brand] ({where})")
elif action == "set":
before = prefs.get(op[1])
after = prefs.set(op[1], op[2])
if after != before:
changes += 1
applied.append({"key": op[1], "previous": before, "value": after})
elif action == "reset":
was = {s.key: prefs.get(s.key) for s in PREFERENCES if prefs.is_overridden(s.key)}
changes += prefs.reset()
applied.extend(
{"key": key, "previous": before, "value": prefs.get(key)}
for key, before in was.items()
)
if changes:
prefs.save()
if scripted and any(op[0] in ("set", "reset") for op in ops):
blocks.append(_written(applied, prefs))
plural = "" if changes == 1 else "s"
message = (
@@ -106,7 +135,9 @@ class PreferencesTool(Tool):
if changes
else None
)
return ToolResult(summary={"changes": changes}, message=message)
return ToolResult(
summary={"changes": changes}, message=message, report=tuple(blocks) or None
)
# -- CLI --------------------------------------------------------------------
@@ -118,7 +149,7 @@ class PreferencesTool(Tool):
"""
from ..cli import run_tool_command
prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode="rich")
prefs_app = typer.Typer(help=self.help, no_args_is_help=False, rich_markup_mode=None)
@prefs_app.callback(invoke_without_command=True)
def _root(ctx: typer.Context) -> None:
@@ -155,3 +186,130 @@ class PreferencesTool(Tool):
__all__ = ["PreferencesTool", "PreferenceError"]
def _script_value(spec: PrefSpec, value: Any) -> str:
"""A preference's value in the form ``preferences set`` accepts back.
:func:`~meshterm.core.preferences.format_value` writes for the page: a number carries
its unit (``5 s``), so a bare figure in the VALUE lane still says what it counts. That
unit is exactly what :func:`~meshterm.core.preferences.parse_value` will not take
back, so the scripted dump drops it. Booleans keep ``on``/``off`` and an enum keeps
its own key, both of which do round-trip.
Args:
spec: The preference the value belongs to.
value: Its current value.
Returns:
The scripted rendering.
"""
if spec.value_type == "bool":
return "on" if value else "off"
if spec.value_type in ("int", "float"):
return f"{value:g}"
return str(value)
def _row(prefs: Preferences, spec: PrefSpec) -> dict[str, Any]:
"""One preference as a report row, in the shared setting shape.
``overridden`` is the fact the plain face makes a reader derive by comparing two
columns; a document that carried the same two numbers and left the comparison to the
consumer would be handing over homework it has the answer to.
"""
from ..ui.fields import Rendered
value = prefs.get(spec.key)
return {
"key": spec.key,
"value": Rendered(value, _script_value(spec, value)),
"type": spec.value_type,
# The reader's word for an enum's own key — never what `set` takes, which is the
# key itself and rides in `value`.
"label": (spec.choices or {}).get(value) if spec.value_type == "enum" else None,
"redacted": False,
"default": Rendered(spec.default, _script_value(spec, spec.default)),
"overridden": prefs.is_overridden(spec.key),
"help": spec.description,
}
def _columns() -> tuple:
"""The preference row's columns, shared by ``show`` and ``get``."""
from ..ui import fields
return (
fields.word("key", "PREFERENCE"),
fields.rendered("value", "VALUE"),
fields.hidden("type"),
fields.hidden("label"),
fields.hidden("redacted"),
fields.rendered("default", "DEFAULT"),
fields.hidden("overridden"),
fields.note("help", "DESCRIPTION"),
)
def _listing(prefs: Preferences) -> Listing:
"""Every preference, its value, its built-in default, and what it is for.
``DESCRIPTION`` comes straight from :attr:`PrefSpec.help` — the text is already
written, one short line each, and as the *last* column it can never disturb an
alignment or wrap. It used to be cut for parser hygiene, which is a bargain the plain
face no longer has to make: a listing is read by a person now, and a person choosing a
preference is exactly who that sentence was written for.
Args:
prefs: The preference set to report.
Returns:
The listing.
"""
from ..ui.report import Listing
return Listing(
key="preferences",
columns=_columns(),
rows=[_row(prefs, spec) for spec in PREFERENCES],
order=("PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"),
)
def _one(prefs: Preferences, spec: PrefSpec) -> Facts:
"""One preference: the bare value plain, the whole setting object in the document."""
from ..ui.report import BARE, Facts
return Facts(
key="preference",
fields=_columns(),
values=_row(prefs, spec),
shape=BARE,
bare="value",
)
def _written(applied: list[dict[str, Any]], prefs: Preferences) -> Facts:
"""What ``set`` or ``reset`` changed — nothing plain, a record for whoever wants one.
The plain face says it in the exit status and in the acknowledgement on stderr, which
is the whole answer for a person. A caller managing a machine's configuration wants to
know *what moved*, and had no way to ask.
"""
from ..ui import fields
from ..ui.report import SILENT, Column, Facts
return Facts(
key="applied",
fields=(
fields.integer("changes", "changes"),
Column(key="applied"),
fields.word("path", "path"),
),
values={
"changes": len(applied),
"applied": applied,
"path": str(prefs.path) if prefs.path else None,
},
shape=SILENT,
)
+148 -37
View File
@@ -13,16 +13,23 @@ The CLI face prints the stored boards: ``meshterm records`` reads the tables so
from __future__ import annotations
from typing import Any
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import typer
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..services.records import CATEGORIES, CATEGORY_BY_ID
from ..core import exitcodes
from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
from ..ui.script import NONE
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..persistence.repository import DiscoveredPath
from ..ui.fields import NodeRef
from ..ui.report import Column
@register
class TrophyCaseTool(Tool):
@@ -72,7 +79,7 @@ class TrophyCaseTool(Tool):
def _show_records(
self, ctx: AppContext, *, category: str | None, width: int | None
) -> ToolResult:
"""Print the stored record boards (no device, no transmissions).
"""State the stored record boards (no device, no transmissions).
Args:
ctx: Shared application context.
@@ -82,42 +89,62 @@ class TrophyCaseTool(Tool):
Returns:
A :class:`ToolResult` with the record count.
"""
from ..services import trace_runner
from ..ui import fields
from ..ui.report import Listing
wanted = [CATEGORY_BY_ID[category]] if category in CATEGORY_BY_ID else CATEGORIES
total = 0
# Names come from stored history alone — no contacts, because this command reads
# the database and must work with no radio attached at all.
resolve = trace_runner.make_node_resolver(None, ctx.repo.node_names())
rows: list[dict] = []
for cat in wanted:
rows = ctx.repo.discoveries(cat.id, width_bytes=width)
if not rows:
continue
rows.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score))
table = Table(
title=f"{cat.icon} {cat.title} — {cat.description}",
title_justify="left",
)
table.add_column("width", justify="right")
table.add_column("score", justify="right")
table.add_column("route")
table.add_column("recorded")
table.add_column("version")
for record in rows:
score = cat.format_score(record.score)
if cat.id == "long_haul" and not record.stats.get("km_complete", True):
score = "≥ " + score
table.add_row(
f"{record.width_bytes} B",
score,
record.spec,
record.discovered_at.astimezone().strftime("%b %d %Y %H:%M"),
record.app_version,
found = ctx.repo.discoveries(cat.id, width_bytes=width)
found.sort(key=lambda r: (r.width_bytes, r.score if cat.ascending else -r.score))
for record in found:
floor = cat.id == "long_haul" and not record.stats.get("km_complete", True)
rows.append(
{
"category": cat.id,
"hash_bytes": record.width_bytes,
"score": _Score(record.score, cat, floor),
"unit": cat.unit,
"lower_bound": floor,
"recorded_at": record.discovered_at,
"version": record.app_version,
"path": record.spec or None,
"route": _route(record, resolve),
}
)
total += 1
ctx.ui.show(table)
if total == 0:
ctx.ui.show(
Text("no records yet — every trace that comes home is scored here", style="muted")
)
listing = Listing(
key="records",
columns=(
fields.word("category", "CATEGORY"),
# The contract's word for the concept. The plain heading stays WIDTH,
# which is what a column of 1/2/4 reads as.
fields.integer("hash_bytes", "WIDTH"),
_score_column(),
fields.word("unit", "UNIT"),
# `>=273.0` is not a number, and a consumer comparing scores must be able
# to see the qualifier without string-matching a prefix off its own data.
fields.hidden("lower_bound"),
fields.when("recorded_at", "RECORDED"),
fields.word("version", "VERSION"),
# The transmitted spec, hop-aligned with the route beside it. It has no
# column — a route line already runs off the right — but it is what a
# caller re-walks with, so the document carries it.
fields.hidden("path"),
fields.route(),
),
rows=rows,
order=("CATEGORY", "WIDTH", "SCORE", "UNIT", "RECORDED", "VERSION", "ROUTE"),
)
return ToolResult(
summary={"records": total},
message=f"{total} record{'s' if total != 1 else ''} stored",
summary={"records": len(rows)},
message=f"{len(rows)} record{'s' if len(rows) != 1 else ''} stored",
report=(listing,),
exit_code=exitcodes.OK if rows else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
@@ -146,7 +173,91 @@ class TrophyCaseTool(Tool):
) -> None:
tool_params: dict[str, Any] = {}
if category:
# An unknown id used to select *every* discipline, so a misspelled
# `--category long_hual` came back as "no records" — exit 5, which is
# exactly what a real empty result looks like. A closed set refuses.
if category not in CATEGORY_BY_ID:
choices = ", ".join(CATEGORY_BY_ID)
raise typer.BadParameter(
f"--category must be one of: {choices} (got {category!r})"
)
tool_params["category"] = category
if width:
tool_params["width"] = width
run_tool_command(self, tool_params)
@dataclass(frozen=True, slots=True)
class _Score:
"""A record's score, and the one thing about it a bare number cannot carry.
``format_score`` writes ``273.0 km`` for a reader; the unit is its own column here so
the ``SCORE`` column is one comparable number per row. What has to travel *with* the
number is the ``>=`` on a Longest-distance walk whose kilometre total is incomplete —
some hop on it has no known position, so the distance is a floor rather than a
measurement, and dropping the qualifier would turn a lower bound into a claim.
The plain face wears the prefix; the document keeps the number a number and says
``lower_bound`` beside it, because a consumer comparing scores cannot be asked to
string-match a prefix off the front of its own data.
"""
value: float
category: Category
lower_bound: bool
@property
def text(self) -> str:
"""The score as the SCORE column prints it, qualifier and all."""
if self.category.unit == "nodes":
drawn = str(int(self.value))
elif self.category.unit == "dB":
drawn = f"{self.value:+.1f}"
else:
drawn = f"{self.value:.1f}"
return f">={drawn}" if self.lower_bound else drawn
def _score_column() -> Column:
"""The SCORE column: the qualified figure plain, the bare number in the document."""
from ..ui.report import Column, Lane
return Column(
key="score",
lanes=(
Lane(
header="SCORE",
render=lambda s: s.text if s is not None else NONE,
align="right",
),
),
json=lambda s: None if s is None else s.value,
)
def _route(record: DiscoveredPath, resolve: Callable[[str], str | None]) -> list[NodeRef]:
"""A record's walk as the shared route shape.
Each hop is named where history knows the node and carries the hash it was actually
transmitted at — the spec's own hop, at this record's width — so the line can be read
against the ``path`` a re-walk would take. Where the two lists disagree in length the
node id stands in, which is the only identity that hop has.
Our own node never appears in a record's walk: a discovery is scored on what it
reached, and the two ends of it are somebody else's.
Args:
record: The record whose walk to render.
resolve: Maps a node id to a friendly name, or ``None`` when unknown.
Returns:
The hops in propagation order.
"""
from ..ui.fields import NodeRef
spec = record.spec.split(",") if record.spec else []
hops: list[NodeRef] = []
for index, node in enumerate(record.route):
addressed = spec[index] if index < len(spec) else node
hops.append(NodeRef(name=resolve(node), hash=addressed))
return hops
+57 -9
View File
@@ -14,15 +14,19 @@ per invocation, the trace tool's rule.
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.connection import DeviceCommandError
from ..core.models import LoginResult
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Facts
@register
class RepeaterAdminTool(Tool):
@@ -67,12 +71,16 @@ class RepeaterAdminTool(Tool):
contacts = await device.get_contacts()
name = str(params["node"])
node = next((c for c in contacts if c.name == name), None)
# Both of these are the *argument* being wrong, and nothing has gone out over
# the air when either fires — so they are usage errors (2), not device failures
# (4), whose promise to a caller is that a retry is worth trying. `tx-optimize`
# already answered the identical question this way for the identical condition.
if node is None:
raise DeviceCommandError(f"unknown contact: {name!r}")
raise typer.BadParameter(f"unknown contact: {name!r}")
password = params.get("password") or ctx.admin_store.get(node)
if not password:
raise DeviceCommandError(
raise typer.BadParameter(
f"no admin password for {name!r}; pass --password or run the "
"interactive flow once to store it."
)
@@ -91,11 +99,13 @@ class RepeaterAdminTool(Tool):
command = str(params["command"])
ctx.remote_store.append_history(node, command)
reply = await device.send_remote_command(node, command, timeout=10.0)
if reply is None:
ctx.ui.note("[warn]no reply — the command may still have landed[/warn]")
else:
ctx.ui.note(reply)
return ToolResult(summary={"node": name, "command": command, "replied": reply is not None})
return ToolResult(
report=(_answered(node, command, reply),),
summary={"node": name, "command": command, "replied": reply is not None},
# Silence is not failure — the command may well have landed — but there is
# nothing to report, and a script waiting on output should know which it got.
exit_code=exitcodes.OK if reply is not None else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``repeater-admin`` subcommand.
@@ -117,3 +127,41 @@ class RepeaterAdminTool(Tool):
if password is not None:
tool_params["password"] = password
run_tool_command(self, tool_params)
def _answered(node: Contact, command: str, reply: str | None) -> Facts:
"""What a remote node said back.
``reply`` is the node's own text, whole and verbatim — the plain face prints it bare
(as text and never as markup, so a reply holding a square bracket is a reply holding a
square bracket), and the document carries it raw, multi-line where the node sent
several lines.
It is deliberately **not parsed**. MeshTerm does not know the remote node's CLI
grammar, and a document that pretended to would be inventing structure a consumer
would then depend on.
"""
from ..ui import fields
from ..ui.fields import NodeRef
from ..ui.report import BARE, Facts
return Facts(
key="remote",
fields=(
fields.node("node", lanes=()),
fields.hidden("command"),
fields.word("reply", "reply"),
),
values={
"node": NodeRef(
name=node.name,
key=(node.public_key or "").lower() or None,
hash=(node.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(node.node_type),
),
"command": command,
"reply": reply,
},
shape=BARE,
bare="reply",
)
+145 -19
View File
@@ -32,7 +32,14 @@ from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.models import LOCAL_DEVICE_LABEL, PATH_TRACE_TARGET, Contact, TraceStats
from ..core import exitcodes
from ..core.models import (
LOCAL_DEVICE_LABEL,
PATH_TRACE_TARGET,
Contact,
TraceResult,
TraceStats,
)
from ..services import trace_runner
from ..ui.widgets import stats_panel
from .base import Tool, ToolResult, register
@@ -397,6 +404,8 @@ async def _trace_once_cli(
Returns:
A :class:`ToolResult` with the trace's outcome.
"""
from ..ui.surface import TuiUi
is_walk = target == PATH_TRACE_TARGET
device = await ctx.device()
@@ -419,9 +428,6 @@ async def _trace_once_cli(
path: str | None = None
if path_spec:
path = trace_runner.parse_trace_path(path_spec, contacts)
ctx.ui.note(f"[muted]forcing path:[/muted] [brand]{path}[/brand]")
else:
ctx.ui.note("[muted]path: auto (device-routed)[/muted]")
with ctx.ui.progress("trace") as progress:
task = progress.add_task("walking the path" if is_walk else f"tracing {target}", total=1)
@@ -429,18 +435,22 @@ async def _trace_once_cli(
ctx.repo.record_trace(run_id, result)
progress.advance(task)
# The stats panel renders route + per-hop readings for the single trace (its
# medians collapse to the readings themselves).
stats = TraceStats.from_traces(target, [result])
ctx.ui.show(
stats_panel(
stats,
device_label,
resolve,
route=result if result.success else None,
device_hash=device_hash,
if isinstance(ctx.ui, TuiUi):
# The stats panel renders route + per-hop readings for the single trace (its
# medians collapse to the readings themselves).
ctx.ui.show(
stats_panel(
stats,
device_label,
resolve,
route=result if result.success else None,
device_hash=device_hash,
)
)
)
report = None
if not isinstance(ctx.ui, TuiUi):
report = _trace_report(result, target, path, device_label, resolve, device_hash)
summary: dict[str, Any] = {
"target": target,
@@ -453,15 +463,131 @@ async def _trace_once_cli(
summary["path"] = path
via = f" via [brand]{path}[/brand]" if path else ""
if is_walk:
if result.success:
message = f"[ok]✓[/ok] the path came home{via}"
else:
message = f"[err]✗[/err] no reply{via}"
message = (
f"[ok]✓[/ok] the path came home{via}"
if result.success
else f"[err]✗[/err] no reply{via}"
)
elif result.success:
message = f"[ok]✓[/ok] traced [brand]{target}[/brand]{via}"
else:
message = f"[err]✗[/err] no reply from [brand]{target}[/brand]{via}"
return ToolResult(summary=summary, message=message)
# A trace that never came home is not a failure of the command — the radio did
# transmit and the walk did run. It is a walk with nothing to report, which is what
# NO_RESULT says, and it is the answer a script most often branches on.
return ToolResult(
summary=summary,
message=message,
report=report,
exit_code=exitcodes.OK if result.success else exitcodes.NO_RESULT,
)
def _trace_report(
result: TraceResult,
target: str,
path: str | None,
device_label: str,
resolve: Any,
device_hash: str | None,
) -> tuple:
"""State one trace: the walk's outcome, then its per-hop readings.
Two blocks. The first is what the walk *did* — one fact per line, ``route`` among them
as a drawn line, every node named and carrying the hash it was addressed by. The
second is a record per hop, and it names its two ends by that same **hash** rather than
repeating the names: the route line above is where the names are, and the hash is what
joins the two blocks (it is what carries a node's identity here, the way its colour
does on a screen). The document embeds the whole node at both ends instead, because a
structural join needs no key and each edge should be readable on its own.
Our own node is a hop like any other, named and hashed. The menu draws it as ``★``
because a reader never has to be told which node is theirs; this line is as often read
out of a file by somebody who was not at the prompt when it ran.
Args:
result: The trace that ran.
target: The label it was addressed to.
path: The forced route as hex, or ``None`` when the device routed it.
device_label: Our own node's name, at both ends of the walk.
resolve: Maps a hop hash to a friendly name when known.
device_hash: Our own public key, so our ends carry a hash like every other hop.
Returns:
The report's blocks.
"""
from ..ui import fields
from ..ui.fields import NodeRef
from ..ui.report import Facts, Listing
hash_bytes = result.path_hash_bytes
def short(value: str | None) -> str | None:
"""A hash at the width this trace addressed nodes by."""
if not value:
return None
raw = value.lower().removeprefix("0x")
return raw[: hash_bytes * 2] if hash_bytes else raw
def node(label: str) -> NodeRef:
"""One end of a hop as the shared node shape."""
if not label or label == device_label:
return NodeRef(name=device_label, hash=short(device_hash), is_self=True)
return NodeRef(name=resolve(label) or None, hash=short(label))
edges = result.edges(device_label)
route = None
if edges:
route = [node(edges[0].origin)] + [node(edge.destination) for edge in edges]
facts = Facts(
key="trace",
fields=(
fields.word("target", "target"),
fields.spec(),
fields.flag("success", "success"),
fields.integer("hops", "hops"),
fields.snr("min_snr_db", "min_snr_db"),
fields.decimal("rtt_ms", "rtt_ms", ".0f"),
# New surface on the machine face: the model has always carried the TX power
# the walk ran at, and the plain facts block has no room for it.
fields.hidden("tx_dbm"),
# What `hash` means on this walk's nodes, so a consumer can join two traces
# that addressed the same node at different widths.
fields.hidden("hash_bytes"),
fields.route("route", "route"),
),
values={
"target": target if target != PATH_TRACE_TARGET else None,
"path": path,
"success": result.success,
"hops": result.hop_count if result.success else None,
"min_snr_db": result.min_snr,
"rtt_ms": result.round_trip_ms,
"tx_dbm": result.tx_power,
"hash_bytes": hash_bytes,
"route": route,
},
)
hops = Listing(
key="edges",
columns=(
fields.integer("index", "HOP"),
fields.node("from", lanes=(("hash", "FROM"),)),
fields.node("to", lanes=(("hash", "TO"),)),
fields.snr("snr_db", "SNR_DB"),
),
rows=[
{
"index": edge.index,
"from": node(edge.origin),
"to": node(edge.destination),
"snr_db": edge.snr,
}
for edge in edges
],
)
return (facts, hops)
def _last_traced_by_name(
+116 -10
View File
@@ -22,8 +22,9 @@ from typing import Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.connection import DeviceCommandError
from ..core.models import Contact, LoginResult
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult, TxOptResult
from ..services import trace_runner, tx_optimizer
from ..ui.widgets import tx_opt_summary, tx_opt_table
from .base import Tool, ToolResult, register
@@ -257,6 +258,8 @@ class TxOptimizeTool(Tool):
Returns:
A :class:`ToolResult` with the optimum and any chart path.
"""
from ..ui.surface import TuiUi
run_id = params["_run_id"]
device = await ctx.device()
contacts = await device.get_contacts()
@@ -268,14 +271,14 @@ class TxOptimizeTool(Tool):
samples = max(1, min(MAX_SAMPLES, int(params.get("samples", 3))))
if int(params.get("samples", 3)) > MAX_SAMPLES:
ctx.ui.note(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]")
ctx.ui.ack(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]")
ctx.ui.note(
ctx.ui.ack(
f"[muted]logging in to[/muted] [brand]{admin_node.name}[/brand] [muted]…[/muted]"
)
await self._login(ctx, admin_node, params)
ctx.ui.note(
ctx.ui.ack(
f"[muted]tuning[/muted] [brand]{admin_node.name}[/brand] "
f"[muted]→ target[/muted] [brand]{target_label}[/brand] "
f"[muted]via {path}[/muted]"
@@ -316,8 +319,12 @@ class TxOptimizeTool(Tool):
if result.applied:
ctx.log.info("set TX power %s on %s", result.best_tx, admin_node.name)
ctx.ui.show(tx_opt_table(result))
ctx.ui.show(tx_opt_summary(result))
report = None
if isinstance(ctx.ui, TuiUi):
ctx.ui.show(tx_opt_table(result))
ctx.ui.show(tx_opt_summary(result))
else:
report = _sweep_report(result, path, admin_node, contacts)
if no_result:
restored = (
@@ -335,6 +342,10 @@ class TxOptimizeTool(Tool):
)
return ToolResult(
report=report,
# No level got a trace through, so nothing was measured and nothing was tuned:
# the sweep ran and has nothing to report.
exit_code=exitcodes.NO_RESULT if no_result else exitcodes.OK,
summary={
"target": result.target,
"admin_node": result.admin_node,
@@ -354,6 +365,13 @@ class TxOptimizeTool(Tool):
) -> str:
"""Find the admin password: explicit flag, remembered, or an interactive prompt.
The question this asks is **"is anyone there to answer?"**, and it used to ask
``--json`` instead — a flag about what the output looks like, which cannot answer
it and answered it wrong in both directions: with the flag a piped run failed
cleanly, and without it a scheduled run on a terminal sat waiting for a password
nobody was going to type. ``ctx.interactive`` reads stdin, which is where the
answer actually is.
Args:
ctx: Shared application context.
admin_node: The node we're about to log in to.
@@ -363,13 +381,15 @@ class TxOptimizeTool(Tool):
The password to log in with.
Raises:
DeviceCommandError: If no password is available and we can't prompt (JSON mode).
typer.BadParameter: If no password is available and nobody can be asked for
one. A usage error rather than a device failure: nothing was transmitted,
and no retry helps until the command line carries the password.
"""
password = params.get("password") or ctx.admin_store.get(admin_node)
if password:
return str(password)
if ctx.json_output:
raise DeviceCommandError(
if not ctx.interactive:
raise typer.BadParameter(
f"no admin password for {admin_node.name!r}; pass --password or run once "
"interactively to store it."
)
@@ -377,7 +397,7 @@ class TxOptimizeTool(Tool):
# sweep screen), so the surface here is PlainUi — no screen stack, no floating.
entered = await ctx.ui.text(f"Admin password for {admin_node.name}:", password=True)
if not entered:
raise DeviceCommandError("an admin password is required to tune a remote node.")
raise typer.BadParameter("an admin password is required to tune a remote node.")
return entered
def register_cli(self, app: typer.Typer) -> None:
@@ -524,3 +544,89 @@ def _fmt_snr(snr: float | None) -> str:
A short fixed-width string like ``+5.1`` or `` n/a``.
"""
return f"{snr:+.1f}" if snr is not None else " n/a"
def _sweep_report(
result: TxOptResult, path: str, admin_node: Contact, contacts: list[Contact]
) -> tuple:
"""State a TX sweep: the outcome, then every level measured.
The winner comes first, because it is what the command was asked for and what a caller
acts on; the per-level records follow, so the choice can be checked against the
measurements it was made from. The menu's ``★`` on the winning row has no column here
— ``optimal_tx_dbm`` above the table already names it, and a mark is something to look
at rather than something to test.
The two nodes become the shared node shape, and here that matters more than anywhere
else: on the plain line they are told apart by nothing but their names, and a caller
correlating a sweep with a trace has no key to join on.
Args:
result: The completed sweep.
path: The forced route the traces walked, as hex hops.
admin_node: The node whose TX power was tuned.
contacts: The contact list, for placing the target by name.
Returns:
The report's blocks.
"""
from ..ui import fields
from ..ui.fields import NodeRef
from ..ui.report import Facts, Listing
def node(name: str) -> NodeRef | None:
"""One end of the tuned link as the shared node shape."""
contact = next((c for c in contacts if c.name == name), None)
if contact is None:
return NodeRef(name=name) if name else None
return NodeRef(
name=contact.name,
key=(contact.public_key or "").lower() or None,
hash=(contact.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(contact.node_type),
)
facts = Facts(
key="tx_optimize",
fields=(
fields.node("tuning_node", lanes=(("name", "tuning_node"),)),
fields.node("target", lanes=(("name", "target"),)),
fields.spec(),
fields.integer("optimal_tx_dbm", "optimal_tx_dbm"),
fields.snr("target_snr_db", "target_snr_db"),
fields.decimal("reliability", "reliability", ".2f"),
fields.integer("previous_tx_dbm", "previous_tx_dbm"),
fields.flag("applied", "applied"),
),
values={
"tuning_node": node(result.admin_node) or NodeRef(name=admin_node.name),
"target": node(result.target),
"path": path,
"optimal_tx_dbm": result.best_tx,
"target_snr_db": result.best_snr,
# A fraction in [0, 1], not a formatted "1.00": the plain column rounds for
# the eye and the document keeps the number a number.
"reliability": result.best_success_rate,
"previous_tx_dbm": result.original_tx,
"applied": result.applied,
},
)
levels = Listing(
key="levels",
columns=(
fields.integer("tx_dbm", "TX_DBM"),
fields.snr("target_snr_db", "TARGET_SNR_DB"),
fields.integer("successes", "SUCCESSES"),
fields.integer("samples", "SAMPLES"),
),
rows=[
{
"tx_dbm": level.tx_power,
"target_snr_db": level.target_snr,
"successes": level.successes,
"samples": level.samples,
}
for level in result.sorted_by_tx()
],
)
return (facts, levels)
+398 -114
View File
@@ -40,6 +40,7 @@ from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.console import Group
from rich.text import Text
@@ -62,9 +63,20 @@ from ..core.channels import (
from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
from ..platforms import get_platform
from .braillechart import activity_peak, activity_sparkline
from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading
from .menus import (
Lane,
column_header,
fit_cells,
icon_lane,
marked_label,
menu_rows,
run_steps,
section_heading,
)
from .qr import share_popup
from .theme import glyph
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
@@ -72,6 +84,9 @@ if TYPE_CHECKING:
from ..context import AppContext
from ..persistence.repository import ChannelStats
#: The manager's footer sentence: navigation, then the filter, then the action, Esc last.
_MANAGER_HINT = "↑↓ move · type to filter · Enter open · Esc back"
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
_CREATE = "__create__"
_PUBLIC = "__public__"
@@ -146,11 +161,24 @@ class ChannelSlot:
async def manage_channels(ctx: AppContext) -> int:
"""Run the interactive channel manager until the user backs out.
The visit says **nothing** on the way out. It used to bank a note per action and then an
"✓ applied 3 channel changes" line on top of those, shown once the manager had already
closed — an acknowledgement for changes the reader had just watched land in the list in
front of them, arriving as a tidy popup or as a full-frame window depending on how many
lines had piled up. An error is the exception, and is shown when it happens (see
:func:`_import_link`).
What the reader gets instead is the wait itself: every device action reports *while it
runs*, under the modal busy card (:meth:`~meshterm.ui.surface.Ui.busy_dialog`) — see
:func:`write_channel` and ``settle`` below for where those are drawn, and why the card
has to be modal.
Args:
ctx: Shared application context (provides the connected device and UI surface).
Returns:
The number of channels created, changed, or cleared during the session.
How many channels were created, changed, or cleared — the run log's record of what
the visit did (``ToolResult.summary``), not anything the reader is shown.
"""
device = await ctx.device()
# The firmware's slot count is fixed for the session, so discover it once (a read-only
@@ -164,7 +192,6 @@ async def manage_channels(ctx: AppContext) -> int:
stats = _LiveStats(ctx)
changes = 0
highlight: object | None = None
slots: list = []
async def reload() -> tuple[str, list]:
@@ -180,13 +207,38 @@ async def manage_channels(ctx: AppContext) -> int:
slots = list(await ctx.devstate.channel_slots())
return _menu_items(ctx, slots, capacity, stats)
async def settle(*, moved: bool = False) -> tuple[str, list]:
"""Re-probe the layout — and, when a slot actually moved, re-file what points at it.
Two device round-trips back to back when something changed (the chat service's slot
map and the slot probe ``reload`` calls one of the slowest reads the app makes), so
they report as a single wait rather than boxes flashing in sequence — and while the
card is up the reader cannot type into a list whose rows are being replaced
underneath them.
Args:
moved: Whether a slot's occupant changed during the round just finished. The
caller decides that by comparing ``devstate.channels_epoch``, not by trusting
a count (see :func:`write_channel`, which drops the cache as it writes).
"""
async with ctx.ui.busy_dialog("reading channels…", title="Channels"):
title_and_items = await reload()
if moved:
# Inbound messages carry only a slot index, which the chat service maps to a
# channel identity through a cache keyed by slot; refresh it now so a message
# on a reused/re-keyed slot is filed under the channel that's actually there
# and not the one that used to be — otherwise its transcript surfaces in the
# wrong chat. After ``reload``, deliberately: it reads the same cached probe,
# so this way round the pair costs one walk of the device instead of two.
await _refresh_chat_channels(ctx)
return title_and_items
async def handle(choice: object) -> bool:
"""Dispatch one menu choice (over the still-pushed list); ``False`` exits."""
nonlocal changes, highlight
if choice is None: # Esc
return False
highlight = choice
before = changes
if choice == _CREATE:
changes += await _create_private(ctx, device, slots, capacity)
elif choice == _DEFAULT_PUBLIC:
@@ -203,17 +255,6 @@ async def manage_channels(ctx: AppContext) -> int:
slot = next((s for s in slots if s.idx == choice), None)
if slot is not None:
changes += await _channel_detail(ctx, device, slot, stats)
if changes > before:
# A slot's occupant changed (created, re-keyed, cleared, or moved). Drop the
# session cache's channel list so the dashboard (and anything else reading it)
# re-reads the new layout instead of a stale copy.
ctx.devstate.invalidate_channels()
# Inbound messages carry only a slot index, which the chat service maps to a
# channel identity through a cache keyed by slot; refresh it now so a message
# on a reused/re-keyed slot is filed under the channel that's actually there
# and not the one that used to be — otherwise its transcript surfaces in the
# wrong chat.
await _refresh_chat_channels(ctx)
return True
# The list stays pushed for the whole visit: every sub-flow — creating a channel,
@@ -221,20 +262,41 @@ async def manage_channels(ctx: AppContext) -> int:
# swapped in place afterwards, so the highlight (and any typed filter) survives a layout
# that changed under it. A surface with no session — the plain CLI, scripted tests — has
# no stack to stay on and runs the same dispatcher a round at a time.
title, items = await reload()
title, items = await settle()
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
if not await _menu_round(ctx, title, items, default=highlight, handle=handle):
epoch = ctx.devstate.channels_epoch
try:
keep = await _menu_round(ctx, title, items, default=highlight, handle=handle)
except BaseException:
# A write that landed and then raised — or a ^W unwinding out through the QR
# view opened after it — moved the device just as much as one that returned
# cleanly. The epoch says so where the action's return value never got the
# chance to, so the chat map is re-filed on the way past.
if ctx.devstate.channels_epoch != epoch:
await _refresh_chat_channels(ctx)
raise
if not keep:
return changes
title, items = await reload()
menu = SelectScreen(title, items)
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
# ``Enter open``, not the list default: every slot row pushes the channel's detail
# screen and Reorder pushes the reorder screen, which is what the chat picker — the same
# channels, one screen over — has always said.
menu = SelectScreen(title, items, footer_hint=_MANAGER_HINT)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
if not await handle(None if choice is CANCEL else choice):
epoch = ctx.devstate.channels_epoch
try:
keep = await handle(None if choice is CANCEL else choice)
except BaseException:
if ctx.devstate.channels_epoch != epoch: # see the note on the other loop
await _refresh_chat_channels(ctx)
raise
if not keep:
return changes
title, items = await reload()
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
menu.replace_items(items, title=title)
@@ -300,13 +362,43 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots, _complete = await probe_channel_slots(device)
return slots
async def probe_channel_slots(device: Device) -> tuple[list[ChannelSlot], bool]:
"""The probe behind :func:`read_channel_slots`, reporting whether it *finished*.
The scan stops for two very different reasons and the plain list cannot tell them
apart. Off the end of the configured slots (a rejected index, or a long enough run of
empty ones) the answer is complete and worth keeping. A read that simply *failed* —
a timeout, a link hiccup, and then every subsequent read failing too because the
firmware's reply no longer matches the slot asked for — ends the scan early with
whatever it happened to have, which is not the device's layout and must not be cached
or acted on as if it were: an empty one reads as "no channels configured", and a
truncated one makes the next free slot look free when a channel is sitting in it.
The two endings raise different things, and that is what separates them: the firmware
*answering* "no such slot" surfaces as a plain rejection, while a link that stopped
answering surfaces as a timeout, a :class:`~meshterm.core.connection.DeviceCommandError`
(whose whole subject is a companion that did not reply in time), or one of the dropped-
link signatures :func:`~meshterm.core.connection.is_connection_lost` knows. Only the
first ending is a layout. A read that fails for some fourth reason is treated as a
rejection, which is the safe way round: the list is used but the cache re-probes.
Args:
device: The connected device to query.
Returns:
The slots read, and whether the scan ran to a clean end.
"""
slots: list[ChannelSlot] = []
empty_run = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
except Exception as exc: # noqa: BLE001 - a rejected index, or a read that failed
return slots, not _read_failed(exc)
if payload and payload.get("channel_name"):
empty_run = 0
slots.append(
@@ -320,7 +412,19 @@ async def read_channel_slots(device: Device) -> list[ChannelSlot]:
empty_run += 1
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
break # off the end of a never-rejecting firmware; nothing more to find
return slots
return slots, True
def _read_failed(exc: BaseException) -> bool:
"""Whether ``exc`` means the *read* failed, rather than the firmware refusing a slot.
A refusal is the probe's ordinary ending and leaves a complete list behind it. A
failure leaves a short one that looks exactly the same — which is the whole reason
this distinction has to be drawn somewhere (see :func:`probe_channel_slots`).
"""
from ..core.connection import DeviceCommandError, is_connection_lost
return isinstance(exc, (TimeoutError, DeviceCommandError)) or is_connection_lost(exc)
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> int | None:
@@ -344,6 +448,11 @@ async def write_channel(
a name-derived channel's derived key so it can later be replayed as-is. Remembering is
best-effort: it never blocks or fails the actual device write.
Being the one boundary, it is also where the *wait* is reported: the write and the
identity probe behind it are a device round-trip each, and until the card went up the
list sat there looking idle and fully interactive while they ran. A batch (a reorder)
nests inside its caller's card rather than flashing one box per slot.
Args:
ctx: Shared application context (for the channel store and cached self-info).
device: The connected device to write to.
@@ -351,6 +460,21 @@ async def write_channel(
name: The channel name (empty clears the slot).
secret: The 16-byte secret, or ``None`` to let the firmware derive it from the name.
"""
caption = f"clearing slot {idx}…" if not name.strip() else f"saving {name.strip()}…"
async with ctx.ui.busy_dialog(caption, title="Channels"):
await _write_and_remember(ctx, device, idx, name, secret)
# The layout on the device is no longer what anything cached, so say so here rather
# than leaving each caller to remember: the menu's manager did (and lost it whenever an
# action raised on its way back), and the four CLI subcommands never did at all. The
# bump also *is* the signal a caller compares across a round — see
# :attr:`~meshterm.services.device_state.DeviceState.channels_epoch`.
ctx.devstate.invalidate_channels()
async def _write_and_remember(
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
) -> None:
"""Do the write itself and the channel-store bookkeeping (see :func:`write_channel`)."""
await device.set_channel(idx, name, secret)
store = ctx.channel_store
if store is None:
@@ -501,24 +625,36 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
# --- menus -------------------------------------------------------------------
def _lanes_header(name_w: int) -> str:
def _lanes_header(name_w: int, width: int) -> str:
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
lane (3 cells), so each header lands exactly over its column. ``UNREAD`` borrows its
lane's trailing gap — the badge lane itself is one cell too narrow for the word — which
still leaves a space before the message count. (No TYPE or HASH lane: the glyph already
carries the openness and the hash lives in Show key, which buys the activity sparkline
its room on a 72-column terminal.)
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
is two cells on the desktop and one on the console, so a hard-coded indent put every
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
itself is one cell too narrow for the word — which still leaves a space before the
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
hash lives in Show key, which buys the activity sparkline its room on a 72-column
terminal.)
Resolved against the render width, because the header row is pinned and must stay one
row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of
twenty-six and leaving the landmark drawn twice over. ``ACTIVITY`` gives its cells back
first (see :func:`~meshterm.ui.menus.column_header`).
"""
return (
" "
+ "CHANNEL".ljust(name_w + 2)
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
+ f"{'MSGS':>{_COUNT_WIDTH}}"
+ " "
+ f"{'LAST':>{_AGE_WIDTH}}"
+ " ACTIVITY"
return column_header(
[
Lane("CHANNEL", name_w + 2),
Lane("UNREAD", _BADGE_WIDTH + 2),
# Right-aligned, because the values under them are: a count and an age are
# padded to the right edge of their lane, so a left-aligned label would sit
# off the digits it names.
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
Lane(("ACTIVITY", "ACT")),
],
width,
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
)
@@ -555,8 +691,9 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if muted:
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
text.append(" " * (_BADGE_WIDTH - 2))
mark = glyph("🔕") # two cells on the desktop, one on the console
text.append(mark, style="muted")
text.append(" " * (_BADGE_WIDTH - cell_len(mark)))
elif unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
@@ -568,7 +705,11 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
# Clamped so a pathological backlog can't push the row out of its lanes.
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
else:
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
# away in this very row — what the console folds ``🔕`` to, so a muted channel with
# no messages drew the same glyph twice meaning different things.
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
text.append(" ")
age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else ""
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
@@ -593,56 +734,99 @@ def _menu_items(
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
# The lane names are this block's only landmark (its section carries no ── heading ──),
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
items.append(Separator(_lanes_header(name_w), heading=True))
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
else:
items.append(Separator(" no channels configured yet"))
items.append(Separator(" no channels yet — add one below"))
# One measured icon column for every command row on this screen, so the one-cell marks
# (↕) start their labels in the same column as the two-cell ones (+ # 🌐 🔑 🔗) instead
# of a column early — which the Organize row used to fix by hand, with two spaces and a
# four-line comment. Empty where the platform draws no icons, and the labels take the
# cells back.
lane = icon_lane(("↕", "🌐", "+", "#", "🔑", "🔗"))
if len(slots) > 1:
items.append(section_heading("Organize"))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs. Both go together where the platform
# draws no icon lane at all.
items.append(Choice(title=command_label("↕ Reorder channels"), value=_REORDER))
reorder = marked_label("↕", "Reorder channels", "", lane=lane)
items.append(Choice(title=reorder, value=_REORDER))
items.append(section_heading("Add a channel"))
if _next_free_slot(slots, capacity) is None:
# The list already knows there is nowhere to put one, so it says so here rather than
# offering four rows that all dead-end in the same refusal.
items.append(Separator(" every slot is full — clear one first"))
return f"Channels · {len(slots)}/{capacity} slots", items
rows = []
# The firmware's built-in fixed-key Public channel has one well-known secret, so it's the
# same channel on every slot — offer to restore it only while no slot already holds it.
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
rows.append(
("🌐 Standard Public channel", "MeshCore's built-in meshwide channel", _DEFAULT_PUBLIC)
(
marked_label("🌐", "Standard Public channel", "", lane=lane),
"MeshCore's built-in meshwide channel",
_DEFAULT_PUBLIC,
)
)
rows.extend(
[
("+ New private channel…", "A fresh random key", _CREATE),
("# Public channel…", "Key derived from its name", _PUBLIC),
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
(
marked_label("+", "New private channel…", "", lane=lane),
"A fresh random key",
_CREATE,
),
(
marked_label("#", "Public channel…", "", lane=lane),
"Key derived from its name",
_PUBLIC,
),
(
marked_label("🔑", "Join with a key…", "", lane=lane),
"Paste a channel's 32-hex key",
_JOIN,
),
(
marked_label("🔗", "Import a link…", "", lane=lane),
"Paste a meshcore:// share link",
_IMPORT,
),
]
)
items.extend(menu_rows(rows))
return f"Channels — {len(slots)}/{capacity} slots", items
# ``·`` chains a status atom; ``—`` would introduce a subject, and the slot count is not
# what this screen is about (see Contacts, which reads "Contacts · 12 known").
return f"Channels · {len(slots)}/{capacity} slots", items
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
"""One line of vital signs for the detail screen: what it is, then how it has been used.
The openness and the hash lead, having moved here out of the screen's title, which was
carrying them in a parenthesised blob and running to 42 cells — the whole of the
PicoCalc's borderless title bar, leaving no room for the one place that says Esc leaves.
The line is one line. Atoms are shed from the right until it fits the platform's
readable width, because the ones on the left identify the channel and the ones on the
right describe traffic the reader can also see in the row they came from.
"""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
muted = _is_muted(ctx, slot)
kind = "public" if slot.is_public else "private"
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
if st is None or not st.total:
base = f"Slot {slot.idx} · no messages recorded yet"
return f"{base} · muted" if muted else base
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
if unread:
parts.append(f"{unread} unread")
if muted:
parts.append("no messages yet")
else:
parts.append(f"{st.total} msg{'' if st.total == 1 else 's'}")
if unread:
parts.append(f"{unread} unread")
if _is_muted(ctx, slot):
parts.append("muted")
if st.last_at is not None:
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
if st is not None and st.last_at is not None:
parts.append(f"last {format_ago(_age_seconds(st.last_at))}")
width = get_platform().readable_cols
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
parts.pop()
return " · ".join(parts)
@@ -657,31 +841,49 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
err-tinted label so it reads as such.
"""
unread = ctx.chat.unread(slot.conversation.key)
chat_label = Text("💬 Open in chat")
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
# every mark padded out to it — otherwise the edit and clear rows start their labels a
# column left of the rows above them.
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
if _is_muted(ctx, slot):
mute_row = (
"🔔 Unmute notifications",
marked_label("🔔", "Unmute notifications", "", lane=lane),
"Show new messages here in the unread badge",
_MUTE,
)
else:
mute_row = (
"🔕 Mute notifications",
marked_label("🔕", "Mute notifications", "", lane=lane),
"Hide new messages here from the unread badge",
_MUTE,
)
items = menu_rows(
[
("📱 Show QR code", "Share this channel as a scannable code", _QR),
("🔑 Show key", "The name, key, hash, and share link", _KEY),
(
marked_label("📱", "Show QR code", "", lane=lane),
"Share this channel as a scannable code",
_QR,
),
(
marked_label("🔑", "Show key", "", lane=lane),
"The name, key, hash, and share link",
_KEY,
),
(chat_label, "Read and send messages on this channel", _CHAT),
mute_row,
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
(
Text("🗑 Clear this slot…", style="err"),
marked_label("✎", "Rename / change key…", "", lane=lane),
"Edit the name or paste a different key",
_EDIT,
),
(
# The tint goes on the mark, not the words — and falls back to the words only
# where the platform draws no mark at all (see menus.marked_label).
marked_label("🗑", "Clear this slot…", "err", lane=lane),
"Remove the channel from this device",
_CLEAR,
),
@@ -700,17 +902,34 @@ async def _channel_detail(
pushed while each action's prompts float over it, and the rows and the vital-signs line
are swapped in place afterwards rather than the screen being drawn again. Both are live
(the mute row is a toggle; the summary counts unread and ages the last message), so both
are re-read each round, and the highlight stays on the row that was just used. A
rename/re-key or clear closes the detail (the slot's occupant changed, so the caller
re-reads the device); the read-only actions loop back here.
are re-read each round, and the highlight stays on the row that was just used.
A **clear** closes the page, because the channel it was about is gone. A **rename or
re-key does not**: the channel is still there and this is still its page, so the new name
and key are read back into the title, the summary and the rows, and the reader stays
where they were. The page used to close on both, for no better reason than ``slot`` being
a snapshot that the rename had made stale.
"""
kind = "public" if slot.is_public else "private"
title = f"{slot.name} ({kind}, hash {slot.hash})"
changes = 0
def title_for() -> str:
"""``Feature — subject``: the openness and the hash lead the summary line instead."""
return f"Channel — {slot.name}"
async def reread() -> bool:
"""Re-point ``slot`` at what now occupies its index; ``False`` if nothing does."""
nonlocal slot
fresh = next((s for s in await ctx.devstate.channel_slots() if s.idx == slot.idx), None)
if fresh is None:
return False
slot = fresh
return True
async def handle(choice: object) -> int | None:
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
nonlocal changes
if choice is None: # Esc
return 0
return changes
if choice == _QR:
await _show_share(ctx, slot.name, slot.secret)
elif choice == _KEY:
@@ -722,9 +941,11 @@ async def _channel_detail(
# detail (whose rows re-render to the new state) without counting a channel change.
_toggle_mute(ctx, slot)
elif choice == _EDIT and await _edit(ctx, device, slot):
return 1
changes += 1
if not await reread(): # pragma: no cover - the slot we just wrote is there
return changes
elif choice == _CLEAR and await _clear(ctx, device, slot):
return 1
return changes + 1 # the channel this page is about is gone; so is the page
return None
# A surface with no session (the plain CLI, scripted tests) has no stack to stay on and
@@ -732,12 +953,12 @@ async def _channel_detail(
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
result = await _menu_round(ctx, title, _detail_items(ctx, slot), handle=handle)
result = await _menu_round(ctx, title_for(), _detail_items(ctx, slot), handle=handle)
if result is not None:
return result
menu = SelectScreen(
title,
title_for(),
_detail_items(ctx, slot),
prompt=_detail_summary(ctx, slot, stats),
)
@@ -747,7 +968,11 @@ async def _channel_detail(
result = await handle(None if choice is CANCEL else choice)
if result is not None:
return result
menu.replace_items(_detail_items(ctx, slot), prompt=_detail_summary(ctx, slot, stats))
menu.replace_items(
_detail_items(ctx, slot),
title=title_for(),
prompt=_detail_summary(ctx, slot, stats),
)
# --- create / join flows -----------------------------------------------------
@@ -757,7 +982,7 @@ async def _create_private(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a private channel with a fresh random key on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
name = await ctx.ui.text("Channel name:", validate=_nonblank)
@@ -765,7 +990,6 @@ async def _create_private(
return 0
secret = random_secret()
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] created private channel [brand]{name.strip()}[/brand]")
await _show_share(ctx, name.strip(), secret, intro="Share this channel:")
return 1
@@ -773,12 +997,19 @@ async def _create_private(
async def _add_default_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot.
One well-known secret means it is the same channel on every slot, so a second copy is
not a channel but a duplicate row. The menu already drops this action once a slot holds
it; this is the same check where the action *runs*, for a press that was on its way while
the first one was still being written.
"""
if any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
return 0
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
ctx.ui.note("[ok]✓[/ok] added the standard [brand]Public[/brand] channel")
return 1
@@ -786,7 +1017,7 @@ async def _add_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a public channel whose key is derived from its (``#``-prefixed) name."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
raw = await ctx.ui.text(
@@ -801,7 +1032,6 @@ async def _add_public(
name = f"#{name}"
secret = derive_secret(name) # what the firmware will compute; kept for the QR/share
await write_channel(ctx, device, idx, name, None) # None => firmware derives the key from name
ctx.ui.note(f"[ok]✓[/ok] added public channel [brand]{name}[/brand]")
await _show_share(ctx, name, secret, intro="Share this channel:")
return 1
@@ -815,7 +1045,7 @@ async def _join_with_key(
steps back to the name with what was typed still in the field, rather than throwing
both away — a 32-hex key is a long thing to mistype.
"""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
answers = await run_steps(
@@ -833,7 +1063,6 @@ async def _join_with_key(
name, key = answers
secret = normalize_secret(key)
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
return 1
@@ -841,7 +1070,7 @@ async def _import_link(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Import a channel from a pasted ``meshcore://channel/add`` link."""
idx = await _pick_free_slot(ctx, slots, capacity)
idx = await _pick_free_slot(ctx, device, slots, capacity)
if idx is None:
return 0
url = await ctx.ui.text("Paste a meshcore:// channel link:", validate=_valid_link)
@@ -849,11 +1078,10 @@ async def _import_link(
return 0
parsed = parse_share_url(url)
if parsed is None: # pragma: no cover - guarded by the validator
ctx.ui.note("[err]not a valid channel link[/err]")
await _say(ctx, "not a valid channel link", "err")
return 0
name, secret = parsed
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
return 1
@@ -890,7 +1118,6 @@ async def _edit(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
name = name.strip()
secret = normalize_secret(key) if key.strip() else None
await write_channel(ctx, device, slot.idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
return True
@@ -913,7 +1140,6 @@ async def _clear(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
ctx.chat.set_active(slot.conversation.key) # drop its unread before the slot goes away
ctx.chat.set_active(None)
await write_channel(ctx, device, slot.idx, "", None) # empty name => the slot reads as unused
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
return True
@@ -938,14 +1164,22 @@ async def _apply_order(
in-memory snapshot, so the interleaved writes never clobber a not-yet-placed channel.
"""
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
changes = 0
for target_idx, pos in zip(indices, order, strict=True):
slot = slots[pos]
if slot.idx == target_idx:
continue # already in place; no write needed
await _write_slot(ctx, device, target_idx, slot)
changes += 1
return changes
moves = [
(target_idx, slots[pos])
for target_idx, pos in zip(indices, order, strict=True)
if slots[pos].idx != target_idx # already in place; no write needed
]
if not moves:
return 0
# One card for the whole batch, retitled as it walks: this is the longest thing the
# manager does — a write per moved channel, each a device round-trip — and the one place
# where a count is worth showing, because the reader can see it is progressing rather
# than stuck. The per-write cards inside nest into this one.
async with ctx.ui.busy_dialog("reordering channels…", title="Channels") as busy:
for position, (target_idx, slot) in enumerate(moves, start=1):
busy.message = f"moving {slot.name} · {position}/{len(moves)}"
await _write_slot(ctx, device, target_idx, slot)
return len(moves)
async def _reorder_channels(ctx: AppContext, device: Device, slots: list[ChannelSlot]) -> int:
@@ -960,7 +1194,16 @@ async def _reorder_channels(ctx: AppContext, device: Device, slots: list[Channel
# the channels automatically — reordering needs no history migration. The slot→identity
# cache the chat service resolves inbound messages through is refreshed centrally by
# manage_channels once any change lands (see :func:`_refresh_chat_channels`).
return await _apply_order(ctx, device, slots, order)
try:
return await _apply_order(ctx, device, slots, order)
except Exception as exc: # noqa: BLE001 - reported here, where the half-done state is
# A relay is a sequence of writes with no transaction under it, so a link that drops
# partway leaves the slots between the two orders. That is worth saying plainly and
# at once: the manager re-reads on the way out of this round, so what the reader is
# about to look at *is* the half-applied layout, and nothing else would tell them so.
ctx.log.debug("channels: reorder stopped partway: %s", exc)
await _say(ctx, f"reorder stopped partway — {exc}", "err")
return 0
# --- shared views ------------------------------------------------------------
@@ -1016,19 +1259,60 @@ async def _open_chat(ctx: AppContext, slot: ChannelSlot) -> None:
await open_chat(ctx, slot.conversation)
async def _pick_free_slot(ctx: AppContext, slots: list[ChannelSlot], capacity: int) -> int | None:
"""Return the next free slot, warning (and returning ``None``) if all are full."""
async def _pick_free_slot(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int | None:
"""Return a slot that is *confirmed* empty, or ``None`` (having said why) if there is none.
The list this picks from is a snapshot, and a snapshot can be short of the truth: a slot
probe that failed partway returns the slots it managed to read, so a slot holding a
channel the probe never reached looks free. Every add flow lands here, and what follows
it is an unconditional write — so the slot is read back one more time before it is handed
out. A channel is not something to overwrite on the strength of a list that might be
missing a row.
Args:
ctx: Shared application context (for the message surfaces).
device: The connected device, for the confirming read.
slots: The slots as last read.
capacity: The device's slot count.
Returns:
A free slot index, or ``None`` when there is none to give.
"""
idx = _next_free_slot(slots, capacity)
if idx is None:
await ctx.ui.view(
Text.from_markup(
"[warn]All channel slots are full.[/warn] Clear one first, then try again."
),
title="No free slot",
)
await _say(ctx, "all channel slots are full — clear one first, then try again", "warn")
return None
try:
async with ctx.ui.busy_dialog(f"checking slot {idx}…", title="Channels"):
occupant = await device.get_channel(idx)
except Exception as exc: # noqa: BLE001 - unreadable is not provably free
ctx.log.debug("channels: could not confirm slot %s is free: %s", idx, exc)
await _say(ctx, f"could not read slot {idx} — nothing was written", "err")
return None
if occupant and occupant.get("channel_name"):
await _say(ctx, f"slot {idx} is in use — reopen Channels to see what is there", "err")
return None
return idx
async def _say(ctx: AppContext, text: str, style: str) -> None:
"""Tell the reader something *now*, in a popup over the manager it happened in.
An in-visit outcome is not a tool result: it belongs to the action in front of the
reader, so it goes to the dialog surface rather than into the buffer the menu drains
once the whole tool has finished (which is where a failed import used to leave it).
"""
mark = {"err": "✗", "warn": "⚠"}.get(style, "")
body = Text(f"{mark} {text}" if mark else text, style=style)
session = getattr(ctx.ui, "session", None)
if session is None: # the scripted CLI has no dialog surface; print it
ctx.ui.note(f"[{style}]{body.plain}[/{style}]")
return
await session.message_dialog(body, title="Channels")
# --- validators --------------------------------------------------------------
+11 -3
View File
@@ -306,7 +306,7 @@ PIN_KEY = "device_pin"
_PIN_WIDTH = len(str(get_spec(PIN_KEY).maximum))
def conceal(value: str) -> str:
def conceal(value: str, *, absent: str = "?") -> str:
"""``value`` as a row of :data:`_PIN_WIDTH` mask bullets.
A value the device could not report is not a secret, it is an absence: ``format_value``
@@ -314,8 +314,16 @@ def conceal(value: str) -> str:
something to reveal when there is nothing. So only a real value is concealed, and
:func:`has_pin` asks the same question the other way round — a page with nothing to
conceal advertises no key for it.
Args:
value: The already-rendered value to mask.
absent: How *this* surface writes "the device never reported one". The menu's
token is ``?``; the scripted CLI's is :data:`~meshterm.ui.script.NONE`. The
rule is the same on both and the glyph is not, so the caller names it — a
``conceal`` that knew only the menu's token masked the CLI's absence too, and
six bullets told the reader the radio was PIN-locked when it was not.
"""
return value if value == "?" else MASK_MARK * _PIN_WIDTH
return value if value == absent else MASK_MARK * _PIN_WIDTH
def has_pin(snapshot: dict) -> bool:
@@ -995,7 +1003,7 @@ async def _run_now(
"""
from ..tools.config import apply_ops
changes, artifacts = await apply_ops(ctx, device, snapshot, ops)
changes, artifacts, _report = await apply_ops(ctx, device, snapshot, ops)
for artifact in artifacts:
ctx.ui.note(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
await ctx.ui.present(title=title)
+208 -21
View File
@@ -15,6 +15,14 @@ merely detected). Everything else follows in discovery order.
Selecting a device runs an immediate smoke test (via the ``verify`` callback): a genuine
MeshCore companion is remembered — forever — as confirmed and becomes the session's active
device; anything else sends the user back to the list to choose another.
A machine with things permanently plugged into it that are not companions — a debug probe, a
programmer, a serial adapter — shows them here every single time, in front of the one device
the user actually wants. So a row can be **hidden**: ``h`` drops the highlighted device from
this list and remembers that (see :meth:`~meshterm.core.device_store.DeviceStore.hide`), and
``⇧H`` brings every hidden one back. It is a *listing* choice and nothing more: a hidden
device is still remembered, still reachable by ``--port``, and un-hides itself the moment it
is connected to again.
"""
from __future__ import annotations
@@ -35,8 +43,10 @@ from ..core.discovery import (
serial_device,
tcp_device,
)
from ..platforms import get_platform
from .logo import load_logo
from .tui import Choice, DeleteRequest, Separator
from .menus import Lane, column_header, fit_cells
from .tui import Choice, DeleteRequest, KeyRequest, Separator
if TYPE_CHECKING:
from .surface import Ui
@@ -57,6 +67,13 @@ _QUIT = object()
#: companions aren't discoverable, so they're named by hand) and smoke-tests the result.
_ADD_TCP = object()
#: The two shortcut tokens the splash declares, and the keys that raise them. ``h`` acts on
#: the highlighted row; ``⇧H`` is screen-wide, because a hidden row is not there to press a
#: key on — the way back has to be reachable from anywhere in the list.
_HIDE = object()
_SHOW_ALL = object()
_SHORTCUTS = {"h": _HIDE, "H": _SHOW_ALL}
#: TYPE-column glyphs marking how a device connects. Kept as module constants so the splash's
#: look can be retuned without touching the row-building logic. ``ᛒ`` is the *Bjarkan* rune the
#: Bluetooth logo is drawn from — rendered white on the Bluetooth blue (see the ``bluetooth``
@@ -280,6 +297,9 @@ async def prompt_device(
which the caller treats as a request to exit.
"""
scanned = list(devices)
#: The row the *next* redraw should open on, when the pass just finished moved the list
#: under the reader (see :func:`_after_hiding`). Cleared as soon as it is spent.
focus: DiscoveredDevice | None = None
while True:
remembered = store.load()
# The full registry (not just the single last device) so *every* confirmed companion
@@ -297,23 +317,40 @@ async def prompt_device(
# produced by the scan, so fold those in too — after the scanned set, so a port
# pyserial *does* enumerate keeps its richer scanned row rather than the bare profile.
listed += _profile_serial_devices(listed, profiles)
# Preselect the remembered "last known good" device when it is currently attached/in range.
default = next((d for d in listed if remembered and remembered.matches(d)), None)
# Devices the user has told this splash to stop showing (h). Read each pass, so a
# row hidden a moment ago is gone the next time the list is drawn — which is the
# only feedback hiding needs.
hidden = store.hidden_ids()
# In display order, so a shortcut acting on a row can say what the row *after* it is.
ordered = _order([d for d in listed if d.stable_id not in hidden], registry)
# Preselect the remembered "last known good" device when it is currently attached/in
# range — unless the last pass asked for a particular row, which a hide does so the
# highlight lands where the vanished row was rather than jumping back to the default.
default = next((d for d in ordered if remembered and remembered.matches(d)), None)
if focus is not None:
default = focus if focus in ordered else default
focus = None
# Build the rows once so the same list can be redrawn as the backdrop behind a
# removal confirm (so it floats over the picker rather than replacing it).
items = _build_items(listed, remembered, registry)
items = _build_items(ordered, remembered, registry, hidden=len(hidden))
chosen = await ui.select_startup(
"Select a companion device",
items,
default=default,
banner=load_logo(),
keys=_SHORTCUTS,
key_hint=_shortcut_hint(len(hidden)),
)
# Esc (``None``) and the Quit row both mean "leave the picker" — surface that to the
# caller as ``None`` so it can exit the program instead of continuing device-less.
if chosen is None or chosen is _QUIT:
return None
if isinstance(chosen, KeyRequest):
focus = _run_shortcut(store, chosen, ordered)
continue
if chosen is _ADD_TCP:
# Name a network companion by hand and smoke-test it. On success it's returned like
# any picked device; on cancel/failure we loop back to the list.
@@ -344,6 +381,93 @@ async def prompt_device(
return chosen
def _run_shortcut(
store: DeviceStore, request: KeyRequest, listed: list[DiscoveredDevice]
) -> DiscoveredDevice | None:
"""Act on ``h`` / ``⇧H``, and say which row the redrawn list should open on.
Nothing is *said* about either: after a hide the row is gone, after a show-all the hidden
rows are back, and a dialog acknowledging a change the reader is looking straight at is
the acknowledgement this app takes out of screens rather than adding to them. What the
redraw does owe them is their place — see :func:`_after_hiding`.
Args:
store: The registry the hidden set lives in.
request: The shortcut the splash resolved with.
listed: The devices as displayed, in display order.
Returns:
The device to highlight when the list is redrawn, or ``None`` to let the remembered
default decide (which is what a show-all wants: the list it restores is a different
list, and the reader's place in the old one means nothing in it).
"""
if request.action is _SHOW_ALL:
store.show_all()
return None
if request.action is _HIDE and isinstance(request.value, DiscoveredDevice):
# Only a device row can be hidden — ``h`` on Add a network device or Quit is inert,
# the way Delete is on a row that never opted into removal.
store.hide(request.value.stable_id)
return _after_hiding(listed, request.value)
return None
def _after_hiding(
listed: list[DiscoveredDevice], hidden: DiscoveredDevice
) -> DiscoveredDevice | None:
"""The row the highlight should land on once ``hidden`` leaves the list.
The next device down, so a run of adapters can be cleared with a run of presses without
the hand moving; the one above where there is no next, because the highlight has reached
the end and there is nowhere further down to go. Neither ever rolls round to the top —
nothing in the app does — and an emptied list has nothing to land on at all.
"""
remaining = [d for d in listed if d.stable_id != hidden.stable_id]
if not remaining:
return None
at = next((i for i, d in enumerate(listed) if d.stable_id == hidden.stable_id), 0)
return remaining[min(at, len(remaining) - 1)]
def _shortcut_hint(hidden: int) -> Callable[[object], str]:
"""Name the hide shortcuts that would actually do something right here, right now.
Asked of the highlighted row on every paint (see ``key_hint`` on
:class:`~meshterm.ui.tui.select.SelectScreen`), so the footer follows the same rule
``Del remove`` does: a key is named where it acts and nowhere else.
* ``h hide`` only on a device row — on *Add a network device* or *Quit* there is nothing
to hide, and the key is inert there.
* ``⇧H show all`` only while something is actually hidden. It is screen-wide rather than
per-row (a hidden row is not there to press a key on), so it rides whichever row the
reader is standing on — but it stays off the line entirely while it would do nothing,
which is also what keeps the splash from ever naming a key nobody could act on.
"""
def atoms_for(value: object) -> str:
atoms = []
if isinstance(value, DiscoveredDevice):
atoms.append("h hide")
if hidden:
atoms.append("⇧H show all")
return " · ".join(atoms)
return atoms_for
def _hidden_note(hidden: int) -> Separator:
"""The muted row that stands in for a list hiding has emptied.
Only then. With devices still listed, the footer already names ``⇧H`` on every row and a
standing line saying the same thing is a row of the box spent on chrome. With *nothing*
listed there is no footer atom to read it off — and "no companion devices detected" would
be a lie about a machine with a radio plugged into it.
"""
it = "it" if hidden == 1 else "them"
device = "device" if hidden == 1 else "devices"
return Separator(f" {hidden} {device} hidden — press ⇧H to show {it}")
def _where_phrase(device: DiscoveredDevice) -> str:
"""A human phrase for a device's transport, woven into the smoke-test spinner line."""
if device.is_tcp:
@@ -541,6 +665,40 @@ async def _remove_network_device(
store.forget(device.stable_id)
#: The narrowest the HARDWARE column is worth keeping before the name lane has to give way.
#: Below this a model string says nothing at all, and the lane is better spent on the name.
_HARDWARE_MIN = 10
def _name_width(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice], other_lanes: int
) -> int:
"""The DEVICE lane's width: the longest name, capped so HARDWARE keeps a readable column.
A USB adapter's own product string runs to forty-odd characters ("CP2102 USB to UART
Bridge Controller"), and the lane used to size itself to whichever of those was longest.
On a 53-column console that pushed every other lane off the right-hand edge — the row
ended mid-port, and the HARDWARE column that says what the thing *is* was not on screen
at all, nor reachable by scrolling, because the pinned head was already wider than the
box. So the name is the lane that gives: it ellipsizes, and the columns after it survive.
Args:
devices: The devices being listed.
registry: Confirmed companions, for their remembered names.
other_lanes: Cells the PORT and TYPE lanes take between them.
Returns:
The name lane's width in cells.
"""
platform = get_platform()
# What the splash's box gives a row: the terminal, less the gutter it floats over, less
# its own border and padding, less the pointer and star columns the row leads with.
content = platform.readable_cols - platform.dialog_margin - 4 - 4
fixed = other_lanes + 6 # the three two-cell gaps between the four lanes
longest = max(cell_len(_display_name(d, registry)) for d in devices)
return max(len("DEVICE"), min(longest, content - fixed - _HARDWARE_MIN))
def _order(
devices: list[DiscoveredDevice], registry: dict[str, RememberedDevice]
) -> list[DiscoveredDevice]:
@@ -560,6 +718,8 @@ def _build_items(
devices: list[DiscoveredDevice],
remembered: RememberedDevice | None,
registry: dict[str, RememberedDevice],
*,
hidden: int = 0,
) -> list:
"""Build the aligned splash rows (a muted header + one :class:`Choice` per device).
@@ -569,17 +729,33 @@ def _build_items(
vendor); columns are padded to a shared width so they align. Confirmed companions sort to
the top (most-recent first), wear their name in white and a bright tag; the remembered
default is starred. Trailing rows let the user name a network device by hand and quit here.
A device row's fixed lanes are pinned and its HARDWARE tail scrolls: a firmware model
string is as long as its vendor felt like making it, and on a narrow terminal the column
that says *what the thing actually is* was the one being cut off. ←→ read it to its end,
on the highlighted row alone, and only while that row overflows.
Args:
devices: The devices to list — already less anything hidden.
remembered: The last-connected device, starred and preselected when present.
registry: Every confirmed companion, keyed by stable id.
hidden: How many devices were left out for being hidden. Only the empty state uses
it, to tell "nothing is plugged in" apart from "you hid all of it".
"""
if not devices:
# Nothing attached or in range — but a TCP companion can still be reached by hand, so
# show a muted note over the same action rows rather than a dead-end.
note = Separator(" no companion devices detected — add a network device, or quit")
# show a muted note over the same action rows rather than a dead-end. When the list is
# empty *because everything in it is hidden*, say that instead: "nothing detected"
# would be a lie, and the way back is a key the reader has to be told about.
note = (
_hidden_note(hidden)
if hidden
else Separator(" no companion devices detected — add a network device, or quit")
)
return [note, *_action_rows()]
devices = _order(devices, registry)
known: set[str] = set(registry)
name_w = max(cell_len(_display_name(d, registry)) for d in devices)
name_w = max(name_w, len("DEVICE"))
# The middle column holds a serial port, a BLE address, or a TCP host:port; label it for
# whichever kinds are present so a non-serial endpoint never sits under a bare "PORT"
# heading (a BLE address and a network host:port both read as an "address").
@@ -593,21 +769,26 @@ def _build_items(
# The TYPE column holds a small transport badge (at most 3 cells); its heading is wider,
# so the four-cell "TYPE" label sets the column width and every badge pads out to it.
type_w = len("TYPE")
name_w = _name_width(devices, registry, port_w + type_w)
hardware_w = max(cell_len(_hardware_label(d, registry)) for d in devices)
hardware_w = max(hardware_w, len("HARDWARE"))
# A muted, aligned header. The leading spaces mirror the row pointer (2) and the star
# column (2) so the labels sit above their columns. It leads the device rows as their
# landmark, so a long detection list keeps the lane names overhead as it scrolls.
# A muted, aligned header, resolved against the render width because it is *pinned*: it
# leads the device rows as their landmark, so a long detection list keeps the lane names
# overhead as it scrolls — and a pinned row that wraps is drawn outside the body slice,
# where the second line costs the list a device. The indent mirrors the row pointer (2)
# and the star column (2) so each label sits over its own lane.
header = Separator(
" "
+ _pad("DEVICE", name_w)
+ " "
+ _pad(port_label, port_w)
+ " "
+ _pad("TYPE", type_w)
+ " "
+ "HARDWARE",
lambda width: column_header(
[
Lane("DEVICE", name_w + 2),
Lane((port_label, "PORT" if has_serial else "ADDRESS"), port_w + 2),
Lane("TYPE", type_w + 2),
Lane(("HARDWARE", "HW")),
],
width,
indent=4,
),
heading=True,
)
@@ -620,7 +801,8 @@ def _build_items(
row.append(" ")
# A confirmed companion wears its name in white so it stands out from mere detections.
row.append(
_pad(_display_name(device, registry), name_w), style="device.known" if is_known else ""
_pad(fit_cells(_display_name(device, registry), name_w), name_w),
style="device.known" if is_known else "",
)
row.append(" ")
row.append(_pad(_where(device), port_w), style="muted")
@@ -633,6 +815,11 @@ def _build_items(
row.append_text(cell)
row.append(" " * max(0, type_w - cell.cell_len))
row.append(" ")
# Everything up to here is the row's fixed lanes — the reader's place in the list —
# so it is pinned and only what follows slides under ←→ (see Choice.hscroll_from).
# What follows is the hardware model and its tag, which is the part that runs long:
# a firmware model string is as long as its vendor felt like making it.
head = row.cell_len
row.append(_pad(_hardware_label(device, registry), hardware_w), style="muted")
# Only devices we've actually confirmed are billed as MeshCore companions; a USB
# vendor ID (or a BLE advert) is a sort hint, not a claim. A bare serial bridge earns
@@ -652,7 +839,7 @@ def _build_items(
# Only a network device opts into Delete-to-remove: it's listed solely from its
# remembered endpoint, so forgetting it is the only way it leaves the picker. A scanned
# serial/BLE device would just reappear, so Delete stays inert on those rows.
items.append(Choice(title=row, value=device, deletable=device.is_tcp))
items.append(Choice(title=row, value=device, deletable=device.is_tcp, hscroll_from=head))
items.extend(_action_rows())
return items
+480
View File
@@ -0,0 +1,480 @@
"""The shared shapes a report is built out of — one constructor per concept.
A node means the same thing in ``contacts``, ``monitor``, ``courier list``, ``trace`` and
``records``, and it should *look* the same in all five and parse the same in all five. The
way to guarantee that is not review, it is construction: every listing that mentions a
node asks this module for the column, so there is exactly one definition of what a node's
plain lanes are called and what its machine object holds.
Each constructor returns one :class:`~meshterm.ui.report.Column`, which carries both
projections of one typed value. The typed value is what a row holds — a :class:`NodeRef`,
a :class:`Position`, a :class:`~datetime.datetime`, a bare float — never a formatted cell,
so the plain face can print ``+5.1`` and the machine face ``5.1`` without either one
parsing the other's output back.
The interesting one is :func:`node`, and it is what earns the design: **one machine key
expands to as many plain columns as the surface has room for.** ``contacts`` spends four
(a name, a role, a hash, a key), ``monitor``'s live stream spends two, a trace's per-hop
table spends one — and all four documents carry the same five-key object, because the
lanes are a rendering choice and the shape is not.
Two absences, kept apart on purpose. ``None`` is *we do not know*, and it reads as
:data:`~meshterm.ui.script.NONE` plain and ``null`` in JSON. A word standing in for an
absence is not the same thing: ``unknown`` in a ``TYPE`` column is a word a reader
understands, so the plain face keeps it, while the document writes ``null`` — because a
consumer already has one spelling for "there is nothing here" and a second one costs it a
special case.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from . import script
from .report import Column, Lane, normalise
# -- the shared value shapes -----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class NodeRef:
"""A mesh participant's identity, and nothing else.
The most repeated concept in either face. *Identity only*: when a node was heard, how
strong it came in, where it says it is — those belong to the **row**, because they
differ per surface and a node object that carried them would mean something different
in every one.
Attributes:
name: The name the node advertises, or ``None`` where none was ever heard —
including where a surface substitutes a bare hash for one.
key: The full public key, lowercase hex, **never truncated**: a key cut short is
not something a caller can hand back to ``--to`` or ``--path``. ``None`` where
only a hash was ever heard.
hash: The short derived id, at the width the surrounding surface addressed the
node by — never ``key`` shortened by this module's own choosing.
type: The advertised role in the lexicon's words (``node``, ``repeater``,
``room``, ``sensor``), or ``None`` where the advert never said.
is_self: Whether this is our own node. The plain face draws it like any other hop,
which is the point: "you already know who this is" is true of the person at
the prompt and false of whoever reads the file afterwards. The machine face
carries it as ``self``, because a parser has no way to work it out at all.
"""
name: str | None = None
key: str | None = None
hash: str | None = None
type: str | None = None
is_self: bool = False
@dataclass(frozen=True, slots=True)
class Position:
"""A shared location, in decimal degrees.
Unrounded in the document, as stored: the plain face's five decimals are a column-width
concession and a consumer should not inherit one.
"""
lat: float
lon: float
@dataclass(frozen=True, slots=True)
class ChannelRef:
"""One channel slot's identity. The secret is deliberately not in it.
A channel object travels through listings and send receipts, and a key that rode along
in all of them would be a key printed by commands nobody asked to print one. It
appears only where the caller explicitly asked (``channels share``), under its own
field, beside this.
Attributes:
slot: The 0-based index every ``channels`` subcommand's ``INDEX`` takes.
name: The channel name.
public: Whether the key is derived from the name (a ``#`` channel or a firmware
default) rather than secret.
hash: The channel hash, lowercase hex.
"""
slot: int
name: str
public: bool
hash: str | None = None
@dataclass(frozen=True, slots=True)
class Rendered:
"""A typed value beside the one plain rendering only its own registry can produce.
Almost every cell is derivable from its value alone, which is what lets a
:class:`~meshterm.ui.report.Lane` be a function of one argument. A *setting* is the
exception: what ``config set`` takes back for a given key is a question only that key's
spec can answer — an enum wants its number, an empty string wants ``""``, a preference
drops the unit its page shows — and the spec is per row, not per column.
So the row carries both halves, computed once where the spec is in hand. The plain
face prints ``text`` and the document emits ``value``, and neither has to parse the
other's output to get at what it needs, which is the same rule the rest of this module
keeps by other means.
Attributes:
value: The typed value, or ``None`` where the source never reported one.
text: What the plain face prints, in the form the matching ``set`` accepts back.
"""
value: Any
text: str
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
"""One node as its machine object, or ``null`` where there is no node at all."""
if ref is None:
return None
return {
"name": ref.name or None,
"key": (ref.key or None) and ref.key.lower(),
"hash": (ref.hash or None) and ref.hash.lower(),
"type": ref.type or None,
"self": ref.is_self,
}
def position_json(where: Position | None) -> dict[str, Any] | None:
"""One position as its machine object, or ``null`` where none was ever shared.
The whole object is ``null`` rather than a pair of ``null``s: a latitude without a
longitude is not half a location, it is no location.
"""
return None if where is None else {"lat": where.lat, "lon": where.lon}
def channel_json(channel: ChannelRef | None) -> dict[str, Any] | None:
"""One channel as its machine object, or ``null`` for an empty slot."""
if channel is None:
return None
return {
"slot": channel.slot,
"name": channel.name,
"public": channel.public,
"hash": (channel.hash or None) and channel.hash.lower(),
}
# -- the constructors ------------------------------------------------------------------
#: How each of a node's plain lanes reads its cell. A surface picks the ones it has room
#: for; the machine object is the same five keys either way.
_NODE_LANES = {
"name": lambda ref: script.name(ref.name if ref else None),
# The word, not a glyph: `▲` without its colour would need a legend and the CLI has no
# legends. `unknown` is a word a reader acts on; the document writes `null`.
"type": lambda ref: ref.type if ref and ref.type else "unknown",
"hash": lambda ref: ((ref.hash or "").lower() if ref else "") or script.NONE,
"key": lambda ref: ((ref.key or "").lower() if ref else "") or script.NONE,
}
def node(key: str = "node", *, lanes: Sequence[tuple[str, str]] = (("name", "NAME"),)) -> Column:
"""One node: the shared five-key object, and whichever plain lanes fit.
Args:
key: The machine key, and the key the row holds its :class:`NodeRef` at.
lanes: ``(part, header)`` pairs naming the plain columns to draw, in order. The
parts are ``name``, ``type``, ``hash`` and ``key``. A surface with one column
to spend draws the name; a per-hop table draws the hash, because that is what
joins it to the route line above it.
Returns:
The column.
"""
return Column(
key=key,
lanes=tuple(Lane(header=header, render=_NODE_LANES[part]) for part, header in lanes),
json=node_json,
)
def position(key: str = "position", header: str = "LOCATION") -> Column:
"""A shared location: one plain column, one machine object.
One column and not two. A coordinate pair is one fact to a reader, it is what goes
into a map's search box verbatim, and two columns of ``-`` for every node that has
never shared a position is worse than one.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_position_cell),),
json=position_json,
)
def channel(
key: str = "channel",
*,
lanes: Sequence[tuple[str, str]] = (("name", "NAME"),),
) -> Column:
"""One channel slot: the shared four-key object, and whichever plain lanes fit.
Args:
key: The machine key.
lanes: ``(part, header)`` pairs; the parts are ``slot``, ``name``, ``type`` and
``hash``. ``type`` is the plain face's word for the ``public`` boolean —
``public``/``private`` reads where ``true``/``false`` would need the heading
to say which way round it runs.
"""
parts = {
"slot": lambda c: script.number(c.slot if c else None),
"name": lambda c: script.name(c.name if c else None),
"type": lambda c: ("public" if c.public else "private") if c else script.NONE,
"hash": lambda c: ((c.hash or "").lower() if c else "") or script.NONE,
}
return Column(
key=key,
lanes=tuple(Lane(header=header, render=parts[part]) for part, header in lanes),
json=channel_json,
)
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
The default form for every time in a listing, because "recently?" is the question the
reader typed the command to ask and an ISO instant makes them do arithmetic to answer
it. ``--absolute`` swaps the whole language back for a run; the document is UTC
regardless, since it is read somewhere else and often later.
Args:
key: The machine key. It ends in ``_at`` by convention — a JSON key is a type hint
for a parser, and the suffix is the hint.
header: The plain heading. It must name the *fact* — ``HEARD``, ``RECORDED`` —
rather than the form, because ``--absolute`` changes the form underneath it
and a column headed ``AGE`` holding an ISO instant is a heading that lies.
absent: What an unknown time reads as plain. ``never`` where the row exists and
the event has not happened (a node not yet heard, a conversation with nothing
said in it); the default token where the row has no such time at all.
Returns:
The column.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: _time(v, absent=absent), align="right"),),
)
def instant(key: str, header: str) -> Column:
"""A time that *is* the fact, printed absolutely whatever ``--absolute`` says.
Three surfaces want this and each for the same reason — the instant is the answer, not
a way of saying how long ago something was. The device's own clock. An appointment set
with ``--at``, which is a wall-clock time the caller chose. And a live capture's own
``TIME`` column, every row of which would otherwise read ``now``.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.stamp),))
def snr(key: str, header: str) -> Column:
"""A signal-to-noise reading: ``+5.1`` plain, ``5.1`` in the document.
The plain sign is a column convention — a lane of readings only reads as a ladder when
every one of them declares its direction. A number in JSON carries its own sign and a
leading ``+`` would make it a string.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: script.number(v, "+.1f"), align="right"),),
json=lambda v: None if v is None else round(float(v), 1),
)
def integer(key: str, header: str) -> Column:
"""A whole number, right-aligned so a column of them is comparable by eye."""
return Column(key=key, lanes=(Lane(header=header, render=script.number, align="right"),))
def decimal(key: str, header: str, spec: str = ".1f") -> Column:
"""A measured number, formatted for the column and left unrounded in the document."""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: script.number(v, spec), align="right"),),
)
def flag(key: str, header: str) -> Column:
"""A boolean: ``yes``/``no`` for a reader, ``true``/``false`` for a parser.
``None`` is a third answer and stays one — ``acked`` on a channel message is not
``false``, it is *there is no acknowledgement on a channel*, and the two are different
things to anyone counting delivery failures.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_flag_cell),),
)
def word(key: str, header: str) -> Column:
"""A member of a closed set — a state, a role, an outcome — as its own lowercase word.
Both faces say the same word, so ``courier send``'s ``no ack`` reads the same in a
document as it does on the line the help lists it on.
"""
return Column(key=key, lanes=(Lane(header=header, render=lambda v: v or script.NONE),))
def name(key: str, header: str) -> Column:
"""A bare name that is not a node's — a channel label, a conversation, a device."""
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
def free(key: str, header: str) -> Column:
"""Free text a stranger filled in: escaped so it can never end its own record.
Always the last lane in a listing, because it is the one field with no width. The
document carries it **raw and unshortened** — a JSON string holds a newline natively,
so the escaping is a plain-text necessity rather than something to inflict on a
consumer.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
def route(key: str = "route", header: str = "ROUTE") -> Column:
"""A walked hop sequence: drawn with arrows plain, an array of nodes in the document.
The row holds :class:`NodeRef` objects in propagation order, so the document's hops are
the same objects a listing's rows carry and a consumer's node handler is written once.
``None`` where nothing came home — never ``[]``, which would claim a walk of no hops.
"""
return Column(
key=key,
lanes=(
Lane(
header=header,
render=lambda hops: (
script.route([(h.name, h.hash) for h in hops]) if hops else script.NONE
),
),
),
json=lambda hops: None if hops is None else [node_json(hop) for hop in hops],
)
def spec(key: str = "path", header: str = "path") -> Column:
"""A forced path exactly as the radio was given it: ``a1,d4,a1``, or absent.
The one line on either face that round-trips into ``--path``, so it keeps its commas
and grows nothing. ``None`` means the device routed the walk itself: the plain face
says ``auto`` because a reader needs a word there, and the document says ``null``
because ``auto`` is not a path spec and R3 already has a token for "there is no spec
here".
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: v if v else "auto"),),
)
def rendered(key: str, header: str) -> Column:
"""A setting's value: what ``set`` takes back plain, and the typed value in the document.
Takes a :class:`Rendered` (see there for why this one concept needs both halves in the
row). The round-trip claim is the plain face's whole design for these commands —
``config show > f`` and feeding ``f`` back in must work — and the document's typed
value is the machine face's largest single gain over it: ``9`` and not ``"9"``.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_rendered_cell),),
json=lambda r: None if r is None else r.value,
)
def hexid(key: str, header: str) -> Column:
"""A hash or a key on its own, lowercase, never truncated and never elided."""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: (v or "").lower() or script.NONE),),
json=lambda v: (v or None) and str(v).lower(),
)
def note(key: str, header: str) -> Column:
"""Prose for a reader, with no machine face at all.
A preference's DESCRIPTION is the case: it explains a preference to somebody deciding
whether to change it, and there is nothing in it a program would branch on. Declaring
it as a column rather than smuggling it in keeps the listing's two faces stated in one
place.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.text),), json=None)
def plain_only(column: Column) -> Column:
"""The same concept, drawn for a reader and left out of the document.
For a lane the machine face says *better* somewhere else rather than not at all. A
transcript's ``PEER`` is the case: one column holding whichever of a channel or a
contact the conversation had, because a reader has the conversation in front of them —
where the document keeps ``channel`` and ``node`` apart, since a parser has to know
which of the two it is holding and cannot tell from the label.
"""
from dataclasses import replace
return replace(column, json=None)
def hidden(key: str) -> Column:
"""A fact the document carries and the plain face has no room for.
The mirror of :func:`note`. ``config show`` prints a setting's key and its value —
that is what a reader came for — while the document also says what type it is, what an
enum's number is called, and whether the value was withheld.
"""
return Column(key=key)
def _rendered_cell(value: Rendered | None) -> str:
"""One setting's cell: the text its own spec produced, or the absent token."""
return script.NONE if value is None else value.text
def _position_cell(where: Position | None) -> str:
"""One position's cell: the pair, or the absent token."""
return script.NONE if where is None else script.location(where.lat, where.lon)
def _flag_cell(value: bool | None) -> str:
"""One boolean's cell, keeping ``None`` as an absence rather than a ``no``."""
return script.NONE if value is None else ("yes" if value else "no")
def _time(value: Any, *, absent: str) -> str:
"""Render a time in whichever language this run speaks (see :func:`when`).
``absent`` survives the switch to timestamps, because it is not a rendering choice:
``never`` says the event has not happened, and ``--absolute`` was asked for a different
*form* of time, not for one fewer fact.
"""
if not isinstance(value, datetime) and value is not None:
return normalise(value) # pragma: no cover - a row holding something else is a bug
if value is None:
return absent
return script.stamp(value) if _absolute() else script.age(value, absent=absent)
def _absolute() -> bool:
"""Whether this run prints absolute times.
Read from the preference registry rather than a module flag, because *how MeshTerm
behaves* is a preference by this project's own rule — and ``--absolute`` is then
simply that preference overridden for one run (see
:func:`~meshterm.cli.main_callback`), rather than a second switch saying the same
thing in a place a reader cannot find.
"""
from ..core.preferences import current
return str(current().cli_time_format) == "absolute"
+38 -1
View File
@@ -98,11 +98,18 @@ class Block:
heading: Whether this block is one of the page's section landmarks (see
:attr:`MarkdownDoc.sections`).
label: The heading's plain text, for a landmark; empty otherwise.
qr: Whether this block is a ``qr`` fence's scannable code. Marked so a surface
that cannot use one can drop it (see :meth:`MarkdownDoc.for_script`).
rule: Whether this block is a ``---`` horizontal rule. Marked for the same
reason: a rule is drawn to the full width of whatever prints it, and the
scripted console is 16384 cells wide.
"""
renderable: RenderableType
heading: bool = False
label: str = ""
qr: bool = False
rule: bool = False
class MarkdownDoc:
@@ -124,6 +131,34 @@ class MarkdownDoc:
"""The page's section headings, in order — the landmarks a jump steps by."""
return tuple(block.label for block in self.blocks if block.heading)
def for_script(self) -> MarkdownDoc:
"""The same page with what only a screen can use left out, and no gap where it stood.
Two blocks are drawn for an eye and are worse than useless in a pipe:
* A **QR** is a second rendering of a URL the page already prints as a link, drawn
for a phone pointed at a screen. Redirected into a file it is a block of block
characters wrapped around nothing new.
* A **rule** is sized to the console it is printed on, and the scripted console is
:data:`~meshterm.ui.script.WIDTH` cells wide — so the ``---`` above a colophon
arrives as one 16384-character line, tens of kilobytes of ``─`` in a page of
prose. It is also a frame, which the scripted CLI does not draw. The blank line
on each side already says what the rule said.
Returns:
A new document; this one is untouched.
"""
kept: list[Block] = []
for block in self.blocks:
if block.qr or block.rule:
# The blank separator that opened the dropped block goes with it, so
# taking one out does not leave two blank lines where it stood.
if kept and isinstance(kept[-1].renderable, Text) and not kept[-1].renderable.plain:
kept.pop()
continue
kept.append(block)
return MarkdownDoc(kept)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render every block in order (what makes the document a plain renderable)."""
for block in self.blocks:
@@ -190,8 +225,10 @@ def _document(nodes: Sequence[SyntaxTreeNode]) -> list[Block]:
continue
heading = node.type == "heading" and node.tag == landmark
label = _inline(node.children[0], heading=True).plain if heading else ""
qr = node.type == "fence" and node.info.strip() == "qr"
rule = node.type == "hr"
for rendered in _block(node, indent=INDENT, depth=0):
blocks.append(Block(rendered, heading=heading, label=label))
blocks.append(Block(rendered, heading=heading, label=label, qr=qr, rule=rule))
heading = False # a heading is one block; never mark a stray second
return blocks
+57 -28
View File
@@ -527,7 +527,7 @@ class PathLine:
if seam is not None and len(groups) > 1:
# The outbound leg's own last line still continues into the return, so it
# holds the cue back where the whole path's final line doesn't.
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue))
legs = self._flow(self._hops[:seam], budget, plain, len(cue), len(cue), ends=False)
legs += self._flow(self._hops[seam:], budget, plain, len(cue), 0, carry_in=True)
if len(legs) <= len(groups): # the turn folds for free — take it
groups = legs
@@ -582,7 +582,7 @@ class PathLine:
full = self.text().cell_len
if not self._hops:
return Measurement(full, full)
cells, _join, tail, lead, _head = self._measure(
cells, _join, tail, lead, _head, _foot = self._measure(
self._hops, self._resolved_mode() != "powerline"
)
least = max(cells) + tail + lead + WRAP_OFFSET
@@ -590,7 +590,9 @@ class PathLine:
# --- where the breaks fall --------------------------------------------------------
def _measure(self, hops: list[PathHop], plain: bool) -> tuple[list[int], int, int, int, int]:
def _measure(
self, hops: list[PathHop], plain: bool
) -> tuple[list[int], int, int, int, int, int]:
"""Each hop's own width, plus what joining, opening and closing a line costs.
Fitting hops to a column is arithmetic once every hop has been measured once —
@@ -602,24 +604,26 @@ class PathLine:
plain: Measure as arrow text rather than as chips.
Returns:
``(cells, join, tail, lead, head)`` — each hop's cells, the cells one join
between two hops costs (chip mode's seam is the single interlocked chevron —
and so is either side of an elision gap, so the figure holds there too), the
cells a line always closes with (chip mode's edge; nothing in arrow mode), the
cells every line *after the first* opens with (chip mode's notch, the seam
redrawn), and the cells the *first* line opens with (chip mode's rounded cap,
nothing where the font has none).
``(cells, join, tail, lead, head, foot)`` — each hop's cells, the cells one
join between two hops costs (chip mode's seam is the single interlocked
chevron — and so is either side of an elision gap, so the figure holds there
too), the cells a line the path *runs on past* closes with (chip mode's
point; nothing in arrow mode), the cells every line *after the first* opens
with (chip mode's notch, the seam redrawn), and the two outer caps: what the
*first* line opens with and what the line that *ends* the path closes with —
one cell each where the font has the rounded caps, nothing where it hasn't,
a squared end being drawn by appending nothing at all.
"""
if plain:
plain_cells = [self._plain_hop(hop).cell_len for hop in hops]
return plain_cells, cell_len(self._separator), 0, 0, 0
return plain_cells, cell_len(self._separator), 0, 0, 0, 0
widths = [
cell_len(hop.label) if hop.gap else self._chip(hop, self._chip_fill(hop)).cell_len
for hop in hops
]
sep = cell_len(POWERLINE_SEP)
head = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
return widths, sep, sep, sep, head
cap = cell_len(POWERLINE_ROUND_OPEN) if powerline_full() else 0
return widths, sep, sep, sep, cap, cap
@staticmethod
def _fill(
@@ -631,13 +635,15 @@ class PathLine:
last: int,
lead: int = 0,
head: int = 0,
foot: int = 0,
ends: bool = True,
) -> list[int]:
"""Pack hop widths into lines of ``budget`` cells, greedily, never splitting one.
Args:
cells: Each hop's width, in order (see :meth:`_measure`).
join: Cells one join between two hops costs.
tail: Cells every line closes with.
tail: Cells a line the path runs on past closes with.
budget: The content column's width in cells.
reserve: Cells held back on a line for the continuation cue it will carry.
last: Cells held back on a line ending at the *final* hop — ``0`` when the
@@ -646,6 +652,12 @@ class PathLine:
lead: Cells every line but the first opens with — the reopened seam plus
the :data:`WRAP_OFFSET` step it hangs past the indent.
head: Cells the first line opens with (the rounded cap, when drawn).
foot: Cells the line that *ends the path* closes with (the rounded cap, when
drawn) — charged in place of ``tail``, a finished path closing on a cap
or, where the font has none, on nothing at all.
ends: These hops finish the path, so their last line pays ``foot``. ``False``
where they are one leg of a route that goes on and every line, the last
included, still closes on the point.
Returns:
How many hops each line takes; a hop too wide for ``budget`` gets a line of
@@ -655,9 +667,11 @@ class PathLine:
count = 0
width = head
for i, cell in enumerate(cells):
final = i == len(cells) - 1
grown = width + cell + (join if count else 0)
held = last if i == len(cells) - 1 else reserve
if count and grown + tail + held > budget:
held = last if final else reserve
close = foot if final and ends else tail
if count and grown + close + held > budget:
sizes.append(count)
width, count = lead + cell, 1
else:
@@ -673,6 +687,7 @@ class PathLine:
reserve: int,
last: int,
carry_in: bool = False,
ends: bool = True,
) -> list[list[PathHop]]:
"""Break ``hops`` into the fewest lines, then even those lines out.
@@ -692,25 +707,31 @@ class PathLine:
last: Cells held back on the line the hops end on (see :meth:`_fill`).
carry_in: These hops are a *later* leg of a path already under way, so
even their first line opens as a continuation, not as a beginning.
ends: These hops finish the path, so their last line closes on the outer cap
rather than on the continuation point (see :meth:`_fill`).
Returns:
The hops grouped per line.
"""
cells, join, tail, lead, head = self._measure(hops, plain)
cells, join, tail, lead, head, foot = self._measure(hops, plain)
lead += WRAP_OFFSET # every continuation steps in past the hanging indent
if carry_in:
head = lead
lines = len(self._fill(cells, join, tail, budget, reserve, last, lead, head))
def fill(column: int) -> list[int]:
return self._fill(cells, join, tail, column, reserve, last, lead, head, foot, ends)
lines = len(fill(budget))
low, high = 1, budget
while low < high: # the narrowest column still taking `lines` rows
mid = (low + high) // 2
if len(self._fill(cells, join, tail, mid, reserve, last, lead, head)) <= lines:
if len(fill(mid)) <= lines:
high = mid
else:
low = mid + 1
groups: list[list[PathHop]] = []
at = 0
for size in self._fill(cells, join, tail, low, reserve, last, lead, head):
for size in fill(low):
groups.append(hops[at : at + size])
at += size
return groups
@@ -825,12 +846,18 @@ class PathLine:
"""Powerline chips: each hop filled with its hue, a two-cell gap at every seam.
The two outer ends say whether this line *is* the path or only part of it. A
line that opens the path opens rounded (squared off where the font has no
rounded caps); one that continues a wrapped path opens on the break's other
half — which is exactly the seam's own second cell, so a fold looks like the
seam it interrupted. A line that ends the path closes rounded; one the path
outruns closes on the point, the same "goes on" cue arrow mode spells with a
trailing ``→``.
line that opens the path opens rounded; one that continues a wrapped path opens
on the break's other half — which is exactly the seam's own second cell, so a
fold looks like the seam it interrupted. A line that ends the path closes
rounded; one the path outruns closes on the point, the same "goes on" cue arrow
mode spells with a trailing ``→``.
Where the font has no rounded caps both outer ends are drawn **square** — the
chip's own pad is the edge, and nothing is appended. Square is not a degraded
cap, it is the one shape left that says *stop*: the point is spoken for as the
continuation cue, so closing a finished path on one claimed a hop had been cut
off (JP, 2026-09-09). Only the *opening* used to square itself off, which left
every route on a core-only terminal ending on the mark for "there is more".
An elision (:attr:`PathHop.gap`) interrupts the ribbon rather than joining it: the
chip before it closes on the page, the mark sits on the page bare, and the chip
@@ -863,8 +890,10 @@ class PathLine:
text.append_text(self._chip(hop, fills[i]))
if hops[-1].gap:
return text # the line ended on the page; there is no chip left to close
close = POWERLINE_SEP if carry_on or not rounded else POWERLINE_ROUND_CLOSE
text.append(close, style=fills[-1]) # the edge into the page: pointed or round
if carry_on:
text.append(POWERLINE_SEP, style=fills[-1]) # the point: the path goes on
elif rounded:
text.append(POWERLINE_ROUND_CLOSE, style=fills[-1]) # the lozenge's far end
return text
@staticmethod
+43 -3
View File
@@ -51,7 +51,7 @@ from .mapcanvas import RGB, MapCanvas
from .menus import fit_cells, marked_label, section_heading
from .pathgraph import PathLayer, render_path_graph
from .pathline import path_line
from .theme import name_style, snr_style
from .theme import glyph, name_style, snr_style
from .tui.render import render_hanging, render_to_ansi
from .tui.screen import Screen
from .widgets import (
@@ -102,6 +102,42 @@ _SIDE_BY_SIDE_MIN = 44
_LABEL_W = 12
def discipline_label(category: Category, lane: int) -> str:
"""One discipline's mark padded to ``lane`` cells, then its title.
THE way a board's name is written, so the seven headings — and the seven rows of the
delete picker — start their titles in one column instead of two. The marks are not all
the same width: ``🛣`` and ``🕸`` sit outside Emoji_Presentation and are one cell where
the other five are two, which is what left ``🛣 Longest distance`` a column adrift of
``🎯 Farthest node``, and its gap looking closed on a terminal that paints the road
wider than it advances (JP, 2026-09-09).
The same rule as a command row's icon column (:func:`~meshterm.ui.menus.icon_lane`) and
deliberately not that function: a command row *drops* its icon where the platform draws
no icon lane, and a board's mark is part of its name on both platforms. So the lane is
measured over what :func:`~meshterm.ui.theme.glyph` will actually draw here, and the
marks stay.
Args:
category: The discipline being named.
lane: The mark column's width, from :func:`discipline_lane`.
Returns:
``"<mark><pad><title>"``, its trailing separator space included in the pad.
"""
mark = glyph(category.icon)
return f"{mark}{' ' * max(1, lane - cell_len(mark) + 1)}{category.title}"
def discipline_lane() -> int:
"""Cells the widest discipline mark claims on this platform (see :func:`discipline_label`).
Measured, never written down: the marks change with the platform, and a substitution
that later narrows simply tightens every heading by a cell without anything else moving.
"""
return max(cell_len(glyph(category.icon)) for category in CATEGORIES)
def _drawn_rows(lines: list[str]) -> list[str]:
"""``lines`` with the rows nothing actually landed on stripped from either end.
@@ -577,6 +613,9 @@ async def open_records(ctx: AppContext) -> dict:
# its far node). Read once here; the far node is matched against these keys per record.
target_counts = ctx.repo.target_trace_counts()
# The mark column every board heading writes its title after (see discipline_label).
mark_lane = discipline_lane()
# Node positions and types for a record's area drawing, gathered live the same way the
# trace tools gather them to score a walk: adverts we've heard, contacts over them. A
# record stores canonical ids, so match those the resolver's way (a prefix either side).
@@ -694,7 +733,7 @@ async def open_records(ctx: AppContext) -> dict:
def describe(category: Category) -> list:
"""The heading and its word-wrapped description as non-selectable rows."""
rows: list = [section_heading(f"{category.icon} {category.title}")]
rows: list = [section_heading(discipline_label(category, mark_lane))]
desc = category.description
if category.needs_positions and self_pos is None:
desc += " — needs your location (set it in Config) to score"
@@ -776,12 +815,13 @@ async def open_records(ctx: AppContext) -> dict:
async def delete_category_flow() -> None:
"""Pick a discipline, confirm, and delete its records (every width)."""
rows: list = []
lane = discipline_lane()
for category in CATEGORIES:
count = len(ctx.repo.discoveries(category.id))
rows.append(
Choice(
title=Text.assemble(
(f"{category.icon} {category.title} ", ""),
(f"{discipline_label(category, lane)} ", ""),
(f"{count} record{'s' if count != 1 else ''}, all widths", "muted"),
),
value=category.id,
+321
View File
@@ -0,0 +1,321 @@
"""The two faces of one answer: a renderer turns a report into bytes.
A tool states its answer as a :mod:`~meshterm.ui.report`; this module is where that
becomes output, and the CLI boundary picks which one runs (see
:func:`~meshterm.cli.main_callback`). Nothing above here knows what a gutter or a comma is,
and nothing below here knows what a contact is. That separation is the whole point: a
third format later — YAML, CSV, whatever somebody needs — is a new class in this file and
not an edit to twenty tools.
**Plain** (:class:`PlainRenderer`) is for a person at a prompt: aligned columns, ages, a
route drawn with arrows, and a ``sysctl -a`` block for a set of facts about one thing. It
is written in the vocabulary of :mod:`meshterm.ui.script`, which is where the reasons for
each of its rules live.
**JSON** (:class:`JsonRenderer`) is for a program, often on another machine and often
later. It prints **the answer itself** — an array for a listing, an object for a set of
facts — compact, one line, UTF-8, no ASCII escaping, keys in the order the report declares
them. There is no wrapper: ``meshterm contacts --json | jq '.[].node.name'`` reads what it
looks like it reads, and the report a caller needs is still ``$?``, exactly as it is
without the flag. So a failure prints **nothing at all** on stdout — the ``meshterm: what
went wrong`` line stays on stderr where every utility puts it, and stdout holding a valid
document is then a reliable signal that the command worked.
Two rules cover a report of more than one block, and between them they produce the
documents ``trace`` and ``tx-optimize`` are specified with. A **single-block** report *is*
its block's document. A **multi-block** report is one object: each :class:`Listing` under
its own key, and each :class:`Facts` merged in at the top level — a facts block is the set
of facts about the one thing the command answered, so it is the document rather than a
compartment inside it.
"""
from __future__ import annotations
import json
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from enum import Enum
from typing import TYPE_CHECKING, Any
from rich.console import Console
from rich.text import Text
from . import script
from .report import BARE, SILENT, Block, Facts, Lane, Listing, Report
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
class OutputFormat(str, Enum):
"""The formats a scripted run can print its answer in.
An enum rather than the ``--json`` boolean it replaces, because the boolean was
already being asked questions it could not answer — ``tx-optimize`` read it as "may I
prompt?" — and because a third member here costs one renderer and no tool changes at
all. ``--json`` stays the documented spelling and is sugar for this.
"""
PLAIN = "plain"
JSON = "json"
class Renderer:
"""Turns a report into bytes. One subclass per format."""
def render(self, report: Report | None) -> None:
"""Print a completed report, or nothing at all when there is none."""
raise NotImplementedError
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Open a live listing whose rows arrive one at a time.
The narrow second seam, and it exists because a stream has no end to return from:
``monitor`` and ``chat listen`` run until a window closes or somebody interrupts
them, so their answer cannot be a value handed back at the end. Two commands use
it. Nothing else should — a command that can build a whole report should, because
a report is the thing every format already knows how to read.
Args:
listing: The shape of the records to come — its columns, its lane order, and
(for the plain face) the width each lane is pinned to. Its ``rows`` are
ignored; rows arrive through the yielded callable.
Yields:
A callable taking one typed row mapping.
"""
raise NotImplementedError
class PlainRenderer(Renderer):
"""The face a person reads: aligned text on the scripted console.
Attributes:
console: The plain console (see :func:`meshterm.ui.script.console`).
"""
def __init__(self, console: Console) -> None:
"""Bind the renderer to the console its output goes to."""
self.console = console
def render(self, report: Report | None) -> None:
"""Draw each block in order, one blank line between them.
The separator is counted off what was *drawn* rather than off the block index: a
block can render to nothing (a listing with no rows, an acknowledgement whose plain
answer is its exit status), and a blank line above the first visible block would be
a line of output the command did not have.
"""
drawn_any = False
for block in report or ():
drawn = self._block(block)
if drawn is None:
continue
if drawn_any:
self.console.print(script.blank())
self.console.print(drawn)
drawn_any = True
def _block(self, block: Block) -> Any:
"""The renderable for one block, or ``None`` where it prints nothing."""
if isinstance(block, Listing):
return self._listing(block) if block.rows else None
if isinstance(block, Facts):
return self._facts(block)
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
def _listing(self, block: Listing) -> Any:
"""A listing as its header line and its padded records."""
lanes = block.lanes()
table = script.columns(
*(lane.header for _, lane in lanes),
right=[lane.header for _, lane in lanes if lane.align == "right"],
)
# `config show` is an array to a parser and a `sysctl -a` block to a reader, so it
# keeps the columns and drops the heading row. Same table either way — the padding,
# the no-wrap and the crop are what make a record splittable, and none of them is
# the header's doing.
table.show_header = block.headed
for row in block.rows:
table.add_row(*(lane.render(row.get(column.key)) for column, lane in lanes))
return table
def _facts(self, block: Facts) -> Any:
"""A facts block as a key/value listing, one bare value, or nothing."""
if block.shape == SILENT:
return None
if block.shape == BARE:
column = next(c for c in block.fields if c.key == block.bare)
value = block.values.get(column.key)
if value is None:
# The bare form prints the value, and there is no value: a node that never
# answered, a slot with nothing in it. A lone `-` there would be a line of
# output where the exit status is the whole report.
return None
# As a Text, not a markup string: this is a private key, a share URL or a
# remote node's own reply, and Rich would read a bracket in one as a style tag.
return Text(column.lanes[0].render(value))
rows: list[tuple[str, str]] = []
for column in block.fields:
value = block.values.get(column.key)
if block.omit_absent and (value is None or value == ""):
continue
for lane in column.lanes:
rows.append((lane.header, lane.render(value)))
return script.pairs(rows)
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Print the header line, then one pinned-lane record per row as it arrives."""
lanes = listing.lanes()
pinned = script.stream(
*((lane.header, _lane_width(lane)) for _, lane in lanes),
right=[lane.header for _, lane in lanes if lane.align == "right"],
)
self.console.print(pinned.header, highlight=False)
def row(record: Mapping[str, Any]) -> None:
cells = [lane.render(record.get(column.key)) for column, lane in lanes]
self.console.print(pinned.record(*cells), highlight=False)
yield row
class JsonRenderer(Renderer):
"""The face a program reads: one compact document, or one per streamed record.
Attributes:
console: The plain console, used only for its stream — the document is written
through :func:`json.dumps` rather than by Rich, so nothing can pad, wrap,
highlight or otherwise adorn it.
"""
def __init__(self, console: Console) -> None:
"""Bind the renderer to the console its output goes to."""
self.console = console
def render(self, report: Report | None) -> None:
"""Print the report as one document, or nothing when there is no report at all."""
if report is None:
return
blocks = [block for block in report if not block.plain_only]
if not blocks:
return
self._write(self._document(blocks))
def _document(self, blocks: Sequence[Block]) -> Any:
"""Fold the blocks into the one value this command answers with."""
if len(blocks) == 1:
return self._value(blocks[0])
document: dict[str, Any] = {}
for block in blocks:
if isinstance(block, Facts):
document.update(self._facts(block))
else:
document[block.key] = self._value(block)
return document
def _value(self, block: Block) -> Any:
"""One block's document: an array for a listing, an object for a set of facts."""
if isinstance(block, Listing):
return [self._row(block, row) for row in block.rows]
if isinstance(block, Facts):
return self._facts(block)
raise TypeError(f"unrenderable block: {block!r}") # pragma: no cover - closed set
@staticmethod
def _row(block: Listing, row: Mapping[str, Any]) -> dict[str, Any]:
"""One record, with every declared key present (``null`` where it is absent)."""
return {
column.key: column.json(row.get(column.key))
for column in block.columns
if column.json is not None
}
@staticmethod
def _facts(block: Facts) -> dict[str, Any]:
"""One facts block as an object, keeping every declared key.
``omit_absent`` is a *plain* concession — ``info`` leaves out a reading the
firmware does not answer for, so a dash never claims the radio reported nothing.
A consumer cannot act on that distinction and pays a membership test on every
field for it, so the document keeps the key and writes ``null``.
"""
return {
column.key: column.json(block.values.get(column.key))
for column in block.fields
if column.json is not None
}
def _write(self, document: Any) -> None:
"""Write one compact document and terminate it with a newline."""
line = json.dumps(
document,
# A node name in Cyrillic is a node name, not a run of `\uXXXX`. The scripted
# console already reconfigures stdout to UTF-8 for exactly this class of
# character.
ensure_ascii=False,
# Compact and one line, so a stream and a one-shot answer are read the same
# way and `jq .` can re-indent for a person at no cost. Nothing can un-stream
# a document that was pretty-printed across four hundred lines.
separators=(",", ":"),
)
self.console.file.write(line + "\n")
self.console.file.flush()
@contextmanager
def stream(self, listing: Listing) -> Iterator[Any]:
"""Print one document per record, flushed, as each arrives.
Every line is the same shape, which is what makes the stream readable without a
discriminator: a consumer reads line by line and never has to ask which kind of
record it is holding. The closing per-node summary the plain face draws is marked
``plain_only`` for that reason — it is derivable from the records above it, and a
second shape mid-stream would cost every reader a branch it otherwise never needs.
"""
def row(record: Mapping[str, Any]) -> None:
self._write(self._row(listing, record))
yield row
def for_format(output: OutputFormat, console: Console) -> Renderer:
"""The renderer for one output format.
Args:
output: What the caller asked for.
console: The console to print on.
Returns:
The renderer.
"""
return JsonRenderer(console) if output is OutputFormat.JSON else PlainRenderer(console)
@contextmanager
def stream(ctx: AppContext, listing: Listing) -> Iterator[Any]:
"""Open a live listing on whichever renderer this run is using.
Args:
ctx: The application context, for its output format and console.
listing: The stream's shape (see :meth:`Renderer.stream`).
Yields:
A callable taking one typed row mapping.
"""
with for_format(ctx.output, ctx.console).stream(listing) as row:
yield row
def _lane_width(lane: Lane) -> int:
"""How wide a streamed lane is pinned.
A live capture cannot size its columns the way :func:`~meshterm.ui.script.columns`
does, because it has not seen the records yet. So each lane declares the width it is
pinned to, and a value wider than that overruns and pushes the rest of its row right
rather than being cut — which is why the one unbounded field goes last (see
:class:`meshterm.ui.script.Lanes`).
"""
return max(len(lane.header), lane.width)
+243
View File
@@ -0,0 +1,243 @@
"""What a tool answers with, stated as data before anything decides how it looks.
A scripted run used to say its answer by *printing* it: ``Tool.run`` called
``ctx.ui.show(<a table built by ui/script.py>)`` and returned a :class:`ToolResult`
carrying only bookkeeping. By the time the CLI boundary saw that result the contacts had
already gone down the pipe as a space-aligned table, and there was nothing left to hand a
second format. That is exactly why ``--json`` reached two commands out of twenty and
stopped: each one that wanted it grew an ``if ctx.json_output:`` branch *above* the
rendering, restating the whole answer in a second dialect nobody else could reuse.
``exit_code`` already showed the shape that works — the tool states it, the boundary turns
it into a process status, and no tool has ever called :func:`sys.exit`. This module lets
the answer travel the same way:
**A tool states its answer as data. A renderer turns data into bytes. The CLI
boundary picks the renderer** (see :mod:`meshterm.ui.renderers`).
A report is a short sequence of blocks, and there are only two kinds because the plain
face proved only two are needed — a :class:`Listing` (records under a header line) and
:class:`Facts` (one thing described key by key). A :class:`Facts` block covers the
one-scalar answer too, through :data:`BARE`: ``config get`` prints its value alone
*because the caller named the key*, while the machine face still gets the object with the
key, the type and the label in it. Splitting that into a third block type would have
bought a second name for the same data.
**A row holds the typed value.** ``6.0``, ``None``, a :class:`~datetime.datetime` — never
a formatted cell. Each :class:`Column` carries both projections instead: the plain
:class:`Lane` that turns the value into text, and the JSON function that normalises it.
One typed row, two projections, and a third format later is a third function rather than a
third row-builder. :mod:`meshterm.ui.fields` builds the columns for the concepts the whole
app shares — a node, a time, an SNR, a route — so every listing speaks the same shapes by
construction rather than by review.
Nothing here renders. A block does not know what a gutter is, and a column does not know
whether anyone will ever ask it for JSON.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any
#: How a :class:`Facts` block reaches the *plain* face. The machine face is the same
#: object either way — this only chooses what a person at a prompt is shown.
PAIRS = "pairs"
#: One named field's value, alone, with no key in front of it. What ``config get`` prints:
#: the caller named the key, so repeating it back is one more thing to strip off.
BARE = "bare"
#: Nothing at all. An acknowledgement — ``config advert``, ``channels join`` — whose plain
#: report is its exit status. The machine face still gets a document, because "prints
#: nothing" is an answer a person can act on and a program cannot.
SILENT = "silent"
def _text(value: Any) -> str:
"""The last-resort plain projection: the value as text, or the absent token."""
from . import script
return script.NONE if value is None else str(value)
def normalise(value: Any) -> Any:
"""A typed value in its machine form: JSON-ready, and the same on every host.
The one place the contract's cross-cutting value rules are applied, so a command
cannot forget one:
* a **time** is UTC, RFC 3339, ``Z``-suffixed, to the second — always twenty
characters, so string comparison is time comparison. A local offset is a fact about
the machine that ran the command rather than about the event, and two hosts asking
the same radio the same question must produce the same document. A *naive* time is
one whose offset we do not actually know, which is not a fact, so it reads as absent
exactly as it does on the plain face.
* an **enum** is its value, not its Python name.
* a **path** is its text.
* everything else travels as it is, typed: ``9`` and not ``"9"``, ``false`` and not
``"false"``, ``None`` and not the plain face's ``-``.
Args:
value: The typed value from a report row.
Returns:
The value in a form :mod:`json` can write.
"""
if isinstance(value, datetime):
if value.tzinfo is None:
return None
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if isinstance(value, Enum):
return normalise(value.value)
if isinstance(value, Path):
return str(value)
if isinstance(value, (list, tuple)):
return [normalise(item) for item in value]
return value
@dataclass(frozen=True, slots=True, kw_only=True)
class Lane:
"""One plain-text column, drawn from its :class:`Column`'s typed value.
A lane is the *reader's* half of a column. Several may share one column where the
plain face spends more room on a concept than the machine face does — a node is one
JSON object and up to four plain columns (its name, its role, its hash, its key),
which is the whole reason a column owns a list of these rather than one header string.
Attributes:
header: The uppercase heading, and the field a caller greps for. Renaming one
silently breaks a script, so it is as much a contract as the exit status.
render: Turns the column's typed value into this lane's cell.
align: ``"right"`` for a lane of magnitudes — a column of numbers or ages is only
comparable by eye once the digits line up.
width: The cells this lane is pinned to in a *live* listing, where the widths
cannot be measured because the records have not arrived yet. Ignored by an
ordinary listing, which sizes every lane to its widest value.
"""
header: str
render: Callable[[Any], str] = _text
align: str = "left"
width: int = 0
@dataclass(frozen=True, slots=True, kw_only=True)
class Column:
"""One concept in a report, with a projection for each face.
Attributes:
key: The name this concept goes under: the JSON key, and the key a row's mapping
holds its typed value at.
lanes: The plain columns this concept draws — usually one. **Empty means the
concept has no plain face**, which is how ``config show`` carries a setting's
type and enum label into the document while printing the two fields a reader
wants.
json: The typed value's machine projection, or ``None`` where the concept has no
machine face — the mirror of an empty ``lanes``, and what a preference's
DESCRIPTION is: prose for whoever is choosing a value, carrying nothing a
program would branch on. Defaults to :func:`normalise`, which handles the
scalars; a shared shape (a node, a position) passes its own.
"""
key: str
lanes: tuple[Lane, ...] = ()
json: Callable[[Any], Any] | None = normalise
@dataclass(frozen=True, slots=True, kw_only=True)
class Block:
"""One part of an answer.
Attributes:
key: The name this block goes under in a multi-block document.
plain_only: Whether this block is a rendering for a person that the machine face
has no use for. The one case is a stream's closing summary: ``monitor`` prints
a per-node aggregate under its live capture, and a consumer holding every
record of that capture can compute the same thing — where a second *shape* of
line in the middle of an otherwise homogeneous NDJSON stream costs every
reader a discriminator it would otherwise never need.
"""
key: str
plain_only: bool = False
@dataclass(frozen=True, slots=True, kw_only=True)
class Listing(Block):
"""Records under a header line: a listing plain, an array of objects in JSON.
Attributes:
columns: The concepts each record carries, in machine-face order.
rows: One mapping per record, keyed by ``Column.key`` and holding **typed**
values.
order: The lane headers in plain draw order. Empty means "as declared", which is
what most listings want; a listing states it where the two orders genuinely
differ — ``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH,
and ``monitor`` puts the one unbounded field (a node's name) last so it can
never push a column.
headed: Whether the plain face draws the header line. ``config show`` is the one
listing that does not: it is an array of settings to a parser and a
``sysctl -a`` block to a reader, and a header line over two columns whose keys
are the answer would be furniture.
"""
columns: tuple[Column, ...]
rows: Sequence[Mapping[str, Any]] = ()
order: tuple[str, ...] = ()
headed: bool = True
def __post_init__(self) -> None:
"""Reject an ``order`` that does not name this listing's own lanes.
A typo here draws a listing with a column missing and no complaint, which is the
one failure a header line cannot show. Cheaper to raise at construction.
"""
if not self.order:
return
known = {lane.header for column in self.columns for lane in column.lanes}
if set(self.order) != known or len(self.order) != len(known):
raise ValueError(f"{self.key}: order must name every lane exactly once")
def lanes(self) -> list[tuple[Column, Lane]]:
"""The plain columns in draw order, each with the column it reads from."""
pairs = [(column, lane) for column in self.columns for lane in column.lanes]
if not self.order:
return pairs
by_header = {lane.header: (column, lane) for column, lane in pairs}
return [by_header[header] for header in self.order]
@dataclass(frozen=True, slots=True, kw_only=True)
class Facts(Block):
"""One thing, described key by key: a ``sysctl -a`` block plain, an object in JSON.
Attributes:
fields: The concepts, in the order both faces present them.
values: The typed value per ``Column.key``.
shape: :data:`PAIRS`, :data:`BARE` or :data:`SILENT` — what the *plain* face
prints. The machine face is the object either way.
bare: With :data:`BARE`, the one field whose value is printed alone.
omit_absent: Whether a field with no value is left out of the plain block
entirely. Only ``info`` wants this, and deliberately: its readings are
optional device queries, so a missing key says "this radio does not report
it" where a dash would claim it reported nothing. The machine face keeps every
declared key and writes ``null``, because "does not report" and "reported
nothing" are the same fact to a caller and a variable key set costs every
consumer a membership test on every field.
"""
fields: tuple[Column, ...]
values: Mapping[str, Any] = field(default_factory=dict)
shape: str = PAIRS
bare: str = ""
omit_absent: bool = False
#: A tool's whole answer. Deliberately a bare sequence rather than a class of its own:
#: there is nothing a report *does*, and the two rules about how several blocks combine
#: belong to the renderer that combines them (see :mod:`meshterm.ui.renderers`).
Report = tuple[Block, ...]
+640
View File
@@ -0,0 +1,640 @@
"""The plain CLI's output language — text for a person at a prompt, aligned and unadorned.
MeshTerm has two front ends and a third face. The menu is read by a person sitting in
front of it, and everything else in ``meshterm/ui/`` is built for that reader: colour that
carries meaning, glyphs that stand for concepts, frames that group. ``--json`` is read by
a program, often somewhere else and often later. This module is the third: **someone at a
prompt who typed a command to find something out, and wants the answer legible in one
glance.**
That is a change of premise. The plain face used to be a serialisation format a person
could squint at, and several of its rules existed only to make *splitting* safe — quoted
names, absolute timestamps, a comma between path hops. Splitting is the machine face's job
now (see :mod:`meshterm.ui.report` and :mod:`meshterm.ui.renderers`), so those rules were
paying rent they could not afford. What survives is everything that makes the output a
Unix utility's output, and one new rule in place of the quoting.
* **Alignment is the delimiter.** A column that lines up is already one field to the eye,
so a name is bare (:func:`name`). The *escaping* under the quoting is not gone and never
can be (:func:`_escaped`): a name is broadcast by its own node and a message body is
filled in by a stranger, and neither may end the record it sits in.
* **No colour.** ``stdout`` is a pipe more often than a terminal, and a colour that
survives into a file is noise in it. The console this module builds has
``color_system=None``, so Rich emits no escape sequence at all — not a dim, not a bold.
That is stricter than ``no_color``, which keeps the attributes and drops only the hues.
* **No wrapping.** A record is a line. Wrapping turns one record into two and puts the
second one's fields under the wrong headings, so the console is made :data:`WIDTH` cells
wide — far past any real line — and every column is ``no_wrap``. The one deliberate
exception is a *written page* (``about``, ``support``), which is drawn on a console
:data:`PAGE_WIDTH` cells wide instead: a paragraph is not a record, it has no headings
to land under, and unwrapped it is a single 600-cell line no terminal can read.
* **No frames.** No panel borders, no table boxes, no rules, no titles. The command the
reader typed is the title; a box around the answer is a second one.
* **Aligned columns, one header line.** ``ps`` and ``df``'s shape: an uppercase header
row, then records, fields padded apart by :data:`GUTTER` spaces. See :func:`columns`.
* **A time is an age.** ``5m``, ``3h``, ``never`` — the same ladder the menu's columns use
(:func:`age`), because "how long ago" is the question a person is actually asking. An
absolute instant survives where the instant *is* the fact: the device clock, a scheduled
appointment, a live capture's own clock (:func:`stamp`). ``--absolute`` swaps the whole
language back for anyone who wants it, and the machine face is absolute UTC regardless.
* **A route is drawn, a path is typed.** The lexicon already splits these: a *path* is an
ordered hop spec you compose or force, a *route* is the concrete node sequence a walk
took. So a path keeps its commas (:func:`spec`) because it round-trips into ``--path``,
and a route takes arrows (:func:`route`) because it is a picture and visibly is not a
spec. Rendering both with commas promised a round trip only one of them has.
* **One token for "nothing".** :data:`NONE` — a lone ``-`` — in every column, so an absent
value never has to be told apart from an empty one, an ``—``, an ``n/a`` or a blank.
``never`` is a *value*, not an absence: a node that has never been heard is a fact.
* **No trailing whitespace.** Padding the last column to its width would put invisible
spaces at the end of every line; the console trims them on the way out
(:class:`_Trimmed`).
Nothing here is reachable from the menu, and nothing in the menu is reachable from here:
:class:`~meshterm.ui.surface.PlainUi` is the seam, chosen in
:func:`~meshterm.cli.main_callback` by whether a subcommand was named.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from rich.console import Console, Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
#: The scripted console's width. Wide enough that Rich never wraps or truncates anything
#: MeshTerm can produce (a 64-hex key, a full route, a preference description), leaving
#: the reader's own terminal to deal with a line longer than itself. Not ``sys.maxsize``:
#: Rich sizes a few structures against the width, and a merely enormous number costs
#: nothing where an astronomical one does.
WIDTH = 1 << 14
#: Spaces between columns. Two, so a padded column and its neighbour never touch and a
#: lone space is never mistaken for a separator inside an unquoted field.
GUTTER = 2
#: The one token for a value that is absent, unknown, or does not apply. A reader (and a
#: parser) learns it once. Never ``—``, ``n/a``, ``never``, or an empty cell.
NONE = "-"
#: The separator between a route's hops, spaced — the same ``→`` the menu's path lines
#: draw with (:data:`meshterm.ui.pathline._ARROW`), and device-verified present in the
#: PicoCalc console font. It carries the direction the packet travelled, and it is what
#: makes the quoting unnecessary: a comma inside a node's name can no longer be read as a
#: hop boundary.
ARROW = " → "
#: How wide a *written page* wraps. Not the terminal's width: the same paragraph must read
#: the same when it is piped, redirected, or shipped as the machine face's ``text`` field,
#: and 72 is the width every screen in this app is already held to.
PAGE_WIDTH = 72
#: The characters with a readable escape of their own. Everything else that cannot travel
#: in a record falls through to ``\xNN``/``\uNNNN`` below.
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
#: Codepoints that must never reach stdout unescaped, as a *category* rather than a list:
#: every C0 and C1 control, plus the two Unicode separators a terminal also breaks a line
#: on. A four-entry table caught the newline and the tab and let ESC through — and an ESC
#: inside a node name is a live colour run written into the caller's file, which is the
#: one thing "no colour, not a single escape sequence" exists to prevent. A BEL was worse:
#: silently dropped, so the printed name was not the advertised one.
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
def _escaped(text: str) -> str:
r"""``text`` with everything that cannot travel inside one record folded into an escape.
A record is a line and a field is a run of printable cells; this is what makes both
true of a value that arrived over the air. Named escapes where one reads (``\n``),
numeric escapes everywhere else, and the backslash doubled so the whole thing reads
back unambiguously.
This is what survived the retirement of the quoting. The quotes made a name *one
field* for a splitter, and alignment does that now; nothing else can stop a name
holding a newline from ending its own record early.
"""
out: list[str] = []
for ch in text:
if ch in _BREAKS:
out.append(_BREAKS[ch])
elif ord(ch) in _UNPRINTABLE:
out.append(f"\\x{ord(ch):02x}" if ord(ch) < 0x100 else f"\\u{ord(ch):04x}")
else:
out.append(ch)
return "".join(out)
def name(value: str | None) -> str:
r"""A node's name as a field: bare and escaped, or :data:`NONE` when it has none.
The quotes are gone. They existed so a name holding a space or a comma stayed one
field for whatever was splitting the line; the splitter reads JSON now, and in plain
text the column alignment already says where a field ends. What survives is the
escaping — a node broadcasts its own name, so every name here is remote data, and a
newline in one must still not end the record it sits in.
An empty name is still :data:`NONE` rather than an empty cell: ``""`` says *called
nothing* where the token says *never said*, and those are different facts. It is also
the distinction the machine face needs — an absent name is ``null``, an empty one is
``""`` — and it cannot be recovered from a field that flattened both.
Args:
value: The name, or ``None``/empty where the node never supplied one.
Returns:
The escaped name, or :data:`NONE`.
"""
return _escaped(value) if value else NONE
def text(value: str | None) -> str:
r"""Free text as a field that can end a record without breaking it.
For the field a listing puts last because it *is* the rest of the line — a message
body, a preference's description. "The rest of the line" and "anything at all" are not
compatible: a body holding a newline ended its record early and left the remainder
indented under the other columns, reading as a second record with an empty ``TIME``.
A body is also the field a stranger fills in, so that is not a hypothetical shape of
message.
Args:
value: The free text. ``None`` gives :data:`NONE`.
Returns:
The text with its line breaks and tabs escaped.
"""
return NONE if value is None else _escaped(value)
def stamp(when: datetime | None) -> str:
"""Format a timestamp as local ISO-8601 to the second, or :data:`NONE`.
Kept for the times where the instant *is* the fact rather than a way of saying how
long ago something was: the device's own clock, an appointment set with ``--at``, a
live capture's per-row time (every row of which would otherwise read ``now``), and
every column under ``--absolute``.
Stored times are timezone-aware UTC; a naive one is a time whose offset we do not
actually know, which is not a fact and so reads as absent — the same rule
:func:`~meshterm.ui.widgets._age_seconds` applies to ages.
Args:
when: The instant to format.
Returns:
e.g. ``2026-09-07T18:22:41-04:00``, or :data:`NONE`.
"""
if when is None or when.tzinfo is None:
return NONE
return when.astimezone().isoformat(timespec="seconds")
def age(when: datetime | None, *, absent: str = NONE) -> str:
"""How long ago ``when`` was: ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
The default time form, and the reason is the premise change: a person at a prompt
reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to answer "recently?", which
is the question they typed the command to ask. Delegates to
:func:`~meshterm.ui.widgets._format_age` so the two faces cannot drift apart.
Args:
when: The instant to age. A naive datetime has no offset we know, so it reads as
absent, exactly as :func:`stamp` treats it.
absent: What an unknown time reads as. A heard-age passes ``"never"`` — a node
that has never been heard is a fact, not a missing field — and everything else
takes the default token, which says this row has no such time at all. The same
distinction :func:`name` draws between ``-`` and an empty name, one axis over.
Returns:
The age, or ``absent``.
"""
from .widgets import _age_seconds, _format_age
seconds = _age_seconds(when)
return absent if seconds is None else _format_age(seconds)
def number(value: Any, spec: str = "") -> str:
"""Format a number, or :data:`NONE` when it is absent.
Args:
value: The number to format; ``None`` reads as absent.
spec: An optional :func:`format` spec, e.g. ``"+.1f"``.
Returns:
The formatted number, or :data:`NONE`.
"""
if value is None:
return NONE
return format(value, spec) if spec else str(value)
#: The units :func:`duration` counts in, largest first.
_UNITS: tuple[tuple[int, str], ...] = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s"))
def duration(seconds: float | None) -> str:
"""A span of seconds as something readable: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
Two units at most and always adjacent, so the magnitude arrives at a glance and the
precision never outruns what anyone would act on. Used only as a *gloss* beside the
raw figure (``uptime_s 93784 (1d 2h)``), never in place of it — the key says what
the number counts, and a caller reading the key must still find a number under it.
Args:
seconds: The span. ``None`` reads as absent; the sign is dropped, since a caller
glossing a signed reading says which way in its own words.
Returns:
The span in one or two units, or :data:`NONE`.
"""
if seconds is None:
return NONE
whole = int(abs(seconds))
amounts: list[tuple[int, str]] = []
for size, unit in _UNITS:
amounts.append((whole // size, unit))
whole %= size
lead = next((i for i, (amount, _) in enumerate(amounts) if amount), len(amounts) - 1)
parts = [f"{amounts[lead][0]}{amounts[lead][1]}"]
if lead + 1 < len(amounts) and amounts[lead + 1][0]:
parts.append(f"{amounts[lead + 1][0]}{amounts[lead + 1][1]}")
return " ".join(parts)
def location(lat: float | None, lon: float | None) -> str:
"""A shared position as one field: ``45.50190,-73.56740``, or :data:`NONE`.
One column rather than two. A coordinate pair is one fact to a reader, it is what goes
into a map's search box verbatim, and two columns of ``-`` for every contact that has
never shared a position is worse than one.
Args:
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
Returns:
The pair to five decimals, or :data:`NONE` when either half is missing.
"""
if lat is None or lon is None:
return NONE
return f"{lat:.5f},{lon:.5f}"
def route(hops: Iterable[tuple[str | None, str | None]]) -> str:
"""A walked hop sequence, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
A *route* is what a walk actually did, so it is a picture and not a spec — which is
why it takes :data:`ARROW` and not the comma ``--path`` accepts. Rendering it with
commas made it look pasteable, which it is not: the names are in it, and it is a
round trip nothing has.
The hash rides on every hop and always. It is the join key to the per-hop table below
a trace, the token ``--path`` takes, and the disambiguator for two contacts sharing a
name. Dropping it "where the name is unique" would make one line's grammar depend on
another line's content.
Our own node is a hop like any other — named and hashed. The menu draws it as ``★``
because a reader never has to be told which node is theirs; here the line is as often
read out of a file by someone who was not at the prompt when it ran.
Args:
hops: ``(label, hash)`` pairs in propagation order. A hop with both halves reads
``Name (hash)``; a hop with one half is that half alone, never an empty
``()`` — which is how the menu's :class:`~meshterm.ui.pathline.PathLine`
labels an unresolved hop too.
Returns:
The joined route, or :data:`NONE` when there are no hops.
"""
parts: list[str] = []
for label, value in hops:
if label and value:
parts.append(f"{_escaped(label)} ({value})")
else:
parts.append(_escaped(label) if label else (value or NONE))
return ARROW.join(parts) if parts else NONE
def spec(hops: Iterable[str]) -> str:
"""A forced path as the radio was given it: ``a1,d4,a1``.
The other half of the split :func:`route` names. This is the *spec* — comma-separated
hashes, exactly what ``--path`` takes back — so it is the one line on either face that
round-trips, and it must never grow a name, an arrow or a space.
Args:
hops: The hop hashes in propagation order.
Returns:
The comma-joined spec, or :data:`NONE` when there are no hops.
"""
parts = [hop for hop in hops if hop]
return ",".join(parts) if parts else NONE
def columns(*headers: str, right: Sequence[str] = ()) -> Table:
"""Build the scripted table: an uppercase header line, then padded records.
The shape ``ps`` and ``df`` print — no box, no title, no edge padding, every column
sized to its widest value and separated by :data:`GUTTER` spaces, nothing wrapped and
nothing elided. Numeric lanes right-align, which is what makes a column of magnitudes
comparable by eye without changing what splitting it yields; an *age* lane aligns the
same way, so the ladder from ``now`` to ``4w`` reads down the column.
Args:
*headers: The column headings, already uppercase.
right: The headings (drawn from ``headers``) whose values right-align.
Returns:
A Rich :class:`Table` ready for ``ctx.ui.show``.
"""
table = Table(
box=None,
show_edge=False,
pad_edge=False,
expand=False,
# (top, right, bottom, left): the whole gutter hangs to the right of each cell, so
# no line opens with an indent and the last column ends where its value does.
padding=(0, GUTTER, 0, 0),
)
wanted = set(right)
for header in headers:
table.add_column(
header,
justify="right" if header in wanted else "left",
no_wrap=True,
# "crop", not "ignore". Both refuse to elide — neither ever writes the "…"
# that would make a key or a route unusable to the caller — but Rich's
# ``Text.wrap`` returns early on "ignore" (text.py: ``if overflow ==
# "ignore": lines.append(line); continue``) and that early return is *above*
# the justify step, so "ignore" silently discards ``justify="right"``. The
# two settings differ only past the console's 16384 cells, where "crop" cuts
# and "ignore" also cuts; they differ on every line before that, where only
# "crop" aligns.
overflow="crop",
)
return table
def pairs(rows: Iterable[tuple[str, str]]) -> Table:
"""Build the scripted key/value listing: key then value, aligned, no header line.
What a set of facts about one thing prints as (``meshterm info``, ``config show``,
``trace``) — the ``sysctl -a`` shape. Keys are the ones the matching ``get``/``set``
subcommand takes, so a line read out of ``show`` can be typed back in.
Args:
rows: ``(key, value)`` pairs in display order.
Returns:
A Rich :class:`Table` with no header line.
"""
table = Table(
box=None,
show_edge=False,
show_header=False,
pad_edge=False,
expand=False,
padding=(0, GUTTER, 0, 0),
)
table.add_column(no_wrap=True, overflow="crop")
table.add_column(no_wrap=True, overflow="crop")
for key, value in rows:
table.add_row(key, value)
return table
@dataclass(frozen=True, slots=True)
class Lanes:
"""A fixed-width column layout for output whose widths arrive with the data.
:func:`columns` sizes each lane to its widest value, which it can only do once it has
seen every record. A live capture has no "once": ``monitor`` prints a row the instant
a packet lands, and the row after it may be twice as wide. Those two streams used to
be gutter-joined with no alignment at all, which is why a name in one still carried
its quotes — nothing else said where the field ended.
Pinning the lanes up front fixes it properly. A value wider than its lane **overruns
and pushes the rest of that row right** rather than being elided: one row out of many
is simply wider, and nothing lies about what it holds. Put the one unbounded field
(a node's name, a message body) last and it cannot push anything at all.
Attributes:
widths: ``(header, width)`` per lane, in order.
right: The headers whose cells right-align.
"""
widths: tuple[tuple[str, int], ...]
right: frozenset[str] = frozenset()
@property
def header(self) -> str:
"""The one header line, printed before the first record."""
return self.record(*(header for header, _ in self.widths))
def record(self, *cells: str) -> str:
"""One record, padded into the lanes and joined by the gutter."""
out: list[str] = []
for (header, width), cell in zip(self.widths, cells, strict=True):
out.append(cell.rjust(width) if header in self.right else cell.ljust(width))
return (" " * GUTTER).join(out).rstrip()
def stream(*widths: tuple[str, int], right: Sequence[str] = ()) -> Lanes:
"""Build a fixed-lane layout for a live stream (see :class:`Lanes`).
Args:
*widths: ``(header, width)`` per lane, in order.
right: The headings whose values right-align.
Returns:
The layout, which prints its own header and formats each record.
"""
return Lanes(widths=widths, right=frozenset(right))
# -- the console ---------------------------------------------------------------------
class _Trimmed:
"""A text stream that drops trailing whitespace from every line passing through it.
Rich pads a table's cells to their column width, the last column's included, which
would leave invisible spaces at the end of every record. Trimming at the stream is the
one place that catches all of it — table padding, a justified :class:`Text`, a blank
line Rich decided to pad — without any renderable having to know.
What it holds back is only the *trailing whitespace run*, never a whole line. Rich
writes a rendered row segment by segment and, on Windows, flushes after each one, so a
wrapper that waited for the newline before deciding would be asked to decide dozens of
times a row — and one that trimmed at flush would throw away the very padding that
separates two columns. Everything up to the last non-space character goes straight
through; the spaces after it wait to find out whether a newline or another column
follows. At end of stream nothing follows, and trailing whitespace is exactly what it
is then, so the held run is simply dropped.
"""
def __init__(self, stream: Any) -> None:
"""Wrap ``stream``, holding back whatever trailing whitespace has arrived."""
self._stream = stream
self._held = ""
def write(self, text: str) -> int:
"""Write ``text``, emitting whitespace only once something follows it on the line."""
pending = self._held + text
if "\n" in pending:
*lines, pending = pending.split("\n")
self._stream.write("".join(line.rstrip() + "\n" for line in lines))
body = pending.rstrip()
if body:
self._stream.write(body)
self._held = pending[len(body) :]
return len(text)
def flush(self) -> None:
"""Flush the wrapped stream, keeping the held whitespace held.
Deliberately *not* a place that emits: a flush lands between two columns as often
as at the end of a line, and it carries no information about which.
"""
self._stream.flush()
def __getattr__(self, name: str) -> Any:
"""Delegate everything else (``isatty``, ``encoding``, ...) to the real stream."""
return getattr(self._stream, name)
def console(stream: Any = None) -> Console:
"""Build the scripted console: no colour, no wrapping, no trailing whitespace.
Args:
stream: The file to write to; defaults to ``sys.stdout``.
Returns:
A :class:`~rich.console.Console` that emits plain text and nothing else.
"""
import sys
target = sys.stdout if stream is None else stream
reconfigure = getattr(target, "reconfigure", None)
if reconfigure is not None:
try: # a name or mark outside cp1252 must not raise on a legacy Windows console
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
pass
return Console(
file=_Trimmed(target),
# Not `no_color`, which keeps bold, dim, reverse and the rest and drops only the
# hues. `color_system=None` is the one setting under which Rich emits no escape
# sequence at all.
color_system=None,
width=WIDTH,
# Rich would otherwise notice a real terminal and soft-wrap to its width.
soft_wrap=True,
highlight=False,
emoji=False,
# A node broadcasts its own name, so every name on this console is remote data.
# Rich reads `[...]` as a style tag: `[bold]Loud` printed as `Loud` — silently
# corrupted, and no longer the string that identifies the node — while `[/]Bob`
# raised MarkupError and took the whole command down with it. Nothing the scripted
# CLI prints is ever marked up, so the parser has nothing to do here but misfire.
markup=False,
)
def stderr_console() -> Console:
"""The console for everything that is *about* the run rather than part of its answer.
stdout carries what the command was asked for and nothing else, so a progress bar, a
log line, an acknowledgement and an error message all go here instead — visible in a
terminal, absent from ``meshterm contacts > contacts.txt``. This one keeps its colour,
because a terminal is usually the only thing that reads it.
**Off a terminal it stops wrapping**, and that is the point of the width below. Rich
falls back to 80 cells when it cannot measure the destination, so ``2> errors.log``
used to hard-wrap every message at 80 — and ``grep 'not already connected'`` then
found nothing, because the sentence it was looking for had been broken across three
lines by the logger. In a real terminal the width is left alone: a progress bar sized
to 16384 cells is not a progress bar.
Returns:
A themed :class:`~rich.console.Console` writing to ``sys.stderr``.
"""
import sys
from .theme import active_theme
reconfigure = getattr(sys.stderr, "reconfigure", None)
if reconfigure is not None:
try: # the ✓ on an acknowledgement must not depend on the machine's code page
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
pass
try:
wraps = bool(sys.stderr.isatty())
except (AttributeError, ValueError): # pragma: no cover - a closed or exotic stream
wraps = False
return Console(
file=sys.stderr,
theme=active_theme(),
markup=False,
emoji=False,
width=None if wraps else WIDTH,
)
# -- the safety net ------------------------------------------------------------------
def flatten(renderable: RenderableType) -> list[RenderableType]:
"""Strip framing from anything reaching the scripted console still wearing it.
Every CLI surface is *written* plain — a tool states its answer as a
:mod:`~meshterm.ui.report` and the plain renderer builds it through :func:`columns`
and :func:`pairs`. This is the net under that, for a renderable shared with the menu
that still arrives boxed: a :class:`Panel` gives up its border and title and yields
its body, a :class:`Table` gives up its box, title and expansion, and a :class:`Group`
is flattened member by member.
It is deliberately not a *design*: a screen's table has the menu's columns, not the
CLI's, so passing it through here makes it printable, not right.
Args:
renderable: What a tool handed to :meth:`~meshterm.ui.surface.PlainUi.show`.
Returns:
The unframed renderables to print, in order.
"""
if isinstance(renderable, Panel):
return flatten(renderable.renderable)
if isinstance(renderable, Table):
renderable.title = None
renderable.caption = None
renderable.box = None
renderable.show_edge = False
renderable.pad_edge = False
renderable.expand = False
renderable.padding = (0, GUTTER, 0, 0)
for column in renderable.columns:
column.no_wrap = True
column.overflow = "crop"
column.ratio = None
return [renderable]
if isinstance(renderable, Group):
out: list[RenderableType] = []
for child in renderable.renderables:
out.extend(flatten(child))
return out
return [renderable]
def blank() -> Text:
"""One empty line, for separating a listing from the block after it."""
return Text("")
+149 -75
View File
@@ -17,14 +17,11 @@ only), so :class:`PlainUi` leaves them unsupported.
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Mapping
from contextlib import asynccontextmanager
from typing import Any
from rich import box
from rich.console import Console, Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from .tui.session import TuiSession
@@ -55,6 +52,14 @@ def _dialog_max_cells() -> int:
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
class _NullBusy:
"""The scripted surface's stand-in for a busy card: a caption that goes nowhere."""
def __init__(self) -> None:
"""Start with an empty caption; assigning to it is the whole of the contract."""
self.message = ""
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse small, text-only buffered output into one dialog message.
@@ -94,6 +99,23 @@ class Ui:
"""Display a short line of Rich-markup text."""
raise NotImplementedError
def ack(self, markup: str) -> None:
"""Acknowledge that an action was carried out — in the menu only.
The distinction from :meth:`note` is who the line is for. A note is *output*: the
answer the caller asked for. An acknowledgement is reassurance that a thing
happened — "✓ device clock set", "✓ channel 2 = #general" — which a person watching
a screen needs and a script does not, because the exit status already said it and
the line would only be something to filter out of the real output.
Both surfaces show them; what differs is *where*. The menu prints them in its
result window. The CLI prints them on **stderr** (see :meth:`PlainUi.ack`), which
keeps the promise the dropping was made to keep — ``meshterm contacts > f`` still
catches only the answer — while giving the person at the prompt back the ✓ and the
count they were losing to a rule written for a redirect they were not doing.
"""
raise NotImplementedError
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
@@ -120,6 +142,18 @@ class Ui:
"""
raise NotImplementedError
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a **modal** busy card over a screen.
The counterpart to :meth:`busy_overlay`, for slow work a *screen* starts rather than
work that happens in the gap between screens. The overlay is not a screen at all: it
draws only on an empty stack and it takes no keys, so a hub that stays pushed while
it works gets neither the card nor the protection. This one is pushed and modal — see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_dialog` for what that buys. In
scripted CLI mode it does nothing, as the overlay does.
"""
raise NotImplementedError
async def select(
self,
title: str,
@@ -147,8 +181,17 @@ class Ui:
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped."""
"""Choose one item on a chromeless startup splash; ``None`` if skipped.
``keys`` declares bare-key shortcuts the splash answers, resolving with a
:class:`~meshterm.ui.tui.select.KeyRequest` (the picker's hide/show-all pair), and
``key_hint`` names them per highlighted row so the footer only advertises a key
where it would act. ``footer_hint`` replaces the base sentence.
"""
raise NotImplementedError
async def confirm_startup(
@@ -291,69 +334,14 @@ class Ui:
raise NotImplementedError
def _heading(title: str | Text) -> Text:
"""Render a Panel's or Table's hoisted title as an accent heading line.
The ``accent`` theme style (bold indigo) is the one bit of emphasis that earns its
keep here: it marks where each block starts so a scripted run stays scannable, without
reaching for the interactive menu's framing. It must stay a bare theme name — Rich
can't combine the ``bold`` keyword with a named style in one string — but ``accent``
already carries bold, so nothing is lost.
"""
text = title if isinstance(title, Text) else Text.from_markup(str(title))
text.style = "accent"
return text
def _deframe(renderable: RenderableType) -> list[RenderableType]:
"""Strip a renderable's chrome for direct terminal output.
The scripted CLI prints straight to the terminal, with no result window to hold the
output, so a tool's heavy Rich framing — a :class:`Panel`'s border, a :class:`Table`'s
full box and centered title — is chrome between the reader and the data. This lightens
it to the middle ground: enough styling to stay legible, none of the interactive menu's
weight. A Panel becomes its title as a plain heading above its (also de-framed) body; a
Table keeps just a single rule under its header (:data:`~rich.box.SIMPLE_HEAD`) so the
columns still read, and hoists its title to the same plain heading; a :class:`Group` is
de-framed member by member. Everything else passes through untouched.
The tables and panels are built fresh for each scripted run and discarded after
printing, so mutating them in place (clearing ``box``/``title``) is safe here.
Args:
renderable: A renderable a tool handed to :meth:`PlainUi.show` or :meth:`view`.
Returns:
The frame-light renderables to print, in order.
"""
if isinstance(renderable, Panel):
out: list[RenderableType] = []
if renderable.title:
out.append(_heading(renderable.title))
out.extend(_deframe(renderable.renderable))
return out
if isinstance(renderable, Table):
out = []
if renderable.title:
out.append(_heading(renderable.title))
renderable.title = None
renderable.box = box.SIMPLE_HEAD
out.append(renderable)
return out
if isinstance(renderable, Group):
out = []
for child in renderable.renderables:
out.extend(_deframe(child))
return out
return [renderable]
class PlainUi(Ui):
"""CLI surface: print directly to the console; interactive prompts are unsupported.
"""CLI surface: print straight to the scripted console; interactive prompts are unsupported.
Output is de-framed on the way out (see :func:`_deframe`): a scripted run prints
to the bare terminal, so a tool's Panels and boxed Tables are lightened to plain
headings and header-ruled columns — legible and easy to scan, without the framing.
The console it prints on emits no colour, wraps nothing, and trims trailing whitespace
(:func:`meshterm.ui.script.console`), and everything a tool hands over passes through
:func:`~meshterm.ui.script.flatten` on the way out, so no border, box or hoisted title
reaches stdout. That fold is the net, not the design: a surface a script is meant to
read is *written* in the CLI's own vocabulary — see :mod:`meshterm.ui.script`.
"""
def __init__(self, console: Console) -> None:
@@ -365,33 +353,98 @@ class PlainUi(Ui):
self.console = console
def show(self, *renderables: RenderableType) -> None:
"""Print each renderable to the console immediately, de-framed (see :func:`_deframe`)."""
"""Print each renderable immediately, unframed (see :func:`~meshterm.ui.script.flatten`)."""
from . import script
for renderable in renderables:
for item in _deframe(renderable):
for item in script.flatten(renderable):
self.console.print(item)
def note(self, markup: str) -> None:
"""Print a markup line to the console immediately."""
self.console.print(markup)
"""Print a markup line to the console immediately, as plain text.
The parameter is markup — that is the shared surface's contract, and forty-odd
callers write it — but the scripted console does not interpret markup: a node
broadcasts its own name, and Rich would read ``[...]`` in one as a style tag. So
the tags are resolved *here* rather than by the console, and what reaches stdout
is the text they were wrapping. Printing them raw put ``[muted]`` and ``[/muted]``
around the private key that ``config export-key > key.hex`` is supposed to be the
whole content of.
Malformed markup — which is what a *name* holding a bracket looks like — is not an
error to report but a string to print, so it falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
try:
self.console.print(render(markup).plain)
except MarkupError:
self.console.print(markup)
def ack(self, markup: str) -> None:
"""Print it on stderr, where everything *about* the run goes.
These used to be dropped, and the reason was sound as far as it went: an
acknowledgement is not the answer, and a caller redirecting stdout must catch only
the answer. But stdout is not the only stream — putting them on stderr honours that
rule exactly, and a person watching a five-minute ``config restore`` gets the
setting-by-setting ✓ they had no way to see.
The markup is resolved here rather than by the console, as :meth:`note` explains,
and a *name* holding a square bracket is not an error to report but a string to
print — so malformed markup falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
from . import script
try:
line: Text | str = render(markup)
except MarkupError:
line = markup
script.stderr_console().print(line, highlight=False)
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Print the renderable immediately, de-framed (there is no windowing in CLI mode)."""
for item in _deframe(renderable):
"""Print the renderable immediately, unframed (there is no windowing in CLI mode)."""
from . import script
for item in script.flatten(renderable):
self.console.print(item)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return the Rich progress bar used for scripted runs."""
"""Return a progress bar drawn on stderr, so it never lands in piped output.
A progress bar is a courtesy to someone watching a long command run, and noise in
anything reading the command's output. stderr is where it belongs: visible in a
terminal, absent from ``meshterm contacts > contacts.txt``. Rich also draws
nothing at all when stderr is not a terminal, so a fully redirected run is silent.
"""
from . import script
from .widgets import make_progress
return make_progress(self.console)
console = script.stderr_console()
progress = make_progress(console)
progress.disable = not console.is_terminal
return progress
@asynccontextmanager
async def busy_overlay(self, message: str = "", *, title: str = "") -> AsyncIterator[None]:
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
yield
@asynccontextmanager
async def busy_dialog(self, message: str = "", *, title: str = "") -> AsyncIterator[_NullBusy]:
"""Do nothing: there is no screen to interrupt and no keyboard to take.
Still yields something with a settable caption, because a caller that retitles the
card mid-batch must not have to ask which surface it is running on.
"""
yield _NullBusy()
def _no_prompt(self) -> RuntimeError:
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
@@ -417,6 +470,9 @@ class PlainUi(Ui):
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -594,6 +650,10 @@ class TuiUi(Ui):
"""Collect a markup line for the next result window."""
self._buffer.append(Text.from_markup(markup))
def ack(self, markup: str) -> None:
"""Collect an acknowledgement — on this surface, exactly a note."""
self.note(markup)
async def present(self, *, title: str = "") -> None:
"""Show everything buffered since the last present; window or popup to fit.
@@ -637,6 +697,10 @@ class TuiUi(Ui):
"""Float the session's top-most skeleton card while the wrapped block runs."""
return self.session.busy_overlay(message, title=title)
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Push the session's modal busy card over the current screen while the block runs."""
return self.session.busy_dialog(message, title=title)
# --- input ---------------------------------------------------------------
async def select(
@@ -667,10 +731,20 @@ class TuiUi(Ui):
default: Any = None,
banner: Any = None,
footnote: str | None = None,
footer_hint: str | None = None,
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
title, items, default=default, banner=banner, footnote=footnote
title,
items,
default=default,
banner=banner,
footnote=footnote,
keys=keys,
key_hint=key_hint,
**({} if footer_hint is None else {"footer_hint": footer_hint}),
)
async def confirm_startup(
+4 -2
View File
@@ -32,8 +32,8 @@ from .prompt import (
TypedConfirmDialog,
byte_counter,
)
from .screen import CANCEL, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, DeleteRequest, SelectScreen, Separator
from .screen import CANCEL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, DeleteRequest, KeyRequest, SelectScreen, Separator
from .session import TuiSession, Visit
from .spinner import Spinner
@@ -41,10 +41,12 @@ __all__ = [
"TuiSession",
"Screen",
"ScrollScreen",
"BusyDialog",
"BusyScreen",
"SelectScreen",
"Choice",
"DeleteRequest",
"KeyRequest",
"Separator",
"TextScreen",
"ConfirmScreen",
+48 -2
View File
@@ -376,6 +376,48 @@ def _banner_lines(banner: Sequence[str], cols: int) -> list[str]:
return _center(padded, cols)
def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str:
"""Drop ``·`` atoms from a footer hint, right to left, until it fits ``width``.
The chromeless splash has one row of border for its hint and a box no wider than the
terminal less its gutter — 47 cells on the PicoCalc — while the hint itself grows as the
highlight moves onto a row with more keys to offer. Cutting the line at the edge takes
the *end* of it, and the end is ``Esc``: the one atom that must survive, being the only
way out. So atoms are dropped instead, from the right and never the last one, which
spends the cells on the keys nearest the front of the sentence — the ones that move and
commit — and gives up the optional ones a screen advertises elsewhere.
``shed_first`` overrides that order for atoms whose key the reader would find anyway. A
scroll atom is the example: ←→ are the keys already named by the leading move atom, so
trying them costs nothing and teaches the rest, while a bare-letter shortcut is
unguessable and has nowhere else to be advertised on a chromeless splash. Given the
choice between those two, the sentence keeps the letter.
Args:
hint: The composed hint sentence.
width: Cells available.
shed_first: Atoms to drop before any others, in the order given.
Returns:
The hint, or as much of its head plus its final atom as fits (ellipsized only if
even those cannot).
"""
if cell_len(hint) <= width:
return hint
atoms = hint.split(" · ")
for spare in shed_first:
if cell_len(" · ".join(atoms)) <= width:
break
if spare in atoms:
atoms.remove(spare)
while len(atoms) > 2 and cell_len(" · ".join(atoms)) > width:
del atoms[-2] # the last atom is Esc's; drop what sits in front of it
fitted = " · ".join(atoms)
if cell_len(fitted) <= width:
return fitted
return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…"
def compose_startup(screen: Screen, cols: int, rows: int) -> str:
"""Compose a chromeless splash: a centered wordmark above a content-sized panel.
@@ -416,6 +458,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
sizing_footer = getattr(screen, "sizing_footer_hint", screen.footer_hint)
inner_w = max(measured, cell_len(screen.title), cell_len(sizing_footer))
inner_w = max(10, min(inner_w, cols - 6))
# The hint is fitted to the box rather than cut off by it (see :func:`fit_hint`).
hint_text = fit_hint(
screen.footer_hint, inner_w, shed_first=getattr(screen, "spare_hint_atoms", ())
)
# The probe render is the answer whenever it was already made at the width we settled
# on — the common case, since a body narrower than the probe *is* what set inner_w.
@@ -455,10 +501,10 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
body = Text.from_ansi("\n".join(visible))
hint = hint_style("accent")
subtitle = f"[{hint}]{screen.footer_hint}[/]"
subtitle = f"[{hint}]{hint_text}[/]"
if more_above or more_below:
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
subtitle = f"[{hint}]{arrow} · {screen.footer_hint}[/]"
subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]"
panel = Panel(
body,
title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None,
+45
View File
@@ -17,6 +17,7 @@ from collections.abc import Callable, Sequence
from collections.abc import Sequence as _SequenceABC
from typing import Any
from rich.cells import cell_len
from rich.console import RenderableType
from rich.text import Text
@@ -721,3 +722,47 @@ class BusyScreen(Screen):
def handle(self, action: str, data: str = "") -> None:
"""Swallow all keys: the splash dismisses itself when the task finishes."""
return
class BusyDialog(BusyScreen):
"""A busy splash drawn as a box over the screen it interrupts.
:class:`BusyScreen`'s twin, for work started *from* a screen rather than instead of one:
same spinner, same caption, same swallow-everything :meth:`~BusyScreen.handle` — but
``floating``, so the hub that started the work stays visible beneath it as a backdrop
instead of being replaced.
Modal by inheritance, and that is the half that matters. A hub screen kept on the stack
with ``session.stay`` is *armed the whole time* (see
:meth:`~meshterm.ui.tui.session.Visit._arm`), so while it sits there waiting for a slow
device write every key a reader presses still reaches it: letters land in its live filter
and Esc resolves it, to be handed back the instant the work finishes. A modal screen on
top is what stops that — the key reaches this dialog and dies here.
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay` cannot do the job: it is not a
screen, so it is never consulted for a keypress, and it paints only when the stack is
empty — over a pushed hub it draws nothing at all.
"""
floating = True
@property
def dialog_width(self) -> int:
"""Hug the caption rather than stretching to the dialog cap.
A spinner and four words have no business in a 94-cell box. The compositor still
caps this to the terminal (see :func:`~meshterm.ui.tui.frame._dialog_layout`), and
the width ratchets, so a caption that grows mid-batch never makes the box shrink
back and jitter.
"""
return cell_len(self._message) + 8 # spinner, its two spaces, and the padded border
@property
def message(self) -> str:
"""The caption beside the spinner."""
return self._message
@message.setter
def message(self, text: str) -> None:
"""Retitle the card mid-batch (a sequence of writes reporting which one it is on)."""
self._message = text
+54 -1
View File
@@ -9,7 +9,7 @@ arbitrary objects, so the same screen drives the main menu (tool names), the dev
from __future__ import annotations
import inspect
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
@@ -119,6 +119,25 @@ class DeleteRequest:
value: Any
@dataclass
class KeyRequest:
"""A shortcut key pressed on a select list, with the row it was pressed on.
The sibling of :class:`DeleteRequest`, for a list that declares its own bare-key
shortcuts (see :class:`SelectScreen`'s ``keys``): the list resolves with this rather
than with a row's value, so the caller can tell "the user pressed *this key*" apart
from "the user chose this row" and act accordingly before re-opening the list.
Attributes:
action: The token the pressed key was declared for.
value: The :attr:`Choice.value` of the highlighted row, or ``None`` where the list
had no rows to highlight. A screen-wide shortcut simply ignores it.
"""
action: Any
value: Any = None
def _plain(label: str | Text) -> str:
"""The plain-text form of a row label, for filtering (a :class:`Text` keeps its ``plain``)."""
return label.plain if isinstance(label, Text) else label
@@ -300,6 +319,8 @@ class SelectScreen(Screen):
filterable: bool = True,
hscroll: bool = False,
hscroll_hint: str = "←→ scroll",
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> None:
"""Build a select screen.
@@ -331,6 +352,22 @@ class SelectScreen(Screen):
the move atom) while ``hscroll`` is on and the highlighted row overflows —
so ←→ advertises itself exactly when it would do something. Ignored when
``hscroll`` is off.
keys: Bare-key shortcuts this list answers, mapping the character pressed to a
token naming what it means — the idiom
:meth:`~meshterm.ui.tui.session.TuiSession.button_dialog` already uses for a
dialog's y/n accelerators. The list resolves with a :class:`KeyRequest`
carrying the token and the highlighted row's value, so the caller can act
and re-open. **Only honoured on a non-filterable list**, where a bare letter
has nothing else to do: on a filtering list every letter belongs to the
query, and a shortcut stealing one would be a key that silently stops the
reader typing a name.
key_hint: What those shortcuts are called in the footer, asked of the *highlighted
row's value* on every paint and spliced in just before the trailing ``Esc``
clause. Return one ` · `-joined run of atoms, or ``""`` for a row none of the
shortcuts would touch — the same rule ``delete_hint`` follows, and for the
same reason: the footer must never name a key that would do nothing where the
reader is standing. The policy lives with the caller because only it knows
which rows its keys mean anything on.
"""
super().__init__()
self.title = title
@@ -342,6 +379,10 @@ class SelectScreen(Screen):
# wherever it is opened, ``ctx.ui.select`` included.
self._hscroll = hscroll or any(getattr(item, "hscroll_from", 0) > 0 for item in items)
self._hscroll_hint = hscroll_hint
# Shortcuts are the non-filterable list's compensation for having no filter: the
# letters are free, so a list may spend them (see the ``keys`` argument).
self._keys: dict[str, Any] = {} if filterable else dict(keys or {})
self._key_hint = key_hint
self._hshift = 0
self._last_width = 0 # the last render width, for the footer's overflow probe
if footer_hint is None:
@@ -502,6 +543,8 @@ class SelectScreen(Screen):
* the :attr:`_delete_hint` atom, spliced just before the trailing ``Esc`` clause
(see :func:`_splice_hint`) while the highlighted row is :attr:`Choice.deletable`;
* whatever ``key_hint`` names for the highlighted row's value — the shortcut keys
(``keys``) that would act on *it*, spliced the same way;
* the :attr:`_hscroll_hint` atom (``hscroll`` lists only), inserted right after the
move atom (see :func:`_insert_atom`) while the highlighted row overflows the width
— a short row scrolls nowhere, so ←→ stays hidden on it.
@@ -513,6 +556,10 @@ class SelectScreen(Screen):
current = self._current_choice()
if self._delete_hint and current is not None and current.deletable:
base = _splice_hint(base, self._delete_hint)
if self._key_hint is not None:
atoms = self._key_hint(current.value if current is not None else None)
if atoms:
base = _splice_hint(base, atoms)
if self._hscroll and self._hscroll_hint and self._selected_overflows():
base = _insert_atom(base, self._hscroll_hint)
if self._filter:
@@ -858,6 +905,12 @@ class SelectScreen(Screen):
self._filter = self._filter[:-1]
self._index = 0
self._hshift = 0
elif action == "text" and data in self._keys:
# A declared shortcut resolves the list with what was pressed and where the
# highlight was standing; only a non-filterable list can declare any.
self.resolve(
KeyRequest(self._keys[data], choices[self._index].value if choices else None)
)
elif action == "text" and self._filterable and data.isprintable():
# A leading space is ignored (the filter never begins with whitespace); a
# trailing one is dropped when matching (see _rows), so spaces count only
+180 -5
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import asyncio
import os
import sys
from collections.abc import AsyncIterator, Callable, Sequence
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Any
@@ -25,6 +25,7 @@ from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import ConditionalContainer, Float, FloatContainer, Layout, Window
from prompt_toolkit.output.color_depth import ColorDepth
from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
@@ -45,7 +46,7 @@ from .prompt import (
TypedConfirmDialog,
Validator,
)
from .screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
from .screen import CANCEL, POP_ALL, BusyDialog, BusyScreen, PopToMenu, Screen, ScrollScreen
from .select import Choice, ReorderScreen, SelectScreen, Separator
from .spinner import spinner_interval
@@ -252,6 +253,70 @@ def _reclaim_last_column() -> bool:
return get_platform().width_reclaim
#: What each ``color_depth`` preference value asks for. ``auto`` is deliberately absent: it
#: is the one answer that is not a depth, and :func:`_color_depth` resolves it separately.
_DEPTH_BY_NAME = {
"truecolor": ColorDepth.DEPTH_24_BIT,
"256": ColorDepth.DEPTH_8_BIT,
"16": ColorDepth.DEPTH_4_BIT,
}
#: What ``COLORTERM`` says on a terminal that means it about 24-bit colour. There is no
#: in-band way to ask — a terminal that cannot do truecolor answers a truecolor SGR by
#: quietly approximating it, which looks from here exactly like one that can — so this
#: convention, which every truecolor terminal follows, is the whole of the evidence.
_TRUECOLOR_COLORTERM = frozenset({"truecolor", "24bit"})
def _color_depth() -> ColorDepth | None:
"""How many colours to send this terminal, or ``None`` to keep prompt_toolkit's verdict.
prompt_toolkit picks a depth per *output class*, and its two classes disagree:
``Windows10_Output`` returns ``TRUE_COLOR`` outright, while ``Vt100_Output`` returns
``DEPTH_8_BIT`` for every ``TERM`` but ``linux`` and a dumb terminal. So the same build
of MeshTerm, drawing the same theme, is 24-bit on Windows and 256 colours on macOS and
Linux — and it is quantized *silently*, which is why this went unnoticed: the app looks
fine, just flatter. It costs exactly what this palette is made of. The seven ``heat.*``
steps and the per-node hue wheel (:func:`~meshterm.ui.theme.node_style`) are close
pastels chosen to be told apart; snapped to the 216-colour cube, neighbours collide and
identities stop being distinguishable by hue. ``COLORTERM`` is not consulted by
prompt_toolkit at all — only ``PROMPT_TOOLKIT_COLOR_DEPTH`` is — so a terminal
announcing truecolor in the conventional way is still handed 256.
Three sources, in confidence order, the same shape as :func:`_reclaim_last_column`:
``MESHTERM_COLOR_DEPTH`` for a one-off, then the ``color_depth`` preference, then a
reading of the environment.
The reading only ever *raises* the verdict, never lowers it. A terminal we cannot get a
positive claim from returns ``None`` and keeps precisely the depth it had, so a host
this function has never heard of cannot be made worse by it — which matters more than
being right everywhere, because the failure mode of guessing 24-bit at a terminal
without it is not a duller palette but no colour at all.
Returns:
The depth to force, or ``None`` to leave prompt_toolkit's own default in place.
"""
from ...core.preferences import current as current_preferences
override = os.environ.get("MESHTERM_COLOR_DEPTH")
preferred = override if override is not None else current_preferences().color_depth
if preferred in _DEPTH_BY_NAME:
return _DEPTH_BY_NAME[preferred]
if preferred != "auto":
return None
# A 16-slot console is already right and has no truecolor to claim: the theme addresses
# its palette by index (see theme._VT_SLOTS), and promoting the depth would send RGB to
# a screen with sixteen colours to put it in.
if not get_platform().truecolor:
return None
if os.environ.get("COLORTERM", "").strip().lower() in _TRUECOLOR_COLORTERM:
return ColorDepth.DEPTH_24_BIT
# terminfo's own spelling for a direct-colour entry (``xterm-direct``, ``*-direct16m``).
if os.environ.get("TERM", "").endswith(("-direct", "-direct16m")):
return ColorDepth.DEPTH_24_BIT
return None
#: How many stacked dialog layers the layout can float over the background at once. A fixed
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
#: real nesting — a tool's list, an item's detail popup, and a confirm over that is only three.
@@ -431,6 +496,10 @@ class TuiSession:
# deliberately *not* on the screen stack: it hovers above every layer and is shown/
# hidden by busy_overlay, independent of whatever screens are pushed.
self._overlay: BusyOverlay | None = None
# The busy card currently pushed by :meth:`busy_dialog`, or None. Unlike the overlay
# this one *is* a screen — it has to be, to take the keys the hub underneath would
# otherwise bank — and this holds it so a nested wait rides it instead of stacking.
self._busy: BusyDialog | None = None
# What each drawn layer last composed — ``layer -> (text, carries a wide glyph)`` — so
# :meth:`_emit` can tell a frame that actually changed from one the 1 Hz refresh just
# re-rendered identically, and can ask whether anything currently on screen needs the
@@ -826,18 +895,27 @@ class TuiSession:
default: Any = None,
banner: Any | None = None,
footnote: str | None = None,
footer_hint: str = "↑↓ move · Enter select · Esc quit",
footer_hint: str = "↑↓ move · Enter select · Esc bye",
keys: Mapping[str, Any] | None = None,
key_hint: Callable[[Any], str] | None = None,
) -> Any:
"""Show a chromeless select splash (banner above a content-sized box).
Like :meth:`select`, but drawn without the header/footer status bars and centered
under ``banner`` — the startup device picker's presentation. Type-to-filter is off:
under ``banner`` — the startup device picker's presentation. Esc's verb here is
``bye``, the app's one send-off: this splash is the door, and the reader leaving it
has not started anything to quit out of. Type-to-filter is off:
the device list is short and fixed, so stray keys never narrow it. An optional
``footnote`` (e.g. a copyright notice) sits muted below the box. When any row opts
into removal (a :attr:`~meshterm.ui.tui.select.Choice.deletable` row), a "Del remove"
atom joins the footer — but only while the highlight is actually on such a row, so the
removal key advertises itself exactly where it acts (see
:attr:`~meshterm.ui.tui.select.SelectScreen.footer_hint`).
``keys`` hands the list bare-key shortcuts — which a splash can afford precisely
because it does not filter, so every letter is free (see :class:`SelectScreen`) —
and ``key_hint`` says what they are called on the row the highlight is standing on,
so they advertise themselves exactly where they act, as ``Del remove`` does.
"""
delete_hint = (
"Del remove" if any(isinstance(it, Choice) and it.deletable for it in items) else ""
@@ -849,10 +927,19 @@ class TuiSession:
footer_hint=footer_hint,
delete_hint=delete_hint,
filterable=False,
keys=keys,
key_hint=key_hint,
)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
# The splash's border is its only hint line and it is narrow (47 cells on the
# PicoCalc), so when the sentence outgrows the box it gives up the scroll atom
# first — ←→ are already named by the move atom in front of it — and then the
# *hide* key. Between the two shortcuts the one that survives is the way back:
# ⇧H only appears at all once something is hidden, which is exactly the state where
# a reader needs to be told how to undo it, and by then they have already found h.
screen.spare_hint_atoms = ("←→ scroll", "h hide")
result = await self.run_screen(screen)
return None if result is CANCEL else result
@@ -922,7 +1009,7 @@ class TuiSession:
backdrop_title,
backdrop_items,
default=backdrop_default,
footer_hint="↑↓ move · Enter select · Esc quit",
footer_hint="↑↓ move · Enter select · Esc bye",
delete_hint="Del remove",
filterable=False,
)
@@ -1323,6 +1410,91 @@ class TuiSession:
self._overlay = None
self.invalidate()
@asynccontextmanager
async def busy_dialog(
self,
message: str = "",
*,
title: str = "",
interval: float | None = None,
) -> AsyncIterator[BusyDialog]:
"""Float a modal busy card over the current screen for the duration of a block.
The in-stack counterpart to :meth:`busy_overlay`, and the one a screen wants when
*it* starts the slow work::
async with session.busy_dialog("saving Lakeside…", title="Channels"):
await device.set_channel(...)
Two things separate it from the overlay, and both are why the overlay could not do
this job. It is a real :class:`~meshterm.ui.tui.screen.BusyDialog` *pushed on the
stack*, so it draws over a hub instead of only in the gaps between screens — the
overlay paints on an empty stack alone (see :meth:`_overlay_visible`), which is
exactly never while a hub is visited. And being modal, it **owns the keyboard**: a
hub kept up with :meth:`stay` stays armed while the work runs, so without a modal
layer every key pressed during the wait still reaches it — letters landing in its
live filter (a list that comes back showing nothing), Esc resolving it to be handed
back the instant the work finishes (a screen that appears to close on its own a
beat later). Those presses now reach this card and stop there.
Nesting keeps the outermost card, so a batch of writes reports as one wait rather
than flashing a box per write; the inner block still runs. The caller may retitle
the card as it goes — ``busy.message = …`` — which is how a sequence says which
step it is on.
Unlike :meth:`busy_overlay` there is no fade-in. The fade is there so a fast
operation shows nothing at all, and that is a fine trade for a cosmetic card; this
one is also the key guard, and a guard that arrives late is a guard with a hole in
it.
Args:
message: The caption drawn beside the spinner.
title: An optional heading naming the feature doing the work.
interval: Seconds between spinner frames; defaults to the platform's cadence
(see :meth:`busy_startup`, which shares the hazard: the card animates *over*
a device read, so a rate the console can't sustain steals the loop from the
work it is reporting on).
Yields:
The live :class:`~meshterm.ui.tui.screen.BusyDialog`, so its caption can change.
"""
tick = spinner_interval() if interval is None else interval
if self._busy is not None: # a nested wait rides the card its caller already put up
yield self._busy
return
screen = BusyDialog(message, title=title)
# A lone floating screen on an empty stack is drawn *as* the background, framed and
# full-frame rather than as a box, so it gets the same blank backdrop every other
# dialog uses (see :meth:`_run_dialog_screen`).
backdrop = None
if not self._stack:
backdrop = ScrollScreen("", floating=False, footer_hint="")
self.push(backdrop)
self._busy = screen
self.push(screen)
async def animate() -> None:
while True:
await asyncio.sleep(tick)
screen.tick()
self.invalidate()
ticker = asyncio.ensure_future(animate())
try:
yield screen
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner hiccup must never break a flow
pass
self._busy = None
self.pop(screen)
if backdrop is not None:
self.pop(backdrop)
# --- application lifecycle ------------------------------------------------
async def run(self, main: Any) -> None:
@@ -1445,6 +1617,9 @@ class TuiSession:
max_render_postpone_time=None,
input=self._input,
output=self._resolve_output(),
# None leaves the output's own default alone; see _color_depth for why the
# two prompt_toolkit outputs disagree and why only a raise is ever applied.
color_depth=_color_depth(),
)
if fastrender.enabled():
# Swap in the row-diff renderer for plain full-screen frames. Built with the
+35 -5
View File
@@ -1037,6 +1037,16 @@ class ContactsSort:
columns: tuple[str, ...] = _SORT_COLUMNS
opens_ascending: dict[str, bool] = field(default_factory=lambda: _SORT_OPENS_ASCENDING)
@classmethod
def names(cls, columns: tuple[str, ...] = _SORT_COLUMNS) -> tuple[str, ...]:
"""The orders :meth:`from_name` accepts — what a caller may legitimately ask for.
:meth:`from_name` answers "which sort is this?" and forgives anything it does not
recognise. A surface that wants to *refuse* an unknown order needs the set itself,
which is this.
"""
return columns
@classmethod
def from_name(
cls,
@@ -1061,7 +1071,7 @@ class ContactsSort:
self.ascending = self.opens_ascending[self.column]
def _ordered_contacts(
def ordered_contacts(
contacts: list[Contact], counts: dict[str, int], sort: ContactsSort
) -> list[Contact]:
"""Contacts sorted per ``sort`` (our own node is pinned separately, above these).
@@ -1294,7 +1304,7 @@ def contacts_table(
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
)
table.add_section()
for c in _ordered_contacts(contacts, counts, sort):
for c in ordered_contacts(contacts, counts, sort):
secs = _age_seconds(c.last_seen)
glyph, glyph_style = _NODE_GLYPHS.get(c.node_type, _DEFAULT_GLYPH)
pkts = _contact_pkts(c, counts)
@@ -1422,6 +1432,23 @@ _BATTERY_FLASH_PCT = 100 / _BATTERY_STEPS / 2
#: Frames in the charging sweep: empty → four fills → round again (bottom-to-full loop).
_CHARGE_FRAMES = 5
#: A full pack reads ``100``, with no ``%`` — the one charge whose sign is dropped, so the
#: reading is three cells wide exactly as ``99%`` is.
#:
#: Which matters because 99 and 100 are the two numbers a topped-off pack sits *between*:
#: the charger cuts out at full, the pack settles back to 99, the charger restarts, and the
#: companion reports the flip every poll for as long as it is plugged in (JP, 2026-09-09).
#: The gauge is pinned to the header's right edge and the pulse takes what is left, so a
#: number that grew a cell at the top of the range took that cell off the sparkline and
#: redrew the whole activity history at a new scale, twice a minute, on a device sitting
#: still. Every other width change in the range — 9 to 10 on the way down — is a boundary a
#: discharging pack crosses once and never comes back over, so it costs one repaint and
#: needs no answer.
#:
#: A bare ``100`` beside a full block reads as a percentage without being told; ``%`` is
#: doing its work at the readings that could be mistaken for something else.
_BATTERY_FULL = "100"
def _braille_fill(steps: int) -> str:
"""A single braille cell filled from the bottom by ``steps`` (0–8) half-rows.
@@ -1494,7 +1521,9 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
A pack at 100% is drawn as **not charging** whichever way the flag reads: a full cell
resting full is the honest picture, and a topped-off charger left plugged in shouldn't
leave the gauge sweeping forever.
leave the gauge sweeping forever. It also drops its ``%`` (:data:`_BATTERY_FULL`), so
that full reading is the same three cells as the 99% it keeps flipping back to and the
header stops resizing under a pack on a charger.
Args:
percent: State of charge, 0–100 (clamped).
@@ -1503,7 +1532,8 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
steadily-incrementing integer animates the two live states.
Returns:
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — the
A Rich :class:`Text`: the coloured block, a space, and ``NN%`` in muted text — a
full pack's bare ``100`` the one exception (:data:`_BATTERY_FULL`) — with the
block's colours on the glyph's span alone, so the ground stops at the cell.
"""
pct = max(0, min(100, int(percent)))
@@ -1520,5 +1550,5 @@ def battery_cell(percent: int, *, charging: bool = False, frame: int = 0) -> Tex
# muted percent onto the coloured ground alongside the cell.
out = Text()
out.append(_braille_fill(steps), style=color)
out.append(f" {pct}%", style="muted")
out.append(f" {_BATTERY_FULL if pct == 100 else f'{pct}%'}", style="muted")
return out
+28
View File
@@ -18,6 +18,7 @@ always see the colours the app really emits.
from __future__ import annotations
import os
import re
from collections.abc import Callable, Iterable, Iterator
@@ -87,6 +88,33 @@ def _reset_transmit_gate() -> Iterator[None]:
transmit_clock().reset()
@pytest.fixture(autouse=True)
def _isolate_config_dir(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
"""No test may reach the developer's own ``~/.meshterm``.
``$MESHTERM_HOME`` moves the whole directory — the history, the contact and channel
caches, the outbox, the stored admin passwords, the device profiles, the preferences
and the log. ``--db`` moves only the first of those, which is why isolating a CLI test
with ``--db`` alone was not isolation at all: a synthetic contact written by a probe
sat in the real cache and made ``test_cli_contract`` fail on one machine and pass on
every other, and every run appended to the developer's real ``meshterm.log`` and read
their real ``log_level``.
Autouse and unconditional, because the exposure is not the CLI's: anything that
touches ``Settings.load()``, a preference, a store or the logger has it. A test that
wants a *populated* directory sets the variable itself; ``monkeypatch.setenv`` inside
a test still wins over this.
"""
home = tmp_path_factory.mktemp("meshterm-home")
previous = os.environ.get("MESHTERM_HOME")
os.environ["MESHTERM_HOME"] = str(home)
yield
if previous is None:
os.environ.pop("MESHTERM_HOME", None)
else:
os.environ["MESHTERM_HOME"] = previous
@pytest.fixture(autouse=True)
def _reset_platform() -> Iterator[None]:
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
+5 -1
View File
@@ -131,6 +131,10 @@ class _NoteUi:
def note(self, markup: str) -> None:
self.notes.append(markup)
def ack(self, markup: str) -> None:
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
self.note(markup)
async def test_scheduler_first_pass_arms_without_sending(ctx: AppContext) -> None:
"""The first look at a fresh device starts the countdown; nothing is transmitted."""
@@ -190,7 +194,7 @@ async def test_advert_cadence_op_writes_the_store(ctx: AppContext) -> None:
device = await ctx.device()
ctx._ui = _NoteUi() # type: ignore[assignment]
snapshot = dict(await device.get_self_info())
changes, _ = await apply_ops(
changes, _artifacts, _report = await apply_ops(
ctx, device, snapshot, [("advert_cadence", True, 168), ("advert_cadence", False, OFF)]
)
assert changes == 2
+23 -2
View File
@@ -77,6 +77,27 @@ def test_battery_cell_shows_the_true_percent() -> None:
assert battery_cell(42).plain == chr(0x2800 | 0xE4) + " 42%"
def test_battery_cell_drops_the_sign_at_full_so_the_header_stops_moving() -> None:
"""A full pack reads ``100`` — three cells, exactly as ``99%`` is.
Those two readings are the ones a topped-off pack sits between: the charger cuts out
at full, the pack settles to 99, the charger restarts, and the flip repeats every poll
for as long as it is plugged in. The gauge is pinned to the header's right edge and the
pulse takes what is left, so a fourth cell at the top of the range came off the
sparkline and rescaled the whole activity history, twice a minute, on a device sitting
still. Dropping the one sign that can be inferred costs nothing and settles it.
"""
assert battery_cell(100).plain.endswith(" 100") # no sign — a full block says it
assert battery_cell(99).cell_len == battery_cell(100).cell_len
# Every other reading keeps its sign; the charging sweep and the alarm never touch it.
assert battery_cell(99).plain.endswith(" 99%") and battery_cell(9).plain.endswith(" 9%")
assert {
battery_cell(pct, charging=True, frame=f).plain.endswith("%")
for pct in (5, 99)
for f in range(6)
} == {True}
def test_battery_cell_colours_by_band_while_the_dots_carry_the_detail() -> None:
"""Three broad bands over half / over a quarter / below, each a block of its own hue."""
assert _glyph_style(battery_cell(100)) == "batt.full" # light green on green
@@ -143,8 +164,8 @@ def test_battery_cell_animates_off_the_frame_counter_alone() -> None:
def test_battery_cell_at_full_rests_full_however_the_charging_flag_reads() -> None:
"""A topped-off pack draws as not charging, so a plugged-in charger stops the sweep."""
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100%"
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100%"}
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100"
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100"}
# One percent short is still filling, and still sweeps.
assert battery_cell(99, charging=True, frame=0).plain[0] == chr(0x2800)
+288 -3
View File
@@ -7,10 +7,13 @@ run against the :class:`MockDevice` simulator and a temporary database.
from __future__ import annotations
import re
from contextlib import asynccontextmanager
from hashlib import sha256
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.cells import cell_len
from rich.console import Console
from meshterm.context import AppContext
@@ -35,6 +38,7 @@ from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.tools.channels import ChannelsTool
from meshterm.ui.channels import (
_CREATE,
ChannelSlot,
_apply_order,
_next_free_slot,
@@ -369,9 +373,17 @@ class _ScriptedUi:
def show(self, *renderables) -> None: # noqa: ANN002
pass
@asynccontextmanager
async def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""No card without a screen stack — every mutation path goes through this."""
yield SimpleNamespace(message=message)
def note(self, markup: str) -> None:
pass
def ack(self, markup: str) -> None:
pass
async def view(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001
pass
@@ -569,7 +581,7 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non
ctx.chat._unread[slot.conversation.key] = 2 # as the service would after two arrivals
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
assert title == "Channels — 1/8 slots"
assert title == "Channels · 1/8 slots" # a status atom chains with ·, not an em dash
row = next(it for it in items if isinstance(it, Choice) and it.value == 0)
plain = row.label.plain # the title is a live callable; .label resolves it
assert "Ops" in plain
@@ -667,13 +679,47 @@ async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> N
await device.set_channel(3, "Ops", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 3)
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == "Slot 3 · no messages recorded yet"
# What the channel *is* leads — the openness and hash moved here out of a title that
# was 42 cells wide and left no room on the console for the bar that says Esc leaves.
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == (
"private · hash be · slot 3 · no messages yet"
)
ctx.repo.record_chat_message(ChatMessage(text="hi", is_channel=True, channel_id=slot.identity))
ctx.chat._unread[slot.conversation.key] = 1
summary = _detail_summary(ctx, slot, _LiveStats(ctx))
# A fresh age reads as bare "now" (the app-wide format_ago grammar — never "now ago").
assert summary == "Slot 3 · 1 message · 1 unread · last message now"
assert summary == "private · hash be · slot 3 · 1 msg · 1 unread · last now"
async def test_the_detail_summary_sheds_atoms_rather_than_wrapping(ctx: AppContext) -> None:
"""One line, on both platforms: the traffic atoms go before the line does.
The identifying atoms are on the left and the describing ones on the right, so a line
too long for the console loses what the reader could already see in the row they opened
this screen from — never the channel's own identity.
"""
from meshterm.core.models import ChatMessage
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.channels import _detail_summary, _LiveStats
device = await ctx.device()
await device.set_channel(0, "Lakeside emergency net", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
for _ in range(3):
ctx.repo.record_chat_message(
ChatMessage(text="hi", is_channel=True, channel_id=slot.identity)
)
ctx.chat._unread[slot.conversation.key] = 2
try:
for platform in (REGULAR, PICOCALC):
set_platform(platform)
line = _detail_summary(ctx, slot, _LiveStats(ctx))
assert cell_len(line) <= platform.readable_cols, (platform.name, line)
assert line.startswith("private · hash be · slot 0") # identity always survives
finally:
set_platform(REGULAR)
# -- muting channel notifications ---------------------------------------------
@@ -875,3 +921,242 @@ async def test_cli_clear_of_an_empty_slot_is_a_no_op(ctx: AppContext) -> None:
tool = ChannelsTool()
result = await tool.run(ctx, {"cli_action": "clear", "index": 7})
assert result.summary == {"index": 7, "cleared": False}
class _RecordingSession:
"""Records which surface :meth:`TuiUi.present` chose — the popup or the result window."""
def __init__(self) -> None:
self.shown: list[tuple[str, str]] = []
async def message_dialog(self, message, title: str = "") -> None: # noqa: ANN001
self.shown.append(("popup", title))
async def scroll(self, renderable, *, title: str = "", footer_hint: str = "") -> None: # noqa: ANN001
self.shown.append(("window", title))
class _PresentingUi(_ScriptedUi):
"""Scripted answers, but real note buffering — so what the visit *shows* can be asserted."""
def __init__(self, selects: list, texts: list, dialogs: list) -> None:
from meshterm.ui.surface import TuiUi
super().__init__(selects, texts, dialogs)
self.session_spy = _RecordingSession()
self.surface = TuiUi(self.session_spy) # no ``.session`` attribute: stays headless
def note(self, markup: str) -> None:
self.surface.note(markup)
def ack(self, markup: str) -> None:
self.surface.ack(markup)
async def test_a_busy_visit_says_nothing_on_the_way_out(ctx: AppContext) -> None:
"""A visit that changed three channels closes silently — no popup, no window.
The history this pins, in the order it happened: every action banked its own "✓ created
…" note, so the outcome grew a line per change and the third pushed it past the
acknowledgement popup's budget into the full-frame result window — the same visit
reporting itself two different ways depending on how much had been done in it. Folding
those into one summary line fixed the shape and left the real problem: an
acknowledgement for changes the reader had just watched land in the list in front of
them. So the line went too. The count survives in ``summary`` for the run log, which
nobody reads off the screen.
"""
ui = _PresentingUi(
selects=[_CREATE, _CREATE, _CREATE, None], # create three channels, then Esc
texts=["One", "Two", "Three"],
dialogs=[],
)
ctx.ui = ui
result = await ChannelsTool().run(ctx, {})
assert result.summary == {"changes": 3} # the run log still records what was done
assert result.message is None
# The menu's own tail: note the tool's message, then present what the run buffered.
if result.message:
ui.surface.note(result.message)
await ui.surface.present(title="Channels")
assert ui.session_spy.shown == [] # nothing shown, in either shape
class _FlakyDevice:
"""A device whose channel reads stop working partway through the probe.
``fail_from`` is the first slot index that raises, and ``error`` is what it raises —
the two endings the probe has to tell apart: a firmware refusing a slot it does not
have (a plain rejection) and a link that stopped answering (a timeout).
"""
def __init__(self, names: list[str], *, fail_from: int, error: BaseException) -> None:
self._names = names
self._fail_from = fail_from
self._error = error
self.written: list[tuple[int, str]] = []
async def get_channel(self, idx: int): # noqa: ANN201
if idx >= self._fail_from:
raise self._error
if idx < len(self._names):
return {"channel_name": self._names[idx], "channel_secret": bytes(range(16))}
return None
async def set_channel(self, idx: int, name: str, secret) -> None: # noqa: ANN001
self.written.append((idx, name))
async def test_a_probe_cut_short_by_a_failed_read_is_not_a_layout() -> None:
"""A timeout mid-probe reports incomplete; a refused slot reports a finished list.
They look identical in the returned list — a short one either way — which is what let
one timed-out read stand in for "this device has no channels".
"""
from meshterm.core.connection import DeviceCommandError
from meshterm.ui.channels import probe_channel_slots
dropped = _FlakyDevice(["Alpha", "Beta"], fail_from=1, error=DeviceCommandError("no reply"))
slots, complete = await probe_channel_slots(dropped)
assert [s.name for s in slots] == ["Alpha"] and complete is False
at_the_end = _FlakyDevice(["Alpha", "Beta"], fail_from=2, error=RuntimeError("out of range"))
slots, complete = await probe_channel_slots(at_the_end)
assert [s.name for s in slots] == ["Alpha", "Beta"] and complete is True
async def test_an_incomplete_probe_is_answered_but_never_cached(ctx: AppContext) -> None:
"""One bad read must not read as "no channels" for the rest of the session."""
from meshterm.core.connection import DeviceCommandError
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
calls = {"n": 0}
real = device.get_channel
async def flaky(idx: int): # noqa: ANN202
calls["n"] += 1
if calls["n"] == 1:
raise DeviceCommandError("timed out")
return await real(idx)
device.get_channel = flaky # type: ignore[method-assign]
assert await ctx.devstate.channel_slots() == [] # the failed read, answered honestly
# ...and not kept: the next ask re-probes and finds the channel that was there all along.
assert [s.name for s in await ctx.devstate.channel_slots()] == ["Alpha"]
async def test_a_slot_is_confirmed_empty_before_a_channel_is_written_over_it(
ctx: AppContext,
) -> None:
"""A slot missing from a short list must not be handed out as free.
The dangerous version of the bug above: a probe that stopped at slot 1 leaves slot 1
looking free, and every add flow writes to the slot it is given without asking.
"""
from meshterm.ui.channels import _pick_free_slot
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
await device.set_channel(1, "Beta", bytes(range(16, 32)))
ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
ctx.ui = ui
truncated = [ChannelSlot(idx=0, name="Alpha", secret=bytes(range(16)))] # slot 1 unseen
assert await _pick_free_slot(ctx, device, truncated, 8) is None # refused, not slot 1
# With the list telling the truth, the next genuinely free slot is handed out.
full_list = list(await read_channel_slots(device))
assert await _pick_free_slot(ctx, device, full_list, 8) == 2
async def test_a_rename_keeps_the_page_it_renamed(ctx: AppContext) -> None:
"""Renaming a channel re-reads it in place; only a clear closes the page.
The channel is still there and this is still its page. It used to close on both, which
left the reader back in the list to find the row again — for no better reason than the
snapshot the page was holding having gone stale under the rename.
"""
from meshterm.ui.channels import _CHAT, _EDIT, _channel_detail, _LiveStats
device = await ctx.device()
await device.set_channel(0, "Ops", bytes(range(16)))
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
opened: list[str] = []
ui = _ScriptedUi(
# Rename, then open chat (proving the page is still up and knows the new name), Esc.
selects=[_EDIT, _CHAT, None],
texts=["Lakeside", ""], # the new name, and a blank key (derive it from the name)
dialogs=[],
)
ctx.ui = ui
import meshterm.ui.channels as channels_mod
async def fake_chat(_ctx, opened_slot): # noqa: ANN001, ANN202
opened.append(opened_slot.name)
original = channels_mod._open_chat
channels_mod._open_chat = fake_chat
try:
changes = await _channel_detail(ctx, device, slot, _LiveStats(ctx))
finally:
channels_mod._open_chat = original
assert opened == ["Lakeside"] # the page stayed, pointing at the renamed channel
assert changes == 1 # ...and still reports the rename when it finally closes
async def test_the_standard_public_channel_is_not_added_twice(ctx: AppContext) -> None:
"""A re-fired add is refused where it runs, not only where it is drawn.
The menu drops the row once a slot holds the channel, but the row is dispatched by a
sentinel: a press already on its way while the first write ran would add a second copy
of a channel that has exactly one well-known key.
"""
from meshterm.ui.channels import _add_default_public
device = await ctx.device()
ctx.ui = _ScriptedUi(selects=[], texts=[], dialogs=[])
slots: list[ChannelSlot] = []
assert await _add_default_public(ctx, device, slots, 8) == 1
slots = list(await read_channel_slots(device))
assert await _add_default_public(ctx, device, slots, 8) == 0 # refused, not duplicated
assert [s.name for s in await read_channel_slots(device)] == ["Public"]
async def test_a_reorder_that_breaks_partway_says_so(ctx: AppContext) -> None:
"""A relay has no transaction under it, so a link that drops mid-way is reported."""
from meshterm.ui.channels import _reorder_channels
device = await ctx.device()
await device.set_channel(0, "Alpha", bytes(range(16)))
await device.set_channel(1, "Beta", bytes(range(16, 32)))
slots = list(await read_channel_slots(device))
said: list[str] = []
class _Ui(_ScriptedUi):
def note(self, markup: str) -> None:
said.append(markup)
async def reorder(self, title: str, labels: list): # noqa: ANN201
return [1, 0] # swap them
ctx.ui = _Ui(selects=[], texts=[], dialogs=[])
real = device.set_channel
calls = {"n": 0}
async def flaky(idx: int, name: str, secret) -> None: # noqa: ANN001
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("link dropped")
await real(idx, name, secret)
device.set_channel = flaky # type: ignore[method-assign]
assert await _reorder_channels(ctx, device, slots) == 0 # not counted as a clean reorder
assert said and "reorder stopped partway" in said[0]
+144 -17
View File
@@ -11,6 +11,7 @@ import re
from pathlib import Path
import pytest
from rich.cells import cell_len
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.config import Settings
@@ -27,7 +28,7 @@ from meshterm.core.preferences import Preferences
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title
from meshterm.tools.chat import _lanes, _LiveLasts, _preview_text, _title
from meshterm.ui.chat import ChatScreen
from meshterm.ui.tui import fkeys
from meshterm.ui.tui.screen import CANCEL
@@ -441,11 +442,10 @@ def test_preview_colours_channel_sender_and_mentions() -> None:
assert any(s.style == "node.unknown" and s.start <= zed < s.end for s in preview.spans)
def test_preview_ellipsizes_long_text() -> None:
"""An over-long preview is clipped to the width budget with a trailing ellipsis."""
long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0)
out = _preview_text(long, _NO_KEYS).plain
assert len(out) == _PREVIEW_WIDTH and out.endswith("…")
def test_preview_keeps_the_whole_message() -> None:
"""The preview is built whole — the row's own cut is what the ←→ scroll walks past."""
long = ChatMessage(text="x" * 300, is_channel=True, channel_idx=0)
assert _preview_text(long, _NO_KEYS).plain == "x" * 300
def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
@@ -453,7 +453,7 @@ def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
ctx = _RowCtx(repo, unread={"chan:c0": 3})
last = ChatMessage(text="Bob: hi there", is_channel=True, channel_id="c0") # sender inline
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS)
title = _title(ctx, conv, {"chan:c0": last}, _NO_KEYS, _lanes([conv]))
line = title.plain # a Text, since there is unread
assert line.startswith("🔒 General") # a private channel leads with its openness glyph
assert "● 3" in line
@@ -465,7 +465,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
from rich.text import Text
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS)
unread = _title(_RowCtx(repo, unread={"chan:c0": 2}), conv, {}, _NO_KEYS, _lanes([conv]))
assert isinstance(unread, Text)
dot = unread.plain.index("●")
reddened = [
@@ -473,7 +473,7 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
]
assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS)
read = _title(_RowCtx(repo, unread={}), conv, {}, _NO_KEYS, _lanes([conv]))
assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour
assert "●" not in read.plain # nothing unread -> no badge dot
assert not any(span.style == "err" for span in read.spans)
@@ -488,8 +488,9 @@ def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository
)
m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0")
m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1")
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS).plain
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS).plain
lanes = _lanes([short, long]) # one measurement for the list, as the picker does
l0 = _title(ctx, short, {"chan:c0": m0}, _NO_KEYS, lanes).plain
l1 = _title(ctx, long, {"chan:c1": m1}, _NO_KEYS, lanes).plain
assert l0.index("X: hello") == l1.index("Y: hello")
@@ -498,7 +499,7 @@ def test_title_leads_with_openness_glyph(repo: Repository) -> None:
ctx = _RowCtx(repo)
def head(conv):
return _title(ctx, conv, {}, _NO_KEYS).plain.split(" ", 1)[0]
return _title(ctx, conv, {}, _NO_KEYS, _lanes([conv])).plain.split(" ", 1)[0]
named = Conversation(
label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general")
@@ -533,7 +534,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
)
# No history yet — a hollow ring in the standard companion pink.
fresh = _title(ctx, contact, {}, _NO_KEYS)
fresh = _title(ctx, contact, {}, _NO_KEYS, _lanes([contact]))
assert fresh.plain.startswith("○") and dot_is_pink(fresh)
# The name lane carries Alice's key-derived hue.
name_at = fresh.plain.index("Alice")
@@ -541,7 +542,7 @@ def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> No
# Once we've exchanged messages the same pink dot fills in.
last = ChatMessage(text="hi", peer=contact.peer)
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS)
talked = _title(ctx, contact, {contact.key: last}, _NO_KEYS, _lanes([contact]))
assert talked.plain.startswith("●") and dot_is_pink(talked)
@@ -1791,9 +1792,135 @@ async def test_picker_pins_its_column_header_over_the_group_heading(
assert top[0].startswith("CONVERSATION") and top[0].endswith("LAST MESSAGE")
assert top[1] == "── 👤 Direct ──"
assert above is True
# Too narrow for the whole line, the trailing label shortens — never wraps.
assert header.text(50).endswith("LAST MSG")
assert "\n" not in header.text(40)
# Too narrow for the whole line, the trailing label shortens — never wraps. The
# lane is sized to these six-cell names now, so the squeeze starts well below any
# real terminal: the fallback is what is under test, not the width it happens at.
assert header.text(40).endswith("LAST MSG")
assert "\n" not in header.text(30)
async def test_picker_lane_is_sized_to_the_names_it_holds(repo: Repository) -> None:
"""The conversation lane fits the longest name, between its floor and its ceiling.
Every cell the lane holds past that longest name is padding taken out of the message
preview, which is the row's only unbounded content — so it is measured, not written down.
"""
from meshterm.tools.chat import _LABEL_MAX, _LABEL_MIN, _lanes
def lane(*names: str) -> int:
return _lanes([Conversation(label=n, is_channel=True, channel_id=n) for n in names]).label
assert lane("Bob", "Ann") == _LABEL_MIN # short names can't shrink it under the header word
assert lane("Bob", "YUL-Cartierville") == len("YUL-Cartierville")
assert lane("A Rather Long Contact Name Indeed") == _LABEL_MAX # nor grow it without end
async def test_picker_rows_scroll_the_message_under_pinned_lanes(repo: Repository) -> None:
"""A row hands ←→ the preview alone: the lanes in front of it are where it starts to slide.
Those lanes are the reader's place in the list, so the scroll begins exactly where the
message does — which is one column for every row, long name or short.
"""
from types import SimpleNamespace
from meshterm.tools.chat import ChatTool
from meshterm.ui.tui import Choice
contacts = [
Contact(name="Alice", public_key="d4" + "0" * 62, node_type=1),
Contact(name="A Rather Long Contact Name", public_key="60" + "0" * 62, node_type=1),
]
alice = Conversation(label="Alice", is_channel=False, contact=contacts[0])
repo.record_chat_message(ChatMessage(text="x" * 200, peer=alice.peer))
ctx = SimpleNamespace(devstate=_PickerDevstate(contacts), repo=repo, chat=_PickerChat())
rows = [it for it in await ChatTool()._picker_items(ctx) if isinstance(it, Choice)]
heads = {row.hscroll_from for row in rows}
assert len(heads) == 1 and heads.pop() > 0 # one head block for the whole list
talked = next(row for row in rows if row.value.label == "Alice")
line = talked.label.plain
# Measured in cells, not characters: a channel glyph is one character and two cells.
assert cell_len(line[: line.index("x")]) == talked.hscroll_from
# And the preview keeps the whole message: the cut is the row's, and ←→ walk past it.
assert talked.label.plain.endswith("x" * 200)
def test_picker_marker_lane_lines_the_two_groups_up(repo: Repository) -> None:
"""A channel glyph and a contact dot claim the same cells — on either platform.
That glyph is double-cell on regular and single-cell in the console font, so the dot is
padded to whatever it actually measures rather than to a written-down width: a
hard-coded pad left every channel row a cell adrift of every direct row on the PicoCalc.
"""
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.tools.chat import _lanes
channel = Conversation(label="Public", is_channel=True, channel_idx=0, channel_id="c0")
contact = Conversation(
label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" * 32)
)
ctx = _RowCtx(repo)
try:
for platform in (REGULAR, PICOCALC):
set_platform(platform)
lanes = _lanes([channel, contact])
rows = [_title(ctx, c, {}, _NO_KEYS, lanes).plain for c in (channel, contact)]
names = ("Public", "Alice")
at = [cell_len(r[: r.index(n)]) for r, n in zip(rows, names, strict=True)]
assert at[0] == at[1] == lanes.marker
finally:
set_platform(REGULAR)
async def test_picker_footer_fits_with_every_atom_showing(repo: Repository, monkeypatch) -> None:
"""Scroll, erase and the base atoms together stay inside the 72-cell footer budget.
The three of them coincide on an ordinary row — a thread with history long enough to
scroll — so the budget is the wording's constraint, not a corner case (it is what
``Del erase`` is: the row already names the conversation it would take the history of).
"""
import asyncio
from types import SimpleNamespace
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from meshterm.tools.chat import ChatTool
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.session import TuiSession
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
conv = Conversation(label="Ally", is_channel=False, contact=ally)
repo.record_chat_message(ChatMessage(text="y" * 200, peer=conv.peer))
ctx = SimpleNamespace(devstate=_PickerDevstate([ally]), repo=repo, chat=_PickerChat())
hints: list[str] = []
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def main() -> None:
run = asyncio.ensure_future(ChatTool()._run_live(ctx))
loop = asyncio.get_running_loop()
deadline = loop.time() + 2
while not session._stack:
assert loop.time() < deadline, "the picker never opened"
await asyncio.sleep(0)
picker = session._stack[-1]
picker.render_body(72) # the overflow probe reads the last render width
picker.handle("down") # onto the one Direct row: deletable, and overflowing
picker.render_body(72)
hints.append(picker.footer_hint)
picker.resolve(CANCEL)
await asyncio.wait_for(run, timeout=2)
await asyncio.wait_for(session.run(main()), timeout=5)
hint = hints[0]
assert cell_len(hint) <= 72, f"{cell_len(hint)} cells: {hint!r}"
for atom in ("←→ scroll", "Enter open", "Del erase", "Esc back"):
assert atom in hint
async def test_picker_del_deletes_history_after_a_red_confirm(repo: Repository) -> None:
+660
View File
@@ -0,0 +1,660 @@
"""End-to-end tests for what the CLI actually prints and returns.
:mod:`tests.test_script_output` covers the vocabulary and :mod:`tests.test_report` the
seam; this covers the commands built out of both — the real Typer app, driven against the
simulator, asserting the things a caller depends on: no escape sequences, no wrapped
records, a header its records line up under, a documented exit status — and, under
``--json``, a document that parses and a status that did not change because of a rendering
flag.
Every command here is read-only or simulator-backed, so nothing transmits.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from typer.testing import CliRunner
from meshterm.core import exitcodes
#: Any ANSI escape sequence. Nothing on the CLI's stdout may match this.
_ANSI = re.compile(r"\x1b\[")
#: A Rich console-markup tag. The scripted console no longer *interprets* markup — a node
#: name is remote data and `[bold]` in one is a name, not a style — which means a tag
#: MeshTerm writes itself is no longer consumed either, and lands in the caller's pipe.
_MARKUP = re.compile(r"\[/?(?:ok|err|warn|muted|accent|brand|bold|dim|reverse)\b[^\]]*\]")
#: The shape of a drawn route: one or more hops, each a bare name with its hash in
#: parentheses (or one of the two halves alone, where history knows only one), joined by
#: the spaced arrow. This is the whole grammar, and the arrow is what makes it one — a
#: name holding a comma no longer needs quoting to stay one hop.
_HOP = r"[^\n→]+?"
_ROUTE_LINE = re.compile(f"{_HOP}(?: → {_HOP})*")
#: The shape of a path *spec*: comma-joined lowercase hex, exactly what ``--path`` takes
#: back. The one line on either face that round-trips, so nothing may creep into it.
_PATH_SPEC = re.compile(r"[0-9a-f]+(?:,[0-9a-f]+)*")
#: The commands whose output is a listing or a key/value block, and the fields a caller
#: should find in each. Every one runs against the mock companion or the database alone.
_LISTINGS: list[tuple[str, list[str], list[str]]] = [
# (command, arguments, headings or keys the output must carry)
("contacts", [], ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]),
("info", [], ["name", "public_key", "role"]),
("config", ["show"], ["name", "radio_freq", "tx_power"]),
("preferences", ["show"], ["PREFERENCE", "VALUE", "DEFAULT", "DESCRIPTION"]),
("chat", ["list"], ["CONVERSATION", "KIND", "UNREAD", "LAST", "LAST_TEXT"]),
("platform", [], ["platform", "icons"]),
]
def _leaf_commands() -> list[tuple[str, ...]]:
"""Every registered subcommand path, walked out of the real Typer app.
Derived rather than listed: a hand-kept roster covers whatever was reworked the day it
was written, and a command added afterwards inherits none of it. Two rule violations
shipped inside commands the roster did not name — a 16384-cell rule in ``about`` and
markup tags in ``config export-key``.
"""
from typer.main import get_command
from meshterm.cli import app
def walk(command, prefix: tuple[str, ...] = ()) -> list[tuple[str, ...]]: # noqa: ANN001
found: list[tuple[str, ...]] = []
for name, sub in sorted(getattr(command, "commands", {}).items()):
here = (*prefix, name)
found.extend(walk(sub, here) if getattr(sub, "commands", None) else [here])
return found
return walk(get_command(app))
#: Commands that own the terminal or run until interrupted: they have no one-shot output.
_NOT_ONE_SHOT = {("monitor",), ("chat", "listen"), ("tx-optimize",)}
#: The one command whose output *is* its colour, and says so (:mod:`meshterm.ui.specimen`).
_KEEPS_ITS_COLOUR = {("specimen",)}
@pytest.fixture()
def run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # noqa: ANN201 - a closure
"""Invoke the real CLI against the simulator, in a config directory of its own.
``--db`` moves the history and *nothing else*: the contact cache, the outbox, the
channel cache, the stored admin passwords and any device profiles all live in the
config directory regardless. So a suite that set only ``--db`` read whatever was in
the developer's own ``~/.meshterm`` — which is how a contact named ``[/]Bob``, planted
by hand on one machine, could crash this suite on that machine and pass everywhere
else. ``$MESHTERM_HOME`` is the override that moves the whole directory, and it is
the one a test wants.
"""
from meshterm.cli import app
monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home"))
runner = CliRunner()
def invoke(*args: str): # noqa: ANN202
return runner.invoke(app, ["--mock", "--db", str(tmp_path / "test.db"), *args])
return invoke
#: Commands that refuse a machine-readable face outright, and say so as a usage error.
_REFUSES_JSON = {("specimen",)}
# -- the four rules -------------------------------------------------------------------
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
def test_a_command_prints_no_escape_sequence(run, command, args, fields) -> None: # noqa: ANN001
"""Nothing is coloured: stdout is a pipe more often than a terminal."""
result = run(command, *args)
assert result.exit_code == exitcodes.OK, result.output
assert not _ANSI.search(result.stdout)
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
def test_a_command_carries_the_fields_a_caller_looks_for(run, command, args, fields) -> None: # noqa: ANN001
"""The headings and keys are the contract; renaming one silently breaks a script."""
result = run(command, *args)
for field in fields:
assert field in result.stdout, f"{command} lost {field}"
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
def test_a_command_draws_no_frame_and_no_rule(run, command, args, fields) -> None: # noqa: ANN001
"""No borders, no boxes, no header rules — the command typed is the title."""
result = run(command, *args)
assert not set(result.stdout) & set("─│┌┐└┘├┤━┃")
@pytest.mark.parametrize("command,args,fields", _LISTINGS, ids=[c for c, _, _ in _LISTINGS])
def test_a_command_leaves_no_trailing_whitespace(run, command, args, fields) -> None: # noqa: ANN001
"""Column padding must not become invisible spaces at the end of every record."""
for line in result_lines(run(command, *args)):
assert line == line.rstrip(), repr(line)
def result_lines(result) -> list[str]: # noqa: ANN001
"""The command's stdout as lines, with the trailing blank dropped.
``result.output`` is a third buffer that *both* streams copy into, so a rule about
stdout asserted against it passes when the field it wants only ever appeared on
stderr — and fails for the wrong reason the moment anything logs.
"""
return result.stdout.splitlines()
@pytest.mark.parametrize(
"leaf",
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
ids=lambda leaf: "-".join(leaf),
)
def test_every_registered_command_obeys_the_rules(run, leaf) -> None: # noqa: ANN001
"""The rules belong to the CLI, not to the six commands somebody listed by hand.
Run bare, a command that needs arguments fails harmlessly as a usage error and still
proves it wrote nothing awkward on the way out; a command that can answer without
arguments is exercised for real. That is where both shipped violations were — the
``---`` in ``about`` drawn as a rule the width of a 16384-cell console, and the
``[muted]`` tags around what ``config export-key`` exists to emit.
"""
out = run(*leaf).stdout
assert not _ANSI.search(out), f"{' '.join(leaf)} put an escape sequence on stdout"
assert not set(out) & set("─│┌┐└┘├┤━┃╭╮╰╯"), f"{' '.join(leaf)} drew a frame or a rule"
assert not _MARKUP.search(out), f"{' '.join(leaf)} printed a console markup tag"
for line in out.splitlines():
assert line == line.rstrip(), f"{' '.join(leaf)} padded a line: {line!r}"
@pytest.mark.parametrize(
"leaf",
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
ids=lambda leaf: "-".join(leaf),
)
def test_every_registered_command_answers_json_that_parses(run, leaf) -> None: # noqa: ANN001
"""``--json`` is a rendering, so it belongs to the CLI rather than to a list of tools.
It reached two commands out of twenty and stopped, because each one that wanted it had
to restate its whole answer above the rendering. The same sweep that keeps the plain
rules honest now runs every command a second time and reads what comes back: **every
line on stdout is one complete document**, on success and on empty alike, and a
command that fails writes nothing there at all.
"""
result = run("--json", *leaf)
for line in result.stdout.splitlines():
json.loads(line) # raises, with the offending line, if anything else got out
if result.exit_code in (exitcodes.OK, exitcodes.NO_RESULT):
assert result.stdout.strip(), f"{' '.join(leaf)} exited {result.exit_code} silently"
@pytest.mark.parametrize(
"leaf",
[c for c in _leaf_commands() if c not in _NOT_ONE_SHOT | _KEEPS_ITS_COLOUR],
ids=lambda leaf: "-".join(leaf),
)
def test_the_two_faces_never_disagree_about_the_exit_status(run, leaf) -> None: # noqa: ANN001
"""``--json`` changes the rendering, never the report.
The status is the report — it is what a caller branches on — and a flag about *how the
answer looks* has no business moving it. This is the cross-check that stops the two
faces drifting the way they did before: ``devices --json`` used to exit ``0`` where the
plain path said ``5``, so a script could not tell an empty scan from a full one by
asking the same question twice.
"""
assert run("--json", *leaf).exit_code == run(*leaf).exit_code, " ".join(leaf)
@pytest.mark.parametrize("leaf", sorted(_REFUSES_JSON), ids=lambda leaf: "-".join(leaf))
def test_a_command_whose_output_is_the_colour_refuses_json_loudly(run, leaf) -> None: # noqa: ANN001
"""``specimen``'s output *is* the colour; a document of it would be an empty gesture.
A loud refusal is the honest answer, and it is the same one this project already gives
for the map, the dashboard and the live feed — which simply have no subcommand to
refuse from. It is a usage error, so stdout stays empty and the sentence goes where
every other failure's does.
"""
result = run("--json", *leaf)
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
assert "machine-readable" in result.stderr
def test_the_menu_has_no_document_and_says_so(run) -> None: # noqa: ANN001
"""``meshterm --json`` with no subcommand would launch a full-screen session.
There is no document an interactive session can emit, and starting one would hang
whatever was waiting to parse it.
"""
result = run("--json")
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
def test_the_sweep_actually_covers_the_whole_command_surface() -> None:
"""A derived list is only a guarantee while it is still finding the commands.
If the walk breaks — a Typer upgrade moving ``commands``, say — every parametrised
case silently becomes zero cases and the sweep passes by covering nothing.
"""
leaves = _leaf_commands()
assert len(leaves) > 40, f"only found {len(leaves)} commands: {leaves}"
for expected in [("about",), ("config", "export-key"), ("contacts",), ("trace",)]:
assert expected in leaves, expected
def test_a_global_option_may_be_typed_after_the_subcommand(run) -> None: # noqa: ANN001
"""``meshterm contacts --json`` is what a person types, so it has to be what works.
Click binds an option to whatever command declares it, so a global declared on the
callback is a parse error one word later. For an output format that is the wrong way
round — ``-o json`` goes after the verb in every tool that has one — and the failure
was a bare usage error with no hint that the flag simply needed moving.
"""
after = run("contacts", "--json")
before = run("--json", "contacts")
assert after.exit_code == exitcodes.OK, after.output
assert after.stdout == before.stdout
def test_a_global_option_taking_a_value_moves_with_its_value(run) -> None: # noqa: ANN001
"""Lifting ``--db`` to the front must take the path with it, not orphan it."""
assert run("contacts", "--sort", "name", "--json").exit_code == exitcodes.OK
def test_lifting_a_global_never_steals_a_subcommands_own_help(run) -> None: # noqa: ANN001
"""``contacts --help`` must stay the *contacts* help, not silently become the app's.
``--help`` is on every command, so a rule that lifts "the group's options" would take
it too and answer a different question than the one asked.
"""
contacts_help = run("contacts", "--help").stdout
assert "contacts [OPTIONS]" in contacts_help
assert "--sort" in contacts_help # a contacts option, absent from the app's own help
app_help = run("--help").stdout
assert "COMMAND [ARGS]" in app_help
assert "--sort" not in app_help
def test_a_time_column_is_headed_by_its_fact_not_by_its_format(run) -> None: # noqa: ANN001
"""``--absolute`` changes what is under the heading, so the heading must survive it.
A column headed ``AGE`` holding ``2026-09-08T04:18:25-04:00`` is a heading that lies —
and every other time column already names the fact (``HEARD``, ``RECORDED``), which
reads correctly whichever form the run asked for.
"""
run("chat", "send", "--to", "Alice", "hello")
relative = result_lines(run("chat", "history", "--to", "Alice"))[0].split()
absolute = result_lines(run("chat", "history", "--to", "Alice", "--absolute"))[0].split()
assert relative == absolute
assert "AGE" not in relative
# -- listings -------------------------------------------------------------------------
def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noqa: ANN001
"""The two rules that reversed, checked on the listing they were reversed for.
A name is bare — the alignment is what makes it one field now — and ``HEARD`` is an
age, because "heard recently?" is the question this listing is opened to ask and an ISO
instant makes the reader do arithmetic to answer it. ``--absolute`` puts the instants
back, for anyone who wants them.
"""
header, *records = result_lines(run("contacts"))
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"]
assert records, "the simulator always has contacts"
for line in records:
assert not line.startswith('"')
age = line.split()[2]
assert age in ("now", "never") or age[-1] in "mhdw"
# `--absolute` asks for a different *form* of time, not for one fewer fact, so
# `never` survives it: a node that has never been heard has no instant to print.
absolute = result_lines(run("--absolute", "contacts"))[1:]
for line in absolute:
stamp = line.split()[2]
assert stamp in ("-", "never") or stamp[:4].isdigit()
def test_contacts_never_elides_a_key(run) -> None: # noqa: ANN001
"""A truncated key is not something a caller can hand back to ``--to`` or ``--path``."""
for line in result_lines(run("contacts"))[1:]:
assert "…" not in line and not line.endswith("...")
def test_a_config_line_can_be_typed_back_into_config_set(run) -> None: # noqa: ANN001
"""``show`` names each setting by the key ``get``/``set`` take, and prints its value."""
values = dict(line.split(None, 1) for line in result_lines(run("config", "show")) if line)
assert values["name"] == "MockCompanion"
# An enum prints its number, not the reader's label: `parse_value` takes the number.
assert values["adv_loc_policy"].isdigit()
assert run("config", "get", "name").output.strip() == "MockCompanion"
def test_every_setting_show_prints_is_one_set_takes_back(run) -> None: # noqa: ANN001
"""The round-trip claim, checked on every setting rather than on the two easy ones.
``config show > f`` and feeding ``f`` back in is the obvious thing to do with a dump,
and the failure mode is silent: an empty string printed as ``""`` used to parse back
as *the two quote characters*, and the next dump looked identical, so nothing ever
said the setting had been replaced. A refusal is fine here — you cannot set a value
the firmware never reported — but a value that parses to something else is not.
"""
from meshterm.core import device_config as dc
from meshterm.tools.config import _script_value
probes = {
"str": ["", "Yagi-Repeater", "a name with spaces"],
"bool": [True, False],
"int": [0, 1, 12],
"float": [0.0, 3.5],
}
checked = 0
for _category, specs in dc.settings_by_category():
for spec in specs:
if spec.value_type == "enum" and spec.choices:
values: list = list(spec.choices)
else:
values = probes.get(spec.value_type, [])
for value in values:
printed = _script_value(spec, value)
try:
parsed = dc.parse_value(spec, printed, {})
except dc.DeviceConfigError:
continue # a loud refusal is honest; a wrong value is not
checked += 1
assert parsed == value, f"{spec.key}: show printed {printed!r} -> {parsed!r}"
assert checked > 40, f"only {checked} round-trips exercised"
def test_an_unreported_pin_is_not_masked_as_though_one_were_set(run) -> None: # noqa: ANN001
"""Bullets say "there is a secret here". An absence must not borrow that claim.
``conceal`` already declines to mask the *menu's* absence token for exactly this
reason, but the scripted token is ``-``, which it had never been shown — so a radio
that had never reported a PIN dumped six bullets, and the reader (or the bug report
they pasted it into) read that as PIN-locked.
"""
from meshterm.ui import script
from meshterm.ui.config_editor import MASK_MARK, conceal
# The rule at the boundary where it broke: a real value is masked, an absence is not,
# and each surface says which glyph it writes an absence with.
assert conceal("1234", absent=script.NONE) == MASK_MARK * 6
assert conceal(script.NONE, absent=script.NONE) == script.NONE
assert conceal("?") == "?"
# And the two surfaces must never disagree about the same radio: `show` wears bullets
# only where `get` has something to conceal.
values = dict(
line.split(None, 1) for line in result_lines(run("config", "show")) if " " in line
)
got = run("config", "get", "device_pin").output.strip()
assert (MASK_MARK in values["device_pin"]) == (got != script.NONE), (
f"show says {values['device_pin']!r} while get says {got!r}"
)
def test_config_get_prints_the_bare_value(run) -> None: # noqa: ANN001
"""The caller named the key; repeating it back is one more thing to strip off."""
assert run("config", "get", "tx_power").output.strip() == "20"
def test_preferences_get_prints_a_value_its_own_set_would_take(run) -> None: # noqa: ANN001
"""The page's ``5 s`` carries a unit that ``preferences set`` will not accept back."""
assert run("preferences", "get", "trace_cooldown_s").output.strip() == "5"
# -- path lines -----------------------------------------------------------------------
def test_a_trace_draws_its_route_with_arrows_and_keeps_its_path_in_commas(run) -> None: # noqa: ANN001
"""The sharpest of the reversals, and the reason is the project's own lexicon.
A *path* is a spec you compose and can paste back into ``--path``, so it stays
comma-separated and round-trippable. A *route* is what a walk actually did; it is not a
spec, and rendering it with commas promised a round trip it does not have. Our own node
is still named like any other hop — the menu's ``★`` says "you already know who this
is", which is true of the reader and false of whoever opens the file later.
"""
result = run("trace-path", "--path", "a1,d4,a1")
assert result.exit_code == exitcodes.OK, result.output
facts = dict(line.split(None, 1) for line in result_lines(result) if line and " " in line)
assert facts["success"] == "yes"
route = facts["route"]
assert "★" not in route
assert "→" in route
assert _ROUTE_LINE.fullmatch(route), route
assert _PATH_SPEC.fullmatch(facts["path"]), facts["path"]
assert "→" not in facts["path"]
def test_a_trace_reports_its_per_hop_readings_under_a_header(run) -> None: # noqa: ANN001
"""The second block is a record per hop, joined to the route line by hash."""
lines = result_lines(run("trace-path", "--path", "a1,d4,a1"))
header = next(line for line in lines if line.startswith("HOP"))
assert header.split() == ["HOP", "FROM", "TO", "SNR_DB"]
# -- exit statuses --------------------------------------------------------------------
def test_an_empty_result_is_its_own_status_and_prints_nothing(run) -> None: # noqa: ANN001
"""The simulator configures no channel slots: the command worked and found nothing.
``grep`` conflates "found nothing" with "worked"; this is the whole reason
:data:`~meshterm.core.exitcodes.NO_RESULT` exists.
"""
result = run("channels", "list")
assert result.exit_code == exitcodes.NO_RESULT
assert result.output.strip() == ""
def test_a_bad_flag_is_a_usage_error(run) -> None: # noqa: ANN001
"""Click's own status, named in the table so it is complete."""
assert run("contacts", "--no-such-flag").exit_code == exitcodes.USAGE
def test_a_bad_setting_key_is_a_plain_failure(run) -> None: # noqa: ANN001
"""Retrying will not help: the caller has to change what it asked for."""
result = run("config", "get", "nosuchkey")
assert result.exit_code == exitcodes.FAILURE
def test_a_failure_says_so_on_stderr_and_never_on_stdout(run) -> None: # noqa: ANN001
"""``program: what went wrong`` — so a caller parsing stdout never has to filter it."""
result = run("config", "get", "nosuchkey")
assert result.stdout == ""
assert "meshterm: unknown setting" in result.stderr
@pytest.mark.parametrize(
"args",
[
("config", "reboot"),
("config", "factory-reset"),
("config", "import-key", "00" * 32),
("channels", "clear", "1"),
("preferences", "reset"),
],
ids=["reboot", "factory-reset", "import-key", "channels-clear", "preferences-reset"],
)
def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) -> None: # noqa: ANN001
"""A missing confirmation *is* a bad argument, and it says so where errors go.
These used to print a red refusal on stdout and exit 1 — a colour and a sentence that
is not the command's answer, in whatever was reading the command's answer.
"""
result = run(*args)
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
assert "--yes" in result.stderr
@pytest.mark.parametrize(
"args,flag",
[
(("contacts", "--sort", "bogus"), "--sort"),
(("records", "--category", "long_hual"), "--category"),
],
ids=["contacts-sort", "records-category"],
)
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
"""A typo in a closed-set option used to be answered with a different question.
``--sort bogus`` quietly sorted by the default, and ``--category long_hual`` quietly
selected *every* discipline — which then came back as exit ``5``, the code that means
a real empty result. A caller branching on ``5`` could not tell "there are no records"
from "you misspelled the discipline", which is the worst answer a closed set can give.
"""
result = run(*args)
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
assert flag in result.stderr
def test_a_profile_that_names_nothing_is_refused_before_anything_transmits(run) -> None: # noqa: ANN001
"""``--profile`` is a claim about *which* radio, and an unkeepable one is not a default.
An unknown name used to fall through to ordinary discovery, so a scheduled
``--profile yagi config advert`` with a typo transmitted from whichever companion
happened to be attached — the one failure mode where being quietly wrong puts a packet
on the air.
"""
result = run("--profile", "nosuchprofile", "info")
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
assert "nosuchprofile" in result.stderr
def test_a_port_that_will_not_open_is_no_device_not_a_device_failure(run) -> None: # noqa: ANN001
"""The two device statuses answer different questions, and this one answered wrong.
``3`` says nothing was transmitted — look at what is plugged in. ``4`` says the radio
was reached and the operation failed, so a retry is reasonable. An unopenable
``--port`` reported ``4``, with the message "the connection to the device was lost"
describing a connection there had never been. The manual's own ``case $?`` recipe
branches on exactly this split.
"""
from typer.testing import CliRunner
from meshterm.cli import app
result = CliRunner().invoke(app, ["--port", "NOSUCHPORT99", "info"])
assert result.exit_code == exitcodes.NO_DEVICE
assert "could not open" in result.stderr
assert "lost" not in result.stderr
@pytest.mark.parametrize(
"args",
[
("repeater-admin", "Nobody", "get", "name"),
("courier", "queue", "Nobody", "hi"),
("courier", "queue", "Alice", "hi", "--at", "25:99"),
],
ids=["unknown-admin-node", "unknown-courier-contact", "bad-at-time"],
)
def test_a_bad_argument_is_never_reported_as_a_device_failure(run, args) -> None: # noqa: ANN001
"""``4`` promises a caller that the radio answered and a retry is worth trying.
Each of these is the *argument* being wrong, decided before a word goes over the air —
so a caller that retried on ``4`` would retransmit nothing, forever. ``tx-optimize``
already answered the identical question the identical way for a missing password;
these three were the ones that had not caught up.
"""
result = run(*args)
assert result.exit_code == exitcodes.USAGE, result.output
assert result.stdout == ""
def test_a_node_that_answers_no_is_still_a_device_failure(run) -> None: # noqa: ANN001
"""The other side of the line: the radio was reached, so a retry is a real option.
A refused admin login is not a bad argument — the password may simply have changed —
and reclassifying the arguments above must not drag this one along with them.
"""
result = run("repeater-admin", "Yagi-Repeater", "get", "name", "--password", "wrongpw")
assert result.exit_code == exitcodes.DEVICE, result.output
def test_an_empty_result_writes_nothing_to_stdout_at_all(run) -> None: # noqa: ANN001
"""Not a "no channels configured" line: the status carries it, so the pipe stays clean."""
result = run("channels", "list")
assert result.exit_code == exitcodes.NO_RESULT
assert result.stdout == ""
def test_mock_and_an_explicit_radio_are_a_bad_command_line(run) -> None: # noqa: ANN001
"""One flag says "pretend", the other names one exact radio, and both cannot be true.
``--mock`` used to win silently, so a scheduled ``--port COM7 --mock info`` reported on
a simulator while reading as though it had reached the radio. Two contradictory claims
about which device to talk to is a bad command line, not a precedence question.
"""
from typer.testing import CliRunner
from meshterm.cli import app
for flag, value in (("--port", "COM7"), ("--ble", "AA:BB"), ("--tcp", "host:1")):
result = CliRunner().invoke(app, ["--mock", flag, value, "info"])
assert result.exit_code == exitcodes.USAGE, flag
assert result.stdout == ""
assert flag in result.stderr
def test_an_acknowledgement_reaches_the_person_without_reaching_the_pipe(run) -> None: # noqa: ANN001
"""``ack`` used to be dropped on the CLI, and the reason only covered half the case.
An acknowledgement is not the answer, and a caller redirecting stdout must catch only
the answer — but stdout is not the only stream. stderr honours that rule exactly while
giving a person watching a five-minute ``config restore`` the setting-by-setting ✓ they
had no way to see.
"""
result = run("config", "set", "tx_power", "20")
assert result.exit_code == exitcodes.OK
assert result.stdout == ""
assert "tx_power" in result.stderr
def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
"""A closing count is for a person, not a record for a parser.
It restates what the listing above already showed and what ``$?`` already says, so it
has no business in a redirect — and it is exactly what someone wants after a command
that scrolled past them.
"""
result = run("records")
assert result.exit_code == exitcodes.NO_RESULT
assert result.stdout == ""
assert "records stored" in result.stderr
# -- the map --------------------------------------------------------------------------
def test_the_map_has_no_cli_command_at_all(run) -> None: # noqa: ANN001
"""A picture has no scripted face; the located nodes stay in ``contacts``."""
assert run("map").exit_code == exitcodes.USAGE
# -- help -----------------------------------------------------------------------------
def test_help_is_plain_and_names_every_exit_status(run) -> None: # noqa: ANN001
"""Click's own help: no boxed panels, no colour, and the status table under it."""
result = run("--help")
assert not _ANSI.search(result.output)
assert not set(result.output) & set("─│╭╮╰╯")
for code in exitcodes.MEANINGS:
assert f"{code} " in result.output
+4
View File
@@ -687,6 +687,10 @@ class _ScriptedUi:
def note(self, markup: str) -> None:
self.notes.append(markup)
def ack(self, markup: str) -> None:
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
self.note(markup)
def show(self, *renderables: Any) -> None:
pass
+25 -6
View File
@@ -13,6 +13,7 @@ from types import SimpleNamespace
import pytest
from meshterm.core import exitcodes
from meshterm.core.courier_store import DONE_CAP, QUEUED, CourierStore
from meshterm.core.models import ChatMessage, Contact, utcnow
from meshterm.core.watch_store import WatchStore
@@ -229,6 +230,10 @@ class _NoteUi:
def note(self, markup: str) -> None:
self.notes.append(markup)
def ack(self, markup: str) -> None:
# The menu's answer: an acknowledgement is a note. (The scripted CLI drops it.)
self.note(markup)
def show(self, *renderables: object) -> None:
self.shown.extend(renderables)
@@ -269,7 +274,11 @@ async def test_cli_list_renders_waiting_and_finished(tmp_path: Path) -> None:
result = await CourierTool().run(ctx, {"cli_action": "list"})
assert result.summary == {"entries": 2}
assert ctx.ui.shown # a table was rendered
# The outbox is *stated*, not printed: the tool hands back the rows and the CLI
# boundary picks a renderer for them (see meshterm.ui.report).
listing = result.report[0]
assert sorted(row["state"] for row in listing.rows) == ["delivered", "waiting"]
assert sorted(row["text"] for row in listing.rows) == ["landed", "still waiting"]
async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
@@ -279,7 +288,10 @@ async def test_cli_list_empty_notes_and_counts_zero(tmp_path: Path) -> None:
ctx = _ToolCtx(tmp_path)
result = await CourierTool().run(ctx, {"cli_action": "list"})
assert result.summary == {"entries": 0}
assert ctx.ui.shown == []
assert result.exit_code == exitcodes.NO_RESULT
# An empty listing, not an absent one: the plain face draws nothing from it and the
# machine face gets `[]`, which is a document a consumer can read.
assert result.report[0].rows == []
async def test_cli_cancel_removes_a_waiting_entry(tmp_path: Path) -> None:
@@ -308,13 +320,20 @@ async def test_cli_send_forces_one_attempt(tmp_path: Path) -> None:
assert result.summary == {"id": entry.ident, "outcome": "delivered"}
async def test_cli_send_unknown_entry_errors(tmp_path: Path) -> None:
"""Sending a non-existent entry is a clean device-command error, not a crash."""
from meshterm.core.connection import DeviceCommandError
async def test_cli_send_unknown_entry_is_a_bad_argument(tmp_path: Path) -> None:
"""An id nobody has is a *usage* error, and it used to claim the device had failed.
The outbox is a local file: nothing was transmitted, and no retry will make an id
exist. Exit 4 tells a caller "the radio was reached and the operation failed, try
again" — which sent a scheduled sender into a retry loop over a typo. Exit 2 says fix
the command, which is the truth.
"""
import typer
from meshterm.tools.courier import CourierTool
ctx = _ToolCtx(tmp_path)
with pytest.raises(DeviceCommandError):
with pytest.raises(typer.BadParameter):
await CourierTool().run(ctx, {"cli_action": "send", "id": 999})
+15 -8
View File
@@ -198,7 +198,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
from datetime import timedelta
from meshterm.core.models import Contact, utcnow
from meshterm.ui.widgets import ContactsSort, _ordered_contacts
from meshterm.ui.widgets import ContactsSort, ordered_contacts
same = utcnow() - timedelta(minutes=5)
contacts = [
@@ -209,7 +209,7 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
def order(ascending: bool) -> list[str]:
sort = ContactsSort(column="heard", ascending=ascending)
return [c.name for c in _ordered_contacts(contacts, {}, sort)]
return [c.name for c in ordered_contacts(contacts, {}, sort)]
# Freshest first: the two 5-min contacts lead, ordered Alice→Charlie, then Bob (3h).
assert order(ascending=True) == ["Alice", "Charlie", "Bob"]
@@ -219,23 +219,25 @@ def test_contacts_table_breaks_metric_ties_by_name_ascending() -> None:
async def test_contacts_tool_opens_sorted_by_heard(monkeypatch: pytest.MonkeyPatch) -> None:
"""The Contacts list opens most-recently-heard first — who's out there now, not a roll call."""
from meshterm.core.models import Contact
from meshterm.tools.contacts import ContactsTool
from meshterm.ui import widgets
captured: dict = {}
real_order = widgets.ordered_contacts
def fake_table(self_name, self_key, contacts, prefix_bytes, counts, sort):
def fake_order(contacts, counts, sort): # noqa: ANN001, ANN202
captured["sort"] = sort
return ""
return real_order(contacts, counts, sort)
monkeypatch.setattr(widgets, "contacts_table", fake_table)
monkeypatch.setattr(widgets, "ordered_contacts", fake_order)
class _Devstate:
async def self_info(self) -> dict:
return {"name": "Us", "public_key": "aa" * 32}
async def contacts(self) -> list:
return []
return [Contact(name="Alice", public_key="d4" * 32)]
async def path_hash_mode(self) -> int:
return 0
@@ -1325,8 +1327,13 @@ def test_uptime_renders_compact_units() -> None:
def test_require_yes_blocks_without_confirmation() -> None:
"""Destructive CLI commands abort unless --yes is passed."""
with pytest.raises(typer.Exit):
"""Destructive CLI commands abort unless --yes is passed.
As a *usage* error: a missing confirmation is a bad invocation, so it exits under the
parser's own status and prints in the parser's own words on stderr, rather than a red
line of its own on stdout (see :mod:`meshterm.core.exitcodes`).
"""
with pytest.raises(typer.BadParameter, match="--yes"):
_require_yes(False, "factory reset erases all data")
_require_yes(True, "factory reset erases all data") # does not raise
+4
View File
@@ -132,6 +132,10 @@ def test_bare_text_default_emoji_are_left_exactly_as_measured() -> None:
# A heading built around one costs its own cells and no more, so the row frames flush.
caption = cell_len(f"── {_ROAD} Longest distance ──")
assert caption == len("── ") + 1 + len(" Longest distance ──")
# The Trophy case's *visible* gap after the road is not this module's business and must
# not become it: the heading pads the mark out to the widest of the seven disciplines
# (records_screen.discipline_label), which costs a real cell the terminal advances over
# — where widening the glyph here would claim one it does not.
def test_flags_measure_two_cells_in_both_authorities() -> None:
+163
View File
@@ -22,18 +22,21 @@ starts reporting XPASS.
from __future__ import annotations
import asyncio
import logging
import tempfile
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.cells import cell_len
from rich.text import Text
from meshterm.core.advert_store import AdvertPolicy
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from meshterm.core.courier_store import CourierStore
from meshterm.core.models import (
NODE_TYPE_REPEATER,
@@ -339,6 +342,163 @@ def _chat(cols: int, rows: int) -> Screen:
)
class _PickerChat:
"""The unread counter the picker rows read live."""
def __init__(self, unread: dict[str, int]) -> None:
self._unread = unread
def unread(self, key: str) -> int:
return self._unread.get(key, 0)
class _PickerDevstate:
"""The session-cached device reads the picker builds itself from."""
def __init__(self, contacts: list[Contact], slots: list) -> None:
self._contacts = contacts
self._slots = slots
async def channel_slots(self) -> list:
return list(self._slots)
async def contacts(self) -> list[Contact]:
return list(self._contacts)
class _PickerRepo:
"""The stored history behind each row's age, badge and last-message preview."""
def __init__(self, lasts: dict) -> None:
self._lasts = lasts
def last_chat_messages(self) -> dict:
return dict(self._lasts)
def node_names(self) -> dict:
return {}
def _chat_picker(cols: int, rows: int) -> Screen:
"""The conversation picker, built through the tool's own row funnel.
Its widest case: a name at the lane's ceiling, a three-digit-capable unread badge, and
a last message far longer than any terminal — which is the row ←→ scroll, so it has to
be cut rather than fitted (see :meth:`meshterm.tools.chat.ChatTool._picker_items`).
"""
from meshterm.tools.chat import ChatTool
contacts = [
Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6, node_type=1),
Contact(name="A Rather Long Contact Name", public_key="d4" * 32, key_prefix="d4" * 6),
Contact(name="Homestead", public_key="60" * 32, key_prefix="60" * 6, node_type=1),
]
now = datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc)
lasts = {
# The default public channel, as _channels_from_slots names it with no slots read.
"chan:slot:0": ChatMessage(
text="Alice: anyone up around the Plateau tonight? testing a new antenna",
is_channel=True,
channel_idx=0,
channel_id="slot:0",
created_at=now,
),
f"dm:{'aa' * 6}": ChatMessage(
text="on my way, should be there soon - the bridge is backed up again",
peer="aa" * 6,
created_at=now,
),
}
ctx = _PickerCtx(contacts, lasts)
items = asyncio.run(ChatTool()._picker_items(ctx))
return SelectScreen(
"Chat - pick a conversation",
items,
footer_hint="↑↓ move · type to filter · Enter open · Esc back",
delete_hint="Del erase",
)
def _channels_manager(cols: int, rows: int) -> Screen:
"""The channel manager at its widest: every lane populated, every action row drawn.
Built through the feature's own row funnel, so the header line, the glyph lane, the
unread badge, the counts, the ages and the sparkline are laid out by the code the app
runs. The cases that matter are here: a name at the lane's ceiling, a muted channel (its
mark folds to a different glyph on the console), a channel with no messages at all, and
a three-digit unread badge.
"""
from meshterm.ui.channels import _MANAGER_HINT, ChannelSlot, _LiveStats, _menu_items
slots = [
ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET),
ChannelSlot(idx=1, name="Lakeside emergency", secret=bytes(range(16))),
ChannelSlot(idx=2, name="#montreal", secret=derive_secret("#montreal")),
ChannelSlot(idx=3, name="Ops", secret=bytes(range(16, 32))),
]
ctx = _ChannelsCtx(muted={slots[3].identity})
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
return SelectScreen(title, items, footer_hint=_MANAGER_HINT)
def _channel_detail(cols: int, rows: int) -> Screen:
"""One channel's action page, its vital-signs line above the rows."""
from meshterm.ui.channels import ChannelSlot, _detail_items, _detail_summary, _LiveStats
slot = ChannelSlot(idx=2, name="Lakeside emergency", secret=bytes(range(16)))
ctx = _ChannelsCtx(muted=set())
stats = _LiveStats(ctx)
return SelectScreen(
f"Channel — {slot.name}",
_detail_items(ctx, slot),
prompt=_detail_summary(ctx, slot, stats),
footer_hint="↑↓ move · Enter select · Esc back",
)
class _ChannelsChat:
"""The chat service surface the channel rows read: unread counts and mute state."""
def __init__(self, muted: set[str]) -> None:
self._muted = muted
def unread(self, key: str) -> int:
"""A three-digit badge on one channel, so the lane is drawn at its widest."""
return 128 if key.endswith("slot:1") or "Lakeside" in key else 0
class _ChannelsCtx:
"""The minimal AppContext surface the channel rows and detail page read."""
def __init__(self, muted: set[str]) -> None:
self.chat = _ChannelsChat(muted)
self.repo = _ChannelsRepo()
self.preferences = Preferences()
self._muted = muted
@property
def mute_store(self): # noqa: ANN201 - a stand-in for the real store
"""A store answering only the one question a row asks it."""
return SimpleNamespace(is_muted=lambda identity: identity in self._muted)
class _ChannelsRepo:
"""Channel statistics with one busy channel, one quiet one, and one never used."""
def channel_stats(self, *a, **k): # noqa: ANN002, ANN003, ANN201
"""Return nothing: the rows then draw their empty marks, which is the tighter case."""
return {}
class _PickerCtx:
"""The minimal AppContext surface :meth:`ChatTool._picker_items` reads."""
def __init__(self, contacts: list[Contact], lasts: dict) -> None:
self.devstate = _PickerDevstate(contacts, [])
self.repo = _PickerRepo(lasts)
self.chat = _PickerChat({"chan:slot:0": 3, f"dm:{'aa' * 6}": 12})
def _livefeed(cols: int, rows: int) -> Screen:
return LiveFeedScreen(
session=_GallerySession(cols, rows),
@@ -701,6 +861,9 @@ _ENTRIES: list[_Entry] = [
_Entry("node_detail", _node_detail),
_Entry("map", _map),
_Entry("chat", _chat),
_Entry("chat_picker", _chat_picker),
_Entry("channels_manager", _channels_manager),
_Entry("channel_detail", _channel_detail),
_Entry("livefeed", _livefeed),
_Entry("walk", _walk),
_Entry("timemachine", _timemachine),
+7 -62
View File
@@ -1178,36 +1178,6 @@ def ctx(tmp_path: Path):
context.repo.close()
async def test_map_tool_static_render_plots_contacts(ctx) -> None:
"""The CLI path plots the device's located contacts and reports the breakdown.
The mock companion's contacts carry two located repeaters and one located leaf node
(a fourth, Alice, has no fix), so the contact list — not the observation history — is
what fills the map.
"""
# Seed an observation whose key matches a located contact, so its reception detail is
# merged onto that contact's marker.
run_id = ctx.repo.start_run("monitor", {})
ctx.repo.record_observation(
run_id,
Observation(
node="a1b2c3d4",
name="Yagi-Repeater",
node_type=NODE_TYPE_REPEATER,
snr=6.0,
observed_at=utcnow(),
),
)
result = await MapTool().run(ctx, {"static": True, "basemap": False})
assert result.summary["located"] == 3
assert result.summary["repeaters"] == 2
assert result.summary["nodes"] == 1
assert result.summary["self_located"] is False
out = ctx.console.file.getvalue()
assert out != ""
assert "1 pkts" in out # the seeded observation's detail merged onto the contact
def test_usable_fix_rejects_out_of_range_and_null_island() -> None:
"""A fix is usable only in valid lat/lon range and away from the 0/0 no-GPS sentinel."""
from meshterm.core.geo import usable_fix
@@ -2565,41 +2535,16 @@ def test_each_road_class_keeps_its_own_colour_through_the_per_tile_style_memo()
assert mark_rgb(expected[0]) in painted, f"{cls} lost its own colour"
def _run_map_cli(monkeypatch, *args: str):
"""Invoke the ``map`` subcommand with the tool itself stubbed out.
def test_the_map_registers_no_cli_command() -> None:
"""The map is menu-only: a picture has no scripted face.
Returns the params dict the command would have handed :func:`run_tool_command`,
or ``None`` if the invocation failed before getting there.
Its answer is a drawing whose nodes are told apart by colour, which the scripted CLI
does not have (see :mod:`meshterm.ui.script`) — so the tool registers nothing rather
than printing an unparseable block of braille. The located nodes stay scriptable
through ``meshterm contacts``.
"""
import typer
from typer.testing import CliRunner
from meshterm import cli
captured: dict = {}
monkeypatch.setattr(cli, "run_tool_command", lambda tool, params: captured.update(params))
app = typer.Typer()
MapTool().register_cli(app)
result = CliRunner().invoke(app, list(args))
return result, (captured or None)
def test_map_cli_fraction_defaults_to_the_preference(monkeypatch) -> None:
"""No ``--fraction`` means *ask the preferences*, which is a ``None`` to pass on.
The bounds check used to compare that ``None`` against 0.0, so the plain
``meshterm map`` — the one invocation nobody passes a flag to — was the one that
raised.
"""
result, params = _run_map_cli(monkeypatch)
assert result.exit_code == 0, result.output
assert params is not None and params["fraction"] is None
@pytest.mark.parametrize("value,ok", [("0", False), ("0.5", True), ("1.0", True)])
def test_map_cli_fraction_bounds(monkeypatch, value: str, ok: bool) -> None:
"""A fraction of zero frames nothing, so it is refused; anything up to 1.0 stands."""
result, params = _run_map_cli(monkeypatch, "--fraction", value)
assert (result.exit_code == 0) is ok, result.output
if ok:
assert params is not None and params["fraction"] == float(value)
assert app.registered_commands == []
+99 -2
View File
@@ -20,7 +20,15 @@ from prompt_toolkit.output import DummyOutput
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.prompt import ButtonDialog
from meshterm.ui.tui.screen import CANCEL, POP_ALL, BusyScreen, PopToMenu, Screen, ScrollScreen
from meshterm.ui.tui.screen import (
CANCEL,
POP_ALL,
BusyDialog,
BusyScreen,
PopToMenu,
Screen,
ScrollScreen,
)
from meshterm.ui.tui.select import Choice, SelectScreen
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS, TuiSession
@@ -181,8 +189,14 @@ async def test_the_trace_target_picker_stays_pushed_under_the_live_screen(monkey
monkeypatch.setattr("meshterm.ui.trace_screen.open_trace", fake_open_trace)
async def main() -> None:
# The picker is pushed by `_run_live` itself, so the keystroke cannot be
# queued before the call: sending it blind raced the screen that reads it,
# and lost about one full-suite run in six. `_screen_at` exists for exactly
# this — wait until the picker is on the stack, *then* press Enter on it.
running = asyncio.create_task(TraceTool()._run_live(ctx))
await _screen_at(session, 1)
inp.send_text(ENTER) # Enter on the leading row commits it as the target
result = await TraceTool()._run_live(ctx)
result = await running
assert result.summary == {"sessions": 1, "traces": 2}
await asyncio.wait_for(session.run(main()), timeout=5)
@@ -579,6 +593,7 @@ def test_pop_all_is_refused_over_anything_modal() -> None:
ButtonDialog("Delete it?", [("Cancel", False), ("Delete", True)]),
ProgressScreen("Working"),
BusyScreen("checking…"),
BusyDialog("saving Lakeside…"),
):
session.push(layer)
assert session.request_pop_all() is False, f"{type(layer).__name__} must block ^W"
@@ -727,6 +742,10 @@ def test_only_navigational_layers_are_non_modal() -> None:
assert SelectScreen("list", [Choice("a", 1)]).modal is False
assert BusyScreen("checking…").floating is False
assert BusyScreen("checking…").modal is True
# ...and its twin is the same claim on the keyboard drawn as a box over what it
# interrupts, which is the pair's whole point.
assert BusyDialog("saving…").floating is True
assert BusyDialog("saving…").modal is True
assert Screen().modal is False
assert CANCEL is not POP_ALL
@@ -803,3 +822,81 @@ async def test_a_detached_flow_still_reports_a_real_failure() -> None:
task = session.run_detached(work())
with pytest.raises(RuntimeError):
await task
# --- work in flight ----------------------------------------------------------
async def test_a_key_pressed_during_a_slow_action_reaches_the_hub_that_is_still_armed() -> None:
"""The defect ``busy_dialog`` exists to close, stated as a test.
A hub kept up with ``stay`` is armed for the whole visit, so a key pressed while the
caller is off doing slow device work still lands on it. Esc resolves the round nobody
is waiting for yet, and it is handed back the instant the work finishes — the screen
closing on its own a beat after a press that appeared to do nothing.
"""
with create_pipe_input() as inp:
session = _session(inp)
screen = SelectScreen("hub", [Choice("alpha", 1)])
banked: list = []
async def main() -> None:
async with session.stay(screen) as visit:
inp.send_text(ESC) # pressed "while the write is running"
await asyncio.sleep(0.05) # let the input loop dispatch it
banked.append(await asyncio.wait_for(visit.result(), timeout=1))
await asyncio.wait_for(session.run(main()), timeout=5)
assert banked == [CANCEL] # the press was banked and comes straight back
async def test_the_busy_dialog_takes_the_keys_the_hub_would_have_banked() -> None:
"""With the card up, the same press dies on it: nothing is banked, nothing resolves.
This is what makes a slow channel write safe to sit through — the list underneath
cannot be filtered down to nothing, cannot be re-fired by an impatient second Enter,
and cannot be closed by an Esc that looks ignored until the work lands.
"""
with create_pipe_input() as inp:
session = _session(inp)
screen = SelectScreen("hub", [Choice("alpha", 1)])
timed_out = []
async def main() -> None:
async with session.stay(screen) as visit:
async with session.busy_dialog("saving…", title="Channels"):
inp.send_text(ESC + "zzz" + ENTER) # Esc, filter letters, and Enter
await asyncio.sleep(0.05)
assert session.top.__class__ is BusyDialog # the card owns the keyboard
try:
await asyncio.wait_for(visit.result(), timeout=0.2)
except asyncio.TimeoutError:
timed_out.append(True)
await asyncio.wait_for(session.run(main()), timeout=5)
assert timed_out == [True] # nothing was banked while the card was up
assert screen._filter == "" # and the letters never reached the list's filter
async def test_the_busy_dialog_is_one_card_however_deeply_it_nests() -> None:
"""A batch of writes reports as one wait, not a box flashing per write."""
with create_pipe_input() as inp:
session = _session(inp)
depths: list[int] = []
async def main() -> None:
session.push(ScrollScreen("hub", floating=False))
async with session.busy_dialog("reordering channels…") as outer:
depths.append(len(session._stack))
async with session.busy_dialog("moving Lakeside · 1/2") as inner:
assert inner is outer # the nested wait rides the card already up
depths.append(len(session._stack))
outer.message = "moving Dorval · 2/2" # the owner retitles it as it goes
depths.append(len(session._stack))
depths.append(len(session._stack))
await asyncio.wait_for(session.run(main()), timeout=5)
assert depths == [2, 2, 2, 1] # pushed once, popped once, hub still underneath
+24 -16
View File
@@ -112,18 +112,19 @@ def test_empty_path_reads_as_the_callers_word() -> None:
def test_chips_are_joined_by_one_interlocked_chevron() -> None:
"""A seam is one cell, not two.
"""A seam is one cell, not two — and the path's own end is not a seam at all.
The previous chip's point is laid *on* the next chip's fill, so the route reads as
a ribbon whose segments meet on a chevron. The closing edge keeps its lone taper —
nothing follows it to lay the point on.
a ribbon whose segments meet on a chevron. The point is spoken for: it is what says
the route continues, so a finished path never wears one. It ends square on the last
chip's own pad, and the only chevrons in the line are the joins.
"""
alice_fill = node_style("aa").split()[-1]
line = PathLine([PathHop("Alice", key="aa"), PathHop("you", you=True)], mode="powerline")
text = line.text()
assert text.plain == f" Alice {POWERLINE_SEP} you {POWERLINE_SEP}"
assert text.plain == f" Alice {POWERLINE_SEP} you "
seam_styles = [str(s.style) for s in text.spans if text.plain[s.start : s.end] == POWERLINE_SEP]
assert seam_styles == [f"{alice_fill} on {_YOU_BG}", _YOU_BG]
assert seam_styles == [f"{alice_fill} on {_YOU_BG}"]
def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
@@ -136,9 +137,9 @@ def test_chips_keep_the_same_words_and_honour_style_overrides() -> None:
chips = PathLine(hops, mode="powerline").text().plain
assert plain.replace(" → ", " ") == chips.replace(POWERLINE_SEP, "").replace(" ", " ").strip()
themed = PathLine([PathHop("X", style="brand")], mode="powerline").text()
assert str(themed.spans[-1].style) == "#5eead4" # the closing edge, brand-filled
assert pathline._fills(themed)[-1] == "#5eead4" # the chip runs to the very last cell
hexed = PathLine([PathHop("X", style="bold #123456")], mode="powerline").text()
assert str(hexed.spans[-1].style) == "#123456"
assert pathline._fills(hexed)[-1] == "#123456"
def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -161,7 +162,7 @@ def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None:
assert _styles(plain)[CURSOR_GLYPH] == "selected" # the reverse-video block
chips = PathLine(hops, mode="powerline").text()
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b {POWERLINE_SEP}"
assert chips.plain == f" a {POWERLINE_SEP} {CURSOR_GLYPH} {POWERLINE_SEP} b "
slot = next(s for s in chips.spans if chips.plain[s.start : s.end] == CURSOR_GLYPH)
assert str(slot.style).endswith(f"on {_style_hex('cursor')}") # the cursor-white fill
@@ -500,10 +501,11 @@ def test_wrapped_carries_the_cursor_like_any_other_hop() -> None:
def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
"""Chip wrapping never splits a chip; each line opens on the break it continues.
Every line ends on the pointed edge, and every line *but the first* opens on the
break's other half — the point notched out of its own fill in reverse video, so the
page shows through and not a trace of the line above bleeds down. The point always
faces the way the path flows.
Every line the path *outruns* ends on the pointed edge — the last one ends square,
the route having finished — and every line *but the first* opens on the break's
other half: the point notched out of its own fill in reverse video, so the page
shows through and not a trace of the line above bleeds down. The point always faces
the way the path flows.
"""
hops = [
PathHop(label, key=key)
@@ -518,7 +520,8 @@ def test_wrapped_chip_lines_open_on_the_break_they_continue() -> None:
# (it marks the run as a path line for the cursor fold and draws nothing).
carried = next(s for s in lines[1].spans if s.start == step and str(s.style) != PATH_INK)
assert str(carried.style) == f"{_style_hex(node_style('3d'))} reverse"
assert all(line.plain.endswith(POWERLINE_SEP) for line in lines)
assert lines[0].plain.endswith(POWERLINE_SEP) # …the path runs on past this line
assert lines[-1].plain.endswith(" ") # …and stops square on the last chip's pad
assert all(line.cell_len <= 20 for line in lines)
@@ -526,8 +529,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
"""Rounded caps finish a path only where the font actually has them.
A full Nerd Font rounds the path's two *outer* ends into a lozenge; a core-only
terminal squares the opening and points the close, never drawing tofu. Interior
breaks stay angled either way — a rounded end would read as the path stopping.
terminal squares *both* of them off, appending nothing and letting the outer chips'
own pads be the edges, never drawing tofu. Interior breaks stay angled either way —
a rounded end would read as the path stopping, and a pointed one at the finish would
read as the opposite, a route cut off mid-walk.
"""
hops = [
PathHop(label, key=key)
@@ -537,7 +542,10 @@ def test_rounded_caps_finish_a_path_only_where_the_font_has_them(monkeypatch) ->
monkeypatch.setattr(pathline, "powerline_full", lambda: False)
assert line.text().plain.startswith(" AAAA")
assert line.text().plain.endswith(POWERLINE_SEP)
assert line.text().plain.endswith("DDDD ")
# The point is still what a *wrapped* line ends on: there the route really does go on.
squared = line.wrapped(20, indent=2)
assert squared[0].plain.endswith(POWERLINE_SEP) and squared[-1].plain.endswith("DDDD ")
monkeypatch.setattr(pathline, "powerline_full", lambda: True)
assert line.text().plain.startswith(POWERLINE_ROUND_OPEN + " AAAA")
+3
View File
@@ -414,6 +414,9 @@ class _ScriptedUi:
def note(self, markup: str) -> None:
pass
def ack(self, markup: str) -> None:
pass
@pytest.fixture()
def ctx(tmp_path: Path) -> AppContext:
+17 -3
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
from pathlib import Path
import pytest
from rich.cells import cell_len
from rich.console import Console
from meshterm.context import AppContext
@@ -22,7 +23,13 @@ from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.persistence.repository import DiscoveredPath, Repository
from meshterm.services.records import CATEGORIES, CATEGORY_BY_ID
from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records
from meshterm.ui.records_screen import (
RecordDialog,
WalkVertex,
discipline_label,
discipline_lane,
open_records,
)
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui import frame
from meshterm.ui.tui.prompt import TypedConfirmDialog
@@ -513,7 +520,14 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
blocks = [
[_plain([line]).strip() for line in rows] for _idx, rows in browser._sticky_headers
]
assert [rows[0] for rows in blocks] == [f"── {c.icon} {c.title} ──" for c in CATEGORIES]
lane = discipline_lane()
assert [rows[0] for rows in blocks] == [
f"── {discipline_label(c, lane)} ──" for c in CATEGORIES
]
# And every one of them starts its title in the same column: the marks are not all
# the same width (🛣 and 🕸 are one cell where the rest are two), so the lane pads.
starts = {cell_len(discipline_label(c, lane)) - cell_len(c.title) for c in CATEGORIES}
assert len(starts) == 1
assert all(len(rows) >= 2 for rows in blocks) # each carries its description too
# Highlighting deep in the one populated board scrolls its heading off the top of a
# short viewport; the heading leads what pins, its description under it.
@@ -521,7 +535,7 @@ async def test_the_board_pins_its_discipline_heading_and_description(tui_ctx) ->
browser.handle("down")
visible, above, _below = frame._visible_slice(browser, browser.render_body(72), 10)
board = CATEGORY_BY_ID["grand_tour"]
assert _plain([visible[0]]).strip() == f"── {board.icon} {board.title} ──"
assert _plain([visible[0]]).strip() == f"── {discipline_label(board, lane)} ──"
assert _plain([visible[1]]).strip().startswith(board.description[:20])
assert above is True
browser.resolve(None) # Esc
+437
View File
@@ -0,0 +1,437 @@
"""Tests for the seam between a tool's answer and its rendering.
:mod:`meshterm.ui.report` is where a tool states what it found, :mod:`meshterm.ui.fields`
is where the shapes it states it in are defined once, and :mod:`meshterm.ui.renderers` is
where that becomes bytes. The claim the whole design rests on is that **one typed row
projects two ways** — so almost every test here builds one report and asserts both faces
of it, because a rule that holds in only one of them is exactly the drift this replaced.
"""
from __future__ import annotations
import io
import json
from datetime import datetime, timezone
import pytest
from meshterm.ui import fields, script
from meshterm.ui.renderers import JsonRenderer, OutputFormat, PlainRenderer, for_format
from meshterm.ui.report import BARE, SILENT, Column, Facts, Lane, Listing
_WHEN = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
def _plain(*blocks) -> str: # noqa: ANN002 - blocks vary per test
"""Render a report through the plain face and return what came out."""
buffer = io.StringIO()
PlainRenderer(script.console(buffer)).render(blocks)
buffer.flush()
return buffer.getvalue()
def _json(*blocks): # noqa: ANN002, ANN202 - blocks vary per test
"""Render a report through the machine face and return the parsed document."""
buffer = io.StringIO()
JsonRenderer(script.console(buffer)).render(blocks)
lines = [line for line in buffer.getvalue().splitlines() if line]
assert len(lines) == 1, lines
return json.loads(lines[0])
def _contacts(rows) -> Listing: # noqa: ANN001 - rows vary per test
"""A listing in the shape ``contacts`` uses: a node, an age and a count."""
return Listing(
key="contacts",
columns=(
fields.node(
"node",
lanes=(("name", "NAME"), ("type", "TYPE"), ("hash", "HASH"), ("key", "KEY")),
),
fields.when("heard_at", "HEARD", absent="never"),
fields.integer("packets", "PKTS"),
),
rows=rows,
order=("NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"),
)
_ALICE = {
"node": fields.NodeRef(name="Alice", key="d4" * 32, hash="d4", type="node"),
"heard_at": _WHEN,
"packets": 12,
}
# -- one row, two faces ----------------------------------------------------------------
def test_one_typed_row_reaches_both_faces_without_either_parsing_the_other() -> None:
"""The claim the seam exists for: a row holds the value, not somebody's rendering.
The plain face writes ``+5.1`` because a column of readings only compares by eye once
every one declares its sign; the document writes ``5.1`` because a number carries its
own. Neither could be derived from the other without a parser, which is what the two
hand-written ``if ctx.json_output:`` branches were.
"""
block = Listing(key="hops", columns=(fields.snr("snr_db", "SNR_DB"),), rows=[{"snr_db": 5.14}])
assert _plain(block).splitlines()[1].strip() == "+5.1"
assert _json(block) == [{"snr_db": 5.1}]
def test_a_node_is_one_object_however_many_columns_it_is_worth() -> None:
"""One machine key, four plain lanes — the thing :func:`fields.node` earns.
Every listing that mentions a node asks for the same column, so ``jq '.[].node.key'``
reads the same on contacts, courier and monitor and a consumer's node handler is
written once. What differs is only how much room the surface has for it.
"""
header, record = _plain(_contacts([_ALICE])).splitlines()
assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "KEY"]
assert len(record.split()) == len(header.split())
assert "Alice" in record and "d4" * 32 in record
assert _json(_contacts([_ALICE]))[0]["node"] == {
"name": "Alice",
"key": "d4" * 32,
"hash": "d4",
"type": "node",
"self": False,
}
def test_a_listing_may_draw_its_lanes_in_an_order_the_document_does_not_use() -> None:
"""``contacts`` puts HEARD and PKTS between a node's TYPE and its HASH.
The lane order is a reading decision — the fields a person scans first go left — and
the key order is the document's. Tying them together would have meant choosing which
face to make worse.
"""
header = _plain(_contacts([_ALICE])).splitlines()[0].split()
assert header.index("HEARD") < header.index("HASH")
assert list(_json(_contacts([_ALICE]))[0]) == ["node", "heard_at", "packets"]
def test_a_listing_refuses_an_order_that_does_not_name_its_own_lanes() -> None:
"""A typo here draws a listing with a column missing and no complaint.
That is the one failure a header line cannot show — the records still line up, under
the wrong headings — so it is caught where it is written instead.
"""
with pytest.raises(ValueError, match="every lane"):
Listing(
key="x",
columns=(fields.integer("a", "A"), fields.integer("b", "B")),
order=("A", "NOPE"),
)
# -- absences --------------------------------------------------------------------------
def test_the_two_faces_spell_absence_in_their_own_alphabets() -> None:
"""``-`` is learned once by a reader; ``null`` is what a parser already has.
Same fact, and neither face has to know the other's token — which is what stopped the
plain ``-`` from ever reaching a document as a string.
"""
empty = {"node": None, "heard_at": None, "packets": None}
record = _plain(_contacts([empty])).splitlines()[1]
assert record.split() == ["-", "unknown", "never", "-", "-", "-"]
assert _json(_contacts([empty]))[0] == {"node": None, "heard_at": None, "packets": None}
def test_a_word_standing_in_for_an_absence_stays_a_word_only_on_the_plain_face() -> None:
"""``unknown`` is a word a reader acts on; a consumer already has ``null`` for it.
Carrying both would give a program two spellings for one fact and a special case to
remember on every field.
"""
row = {"node": fields.NodeRef(hash="a1")}
block = Listing(key="n", columns=(fields.node("node", lanes=(("type", "TYPE"),)),), rows=[row])
assert _plain(block).splitlines()[1].strip() == "unknown"
assert _json(block)[0]["node"]["type"] is None
def test_a_never_heard_node_is_a_fact_and_an_undated_row_is_an_absence() -> None:
"""Two different things, and the age lane says which without a second column.
A node that has never been heard *has* a heard-age; a row with no such time at all
does not. Flattening both onto ``-`` would have made "we have not heard from it" and
"this kind of row has no heard time" the same reading.
"""
assert script.age(None, absent="never") == "never"
assert script.age(None) == script.NONE
def test_info_leaves_out_a_reading_the_radio_does_not_answer_for_and_json_keeps_the_key() -> None:
"""The one deliberate disagreement between the faces, and why it is deliberate.
A missing plain key says "this radio does not report it", where a dash would claim it
reported nothing — a distinction a person can act on. A consumer cannot, and a
variable key set costs it a membership test on every field, so the document keeps the
key and writes ``null``.
"""
block = Facts(
key="info",
fields=(fields.word("model", "model"), fields.integer("uptime_s", "uptime_s")),
values={"model": None, "uptime_s": 42},
omit_absent=True,
)
assert _plain(block).split() == ["uptime_s", "42"]
assert _json(block) == {"model": None, "uptime_s": 42}
# -- facts, bare and silent ------------------------------------------------------------
def test_a_get_prints_its_value_alone_and_still_documents_the_key() -> None:
"""The caller named the key, so repeating it back is one more thing to strip off.
``$(meshterm config get name)`` needs no stripping — an argument entirely about shell
substitution, which a JSON consumer is not doing, so the document keeps the key, the
type and the label the bare form threw away.
"""
block = Facts(
key="setting",
fields=(
fields.word("key", "key"),
fields.word("value", "value"),
fields.hidden("type"),
),
values={"key": "tx_power", "value": "20", "type": "int"},
shape=BARE,
bare="value",
)
assert _plain(block).strip() == "20"
assert _json(block) == {"key": "tx_power", "value": "20", "type": "int"}
def test_a_bare_answer_with_nothing_in_it_prints_nothing_at_all() -> None:
"""A node that never answered, a slot with nothing in it — and no lone dash.
The bare form prints *the value*, so where there is none the exit status is the whole
report and stdout stays empty. The document still carries the shape, with ``null``
where the answer would have been, because a consumer branching on it needs the key.
"""
block = Facts(
key="remote",
fields=(fields.word("command", "command"), fields.word("reply", "reply")),
values={"command": "get name", "reply": None},
shape=BARE,
bare="reply",
)
assert _plain(block) == ""
assert _json(block) == {"command": "get name", "reply": None}
def test_an_acknowledgement_prints_nothing_and_is_still_a_document() -> None:
"""Printing nothing is an answer a person can act on and a program cannot.
``config advert`` says everything it has to say in its exit status, and that is the
right plain answer. A caller that needs to log *what was sent* has nowhere to read it,
which is what the document is for.
"""
block = Facts(
key="advert",
fields=(fields.flag("sent", "sent"), fields.flag("flood", "flood")),
values={"sent": True, "flood": False},
shape=SILENT,
)
assert _plain(block) == ""
assert _json(block) == {"sent": True, "flood": False}
# -- several blocks --------------------------------------------------------------------
def test_a_single_block_report_is_its_block_and_nothing_around_it() -> None:
"""``meshterm contacts --json | jq '.[].node.name'`` reads what it looks like.
There is no envelope: the report a caller needs is ``$?``, exactly as it is without
the flag, so nothing has to be reached through to get at the answer.
"""
assert isinstance(_json(_contacts([_ALICE])), list)
def test_several_blocks_make_one_object_with_the_facts_at_its_top_level() -> None:
"""A trace is a set of facts *and* a per-hop table, and the document says so.
The facts block is the set of facts about the one thing the command answered, so it is
the document rather than a compartment inside it; a listing needs a name and takes its
own key.
"""
facts = Facts(
key="trace", fields=(fields.flag("success", "success"),), values={"success": True}
)
edges = Listing(key="edges", columns=(fields.integer("index", "HOP"),), rows=[{"index": 0}])
assert _json(facts, edges) == {"success": True, "edges": [{"index": 0}]}
assert _plain(facts, edges).splitlines() == ["success yes", "", "HOP", " 0"]
def test_a_block_drawn_only_for_a_person_never_reaches_the_document() -> None:
"""``monitor``'s closing per-node summary is derivable from the records above it.
Emitting it as a second *shape* of line mid-stream would cost every reader a
discriminator it otherwise never needs, so the plain face keeps the aggregate and the
stream stays homogeneous.
"""
facts = Facts(
key="window", fields=(fields.integer("packets", "packets"),), values={"packets": 2}
)
summary = Listing(
key="heard",
columns=(fields.integer("packets", "PKTS"),),
rows=[{"packets": 2}],
plain_only=True,
)
assert "PKTS" in _plain(facts, summary)
assert _json(facts, summary) == {"packets": 2}
def test_a_block_that_prints_nothing_leaves_no_blank_line_where_it_stood() -> None:
"""The separator is counted off what was drawn, not off the block index.
``config restore --dry-run`` states its outcome silently and its plan as a listing, and
counting positions instead opened the command with an empty line — output it did not
have, in whatever was reading it.
"""
silent = Facts(
key="restore", fields=(fields.flag("dry_run", "dry_run"),), values={}, shape=SILENT
)
plan = Listing(
key="operations",
columns=(fields.word("operation", "OPERATION"),),
rows=[{"operation": "set"}],
)
assert _plain(silent, plan).splitlines()[0] == "OPERATION"
# -- the document's own rules ----------------------------------------------------------
def test_a_document_is_one_compact_line_that_never_escapes_a_name() -> None:
r"""A node name in Cyrillic is a node name, not a run of ``\\uXXXX``.
One line, so a stream and a one-shot answer are read the same way and ``jq .`` can
re-indent for a person at no cost. Nothing can un-stream a document that was
pretty-printed across four hundred lines.
"""
buffer = io.StringIO()
row = {"node": fields.NodeRef(name="Наблюдатель", hash="c3")}
block = Listing(key="n", columns=(fields.node("node"),), rows=[row])
JsonRenderer(script.console(buffer)).render((block,))
out = buffer.getvalue()
assert out.endswith("\n") and out.count("\n") == 1
assert "Наблюдатель" in out
assert ", " not in out and ": " not in out
def test_every_time_in_a_document_is_utc_to_the_second() -> None:
"""Twenty characters, always, so string comparison is time comparison.
A local offset is a fact about the machine that ran the command rather than about the
event, and two hosts asking the same radio the same question must produce the same
document.
"""
stamped = _json(_contacts([_ALICE]))[0]["heard_at"]
assert stamped == "2026-09-07T22:22:41Z"
assert len(stamped) == 20
def test_a_time_whose_offset_we_do_not_know_is_absent_on_both_faces() -> None:
"""A naive datetime is not a fact, and neither face may invent an offset for it."""
naive = {"node": None, "heard_at": datetime(2026, 9, 7, 22, 22, 41), "packets": None}
assert "2026" not in _plain(_contacts([naive]))
assert _json(_contacts([naive]))[0]["heard_at"] is None
def test_a_concept_may_have_a_face_on_only_one_side() -> None:
"""Both directions, and each has a real call site.
A preference's DESCRIPTION is prose for whoever is choosing a value and carries
nothing a program would branch on; a setting's declared type is the reverse — a fact
the document wants and a reader dumping ``config show`` does not.
"""
block = Listing(
key="prefs",
columns=(
fields.word("key", "PREFERENCE"),
fields.note("help", "DESCRIPTION"),
fields.hidden("type"),
),
rows=[{"key": "trace_cooldown_s", "help": "Wait this long", "type": "int"}],
)
assert "Wait this long" in _plain(block)
assert _json(block) == [{"key": "trace_cooldown_s", "type": "int"}]
# -- streams ---------------------------------------------------------------------------
def _stream_listing() -> Listing:
"""A live listing in the shape ``monitor`` uses: pinned lanes, the name last."""
return Listing(
key="observations",
columns=(
fields.instant("observed_at", "TIME"),
fields.node("node", lanes=(("hash", "NODE"), ("name", "NAME"))),
),
order=("TIME", "NODE", "NAME"),
)
def test_a_stream_pins_its_lanes_because_the_widths_arrive_with_the_data() -> None:
"""A live capture has no "once every record is in", which is when columns are sized.
Both streams used to be gutter-joined with no alignment at all, which is why a name in
one still carried its quotes — nothing else said where the field ended.
"""
buffer = io.StringIO()
listing = _stream_listing()
with PlainRenderer(script.console(buffer)).stream(listing) as row:
row({"observed_at": _WHEN, "node": fields.NodeRef(name="Alice", hash="d4e5f6a7")})
row({"observed_at": _WHEN, "node": fields.NodeRef(name="A Very Long Node Name", hash="c3")})
header, first, second = buffer.getvalue().splitlines()
assert header.split() == ["TIME", "NODE", "NAME"]
assert first.index("d4e5f6a7") == second.index("c3")
def test_a_streamed_document_is_one_record_per_line_all_the_same_shape() -> None:
"""A consumer reads line by line and never has to ask which kind of record it holds."""
buffer = io.StringIO()
listing = _stream_listing()
with JsonRenderer(script.console(buffer)).stream(listing) as row:
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="d4")})
row({"observed_at": _WHEN, "node": fields.NodeRef(hash="a1")})
documents = [json.loads(line) for line in buffer.getvalue().splitlines()]
assert [set(d) for d in documents] == [{"observed_at", "node"}] * 2
assert [d["node"]["hash"] for d in documents] == ["d4", "a1"]
# -- the boundary ----------------------------------------------------------------------
def test_the_format_picks_the_renderer_and_nothing_else_does() -> None:
"""One choice, made once, at the one place that knows what the caller asked for."""
console = script.console(io.StringIO())
assert isinstance(for_format(OutputFormat.PLAIN, console), PlainRenderer)
assert isinstance(for_format(OutputFormat.JSON, console), JsonRenderer)
def test_a_tool_with_no_scripted_face_renders_nothing_at_all() -> None:
"""The map and the dashboard state no report, and both renderers must be fine with it."""
assert _plain() == ""
buffer = io.StringIO()
JsonRenderer(script.console(buffer)).render(None)
assert buffer.getvalue() == ""
def test_a_lane_and_a_column_are_separable_so_a_third_format_needs_neither() -> None:
"""The point of the split, stated as a test: a format is a function of the typed row.
A renderer that wanted CSV would read the same columns and the same rows and write its
own cells. Nothing about the report knows a gutter, a comma or a brace.
"""
column = Column(key="n", lanes=(Lane(header="N", render=lambda v: f"<{v}>"),))
assert column.lanes[0].render(3) == "<3>"
assert column.json(3) == 3
+568
View File
@@ -0,0 +1,568 @@
"""Tests for the plain CLI's output language (:mod:`meshterm.ui.script`).
The plain face is read by a person at a prompt — ``--json`` carries the machine now — and
every rule that makes it legible is testable: no colour, no wrapping, no framing, bare
names that still cannot break a record, relative ages, arrows for a route and commas for a
path, one token for absent, one documented exit status per outcome. These are the
enforcement point for :mod:`meshterm.ui.script` the way ``test_gallery`` is for the
screens, and :mod:`tests.test_report` is for the seam above them.
"""
from __future__ import annotations
import io
import re
from datetime import datetime, timedelta, timezone
import pytest
from rich.console import Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from meshterm.core import exitcodes
from meshterm.ui import script
#: Any ANSI escape sequence. Nothing the scripted console emits may match this.
_ANSI = re.compile(r"\x1b\[")
def _rendered(*renderables: object) -> str:
"""Print ``renderables`` through the scripted console and return what came out."""
buffer = io.StringIO()
console = script.console(buffer)
for renderable in renderables:
console.print(renderable)
console.file.flush()
return buffer.getvalue()
# -- names ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("Yagi-Repeater", "Yagi-Repeater"),
("YUL Cartierville", "YUL Cartierville"),
("Node, Inc", "Node, Inc"),
('He said "hi"', 'He said "hi"'),
("back\\slash", "back\\\\slash"),
],
)
def test_a_name_is_bare_because_alignment_is_the_delimiter(value: str, expected: str) -> None:
"""The quotes are retired; a column that lines up is already one field to the eye.
They existed so a name holding a space or a comma stayed one field for whatever was
splitting the line. The splitter reads JSON now, and in plain text the padding says
where a field ends — so a reader gets back the six cells the quotes were spending on
every row, and a name reads as the name the node broadcast.
"""
assert script.name(value) == expected
def test_a_name_still_cannot_end_the_record_it_sits_in() -> None:
"""The escaping under the quoting stays, and it is the half that was load-bearing.
A node broadcasts its own name, so every name here is remote data. Nothing about
alignment stops a newline, a tab or an ESC in one from ending the record early — and
an ESC inside a name is a live colour run written into the caller's file, which is the
one thing "not a single escape sequence" exists to prevent.
"""
assert script.name("two\nlines") == "two\\nlines"
assert script.name("tabbed\there") == "tabbed\\there"
assert script.name("bell\a") == "bell\\x07"
assert "\x1b" not in script.name("esc\x1b[31m")
@pytest.mark.parametrize(
"body,expected",
[
("line one\nline two", "line one\\nline two"),
("tabbed\there", "tabbed\\there"),
("carriage\rreturn", "carriage\\rreturn"),
("back\\slash", "back\\\\slash"),
('a "quoted" body', 'a "quoted" body'),
("plain", "plain"),
],
)
def test_a_message_body_can_never_end_its_own_record(body: str, expected: str) -> None:
"""``TEXT`` is the rest of the line, and "the rest of the line" has to stay one line.
A body holding a newline used to end its record early and leave the remainder indented
under the other columns, reading as a second record with an empty ``TIME``. The body is
also the one field a stranger fills in, so this is not a hypothetical message. Quotes
inside it are left alone: nothing wraps it, so nothing needs escaping from.
"""
assert script.text(body) == expected
assert "\n" not in script.text(body)
@pytest.mark.parametrize("absent", [None, ""])
def test_a_node_that_never_gave_a_name_reads_as_absent_not_as_empty(absent: str | None) -> None:
"""``""`` says "called nothing"; ``-`` says "never said". They are different facts.
Seven of the ten places that printed a name made the first claim by accident, so the
same unnamed node read as ``""`` in one listing and ``-`` in the next. One helper now
answers it, and the distinction is the one the machine face needs too — an absent name
is ``null``, an empty one is ``""``, and a field that flattened both cannot say which
it meant.
"""
assert script.name(absent) == script.NONE
assert script.name("Alice") == "Alice"
# -- timestamps ----------------------------------------------------------------------
def test_stamp_is_absolute_local_iso_to_the_second() -> None:
"""A time is printed as something a script can sort and subtract."""
when = datetime(2026, 9, 7, 22, 22, 41, tzinfo=timezone.utc)
text = script.stamp(when)
assert datetime.fromisoformat(text) == when
assert text.endswith(when.astimezone().strftime("%z")[:3] + ":00")
def test_stamp_reads_an_unknown_offset_as_absent() -> None:
"""A naive datetime is a time whose offset we don't know, which is not a fact.
The same rule :func:`~meshterm.ui.widgets._age_seconds` applies to relative ages.
"""
assert script.stamp(None) == script.NONE
assert script.stamp(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
def test_a_time_is_an_age_unless_the_instant_is_itself_the_fact() -> None:
"""The rule that reversed, and the reason it reversed.
A person at a prompt reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to
answer "recently?", which is the question they typed the command to ask. So an age is
the default now. :func:`~meshterm.ui.script.stamp` survives for the three places the
instant *is* the answer — the device clock, an appointment set with ``--at``, and a
live capture's own ``TIME`` column, every row of which would otherwise read ``now`` —
and for every column under ``--absolute``.
"""
fresh = datetime.now(timezone.utc) - timedelta(seconds=5)
assert script.age(fresh) == "now"
assert script.stamp(fresh)[:4].isdigit()
@pytest.mark.parametrize(
"seconds,expected",
[
(0, "now"),
(59, "now"),
(60, "1m"),
(3599, "59m"),
(3600, "1h"),
(86400, "1d"),
(604800, "1w"),
],
)
def test_the_age_ladder_steps_where_a_person_would_step(seconds: int, expected: str) -> None:
"""``now``, ``5m``, ``3h``, ``2d``, ``4w`` — the menu's own column ladder.
Delegated to :func:`~meshterm.ui.widgets._format_age` rather than re-derived, so the
two faces of the same age cannot drift apart.
"""
assert script.age(datetime.now(timezone.utc) - timedelta(seconds=seconds)) == expected
def test_an_age_reads_an_unknown_offset_as_absent_exactly_as_a_stamp_does() -> None:
"""A naive datetime is a time whose offset we don't know, which is not a fact."""
assert script.age(None) == script.NONE
assert script.age(datetime(2026, 9, 7, 22, 22, 41)) == script.NONE
def test_never_is_a_value_and_the_absent_token_is_not() -> None:
"""A node that has never been heard is a *fact*; a row with no such time is not.
Flattening both onto ``-`` made "we have not heard from it" and "this kind of row has
no heard time" read the same, which is the distinction the column exists to draw.
"""
assert script.age(None, absent="never") == "never"
assert script.age(None) == script.NONE
@pytest.mark.parametrize(
"seconds,expected",
[(42, "42s"), (360, "6m"), (125, "2m 5s"), (93784, "1d 2h"), (3600, "1h"), (-125, "2m 5s")],
)
def test_a_duration_reads_in_two_units_at_most(seconds: int, expected: str) -> None:
"""``93784`` is a number nobody can hold in their head; ``1d 2h`` is the same fact.
Two adjacent units, largest first, so the magnitude arrives at a glance and the
precision never outruns what anyone would act on. Only ever a gloss beside the raw
figure — the key says what the number counts, and a caller reading the key must still
find a number under it.
"""
assert script.duration(seconds) == expected
def test_a_location_is_one_field_because_it_is_one_fact() -> None:
"""A coordinate pair is what goes into a map's search box, verbatim.
Two columns would also mean two ``-`` for every node that has never shared a position,
which is worse than one.
"""
assert script.location(45.50190, -73.56740) == "45.50190,-73.56740"
assert script.location(45.5, None) == script.NONE
assert script.location(None, None) == script.NONE
# -- the absent token -----------------------------------------------------------------
def test_one_token_stands_for_every_kind_of_absence() -> None:
"""A reader learns ``-`` once, and never meets an em dash, an ``n/a`` or a blank.
``never`` is the one word that is *not* an absence: it is a value, and the age lane is
where it is spelled (see the ladder tests above).
"""
assert script.NONE == "-"
assert script.number(None) == script.NONE
assert script.route([]) == script.NONE
assert script.spec([]) == script.NONE
def test_number_formats_with_its_spec_when_there_is_one() -> None:
"""A present number is formatted; only an absent one becomes the token."""
assert script.number(2.64, "+.1f") == "+2.6"
assert script.number(-1.5, "+.1f") == "-1.5"
assert script.number(7) == "7"
# -- routes and paths ------------------------------------------------------------------
def test_a_route_is_drawn_with_arrows_and_a_path_keeps_its_commas() -> None:
"""The lexicon's own split, finally drawn: a *route* is walked, a *path* is typed.
Both used to be comma-separated, which made a route look like something you could
paste back into ``--path``. You cannot — the names are in it — so that comma was
promising a round trip only the spec has. The arrow says "this is a picture", and
carries the direction the packet travelled while it is there.
"""
assert (
script.route([("Alice", "3d"), ("Yagi-Repeater", "f2")])
== "Alice (3d) → Yagi-Repeater (f2)"
)
assert script.spec(["a1", "d4", "a1"]) == "a1,d4,a1"
def test_a_route_names_our_own_node_like_any_other_hop() -> None:
"""No star, and the reason is who reads the line.
The menu draws our own node as ``★`` because a reader never has to be told which node
is theirs. A route line is as often read out of a file, by somebody who was not at the
prompt when it ran — and by then the star names nothing.
"""
line = script.route([("MockCompanion", "00"), ("Alice", "3d"), ("MockCompanion", "00")])
assert "★" not in line
assert line.startswith("MockCompanion (00)")
assert line.endswith("MockCompanion (00)")
def test_a_route_hop_never_shows_an_empty_pair_of_parentheses() -> None:
"""Half an identity prints as the half there is — a name alone, or a hash alone.
Which is how the menu's own :class:`~meshterm.ui.pathline.PathLine` labels an
unresolved hop, and the reason both halves are optional: history often knows one.
"""
assert script.route([("Alice", None)]) == "Alice"
assert script.route([(None, "3d")]) == "3d"
assert script.route([(None, None)]) == script.NONE
def test_the_arrow_is_what_makes_the_quoting_unnecessary() -> None:
"""The claim moved: the quotes made a comma safe, and now nothing has to.
A comma inside a node's name can no longer be read as a hop boundary, because the hop
boundary is not a comma any more.
"""
line = script.route([("Node, Inc", "3d"), ("Bob", "f2")])
assert line == "Node, Inc (3d) → Bob (f2)"
assert line.count("→") == 1
def test_a_spec_is_the_one_line_on_either_face_that_round_trips() -> None:
"""It is what ``--path`` takes back, so it grows no name, no arrow and no space."""
assert script.spec(["a1b2", "d4e5", "a1b2"]) == "a1b2,d4e5,a1b2"
assert " " not in script.spec(["a1", "d4"])
# -- live streams ----------------------------------------------------------------------
def test_a_stream_pins_its_lanes_because_it_cannot_measure_them() -> None:
"""A live capture cannot size its columns, because it has not seen the records yet.
:func:`~meshterm.ui.script.columns` sizes each lane to its widest value once every
record is in. ``monitor`` prints a row the instant a packet lands, and the row after it
may be twice as wide — so those two streams were gutter-joined with no alignment at
all, which is the last thing the quoting was holding up.
"""
lanes = script.stream(("TIME", 19), ("NODE", 8), ("SNR_DB", 6), right=("SNR_DB",))
assert lanes.header == "TIME NODE SNR_DB"
assert lanes.record("2026-09-08T02:25:45", "a1b2c3d4", "+7.0") == (
"2026-09-08T02:25:45 a1b2c3d4 +7.0"
)
def test_a_streamed_value_wider_than_its_lane_overruns_rather_than_lying() -> None:
"""One row out of many is simply wider, and nothing is elided.
Which is why the one unbounded field — a node's name, a message body — goes last: it
can then push nothing at all.
"""
lanes = script.stream(("NODE", 8), ("NAME", 6))
assert lanes.record("a1", "A Very Long Node Name") == "a1 A Very Long Node Name"
# -- the console ----------------------------------------------------------------------
def test_the_scripted_console_emits_no_escape_sequence_at_all() -> None:
"""Not a colour, not a bold, not a dim — ``stdout`` is a pipe more often than a screen."""
loud = Text.assemble(
("brand", "brand"), ("bold", "bold"), ("hex", "#ff8800"), ("reverse", "reverse")
)
assert not _ANSI.search(_rendered(loud))
@pytest.mark.parametrize(
"name",
["[bold]Loud", "[/]Bob", "[red]a[/red]", ":fire:Hot", "[[weird]]", "100% [done]"],
)
def test_a_node_name_is_never_read_as_rich_markup(name: str) -> None:
"""A node broadcasts its own name, so every name printed here is remote data.
Rich reads ``[...]`` as a style tag unless told not to. That made a name into two
different bugs: ``[bold]Loud`` printed as ``Loud`` — silently corrupted, and no longer
the string that identifies the node to whoever reads the output — and ``[/]Bob`` raised
``MarkupError`` and took the whole command down. ``:fire:`` is the same hazard one
parser over. Neither is hypothetical: a name is the one field a stranger controls.
"""
table = script.columns("NAME", "TYPE")
table.add_row(script.name(name), "node")
record = _rendered(table).splitlines()[1]
assert record.startswith(script.name(name)), record
def test_the_scripted_console_never_wraps_a_record_onto_a_second_line() -> None:
"""A record is a line: wrapping would put half its fields under the wrong headings."""
table = script.columns("KEY", "VALUE")
table.add_row("basemap_tilejson_url", "x" * 400)
lines = _rendered(table).splitlines()
assert len(lines) == 2 # the header and the one record
assert lines[1].endswith("x" * 400)
def test_the_scripted_console_leaves_no_trailing_whitespace() -> None:
"""Padding the last column to its width would put invisible spaces on every line."""
table = script.columns("NAME", "TYPE")
table.add_row(script.name("a-long-name"), "node")
table.add_row(script.name("b"), "repeater")
for line in _rendered(table).splitlines():
assert line == line.rstrip(), repr(line)
def test_the_gutter_still_separates_columns_after_the_trimming() -> None:
"""The trimmer holds trailing whitespace back; it must not eat the padding *between*.
Rich writes a rendered row segment by segment and flushes after each one, so a
wrapper that trimmed at flush would throw away the very spaces that make the columns
columns (see :class:`meshterm.ui.script._Trimmed`).
"""
table = script.columns("NAME", "TYPE", "PKTS", right=("PKTS",))
table.add_row(script.name("Alice"), "node", "12")
header, record = _rendered(table).splitlines()
assert header.split() == ["NAME", "TYPE", "PKTS"]
assert record.split() == ["Alice", "node", "12"]
# Spelled out, not written as `" " * script.GUTTER`: an assertion that reads the
# constant it is checking narrows to `" " in record` when the gutter narrows, which
# is exactly the state the gutter exists to prevent.
assert "Alice node" in record
def test_columns_align_so_every_record_splits_the_same_way() -> None:
"""Every field lands in its heading's column, whatever the widths."""
table = script.columns("NAME", "TYPE")
table.add_row(script.name("Yagi-Repeater"), "repeater")
table.add_row(script.name("Al"), "node")
header, *records = _rendered(table).splitlines()
starts = {line.index("re") if "repeater" in line else line.index("node") for line in records}
assert starts == {header.index("TYPE")} # every TYPE value opens under its heading
def test_a_numeric_column_really_right_aligns() -> None:
"""A column of magnitudes is only comparable by eye if the digits line up.
Rich's ``Text.wrap`` returns early on ``overflow="ignore"`` — and that early return
sits *above* the justify step, so asking for ``overflow="ignore"`` throws
``justify="right"`` away without a word. Every ``right=`` in the CLI was a no-op
until the columns asked for ``"crop"``, which refuses to elide just as firmly and
still aligns. Checking where a field *starts* cannot see this; only its end can.
"""
table = script.columns("NAME", "PKTS", right=("PKTS",))
table.add_row(script.name("Alice"), "1174")
table.add_row(script.name("Bob"), "3")
header, *records = _rendered(table).splitlines()
assert len({len(line) for line in records}) == 1, records
assert {len(line) for line in records} == {len(header)}
def test_no_column_elides_however_long_its_value_runs() -> None:
"""A key cut short is not something a caller can hand back to ``--to`` or ``--path``.
The guard against eliding and the guard against mis-alignment are the same setting,
so a change to one silently moves the other; both are pinned here.
"""
table = script.columns("KEY", "PKTS", right=("PKTS",))
table.add_row("d4" * 32, "7")
out = _rendered(table)
assert "…" not in out
assert "d4" * 32 in out
def test_the_trimmer_keeps_the_gutter_when_the_stream_flushes_between_columns() -> None:
"""Rich's legacy-Windows renderer writes a row one segment at a time, flushing each.
``LegacyWindowsTerm.write_text`` is ``write(text); flush()``, so on that path a flush
lands *between two columns* as often as at the end of a line. A wrapper that emptied
its held run at flush destroyed every gutter in real use while the in-process tests —
which see one write and one flush per ``print`` — went on passing. That is not a
hypothetical: it shipped, and this drives the stream the way that renderer does.
"""
buffer = io.StringIO()
stream = script._Trimmed(buffer)
for segment in ("Alice", " ", "node", " ", "12", "\n"):
stream.write(segment)
stream.flush()
assert buffer.getvalue() == "Alice node 12\n"
def test_the_trimmer_still_drops_the_run_that_ends_a_line() -> None:
"""The other half of the rule: holding padding back is only right if it is still cut.
Held whitespace is emitted when something follows it on the line, and dropped at the
newline and at the end of the stream — otherwise every record would end in the
invisible padding of its last column.
"""
buffer = io.StringIO()
stream = script._Trimmed(buffer)
for segment in ("row", " ", "\n", "next", " "):
stream.write(segment)
stream.flush()
assert buffer.getvalue() == "row\nnext"
def test_a_value_wider_than_the_console_is_cut_rather_than_elided() -> None:
"""Past :data:`~meshterm.ui.script.WIDTH` cells something gives; it must not be a "…".
An elision is indistinguishable from a value that really ends there, so a key read out
of a listing would go back into ``--to`` subtly wrong. A 64-hex key never reaches the
overflow path at all, so only an over-width value tests the guard that stands there.
"""
table = script.columns("KEY")
table.add_row("d" * (script.WIDTH + 500))
assert "…" not in _rendered(table)
def test_an_error_stays_greppable_when_stderr_is_not_a_terminal() -> None:
"""A sentence broken across three lines is a sentence nothing can find.
Rich falls back to 80 cells when it cannot measure the destination, so
``2> errors.log`` hard-wrapped every message — and ``grep 'not already connected'``
then found nothing, because the words it was looking for had a newline in the middle
of them. In a real terminal the width is left alone: a progress bar sized to 16384
cells is not a progress bar.
"""
import sys
buffer = io.StringIO()
stderr, sys.stderr = sys.stderr, buffer
try:
console = script.stderr_console()
console.print("meshterm: " + "the connection was not already connected " * 6)
finally:
sys.stderr = stderr
assert len(buffer.getvalue().splitlines()) == 1
# -- framing --------------------------------------------------------------------------
def test_a_panel_gives_up_its_border_and_its_title() -> None:
"""No frames: the command the reader typed is the title."""
out = _rendered(*script.flatten(Panel(Text("body"), title="Trace summary")))
assert out.strip() == "body"
def test_a_table_gives_up_its_box_its_title_and_its_expansion() -> None:
"""A shared table that still arrives boxed is unframed on the way out."""
from rich import box
table = Table(title="Contacts", box=box.SIMPLE_HEAD, expand=True)
table.add_column("NAME")
table.add_row("Alice")
out = _rendered(*script.flatten(table))
assert "Contacts" not in out
assert not any(ch in out for ch in "─│┌└")
def test_a_group_is_unframed_member_by_member() -> None:
"""Framing is stripped however deep a shared renderable nests it."""
nested = Group(Panel(Text("one"), title="First"), Panel(Text("two"), title="Second"))
assert _rendered(*script.flatten(nested)).split() == ["one", "two"]
def test_no_rendered_line_is_a_box_drawing_rule() -> None:
"""No borders and no header rules — a listing is its header line and its records."""
table = script.columns("NAME", "TYPE")
table.add_row(script.name("Alice"), "node")
assert "─" not in _rendered(table)
# -- key/value listings ---------------------------------------------------------------
def test_pairs_print_one_fact_per_line_with_no_header() -> None:
"""The ``sysctl -a`` shape: a key, the gutter, and the rest of the line as its value."""
out = _rendered(script.pairs([("name", "MockCompanion"), ("uptime_s", "93784")]))
assert [line.split(None, 1) for line in out.splitlines()] == [
["name", "MockCompanion"],
["uptime_s", "93784"],
]
def test_a_pairs_value_is_unquoted_because_it_is_the_rest_of_the_line() -> None:
"""Quoting is for a name sharing a line with other fields; here there are only two."""
out = _rendered(script.pairs([("name", "YUL Cartierville")]))
assert out.splitlines()[0].split(None, 1) == ["name", "YUL Cartierville"]
# -- exit statuses --------------------------------------------------------------------
def test_the_exit_statuses_are_the_numbers_a_caller_hard_codes() -> None:
"""``case $? in 3)`` is written against the digits, so the digits are the contract.
Stated as literals on purpose. Asserting ``set(MEANINGS) == {OK, FAILURE, …}``, or
``len(set(d)) == len(d)``, cannot fail however the constants are renumbered: a dict
has no duplicate keys, so two statuses colliding *removes* a row and both sides of
the comparison lose the same one together. Renumbering ``NO_DEVICE`` to ``4`` left
that whole family of assertions green while status 3 vanished from ``--help``.
"""
assert (exitcodes.OK, exitcodes.FAILURE, exitcodes.USAGE) == (0, 1, 2)
assert (exitcodes.NO_DEVICE, exitcodes.DEVICE, exitcodes.NO_RESULT) == (3, 4, 5)
assert sorted(exitcodes.MEANINGS) == [0, 1, 2, 3, 4, 5]
assert all(meaning for meaning in exitcodes.MEANINGS.values())
def test_the_help_epilog_names_every_status() -> None:
"""A caller should not have to find the table in a README."""
from meshterm.cli import EXIT_STATUS_EPILOG
for code, meaning in exitcodes.MEANINGS.items():
assert f"{code} {meaning}" in EXIT_STATUS_EPILOG
+107
View File
@@ -0,0 +1,107 @@
"""Session colour tests: how many colours the terminal is actually sent.
prompt_toolkit decides this per *output class*, and its two classes disagree —
``Windows10_Output`` returns true colour outright, ``Vt100_Output`` returns 256 for every
``TERM`` but ``linux`` — so the same theme was drawn at 24 bits on Windows and quantized to
the 216-colour cube on macOS and Linux, silently. The resolver closes that gap, and these
tests pin the one property that makes it safe to run everywhere: it only ever *raises* the
verdict. A terminal that cannot be shown to do better returns ``None`` and keeps exactly the
depth prompt_toolkit gave it, because guessing 24-bit at a terminal without it costs not a
duller palette but the colour entirely.
"""
from __future__ import annotations
from prompt_toolkit.output.color_depth import ColorDepth
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui.session import _color_depth
def _auto(monkeypatch) -> None:
"""Clear both overrides so the environment reading is what is under test."""
monkeypatch.delenv("MESHTERM_COLOR_DEPTH", raising=False)
monkeypatch.delenv("COLORTERM", raising=False)
monkeypatch.delenv("TERM", raising=False)
def test_an_unremarkable_terminal_keeps_prompt_toolkits_own_verdict(monkeypatch) -> None:
"""Nothing claimed → ``None``, so a host we have never heard of is never made worse."""
_auto(monkeypatch)
set_platform(REGULAR)
assert _color_depth() is None
def test_colorterm_truecolor_raises_the_depth(monkeypatch) -> None:
"""The convention every truecolor terminal follows — and the one prompt_toolkit ignores."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_colorterm_24bit_is_the_same_claim(monkeypatch) -> None:
"""``24bit`` is the other spelling in circulation; case is not part of the claim."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "24BIT")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_a_direct_colour_terminfo_entry_raises_the_depth(monkeypatch) -> None:
"""``*-direct`` is terminfo's own spelling for a direct-colour terminal."""
_auto(monkeypatch)
monkeypatch.setenv("TERM", "xterm-direct")
set_platform(REGULAR)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
def test_picocalc_is_never_promoted(monkeypatch) -> None:
"""A 16-slot console addresses its palette by index; RGB has nowhere to land there."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(PICOCALC)
assert _color_depth() is None
def test_env_override_names_a_depth_outright(monkeypatch) -> None:
"""``MESHTERM_COLOR_DEPTH`` wins over the reading, in either direction."""
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "256")
assert _color_depth() is ColorDepth.DEPTH_8_BIT
monkeypatch.setenv("MESHTERM_COLOR_DEPTH", "16")
assert _color_depth() is ColorDepth.DEPTH_4_BIT
def test_the_preference_is_consulted_below_the_env_override(monkeypatch) -> None:
"""A stored preference names a depth the same way; ``auto`` falls through to the reading."""
from types import SimpleNamespace
_auto(monkeypatch)
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="truecolor"),
)
assert _color_depth() is ColorDepth.DEPTH_24_BIT
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="auto"),
)
assert _color_depth() is None
def test_an_unrecognised_preference_value_changes_nothing(monkeypatch) -> None:
"""A hand-edited YAML value we cannot read is not grounds for guessing."""
from types import SimpleNamespace
_auto(monkeypatch)
monkeypatch.setenv("COLORTERM", "truecolor")
set_platform(REGULAR)
monkeypatch.setattr(
"meshterm.core.preferences.current",
lambda: SimpleNamespace(color_depth="lots"),
)
assert _color_depth() is None
+1 -1
View File
@@ -224,7 +224,7 @@ async def test_apply_setting_remembers_through_the_config_executor(ctx) -> None:
pubkey = (await device.get_self_info())["public_key"]
snapshot = await build_snapshot(device)
changes, _ = await apply_ops(
changes, _artifacts, _report = await apply_ops(
ctx, device, snapshot, [("set", "name", "Ops-Node"), ("set", "tx_power", "18")]
)
assert changes == 2
+298 -15
View File
@@ -1226,7 +1226,9 @@ def test_device_picker_builds_aligned_columns(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
captured["banner"] = banner
captured["footnote"] = footnote
@@ -1278,7 +1280,9 @@ def test_device_picker_names_and_sorts_known_devices(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1315,7 +1319,9 @@ def test_device_picker_reinjects_remembered_tcp_device(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1346,7 +1352,9 @@ def test_device_picker_lists_configured_tcp_profile(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1379,7 +1387,9 @@ def test_device_picker_lists_configured_serial_profile(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None # skip past the smoke test
@@ -1412,7 +1422,9 @@ def test_device_picker_serial_profile_yields_to_scanned_port(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
@@ -1442,7 +1454,9 @@ def test_device_picker_profile_yields_to_remembered_endpoint(tmp_path) -> None:
captured: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
captured["items"] = items
return None
@@ -1467,7 +1481,9 @@ def test_device_picker_adds_network_device(tmp_path) -> None:
probed: dict = {}
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# Choose the "add a network device" action row.
return next(it.value for it in items if isinstance(it, Choice) and it.value is _ADD_TCP)
@@ -1517,7 +1533,9 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
def __init__(self) -> None:
self._passes = 0
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
names = [
it.label.plain
for it in items
@@ -1583,7 +1601,9 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
def __init__(self) -> None:
self._passes = 0
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
self._passes += 1
if self._passes == 1:
row = next(
@@ -1662,7 +1682,9 @@ class _PickerUi:
def __init__(self) -> None:
self.notes: list = []
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
@@ -1707,7 +1729,9 @@ def test_device_picker_leaves_the_copyright_to_the_wordmark(tmp_path) -> None:
footnotes: list = []
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
footnotes.append(("select", footnote))
return next(it.value for it in items if isinstance(it, Choice))
@@ -1748,7 +1772,9 @@ def test_device_picker_prompts_and_retries_ble_pin(tmp_path) -> None:
errors: list = []
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
return next(it.value for it in items if isinstance(it, Choice))
async def notify_startup(self, renderable, *, title="", banner=None, footnote=None):
@@ -1793,7 +1819,9 @@ def test_device_picker_pin_cancel_returns_to_list(tmp_path) -> None:
picks = iter([0, "quit"]) # pick the device once, then quit the re-opened list
class _Ui:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
choices = [it for it in items if isinstance(it, Choice)]
step = next(picks)
if step == "quit":
@@ -1825,7 +1853,9 @@ def test_device_picker_quit_row_returns_none(tmp_path) -> None:
devices = [DiscoveredDevice(port="COM5", product="Wio SX1262")]
class _QuitUi:
async def select_startup(self, title, items, *, default=None, banner=None, footnote=None):
async def select_startup( # noqa: ANN001, ANN201, ANN003
self, title, items, *, default=None, banner=None, footnote=None, **_kw
):
# The last choice is the Quit row; picking it signals "exit".
quit_choice = [it for it in items if isinstance(it, Choice) and it.value is _QUIT]
assert quit_choice, "the picker offers a Quit row"
@@ -2958,3 +2988,256 @@ def test_a_button_dialogs_hint_never_names_the_verb_of_one_button() -> None:
assert "←→" in hint, (
f"{where}:{line} hides ←→, the key that moves the highlight Enter commits"
)
# --- hiding devices on the splash --------------------------------------------
def _hide_store(tmp_path): # noqa: ANN001, ANN202
"""A device registry on a scratch file."""
from meshterm.core.device_store import DeviceStore
return DeviceStore(tmp_path / "devices.json")
def test_hidden_devices_survive_the_session_that_hid_them(tmp_path) -> None: # noqa: ANN001
"""Hiding is written to the registry file, so a fresh store still knows about it."""
store = _hide_store(tmp_path)
assert store.hidden_ids() == set()
store.hide("usb-0001")
store.hide("usb-0002")
store.hide("usb-0001") # idempotent
assert _hide_store(tmp_path).hidden_ids() == {"usb-0001", "usb-0002"}
assert _hide_store(tmp_path).show_all() == 2
assert _hide_store(tmp_path).hidden_ids() == set()
assert _hide_store(tmp_path).show_all() == 0 # nothing left to bring back
def test_hiding_leaves_the_registry_and_its_default_alone(tmp_path) -> None: # noqa: ANN001
"""A hidden device is still remembered, still the default, still reachable by name.
Hiding is a *listing* choice on one screen. Everything that resolves a device without
the splash — ``--port``, a profile, the reconnect — must not be able to tell.
"""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.remember(device, node_name="Wardriver")
store.hide(device.stable_id)
fresh = _hide_store(tmp_path)
assert fresh.hidden_ids() == {device.stable_id}
assert fresh.is_known(device)
remembered = fresh.load()
assert remembered is not None and remembered.node_name == "Wardriver"
def test_connecting_to_a_hidden_device_shows_it_again(tmp_path) -> None: # noqa: ANN001
"""Confirming a device is the plainest statement that it belongs on the list."""
from meshterm.core.discovery import serial_device
store = _hide_store(tmp_path)
device = serial_device("COM7", name="A Radio")
store.hide(device.stable_id)
store.remember(device, node_name="Wardriver")
assert store.hidden_ids() == set()
async def test_the_splash_hides_the_highlighted_device_and_redraws(tmp_path) -> None: # noqa: ANN001
"""Pressing h drops the row and remembers it; the redrawn list is the feedback."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import prompt_device
from meshterm.ui.tui import Choice, KeyRequest
probe = serial_device("COM3", name="A Debug Probe")
radio = serial_device("COM7", name="A Radio")
store = _hide_store(tmp_path)
drawn: list[list] = []
class _Ui:
"""Presses h on the probe, then picks whatever is left."""
def __init__(self) -> None:
self.round = 0
async def select_startup(self, title, items, **kw): # noqa: ANN001, ANN003, ANN201
drawn.append([it for it in items if isinstance(it, Choice)])
self.round += 1
if self.round == 1:
return KeyRequest(kw["keys"]["h"], probe)
return radio
async def busy_startup(self, message, coro, **kw): # noqa: ANN001, ANN003, ANN201
return await coro
async def verify(device, pin): # noqa: ANN001, ANN202
return {"name": "Wardriver"}
chosen = await prompt_device(_Ui(), [probe, radio], store, verify)
assert chosen is radio
assert store.hidden_ids() == {probe.stable_id}
# The first pass offered both devices; the second offered only the one left.
assert probe in [row.value for row in drawn[0]]
assert probe not in [row.value for row in drawn[1]]
def test_the_splash_says_so_when_it_is_empty_only_because_of_hiding() -> None:
"""Nothing-detected would be a lie, and with no rows there is no footer atom either."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _build_items
lines = [str(it.title) for it in _build_items([], None, {}, hidden=2)]
assert any("2 devices hidden" in line and "⇧H" in line for line in lines)
detected = [str(it.title) for it in _build_items([], None, {}, hidden=0)]
assert any("no companion devices detected" in line for line in detected)
# With something still listed the footer names ⇧H on every row, so the line would be a
# row of the box spent saying it twice.
radio = serial_device("COM7", name="A Radio")
rows = [str(it.title) for it in _build_items([radio], None, {}, hidden=2)]
assert not any("hidden" in line for line in rows)
def test_hiding_a_device_leaves_the_highlight_where_the_row_was() -> None:
"""The next device down, or the one above at the end — so a run of adapters clears in a run."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _after_hiding
first, middle, last = (serial_device(f"COM{n}", name=f"D{n}") for n in (1, 2, 3))
listed = [first, middle, last]
assert _after_hiding(listed, first) is middle
assert _after_hiding(listed, middle) is last
# Off the end there is nowhere further down, so the highlight steps up rather than
# rolling round to the top — nothing in the app rolls over.
assert _after_hiding(listed, last) is middle
assert _after_hiding([first], first) is None # an emptied list has nothing to land on
def test_the_splash_names_a_shortcut_only_where_it_would_act() -> None:
"""Named on a device row only, and ⇧H only while hidden — the Del remove rule."""
from meshterm.core.discovery import serial_device
from meshterm.ui.device_picker import _ADD_TCP, _QUIT, _shortcut_hint
radio = serial_device("COM7", name="A Radio")
nothing_hidden = _shortcut_hint(0)
assert nothing_hidden(radio) == "h hide" # no way back is offered; there is nothing back
assert nothing_hidden(_ADD_TCP) == "" # nothing to hide on the action rows
assert nothing_hidden(_QUIT) == ""
assert nothing_hidden(None) == "" # an empty list highlights nothing at all
with_hidden = _shortcut_hint(2)
assert with_hidden(radio) == "h hide · ⇧H show all"
# ⇧H is screen-wide, so it rides whatever row the reader happens to be standing on.
assert with_hidden(_QUIT) == "⇧H show all"
def test_a_hint_too_long_for_its_box_drops_atoms_rather_than_its_tail() -> None:
"""Cutting the line at the edge would take Esc, which is the one atom that must survive."""
from meshterm.ui.tui.frame import fit_hint
full = "↑↓ move · ←→ scroll · Enter select · h hide · ⇧H show all · Esc quit"
assert fit_hint(full, 100) == full # room for everything: untouched
# Dropped from the right, in front of Esc — never Esc itself.
assert fit_hint(full, 56) == "↑↓ move · ←→ scroll · Enter select · h hide · Esc quit"
assert fit_hint(full, 45) == "↑↓ move · ←→ scroll · Enter select · Esc quit"
assert fit_hint(full, 20).endswith("Esc quit")
# ...unless the caller names atoms it can spare first, in the order it can spare them:
# the scroll keys are already named by the move atom, and between the two hide keys the
# one to keep is the way back.
spared = fit_hint(full, 56, shed_first=("←→ scroll", "h hide"))
assert spared == "↑↓ move · Enter select · h hide · ⇧H show all · Esc quit"
assert (
fit_hint(full, 50, shed_first=("←→ scroll", "h hide"))
== "↑↓ move · Enter select · ⇧H show all · Esc quit"
)
def test_the_splash_scrolls_the_hardware_column_of_the_row_it_is_on() -> None:
"""←→ read a long firmware model to its end; the lanes in front of it stay put."""
from meshterm.core.device_store import RememberedDevice
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.device_picker import _build_items
from meshterm.ui.tui import Choice, SelectScreen
radio = serial_device("COM7", name="Wardriver")
registry = {
radio.stable_id: RememberedDevice(
stable_id=radio.stable_id,
port="COM7",
label="COM7",
last_connected="2026-09-01T00:00:00+00:00",
node_name="Wardriver",
hardware_model="Seeed Wio Tracker 1110 Development Kit",
)
}
try:
for platform, width in ((REGULAR, 66), (PICOCALC, 47)):
set_platform(platform)
items = _build_items([radio], registry[radio.stable_id], registry)
screen = SelectScreen("Select a companion device", items, filterable=False)
row = next(it for it in items if isinstance(it, Choice))
assert row.hscroll_from > 0 # the fixed lanes are pinned...
assert row.hscroll_from < width # ...and leave something to scroll into
before = _plain(screen.render_body(width)[1])
for _ in range(3):
screen.handle("right")
after = _plain(screen.render_body(width)[1])
assert before != after, platform.name
assert after.startswith(before[: row.hscroll_from - 4]), "the lanes moved"
finally:
set_platform(REGULAR)
def test_the_splash_hint_stays_inside_the_box_it_is_drawn_in() -> None:
"""The chromeless splash keeps its hint in its own border, on both platforms.
The border is the terminal less the gutter the box floats over — not the full readable
width — and on the PicoCalc that is 47 cells, which is what makes every atom here
conditional rather than merely tidy.
"""
from rich.cells import cell_len
from meshterm.core.discovery import serial_device
from meshterm.platforms import PICOCALC, REGULAR
from meshterm.ui.device_picker import _shortcut_hint
from meshterm.ui.tui.select import _splice_hint
base = "↑↓ move · Enter select · Esc bye" # the splash's own send-off
radio = serial_device("COM7", name="A Radio")
common = _splice_hint(base, _shortcut_hint(0)(radio))
for platform in (REGULAR, PICOCALC):
budget = platform.readable_cols - platform.dialog_margin - 2
assert cell_len(common) <= budget, (platform.name, common)
def test_a_shortcut_is_only_honoured_where_a_letter_is_free() -> None:
"""A filtering list spends its letters on the query, so it declares no shortcuts.
Otherwise a device named "Homestead" could not be typed on a list that had claimed h.
"""
from meshterm.ui.tui import Choice, KeyRequest
from meshterm.ui.tui.select import SelectScreen
token = object()
fixed = SelectScreen("fixed", [Choice("a row", 1)], filterable=False, keys={"h": token})
fixed.future = None
resolved: list = []
fixed.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
fixed.handle("text", "h")
assert resolved == [KeyRequest(token, 1)]
filtering = SelectScreen("filtering", [Choice("Homestead", 1)], keys={"h": token})
filtering.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
filtering.handle("text", "h")
assert len(resolved) == 1 # nothing new resolved
assert filtering._filter == "h" # the letter went to the query, where it belongs
+43
View File
@@ -131,3 +131,46 @@ def test_the_loader_path_the_bootloader_replaced_is_put_back(
assert environment["LD_LIBRARY_PATH"] == "/opt/mine/lib"
assert "LD_LIBRARY_PATH_ORIG" not in environment
assert "DYLD_LIBRARY_PATH" not in environment
def test_the_window_asked_for_fits_the_startup_wordmark() -> None:
"""The size request exists because a too-small window fails quietly, not loudly.
The startup wordmark is 71 columns; below that the splash silently swaps to the narrow
one drawn for the PicoCalc, which is how this was found — a desktop session showing the
handheld's mark. The request has to clear that, not merely the platform's minimum.
"""
from meshterm.platforms import REGULAR, set_platform
from meshterm.ui.logo import load_logo, logo_width
set_platform(REGULAR)
cols, rows = (int(part) for part in winterminal._wanted_size().split(","))
assert logo_width(load_logo(cols)) == 71, "the window asked for would get the narrow mark"
assert cols >= REGULAR.readable_cols
assert rows >= REGULAR.readable_rows
def test_the_session_gets_its_own_window_at_that_size(monkeypatch: pytest.MonkeyPatch) -> None:
"""Both halves matter, and each was a separate way to arrive somewhere too small.
Without ``-w new`` the session lands as a tab in whatever window is already open and
inherits its size — measured at 64 columns on a real machine, which is why the wrong
mark appeared. Without ``--size`` a new window takes the reader's global launch size,
a setting about their shell rather than about this app.
"""
started: list[list[str]] = []
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.delenv(winterminal.REOPENED_ENV, raising=False)
monkeypatch.setattr(winterminal.shutil, "which", lambda _: r"C:\wt.exe")
monkeypatch.setattr(
winterminal.subprocess, "Popen", lambda argv, **_: started.append(argv) or None
)
assert winterminal.reopen() is True
argv = started[0]
assert argv[:3] == [r"C:\wt.exe", "-w", "new"]
assert "--size" in argv
assert argv[argv.index("--size") + 1] == winterminal._wanted_size()
# `--` must still separate wt's options from MeshTerm's, or `--mock` becomes wt's.
assert "--" in argv and argv.index("--") > argv.index("--size")