diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c17531..8e30d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index cd47361..730b3ed 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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` diff --git a/meshterm/tools/diagnostics.py b/meshterm/tools/diagnostics.py index 7c62be5..ca10096 100644 --- a/meshterm/tools/diagnostics.py +++ b/meshterm/tools/diagnostics.py @@ -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)} diff --git a/meshterm/ui/diagnostics.py b/meshterm/ui/diagnostics.py index c618b42..8b81a23 100644 --- a/meshterm/ui/diagnostics.py +++ b/meshterm/ui/diagnostics.py @@ -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"] diff --git a/meshterm/ui/renderers.py b/meshterm/ui/renderers.py index 8dc73b0..ac02a3d 100644 --- a/meshterm/ui/renderers.py +++ b/meshterm/ui/renderers.py @@ -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]]: diff --git a/meshterm/ui/report.py b/meshterm/ui/report.py index eb0d99c..0fd6cea 100644 --- a/meshterm/ui/report.py +++ b/meshterm/ui/report.py @@ -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 diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index fb5e2cd..9749f97 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -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: diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 6e9fdd2..d6b6188 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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: