Diagnostics is prose, so it is a written page

The block is a title, a standfirst, sections and a list under each. That
is a page of writing, not a screenful of rows, and the app already knows
how to draw one: the About pages' markdown renderer. So the tool states
its report as before and a third projection turns it into markdown, which
is exactly what the report seam was built for -- no tool touched, no
report changed, one new function beside the other two faces.

Being a written page buys the two things a long block most needs and that
a hand-built screen had neither of. Its ## headings are landmarks, so each
pins to the top row while its own section scrolls under it and ^PgUp/^PgDn
step section by section; and the PicoCalc lane picks up Sect up / Sect
down for the same reason a grouped list does. AboutPage already does all
of it, so the page is that page with one verb added.

The frame comes back with it. The bare frame was there to keep a border
off every line a mouse would drag through, which mattered while copying
off the screen was the only way out; saving is the way out now, so the
page can be a normal screen and the save can be advertised where this app
advertises verbs -- the footer hint on a desktop, the lane's one free F3
slot on the console -- instead of in the page's own body, which belongs to
the report. The answer moves to a popup with it: a path is worth reading
carefully, a write can fail, and the page underneath is unchanged and
still there when the popup goes.

Saving markdown is the same decision as drawing it. One source is what the
page renders and what the file holds, and an issue tracker renders it as
written, with no fence and no apology. Every value is a code span, which
is correctness and not taste: a Windows path is full of backslashes that
markdown reads as escapes, and an error a stranger's firmware wrote can
hold an asterisk. A value already holding a backtick gets a longer fence.

Rows are a list and not a table, measured rather than assumed -- a
markdown table's cells are cropped with an ellipsis at 53 columns, and a
bug report whose one long value is cut is missing the half that mattered.
A list item wraps and hangs under its own text at any width.

Two smaller things fell out. A block may carry a caption, the heading a
format that has headings draws above it -- plain and JSON have no use for
one and ignore it. And the host block splits in two, because a "it looks
wrong" report is about the terminal and its reader should not have to pass
the processor architecture on the way to it.

The command line is untouched: aligned records, because that face is a
stream to grep and headings are furniture on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-19 17:10:31 -04:00
1 parent 41c2cf416b
commit 72f72b84cd
8 files changed
+532 -378

No files matched your search

+25 -18
View File
@@ -19,12 +19,6 @@ one caveat SemVer makes for a leading zero: while the major version is still `0`
are the questions an issue thread otherwise asks one at a time, and the person who hit the
bug is the one least able to answer them.
- **The page is a bare frame** — no border, no header, no footer, no key hints. Not a
style choice: a terminal is selected by dragging, so every one of those is a character
the clipboard carries into the issue. A long value hangs under its own block rather than
being cropped, which is the one place the page and the command line lay the same facts
out differently.
- **Nothing in it is private.** No pairing PIN, no admin password, no channel secret, no
private key, no position, and no contact's name or key. The mesh is described in
**aggregate** — a row count per table and the span the observations cover — which is the
@@ -42,22 +36,35 @@ one caveat SemVer makes for a leading zero: while the major version is still `0`
somewhere, so a table added later starts being reported the day it lands — and the
interesting count is always the table nobody expected to be full.
- **It saves to a file, for attaching rather than pasting.** `s` on the page writes
`meshterm-diagnostics.txt` into the config directory beside the log, and says so on the
page's own last line rather than in a dialog over the block you were about to read.
- **It is one of the app's written pages.** The block is markdown, drawn through the same
renderer behind the About pages and saved as the same document, so what you read and
what you attach are one source. Its `##` headings are landmarks: each pins to the top
row while its own section scrolls under it, and `^PgUp`/`^PgDn` step section by section.
Every value is a code span, so a Windows path's backslashes and a firmware error's
asterisks arrive as themselves rather than as markdown.
- **It saves, for attaching rather than pasting.** `s` writes `meshterm-diagnostics.md`
into the config directory beside the log, and answers in a popup naming the path; a
write that fails says so in the same popup, in red, and leaves the page readable. The
key is advertised in the footer hint on a desktop and on the lane's free **F3** slot on
the PicoCalc — never in the page's body, which belongs to the report.
`meshterm diagnostics --out PATH` does the same from a shell, spelled the way
`config export-key --out` already spells it. The file is always the plain block, even
under `--json` — it exists to be read by a person — and it is laid out at full width, so
nothing is folded to whatever the terminal happened to be.
`config export-key --out` already spells it.
### Changed
- `--json` and the plain face grew a shared projection for a set of facts
(`ui/renderers.facts_pairs`), so the Diagnostics page cannot drift from what
`meshterm diagnostics` prints: a field added to the report reaches both without anyone
remembering to. A path also gained the lane constructor it never had
(`ui/fields.path`) — the machine face had always spelled one correctly and the plain
face could not render one at all.
- **A report has a third face.** `ui/renderers.markdown_blocks` projects any report as
markdown sections — the *document* face, where the other two are a record stream and a
machine document. It is what the seam was built for: no tool was touched and no report
changed. A block may now carry a `caption`, the heading a format that *has* headings
draws above it; plain and JSON ignore it, and markdown makes it a `##`.
Rows are a list rather than a table, and that was measured: a markdown table's cells are
cropped with an ellipsis at a narrow width, and a diagnostics page whose one long value is
cut is missing the half that mattered.
- A path gained the lane constructor it never had (`ui/fields.path`) — the machine face had
always spelled one correctly and the plain face could not render one at all.
## [0.9.0] — 2026-09-22
+34 -22
View File
@@ -1489,10 +1489,11 @@ $ meshterm diagnostics
meshterm 0.3.7
install frozen
os Windows 11
os_build 10.0.26100
arch AMD64
python 3.13.1
os Windows 11
os_build 10.0.26100
arch AMD64
python 3.13.1
terminal Windows Terminal
term -
colorterm truecolor
@@ -1525,17 +1526,21 @@ runs_failed 3
first_heard 8mo
last_heard now
TABLE ROWS
observations 418203
runs 912
traces 47
...
TABLE ROWS
observations 418203
runs 912
traces 47
PREFERENCE VALUE
log_level DEBUG
map_style terrain
```
The seven groups are the page's seven sections — build, host, terminal, radio, storage,
stored rows, changed preferences. The command line shows them as blank-line-separated
records, because that face is a stream to grep; the page and the saved file show them as
markdown headings, because that one is a document to read.
**The radio is asked but never required.** A report about a companion that will not connect
is exactly the report most worth filing, so a failure to reach it becomes `connected no`
with the radio's own words in `error`, and every other fact still arrives.
@@ -1553,7 +1558,7 @@ feature and not a habit: the block is designed to be pasted in public by somebod
not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds
one and fails if any of them — or a field merely *named* like one — reaches either face.
Under `--json`, the three fact blocks merge into one flat object and the two listings keep
Under `--json`, the five fact blocks merge into one flat object and the two listings keep
a key of their own:
```json
@@ -1563,23 +1568,30 @@ a key of their own:
**`--out PATH` writes it to a file instead of printing it**, and the answer becomes the
path — the same trade `config export-key --out` makes, since a caller who asked for a file
wants to be told where it is rather than handed the contents they just redirected into it.
The file is always the plain block, even under `--json`: it exists to be attached to an
issue and read by a person. Redirection still works and is not replaced; what `--out` adds
is a file laid out at full width rather than folded to whatever the terminal happened to be.
```console
$ meshterm diagnostics --out meshterm-diagnostics.txt
$ meshterm diagnostics --out meshterm-diagnostics.md
✓ diagnostics written — attach it to the report.
/home/jp/meshterm-diagnostics.txt
/home/jp/meshterm-diagnostics.md
```
In the menu this is the **Diagnostics** page under *This app*, drawn on a bare frame — no
border, no header, no footer, no key hints — because a terminal is selected by dragging and
every one of those is a character the clipboard would carry into the issue. Its one line of
its own is `s save this to a file`, which writes `meshterm-diagnostics.txt` into the config
directory beside the log and then says so on that same line. A bare frame has nowhere else
to advertise a key, and one trailing line after a blank row costs a paste far less than a
border costs it on every line.
**The file is markdown**, whichever face the run was printing, because an issue tracker
renders markdown as written — no fence, no apology. Every value is a code span, so a
Windows path's backslashes and a firmware error's asterisks arrive as themselves rather
than as markdown. Redirection still works and is not replaced; `--out` is what gives you
the document rather than the record stream.
In the menu this is the **Diagnostics** page under *This app*, and it is one of the app's
**written pages** — the same markdown renderer behind the About pages, drawing the same
document the file holds. That buys what a long block most needs: its `##` headings are
landmarks, so each pins to the top row while its own section scrolls under it and
`^PgUp`/`^PgDn` step section by section.
`s` saves, advertised in the footer hint on a desktop and on the lane's free **F3** slot on
the PicoCalc — never in the page's body, which belongs to the report. It writes
`meshterm-diagnostics.md` into the config directory beside the log, and answers in a popup
naming the path. A write that fails says so in the same popup, in red, and leaves the page
readable.
#### `meshterm platform`
+112 -50
View File
@@ -17,10 +17,11 @@ without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host
own side, so it cannot be crossed here by accident.
**One report, three faces**, which is the whole reason this is a report and not a
``print``: the menu draws it on a bare frame with nothing around it to select by mistake
(see :mod:`meshterm.ui.diagnostics`), ``meshterm diagnostics`` prints the same rows for
anyone who would rather redirect than copy, and ``--json`` hands the same facts to
whatever files the issue.
``print``: the menu draws it as a written page (see :mod:`meshterm.ui.diagnostics`) and
saves it as markdown, ``meshterm diagnostics`` prints the same facts as aligned records for
anyone who would rather redirect than read, and ``--json`` hands them to whatever files the
issue. The document face is the one that leaves this machine, so it is the one written in
the format an issue tracker already renders.
The device is asked but never required. A report about a radio that will not connect is
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
@@ -29,6 +30,7 @@ the block (``connected``/``error``) rather than an error that replaces it.
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -44,13 +46,10 @@ if TYPE_CHECKING: # pragma: no cover - typing only
_ROLES = {1: "client", 2: "repeater", 3: "room server", 4: "sensor"}
#: What the saved file is called, in the config directory beside the log it sits next to
#: in a bug report. Named for the app rather than just ``diagnostics.txt`` because the one
#: in a bug report. Named for the app rather than just ``diagnostics.md`` because the one
#: thing that happens to this file is being moved somewhere else and attached, and by then
#: the directory it came from is no longer saying what it is.
#:
#: ``.txt`` and not ``.md``: the content is aligned columns, which markdown would reflow
#: into a paragraph, and a plain-text extension is the one every issue tracker accepts.
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.txt"
DIAGNOSTICS_FILENAME = "meshterm-diagnostics.md"
@register
@@ -88,12 +87,10 @@ class DiagnosticsTool(Tool):
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the page in the menu; returning ``None`` completes with no run row.
The same shape the About pages use: the menu's whole interaction *is* the screen,
and a run row recording that somebody looked at their own version number would be
a line in the log for every glance. Saving from the page is likewise not a run:
the page writes the file itself and says so on its own last line, because a
confirmation dialog over a page whose entire point is being unobstructed would
cover the thing it was confirming.
The same shape the About pages use — and the same screen, since the page *is* one:
the menu's whole interaction is the reading of it, and a run row recording that
somebody looked at their own version number would be a line in the log for every
glance. Saving from the page is likewise not a run.
Args:
ctx: Shared application context.
@@ -103,7 +100,8 @@ class DiagnosticsTool(Tool):
"""
from ..ui.diagnostics import open_diagnostics_page
await open_diagnostics_page(ctx, await build_report(ctx), save_path=default_path(ctx))
report = await build_report(ctx)
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
@@ -125,7 +123,7 @@ class DiagnosticsTool(Tool):
if out is None:
return ToolResult(summary={"tool": self.name}, report=report)
written = write_report(report, out)
written = write_markdown(markdown_source(report), out)
ctx.ui.ack("[ok]✓[/ok] diagnostics written — attach it to the report.")
return ToolResult(
summary={"tool": self.name, "path": str(written)},
@@ -137,22 +135,26 @@ class DiagnosticsTool(Tool):
async def build_report(ctx: AppContext) -> Report:
"""Gather every diagnostic fact, as the report both faces render.
Five blocks in the order a reader needs them: what this program is, what it is running
on, what it is drawing into, what radio it found, and how much history it has. The
three :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and the
two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own, which is also
why the table counts are a listing — a table named after a fact would otherwise be able
to collide with it.
Seven blocks in the order a reader needs them: what this program is, what it is running
on, what it is drawing into, what radio it found, how much history it has, what that
history is made of, and what has been changed from the defaults. Each carries a
``caption``, which is what the markdown face turns into the page's ``##`` landmarks.
The five :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and
the two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own — which is
also why the table counts are a listing, since a table named after a fact would
otherwise be able to collide with it.
Args:
ctx: Shared application context.
Returns:
The five-block report.
The seven-block report.
"""
return (
_app_facts(),
_host_facts(),
_terminal_facts(),
await _device_facts(ctx),
_mesh_facts(ctx),
_table_listing(ctx),
@@ -160,6 +162,44 @@ async def build_report(ctx: AppContext) -> Report:
)
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
"""The whole block as a markdown document — the page's source, and the file's contents.
Markdown because both places this goes already speak it: the menu draws it through
:func:`~meshterm.ui.markdown.render_markdown`, the same renderer behind the About
pages, so the page gets real ``##`` landmarks that pin as it scrolls and that its
section jumps step by; and an issue tracker renders it as written, with no fence and
no apology. The command line keeps its aligned records — that face is a stream a
person greps, and headings are furniture on it.
Args:
report: The blocks to write out.
when: The moment to stamp in the colophon (defaults to now, local). Injectable so
the same report can be rendered twice and compared.
Returns:
Markdown source, ready for :func:`~meshterm.ui.markdown.render_markdown` or a file.
"""
from .. import __version__
from ..ui.renderers import markdown_blocks
stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
return (
# The `#` title carries what the frame around it cannot — which is the same reason
# `about.md` has one and the other three written pages do not. The screen is already
# headed "Diagnostics"; repeating that would spend the page's first line saying
# nothing, while the version belongs at the top of a file somebody will open later.
# Emphasis makes it the muted qualifier beside the name.
f"# MeshTerm diagnostics *v{__version__}*\n\n"
# The standfirst sits flush and muted under the title, and says the one thing a
# reader needs before reading any of it: this is safe to hand over.
"Everything a bug report opens with. Nothing here is private.\n\n"
f"{markdown_blocks(report)}\n\n"
"---\n\n"
f"Taken {stamp}.\n"
)
def default_path(ctx: AppContext) -> Path:
"""Where the page saves when nobody named a place: beside the log, in the config dir.
@@ -170,29 +210,27 @@ def default_path(ctx: AppContext) -> Path:
return ctx.settings.config_dir / DIAGNOSTICS_FILENAME
def write_report(report: Report, path: Path | str) -> Path:
"""Write a report's plain rendering to ``path``, creating its directory.
def write_markdown(source: str, path: Path | str) -> Path:
"""Write a markdown document to ``path``, creating its directory.
**Always the plain face**, whatever the run was printing. A file exists here to be
attached to an issue and read by a person, and a document of the same facts helps that
person less than the block does; anyone who wants the machine face can redirect
``--json`` and already has a better filename for it than this one.
**Always markdown**, whatever face the run was printing. This file exists to be
attached to an issue and read there, and an issue tracker renders markdown; anyone who
wants the machine face can redirect ``--json`` and already has a better name for that
file than this one.
Args:
report: The block to write.
source: The document, from :func:`markdown_source`.
path: The destination.
Returns:
The path written, resolved.
The path written.
Raises:
OSError: If the directory cannot be made or the file cannot be written.
"""
from ..ui.renderers import render_to_text
written = Path(path)
written.parent.mkdir(parents=True, exist_ok=True)
written.write_text(render_to_text(report), encoding="utf-8")
written.write_text(source, encoding="utf-8")
return written
@@ -219,6 +257,7 @@ def _app_facts() -> Any:
return Facts(
key="meshterm",
caption="Build",
fields=(
fields.word("meshterm", "meshterm"),
fields.word("install", "install"),
@@ -228,12 +267,38 @@ def _app_facts() -> Any:
def _host_facts() -> Any:
"""The machine and the terminal, including every verdict resolved once at boot.
"""The machine underneath: which OS, which build, which processor, which Python."""
from ..core import hostinfo
from ..ui import fields
from ..ui.report import Facts
The boot-time verdicts are the point of this block. Whether icons draw, whether the
path widget's separators draw, which platform flavour resolved and *why* — each is
decided from the environment before the first frame, none is visible on any screen,
and each is the answer to a whole family of "it looks wrong" reports.
machine = hostinfo.host()
return Facts(
key="host",
caption="Host",
fields=(
fields.word("os", "os"),
fields.word("os_build", "os_build"),
fields.word("arch", "arch"),
fields.word("python", "python"),
),
values={
"os": machine.os,
"os_build": machine.os_build,
"arch": machine.arch,
"python": machine.python,
},
)
def _terminal_facts() -> Any:
"""The terminal, and every verdict about it resolved once at boot.
Its own section rather than more host facts, because these are what a "it looks wrong"
report is actually about, and a reader skimming for them should not have to pass the
processor architecture on the way. Whether icons draw, whether the path widget's
separators draw, which platform flavour resolved and *why* — each is decided from the
environment before the first frame and none of it is visible on any screen.
"""
from ..core import hostinfo
from ..platforms import get_platform
@@ -241,17 +306,14 @@ def _host_facts() -> Any:
from ..ui.report import Facts
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
machine, term = hostinfo.host(), hostinfo.terminal()
term = hostinfo.terminal()
emoji = emoji_support()
powerline = powerline_support()
font = detect_terminal_font()
return Facts(
key="host",
key="terminal",
caption="Terminal",
fields=(
fields.word("os", "os"),
fields.word("os_build", "os_build"),
fields.word("arch", "arch"),
fields.word("python", "python"),
fields.word("terminal", "terminal"),
fields.word("term", "term"),
fields.word("colorterm", "colorterm"),
@@ -264,10 +326,6 @@ def _host_facts() -> Any:
fields.word("powerline", "powerline"),
),
values={
"os": machine.os,
"os_build": machine.os_build,
"arch": machine.arch,
"python": machine.python,
"terminal": term.program,
"term": term.term,
"colorterm": term.colorterm,
@@ -340,6 +398,7 @@ async def _device_facts(ctx: AppContext) -> Any:
return Facts(
key="device",
caption="Radio",
fields=(
fields.flag("connected", "connected"),
fields.word("transport", "transport"),
@@ -375,6 +434,7 @@ def _mesh_facts(ctx: AppContext) -> Any:
first, last = ctx.repo.observation_span()
return Facts(
key="mesh",
caption="Storage",
fields=(
fields.path("config_dir", "config_dir"),
fields.integer("db_size_kb", "db_size_kb"),
@@ -412,6 +472,7 @@ def _table_listing(ctx: AppContext) -> Any:
counts = ctx.repo.table_counts()
return Listing(
key="tables",
caption="Stored rows",
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
rows=[{"table": name, "rows": n} for name, n in counts.items()],
)
@@ -431,6 +492,7 @@ def _preference_listing(ctx: AppContext) -> Any:
preferences = ctx.preferences or current()
return Listing(
key="preferences",
caption="Changed preferences",
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
rows=[
{"preference": key, "value": str(value)}
+107 -166
View File
@@ -1,29 +1,24 @@
# SPDX-License-Identifier: Apache-2.0
"""The Diagnostics page: the bug-report block, on a frame with nothing else on it.
"""The Diagnostics page: the bug-report block, drawn as one of the app's written pages.
**A bare frame, and the reason is mechanical rather than aesthetic.** The one thing this
page is for is getting its text out of the terminal and into an issue, and a terminal is
selected by dragging: a panel border is characters sitting on the *same lines* as the
content, so every line of a line-wise selection arrives with a box-drawing glyph welded to
each end. Same for a header, a footer hint and an F-key lane, which arrive as text that
looks like part of the report to whoever reads the paste. So this page draws its rows on a
:attr:`~meshterm.ui.tui.screen.Screen.bare` frame, the one the share screen uses for the
same kind of reason — there, a camera wants no contrast arguing with the code; here, a
clipboard wants no furniture pretending to be data. Esc leaves, as everywhere.
It is prose-shaped rather than screen-shaped — a title, a standfirst, sections, and a list
under each — so it is markdown, and it goes through the renderer the About pages already
use (:mod:`meshterm.ui.markdown`). That is not a presentational choice made twice: the same
document is what gets **saved**, and an issue tracker renders markdown as written. One
source, one look, on screen and in the issue.
The page's **one** line of its own is the save hint, and that is a trade rather than an
exception: a bare frame has nowhere to advertise a key, so the alternative was a page that
could write the file and never said so. One trailing line after a blank row costs a paste
far less than a border costs it on every line, and a drag that follows the block stops
above it anyway (see :class:`DiagnosticsPage`).
Being a written page buys the two things a long block most needs and neither of which a
hand-built screen had: its ``##`` headings are landmarks, so each pins to the top row while
its own section scrolls under it and ``^PgUp``/``^PgDn`` step section by section; and the
PicoCalc's F-key lane picks up ``Sect ↑``/``Sect ↓`` for the same reason a grouped list
does. :class:`~meshterm.ui.about.AboutPage` already does all of it, so this is that page
with one verb added.
The rows come from the tool's :mod:`~meshterm.ui.report`, projected through the very lanes
the command line prints (:func:`~meshterm.ui.renderers.facts_pairs`), so what is copied off
this screen and what ``meshterm diagnostics`` writes to a pipe are the same facts in the
same words. Only the *layout* differs, and in one respect: the scripted face crops a value
too wide for its lane, which is right for a record somebody will split on whitespace and
wrong for a page whose entire job is to be complete — so here a long value hangs under its
own block instead (the app-wide rule for a wrapped labelled row).
The verb is **save** (:data:`SAVE_KEY`), advertised where the platform advertises verbs —
in the footer hint on a desktop, on the free F3 slot of the lane on the console — and never
in the page's own body, which belongs to the report. It answers in a popup, because the
answer is a path the reader may need to read carefully and because a write can fail; the
page underneath is unchanged and still there when the popup goes.
"""
from __future__ import annotations
@@ -31,100 +26,122 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich.console import Group, RenderableType
from rich.table import Table
from rich.text import Text
from .tui.screen import ScrollScreen
from .about import AboutPage
from .markdown import render_markdown
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
from .report import Facts, Listing, Report
from .tui.session import TuiSession
#: The key that writes the block to a file, and the only affordance this page advertises.
#: The key that writes the page to a file. A bare letter, which a read-only page can
#: afford because it has no filter and no field for one to mean anything else in.
SAVE_KEY = "s"
#: The action both the key and the lane's chip dispatch, so the chip is not a second
#: implementation of the key.
SAVE_ACTION = "save"
class DiagnosticsPage(ScrollScreen):
"""The bug-report block, full-screen and unadorned, with one thing it can do.
Bare, so a mouse drag takes the facts and only the facts; scrollable, because the
block outgrows a 26-row console and a truncated bug report is worse than a scrolled
one.
class DiagnosticsPage(AboutPage):
"""The written diagnostics page, plus the one verb that writes it to a file.
**The save line is the one concession, and it is a deliberate one.** A bare frame has
no footer and no F-key lane, so a page that could save and never said so would be a
key nobody could find — on the PicoCalc especially, where the lane is normally the only
place an affordance is advertised at all. So the page carries its own last line. It
costs a paste one trailing line where a border would have cost it a character on
*every* line, and a blank row above it means a drag that follows the block stops
before it of its own accord.
Saving answers **on that same line** rather than in a dialog: the reader pressed a key
to be told where a file went, and a box over the page would cover the thing they were
about to copy. It is also what lets a failure — a read-only home, a full disk — be said
without inventing an error screen for it.
Everything about how it *reads* is :class:`~meshterm.ui.about.AboutPage`: the pager,
the sticky ``##`` headings, the section jumps, the lane that dims when there is nothing
to page. What is added is the save, on the one slot the About pages leave free.
"""
bare = True
def __init__(self, report: Report, *, save_path: Path) -> None:
"""Lay the report out as the page's body.
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
"""Show ``source`` as the page, saving to ``save_path`` on demand.
Args:
report: The tool's report — the same blocks the CLI renders.
save_path: Where :data:`SAVE_KEY` writes the block.
source: The markdown document, from
:func:`~meshterm.tools.diagnostics.markdown_source`.
session: The session, for the popup the save answers in.
save_path: Where :data:`SAVE_KEY` writes.
"""
super().__init__(Text(""), title="Diagnostics", floating=False)
self._report = report
super().__init__("Diagnostics", render_markdown(source))
self._source = source
self._session = session
self._save_path = save_path
self._saved: Path | None = None
self._error: str | None = None
self._refresh()
self._saving = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The read-only page's keys, with the save between the pager and Esc.
Navigation first, then the action, then Esc — the standard sentence shape. The
pager atom comes and goes with :attr:`~Screen.content_overflows`, as it does on
every read-only page, so the line never names a key that would do nothing.
"""
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
return f"{scroll}{SAVE_KEY} save · Esc back"
@property
def fkey_lane(self):
"""The About page's lane, with ``Save`` on the slot it leaves free.
F1/F2 are the section pair and F4/F5 the pager, so F3 is the one free primary slot
— and on this platform the lane is the *only* place a bare letter can advertise
itself at all, which is what makes the chip load-bearing rather than decorative.
"""
from .tui.fkeys import FPair
lane = list(super().fkey_lane)
lane[2] = FPair("Save", SAVE_ACTION)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Save on :data:`SAVE_KEY`; scroll or leave on everything else.
The key arrives as bare text, which a read-only page can afford precisely because
it has no filter and no field for a letter to mean anything else in.
"""
if action == "text" and data.lower() == SAVE_KEY:
self._save()
"""Save on the key or the chip; scroll, jump or leave on everything else."""
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
self._begin_save()
return
super().handle(action, data)
def _save(self) -> None:
"""Write the block, and say on the page's own last line what happened."""
from ..tools.diagnostics import write_report
def _begin_save(self) -> None:
"""Write the file and report it in a popup, off the key handler.
``run_detached`` because a screen's ``handle`` is synchronous and the popup is a
screen: the flow can only be *started* here, and the task it starts sits outside
the navigation chain (see :meth:`~meshterm.ui.tui.session.TuiSession.run_detached`).
One save at a time, so a held key cannot stack popups over each other.
"""
if self._saving:
return
self._saving = True
async def run() -> None:
try:
await self._session.message_dialog(self._write(), title="Diagnostics")
finally:
self._saving = False
self._session.run_detached(run())
def _write(self) -> str:
"""Write the document, and say what happened in one marked-up line.
The message carries its own mark — ``✓`` or ``✗`` — which is also what tells the
popup which border to take, so a failed write arrives red without anything here
naming a colour.
"""
from ..tools.diagnostics import write_markdown
try:
self._saved, self._error = write_report(self._report, self._save_path), None
written = write_markdown(self._source, self._save_path)
except OSError as exc:
# A home that is read-only, full, or not there. The reader can still copy the
# block off the screen, which is the whole reason that path exists as well.
self._saved, self._error = None, exc.strerror or str(exc)
self._refresh()
def _refresh(self) -> None:
"""Rebuild the body: the block, a blank row, then the page's one line of its own."""
self.replace_content(Group(render_report(self._report), Text(""), self._status()))
def _status(self) -> Text:
"""The last line: what the key does, or what it did."""
if self._error is not None:
return Text(f"could not save to {self._save_path}: {self._error}", style="err")
if self._saved is not None:
return Text(f"saved to {self._saved}", style="ok")
return Text(f"{SAVE_KEY} save this to a file", style="muted")
# A read-only home, a full disk, a path that is not there. The page itself is
# untouched and still readable, which is the fallback that matters.
reason = exc.strerror or str(exc)
return f"[err]✗[/err] could not save to {self._save_path}\n{reason}"
return f"[ok]✓[/ok] saved to\n{written}"
async def open_diagnostics_page(ctx: AppContext, report: Report, *, save_path: Path) -> None:
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
Args:
ctx: The shared application context (must be running the interactive TUI).
report: The block to show.
source: The markdown document to show and to save.
save_path: Where the page's save key writes.
Raises:
@@ -134,84 +151,8 @@ async def open_diagnostics_page(ctx: AppContext, report: Report, *, save_path: P
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the Diagnostics page is only available in the menu")
await ctx.ui.session.run_screen(DiagnosticsPage(report, save_path=save_path))
session = ctx.ui.session
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
def render_report(report: Report) -> RenderableType:
"""Lay a report out as the page's body: each block in order, a blank row between.
A block that would draw nothing — a listing with no rows — is left out along with its
separator, exactly as the scripted face leaves it out, so the two never disagree about
whether a section exists.
Args:
report: The blocks to draw.
Returns:
The page body.
"""
from .report import Facts
parts: list[RenderableType] = []
for block in report:
drawn = _facts(block) if isinstance(block, Facts) else _listing(block)
if drawn is None:
continue
if parts:
parts.append(Text(""))
parts.append(drawn)
return Group(*parts)
def _facts(block: Facts) -> RenderableType | None:
"""One facts block: key on the left, value on the right, wrapping under itself."""
from .renderers import facts_pairs
rows = facts_pairs(block)
if not rows:
return None
table = _grid()
table.add_column(no_wrap=True)
# The one column allowed to wrap, and the reason this page is not just the scripted
# renderer at a different width: a config directory or a connection error cropped to
# the frame is a bug report missing the half that mattered.
table.add_column(overflow="fold")
for key, value in rows:
table.add_row(Text(key, style="muted"), Text(value))
return table
def _listing(block: Listing) -> RenderableType | None:
"""One listing: its header row, then its records. Nothing where there are no records."""
if not block.rows:
return None
lanes = block.lanes()
table = _grid()
for _column, lane in lanes:
table.add_column(justify=lane.align, no_wrap=True)
table.add_row(*(Text(lane.header, style="muted") for _column, lane in lanes))
for row in block.rows:
table.add_row(*(Text(lane.render(row.get(column.key))) for column, lane in lanes))
return table
def _grid() -> Table:
"""A frameless table that starts in column zero.
No box, no edge padding and no leading pad: every cell of chrome on a row is a
character the clipboard carries, and an indent before the first key is one the reader
of the issue has to strip off every line.
"""
from .script import GUTTER
return Table(
box=None,
show_header=False,
show_edge=False,
pad_edge=False,
expand=False,
padding=(0, GUTTER, 0, 0),
)
__all__ = ["DiagnosticsPage", "open_diagnostics_page", "render_report"]
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
+58 -30
View File
@@ -33,6 +33,7 @@ compartment inside it.
from __future__ import annotations
import json
import re
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from enum import Enum
@@ -61,44 +62,71 @@ class OutputFormat(str, Enum):
JSON = "json"
#: How wide a report is drawn when it is going into a *file* rather than onto a terminal.
#: Wide enough that no lane ever crops — a saved report exists to be complete, and the
#: table sizes each column to its content, so nothing is padded out to this either.
FILE_WIDTH = 200
def markdown_blocks(report: Report | None) -> str:
"""Project a report as markdown sections — the third face, and the only *document* one.
The plain face is a stream of records and the machine face is a document for a
program; this one is a page for a person to read, on a screen through
:func:`~meshterm.ui.markdown.render_markdown` and in an issue tracker through nothing
at all. It is where :attr:`~meshterm.ui.report.Block.caption` finally means something:
markdown has headings, and a heading is what a reader navigates by.
def render_to_text(report: Report | None, fmt: OutputFormat = OutputFormat.PLAIN) -> str:
"""Render a report to a string, for writing somewhere that is not a terminal.
Every value is written as **inline code**, and that is correctness rather than taste. A
Windows path is full of backslashes, which markdown reads as escapes, and an error
message a stranger's firmware wrote can hold an asterisk or a bracket. Inside a code
span none of it is interpreted, so the value that arrives is the value that left — the
same reason :func:`~meshterm.ui.script.name` quotes what it cannot vouch for. A value
already holding a backtick gets a longer fence rather than a broken one.
The same renderers, on a capture console: a file gets exactly what the corresponding
face would have printed, so there is no third rendering to keep in step with the other
two. No colour, no highlighting and no markup interpretation — a node's name can
contain a bracket, and a file is read by things that do not speak SGR.
Rows are a **list**, not a table, and that was measured rather than assumed: a markdown
table's cells are cropped with an ellipsis when the frame is narrow, and a diagnostics
page whose one long value is cut is missing the half that mattered. A list item wraps
and hangs under its own text at any width.
Args:
report: The report to render, or ``None`` for nothing at all.
fmt: Which face to write.
report: The blocks to project, or ``None`` for nothing at all.
Returns:
The rendered text, newline-terminated where it has any content.
Markdown source: one ``##`` section per captioned block, blank-line separated.
"""
console = Console(
width=FILE_WIDTH,
color_system=None,
highlight=False,
markup=False,
emoji=False,
# A capture must not consult the real terminal for its width or its legs.
force_terminal=False,
)
with console.capture() as captured:
for_format(fmt, console).render(report)
# Rich pads every cell out to its column's width, which is invisible on a terminal and
# is trailing whitespace on disk — noise in a diff, and something a reader pasting the
# file onward has to strip. The written pages already do the same (see
# ``tools.about._rendered_page``).
text = "\n".join(line.rstrip() for line in captured.get().splitlines())
return f"{text}\n" if text.strip() else ""
sections: list[str] = []
for block in report or ():
rows = _markdown_rows(block)
head = f"## {block.caption}\n\n" if block.caption else ""
# A section with nothing in it still says so. An absence is a fact here — "no
# preferences changed" is something a maintainer can act on, where a section
# silently missing only raises the question of whether it was ever built.
sections.append(head + ("\n".join(rows) if rows else "*none*"))
return "\n\n".join(sections)
def _markdown_rows(block: Block) -> list[str]:
"""One markdown list item per row of a block."""
if isinstance(block, Facts):
return [f"- **{key}** — {_code(value)}" for key, value in facts_pairs(block)]
lanes = block.lanes()
if not lanes:
return []
items: list[str] = []
for row in block.rows:
# The first lane names the record and the rest describe it, which is what a
# two-lane listing (a table and its count, a preference and its value) wants and
# what a wider one degrades to gracefully.
cells = [lane.render(row.get(column.key)) for column, lane in lanes]
described = " · ".join(_code(cell) for cell in cells[1:])
items.append(f"- **{cells[0]}** — {described}" if described else f"- **{cells[0]}**")
return items
def _code(value: str) -> str:
"""One value as a markdown code span, fenced long enough to survive its own backticks."""
if not value:
return ""
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
fence = "`" * (longest + 1)
# A span that starts or ends with a backtick needs a space the parser then eats.
pad = " " if value.startswith("`") or value.endswith("`") else ""
return f"{fence}{pad}{value}{pad}{fence}"
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
+8
View File
@@ -155,6 +155,13 @@ class Block:
Attributes:
key: The name this block goes under in a multi-block document.
caption: The heading a format that *has* headings draws above this block. The
plain face has none — a heading over a key/value block is furniture on a
line-oriented face, which is why ``config show`` drops even its column
headers — and the machine face has ``key`` instead. The markdown projection
makes it a ``##`` section, which is what gives a written page the landmarks
its section jumps and its sticky headings step by. Empty means the block is
drawn without one.
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
@@ -164,6 +171,7 @@ class Block:
"""
key: str
caption: str = ""
plain_only: bool = False
+168 -79
View File
@@ -27,8 +27,16 @@ from meshterm.core.device_store import DeviceStore
from meshterm.core.models import Contact
from meshterm.core.preferences import Preferences
from meshterm.persistence.repository import Repository
from meshterm.tools.diagnostics import build_report
from meshterm.ui.renderers import JsonRenderer, PlainRenderer, facts_pairs
from meshterm.tools.diagnostics import build_report, markdown_source
from meshterm.ui import fields
from meshterm.ui.about import AboutPage
from meshterm.ui.markdown import render_markdown
from meshterm.ui.renderers import (
JsonRenderer,
PlainRenderer,
facts_pairs,
markdown_blocks,
)
from meshterm.ui.report import Facts, Listing
#: Planted into the stores that really hold this kind of value. Each is the kind of thing
@@ -197,115 +205,163 @@ def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) ->
assert value in printed
def test_the_page_draws_no_chrome(ctx: AppContext) -> None:
"""The page is bare, and its rows start in column zero.
def test_the_page_is_a_written_page_with_landmarks(ctx: AppContext) -> None:
"""The page is markdown through the About-page renderer, and its sections are landmarks.
Both are the same requirement seen twice: a terminal is selected by dragging, so a
border, a header or a leading indent is a character the clipboard carries into the
issue.
The sections are the point of the format: a heading pins to the top row while its own
rows scroll under it, the section jumps step by them, and the PicoCalc lane earns its
``Sect up``/``Sect down`` pair for the same reason a grouped list does.
"""
page = _page(ctx)
assert page.bare is True
lines = [_strip(line) for line in page.render_body(72)]
content = [line for line in lines if line.strip()]
assert content, "the page should have drawn something"
assert not content[0].startswith(" ")
assert isinstance(page, AboutPage)
assert page.bare is False
assert {"Build", "Host", "Terminal", "Radio", "Storage"} <= set(page._doc.sections)
def test_the_save_is_advertised_on_both_platforms(ctx: AppContext) -> None:
"""The verb is named in the footer hint and on the lane's one free slot.
Never in the page's body, which belongs to the report — and never nowhere, which is
what a bare frame would have forced. F1/F2 are the section pair and F4/F5 the pager,
so F3 is the slot the About pages leave free.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
page = _page(ctx)
assert f"{SAVE_KEY} save" in page.footer_hint
assert page.footer_hint.endswith("Esc back")
assert page.fkey_lane[2] is not None
assert page.fkey_lane[2].label == "Save"
assert page.fkey_lane[2].action == SAVE_ACTION
# --- saving it ------------------------------------------------------------------------
def test_the_saved_file_is_what_the_command_line_prints(ctx: AppContext, tmp_path: Path) -> None:
"""The file holds the plain block, drawn wide enough that no lane is cropped.
def test_the_saved_file_is_the_document_the_page_shows(ctx: AppContext, tmp_path: Path) -> None:
"""The file holds the very markdown the page was built from — one source, two places."""
from meshterm.tools.diagnostics import markdown_source, write_markdown
Always the plain face, whatever the run was printing: this file exists to be attached
to an issue and read by a person.
"""
from meshterm.tools.diagnostics import write_report
report = _report(ctx)
written = write_report(report, tmp_path / "out.txt")
saved = written.read_text(encoding="utf-8")
assert saved.strip()
for block in report:
if isinstance(block, Facts):
for key, value in facts_pairs(block):
assert key in saved
assert value in saved
source = markdown_source(_report(ctx))
written = write_markdown(source, tmp_path / "out.md")
assert written.read_text(encoding="utf-8") == source
assert source.startswith("# MeshTerm diagnostics")
def test_saving_creates_the_directory_it_needs(ctx: AppContext, tmp_path: Path) -> None:
"""A destination whose parent does not exist yet is made rather than refused."""
from meshterm.tools.diagnostics import write_report
from meshterm.tools.diagnostics import markdown_source, write_markdown
written = write_report(_report(ctx), tmp_path / "nested" / "deeper" / "out.txt")
written = write_markdown(markdown_source(_report(ctx)), tmp_path / "a" / "b" / "out.md")
assert written.is_file()
def test_the_page_advertises_the_save_key_then_reports_where_it_went(
def test_the_key_and_the_chip_both_save_and_answer_in_a_popup(
ctx: AppContext, tmp_path: Path
) -> None:
"""The page names its one key, and answers on the same line once it is pressed.
"""Either affordance writes the file and reports the path in an acknowledgement popup.
A bare frame has no footer to advertise a key from, so the page carries the hint
itself — and the confirmation replaces it rather than opening a dialog over the block
the reader is about to copy.
The chip dispatches the same action the key does, so it is not a second implementation
of the same verb.
"""
from meshterm.ui.diagnostics import SAVE_ACTION, SAVE_KEY
for trigger, target in ((("text", SAVE_KEY), "key.md"), ((SAVE_ACTION, ""), "chip.md")):
path = tmp_path / target
session = _Session()
page = _page(ctx, session=session, save_path=path)
page.handle(*trigger)
assert path.is_file()
assert len(session.messages) == 1
assert "\u2713" in session.messages[0]
assert str(path) in session.messages[0]
def test_a_save_that_fails_is_reported_not_raised(ctx: AppContext, tmp_path: Path) -> None:
"""An unwritable destination reports itself and leaves the page readable.
The page is still there to be read and copied off, which is the fallback that matters,
so a failed write must never take it down. The mark is also what turns the popup red.
"""
from meshterm.ui.diagnostics import SAVE_KEY
target = tmp_path / "saved" / "meshterm-diagnostics.txt"
page = _page(ctx, save_path=target)
assert "save this to a file" in _body(page)
page.handle("text", SAVE_KEY)
assert target.is_file()
# Matched in two parts because a tmp_path is long enough to wrap the status line,
# which is the line behaving correctly rather than a failure.
body = _body(page)
assert "saved to" in body
assert target.name in body
assert "save this to a file" not in body
def test_a_save_that_fails_is_said_on_the_page_not_raised(ctx: AppContext, tmp_path: Path) -> None:
"""An unwritable destination leaves the block readable and explains itself.
The block on screen is still copyable, which is the whole reason that path exists
alongside the file — so a failed write must never take the page down with it.
"""
from meshterm.ui.diagnostics import SAVE_KEY
# A path whose "directory" is an existing file: mkdir and write both refuse, on
# every OS, without needing permissions the suite cannot count on.
# A path whose "directory" is an existing file: mkdir and write both refuse, on every
# OS, without needing permissions the suite cannot count on.
blocker = tmp_path / "blocker"
blocker.write_text("not a directory", encoding="utf-8")
page = _page(ctx, save_path=blocker / "inside" / "out.txt")
session = _Session()
page = _page(ctx, session=session, save_path=blocker / "inside" / "out.md")
page.handle("text", SAVE_KEY)
body = _body(page)
assert "could not save" in body
assert "meshterm" in body # the block itself is untouched
assert "\u2717" in session.messages[0]
assert "could not save" in session.messages[0]
assert "Build" in _body(page) # the page itself is untouched
def test_the_save_key_is_the_only_letter_the_page_claims(ctx: AppContext, tmp_path: Path) -> None:
"""Any other letter falls through to the scroll handler rather than doing something."""
page = _page(ctx, save_path=tmp_path / "out.txt")
def test_a_held_save_key_does_not_stack_popups(ctx: AppContext, tmp_path: Path) -> None:
"""One save at a time: a second press while one is in flight is ignored."""
from meshterm.ui.diagnostics import SAVE_KEY
session = _Session(defer=True)
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", SAVE_KEY)
page.handle("text", SAVE_KEY)
assert len(session.started) == 1
def test_another_letter_falls_through_to_the_pager(ctx: AppContext, tmp_path: Path) -> None:
"""Any other letter is the scroll handler's, not the save's."""
session = _Session()
page = _page(ctx, session=session, save_path=tmp_path / "out.md")
page.handle("text", "q")
assert not (tmp_path / "out.txt").exists()
assert "save this to a file" in _body(page)
assert not (tmp_path / "out.md").exists()
assert session.messages == []
def test_an_empty_listing_is_drawn_by_neither_face() -> None:
"""A listing with no rows is absent from the page exactly as it is from the output.
# --- the markdown face ------------------------------------------------------------------
The two faces must not disagree about whether a section exists: a reader comparing a
pasted block against their own would otherwise read the difference as meaningful.
#: A value that is markdown in every direction: backslashes markdown reads as escapes,
#: asterisks and underscores it reads as emphasis. None of it may be reinterpreted.
_HOSTILE = r"C:\Users\*jp*\_meshterm_"
def test_every_value_survives_markdown_untouched() -> None:
"""Values are code spans, so a path's backslashes and stars arrive as themselves.
A path, or an error message a stranger's firmware wrote, is not markdown and must never
be silently read as it. A value already holding a backtick gets a longer fence.
"""
from meshterm.ui.diagnostics import render_report
source = markdown_blocks(
(
Facts(
key="x",
caption="X",
fields=(fields.word("p", "p"), fields.word("q", "q")),
values={"p": _HOSTILE, "q": "a `backtick` inside"},
),
)
)
drawn = _render(render_markdown(source))
assert _HOSTILE in drawn
assert "a `backtick` inside" in drawn
empty = (Listing(key="preferences", columns=(), rows=[]),)
assert _plain(empty).strip() == ""
assert _render(render_report(empty)).strip() == ""
def test_a_caption_becomes_the_section_heading() -> None:
"""A block's caption is its ``##``; a block without one is drawn with no heading."""
assert markdown_blocks((Facts(key="x", caption="Radio", fields=()),)).startswith("## Radio")
assert not markdown_blocks((Facts(key="x", fields=()),)).startswith("#")
def test_an_empty_section_says_so_rather_than_vanishing() -> None:
"""An absence is a fact here: "no preferences changed" is something to act on.
The plain face drops an empty listing because a record stream has nothing to say about
one. A document does — a section silently missing only raises the question of whether
it was ever built.
"""
empty = Listing(key="preferences", caption="Changed preferences", columns=(), rows=[])
assert markdown_blocks((empty,)).endswith("*none*")
assert "none" in _render(render_markdown(markdown_blocks((empty,))))
# --- the host probe -------------------------------------------------------------------
@@ -443,12 +499,45 @@ def _strip(line: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", line)
def _page(ctx: AppContext, *, save_path: Path | None = None):
class _Session:
"""A session stand-in that runs the save flow inline and records the popup.
``run_detached`` is where the real session hands a key handler's flow to the event
loop; here it simply runs it, so a test can press a key and assert on what the reader
would have been shown. ``defer`` holds the coroutine instead, for the one test that
needs a save still in flight.
"""
def __init__(self, *, defer: bool = False) -> None:
"""Start with nothing shown and nothing started."""
self.messages: list[str] = []
self.started: list[object] = []
self._defer = defer
def invalidate(self) -> None:
"""A repaint request, which a test has no screen to repaint."""
def run_detached(self, work):
"""Run the flow now (or bank it, with ``defer``)."""
self.started.append(work)
if self._defer:
work.close()
return None
return asyncio.run(work)
async def message_dialog(self, message, *, title: str = "") -> None:
"""Record what the acknowledgement popup would have said."""
self.messages.append(str(message))
def _page(ctx: AppContext, *, session: _Session | None = None, save_path: Path | None = None):
"""The page over this context's report, saving wherever the caller wants."""
from meshterm.ui.diagnostics import DiagnosticsPage
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.txt")
return DiagnosticsPage(_report(ctx), save_path=target)
target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md")
return DiagnosticsPage(
markdown_source(_report(ctx)), session=session or _Session(), save_path=target
)
def _body(page) -> str:
+20 -13
View File
@@ -987,42 +987,45 @@ def _join_discord(cols: int, rows: int) -> Screen:
def _diagnostics(cols: int, rows: int) -> Screen:
"""The Diagnostics page, carrying the widest values a real one can.
A deep Windows config directory and a connection error in the radio's own words are
the two fields with no natural bound, and both are here on purpose: this page folds
where the scripted face crops, so the fold is what has to survive 53 columns.
A deep Windows config directory and a connection error in the radio's own words are the
two fields with no natural bound, and both are here on purpose: a markdown list item
hangs its wrapped text under itself at any width, and that is what has to survive 53
columns.
"""
from meshterm.ui.diagnostics import DiagnosticsPage
return DiagnosticsPage(
_diagnostics_report(),
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.txt"),
_diagnostics_source(),
session=_GallerySession(cols, rows),
save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.md"),
)
def _diagnostics_report():
"""A representative diagnostics report, shaped exactly as the tool states one."""
def _diagnostics_source() -> str:
"""A representative diagnostics document, built the way the tool builds one."""
from datetime import datetime
from meshterm.tools.diagnostics import markdown_source
from meshterm.ui import fields
from meshterm.ui.report import Column, Facts, Lane, Listing
return (
report = (
Facts(
key="meshterm",
caption="Build",
fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")),
values={"meshterm": "0.3.7", "install": "frozen"},
),
Facts(
key="host",
key="terminal",
caption="Terminal",
fields=(
fields.word("os", "os"),
fields.word("python", "python"),
fields.word("terminal", "terminal"),
fields.word("terminal_size", "terminal_size"),
fields.flag("over_ssh", "over_ssh"),
fields.word("powerline", "powerline"),
),
values={
"os": "Debian GNU/Linux 12 (bookworm)",
"python": "3.11.2",
"terminal": "Windows Terminal",
"terminal_size": "53x26",
"over_ssh": True,
@@ -1031,6 +1034,7 @@ def _diagnostics_report():
),
Facts(
key="device",
caption="Radio",
fields=(
fields.flag("connected", "connected"),
fields.path("config_dir", "config_dir"),
@@ -1044,6 +1048,7 @@ def _diagnostics_report():
),
Listing(
key="tables",
caption="Stored rows",
columns=(
fields.word("table", "TABLE"),
Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)),
@@ -1052,10 +1057,12 @@ def _diagnostics_report():
),
Listing(
key="preferences",
caption="Changed preferences",
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
rows=[{"preference": "log_level", "value": "DEBUG"}],
),
)
return markdown_source(report, when=datetime(2026, 9, 19, 17, 0))
def _share_qr(cols: int, rows: int) -> Screen: