Platform seam: meshterm/platforms.py, --platform flag, dual-platform gallery

One frozen Platform spec (REGULAR/PICOCALC), resolved once at boot
(--platform flag > MESHTERM_PLATFORM env > /proc/device-tree/model
auto-detect > REGULAR) and read via get_platform()/set_platform() -
never a stale `from platforms import PLATFORM`, which the module
docstring calls out. `meshterm platform` reports the resolution and
everything it looked at.

Wires the two correctness bindings that matter before any flavour
work lands: width_reclaim off on PicoCalc's exact-width console
(session._reclaim_last_column, env override still wins), and the
emoji-width calibration probe skipped outright when a platform never
draws emoji.

tests/test_gallery.py renders all 16 screens the interactive menu
opens against REGULAR (72x24) and PICOCALC (53x26 live floor, 53x40
lux case), asserting no line overflows its platform - 35 passing
cases, 13 xfailed as a real P6 worklist (contacts, map, walk,
livefeed, remote_cli, path_composer). Also caught and left alone: the
map screen's footer hint already overflowed the *existing* 72-col
standard before this seam existed, unrelated to picocalc - xfailed
rather than fixed as a drive-by. tests/test_platforms.py unit-tests
the module directly; conftest.py gained an autouse fixture resetting
the platform between tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Sonnet 5 committed 2026-08-01 02:18:22 -04:00
1 parent 077742868a
commit 8f1fba135e
8 files changed
+1044 -26

No files matched your search

+38 -1
View File
@@ -24,6 +24,7 @@ from .core.device_store import DeviceStore
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui.theme import make_console
@@ -72,6 +73,10 @@ app = typer.Typer(
# The context built by the callback and consumed by subcommands within one process.
_state: Optional[AppContext] = None
# The platform resolution the callback made, for the ``platform`` diagnostic subcommand
# to report back (see :func:`platform_command`) without re-deriving it independently.
_platform_resolution: Optional[Resolution] = None
@app.callback(invoke_without_command=True)
def main_callback(
@@ -93,6 +98,11 @@ def main_callback(
db_path: Optional[Path] = typer.Option(None, "--db", help="SQLite database path"),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
platform: Optional[str] = typer.Option(
None,
"--platform",
help="Force the UI platform (regular|picocalc) instead of auto-detecting it",
),
) -> None:
"""Build the application context and dispatch to the menu or a subcommand.
@@ -107,8 +117,17 @@ def main_callback(
db_path: Override the database location.
json_output: Request machine-readable output from tools.
quiet: Suppress console logging (file logging continues).
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
"""
global _state
global _state, _platform_resolution
# Resolved and installed first, before anything below it (make_console, the theme,
# the eventual TuiSession) reads the active platform.
try:
_platform_resolution = resolve(platform)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--platform") from exc
set_platform(_platform_resolution.platform)
settings = Settings.load()
if db_path is not None:
@@ -145,6 +164,24 @@ def main_callback(
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
@app.command(name="platform")
def platform_command() -> None:
"""Print the resolved platform and every input the resolution looked at.
A diagnostic for the platform seam (see ``meshterm/platforms.py``): confirms which
flavour a given invocation would run as, and why — the ``--platform``/
``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off
the actual hardware), and which of those decided it.
"""
assert _platform_resolution is not None # set by the callback that always runs first
r = _platform_resolution
console = make_console()
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
console.print(f" --platform: {r.flag or '(not passed)'}")
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
def run_tool_command(tool: Tool, params: dict) -> None:
"""Execute a tool from a CLI subcommand and render its result.
+286
View File
@@ -0,0 +1,286 @@
"""The platform seam: a frozen spec every consumer binds to once, at boot.
MeshTerm runs two flavours from one codebase — the regular desktop terminal and the
PicoCalc's 53-column framebuffer console — with no forked screens and no runtime layer.
A :class:`Platform` is resolved once, at process start, and held in a module-level
singleton; everything downstream (the theme, the frame compositor, the header, the
session's width handling) reads it at *its own* construction/call time and binds its
behaviour accordingly, so the render loop itself never branches on platform.
Read the active platform through :func:`get_platform` (or the module-qualified
``platforms.PLATFORM``) — never ``from meshterm.platforms import PLATFORM`` at a
module's top level. That statement runs once, at import time, and copies whatever
:data:`PLATFORM` pointed to *then*; :func:`set_platform` rebinding the module global
afterwards would never reach that stale copy. A function call always re-reads the
current value, so it has no such trap.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass(frozen=True, slots=True)
class Platform:
"""One flavour's complete rendering/behaviour spec.
Every field is data a consumer binds to at its own construction/call time (see the
module docstring) — there is no code path that branches on ``platform.name`` itself
outside this module and the small set of binding points documented at each field.
Attributes:
name: Stable identifier — ``"regular"`` or ``"picocalc"`` — used for
``--platform``/``MESHTERM_PLATFORM`` matching and diagnostics.
readable_cols: The readability standard (CLAUDE.md's "screens stay readable at
N columns") and the dual-platform gallery's test width.
readable_rows: The row *floor* a screen designs to. PicoCalc's live console is
53×26 (custom 6×12 font); a 6×8 font rebuild (P5) gives 53×40. Screens design
to this floor and exploit extra rows fluidly; the gallery renders picocalc at
both 26 and 40 rows.
frame_border: Whether the base screen draws inside a bordered ``Panel``. ``False``
swaps it for a title-bar row instead (+4 cols, +1 row reclaimed) — a PicoCalc
chrome saving, wired in P4.
header_atoms: Which segments compose the persistent header, in the vocabulary
``"version"``, ``"device"``, ``"badges"``, ``"pulse"``, ``"battery"``. Wired
into :func:`~meshterm.ui.menu._header` in P4; unconsumed until then.
footer_fkeys: Whether the footer is the fixed F1–F5 hint lane (with F6–F10 as
their paired opposites) instead of each screen's own ``footer_hint`` string.
Wired in P4.
width_reclaim: Whether the session may report one extra terminal column (see
:class:`~meshterm.ui.tui.session._WidthExtendedOutput`). Correctness-critical
on PicoCalc's exact-width console: a phantom extra column tears the frame, so
this is ``False`` there. ``MESHTERM_FULL_WIDTH`` remains an explicit override
on top of this default.
emoji: Whether emoji icons render at all. ``False`` means every icon funnel routes
through a compact single-BMP-glyph table instead (P3), and the emoji-width
calibration probe (:func:`~meshterm.ui.tui.emoji_width.calibrate`) is skipped
outright — there is nothing to calibrate once no emoji are ever drawn, and the
probe writes escape sequences the console font can't shape anyway.
ascii_fold: Whether names and message bodies are NFKD-folded (accents stripped) at
the render boundary before display. Storage is never touched. Wired in P3.
truecolor: Whether the theme may use arbitrary 24-bit SGR colour. ``False`` selects
a 16-slot palette theme instead (the console's real ceiling — no per-cell RGB).
Wired in P3.
name_colour: ``"key"`` (today's per-node hash-derived hue spectrum) or ``"type"``
(colour by node type — client/repeater/room/sensor — because PicoCalc's 16
colour slots can't also afford a hash spectrum without collisions). Wired into
:func:`~meshterm.ui.theme.name_style` in P3.
effects: Whether animated/decorative rendering runs at all: corner glow, the
header battery blink, the braille spinner. ``False`` drops to static chrome
and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a
non-truecolor theme, so this just makes that explicit. Wired in P2/P3.
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval.
Both platforms start at today's 1.0s; PicoCalc's is revisited in P2 once
on-device CPU numbers exist to tune against.
battery: Which source feeds the header's battery gauge — ``"companion"`` (read
from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs
``power_supply`` driver, confirmed present in P0). Wired in P5.
modifier_watch: Whether the optional keyboard-lib/evdev Shift-state watcher may
engage, flipping the displayed F-key lane live while Shift is held. An
experiment gated to PicoCalc only — desktop terminals have no such lane to
flip, and the watcher needs ``/dev/input`` access this platform is known to
have (the deploy user in the ``input`` group). Always optional and lazy; wired in P4.
"""
name: str
readable_cols: int
readable_rows: int
frame_border: bool
header_atoms: tuple[str, ...]
footer_fkeys: bool
width_reclaim: bool
emoji: bool
ascii_fold: bool
truecolor: bool
name_colour: str
effects: bool
tick_s: float
battery: str
modifier_watch: bool
#: Exactly today's behaviour — the desktop/ssh terminal, unchanged by this seam's arrival.
REGULAR = Platform(
name="regular",
readable_cols=72,
readable_rows=24,
frame_border=True,
header_atoms=("version", "device", "badges", "pulse", "battery"),
footer_fkeys=False,
width_reclaim=True,
emoji=True,
ascii_fold=False,
truecolor=True,
name_colour="key",
effects=True,
tick_s=1.0,
battery="companion",
modifier_watch=False,
)
#: The PicoCalc/Lyra/Calculinux framebuffer console. Field values not yet consumed by a
#: binding point (header_atoms, ascii_fold, truecolor, name_colour, effects beyond the
#: two P1 bindings, tick_s, battery, modifier_watch) are P2–P5's targets, recorded here
#: now so the seam exists before the flavour work lands — see .claude/plans/picocalc-platform.md.
PICOCALC = Platform(
name="picocalc",
readable_cols=53,
readable_rows=26,
frame_border=False,
# P4 decides "keep pulse" once it can measure whether it still earns its cells at 53;
# conservatively excluded until then. Version is dropped outright (plan decision).
header_atoms=("device", "badges", "battery"),
footer_fkeys=True,
width_reclaim=False,
emoji=False,
ascii_fold=True,
truecolor=False,
name_colour="type",
effects=False,
tick_s=1.0,
battery="host",
modifier_watch=True,
)
_BY_NAME = {p.name: p for p in (REGULAR, PICOCALC)}
#: The active platform. Do not import this name directly (see the module docstring) —
#: read it through :func:`get_platform`, and change it only through :func:`set_platform`.
PLATFORM = REGULAR
def get_platform() -> Platform:
"""Return the active :class:`Platform`.
Safe to call from anywhere, at any time — always reflects the most recent
:func:`set_platform` call, however that module was imported.
"""
return PLATFORM
def set_platform(platform: Platform) -> None:
"""Install ``platform`` as the active one.
Called exactly once by the CLI callback, before :func:`~meshterm.ui.theme.make_console`
— every other read of :func:`get_platform` in the same process happens after this.
Idempotent (setting the same platform twice is a no-op in effect) and reversible, so
tests can swap in a platform for one test and restore the previous one afterwards
(see the ``_reset_platform`` autouse fixture in ``tests/conftest.py``).
Args:
platform: The platform to make active.
"""
global PLATFORM
PLATFORM = platform
@dataclass(frozen=True, slots=True)
class Resolution:
"""What :func:`resolve` looked at and what it landed on.
Exists so the ``meshterm platform`` diagnostic can report every input the resolution
considered, not just the final answer.
Attributes:
flag: The ``--platform`` value passed on the command line, if any.
env: The ``MESHTERM_PLATFORM`` environment variable's value, if set.
detected_model: The ``/proc/device-tree/model`` contents read during resolution
(trailing NUL/space stripped), or ``None`` when the file doesn't exist (every
non-Lyra machine, including every desktop and CI runner).
source: Which input decided the outcome — ``"--platform flag"``,
``"MESHTERM_PLATFORM env var"``, ``"auto-detect"``, or ``"default"``.
platform: The resolved :class:`Platform`.
"""
flag: Optional[str]
env: Optional[str]
detected_model: Optional[str]
source: str
platform: Platform
#: Where the Lyra reports itself in the device tree. P0 (2026-08-01) recorded a trailing
#: space before the NUL; re-verified live on-device during P1 (2026-08-01) as exactly
#: ``b"Luckfox Lyra\x00"`` — no trailing space. :func:`_read_device_tree_model` strips
#: whatever surrounds it either way, so this is a documentation correction, not a behaviour
#: change — flagged here in case a different unit/firmware revision does carry one.
_LYRA_MODEL = "Luckfox Lyra"
_DEVICE_TREE_MODEL_PATH = Path("/proc/device-tree/model")
def _read_device_tree_model() -> Optional[str]:
"""Read and normalize ``/proc/device-tree/model``, or ``None`` when it doesn't exist.
Absent on every machine that isn't running a device-tree-based Linux kernel — every
desktop, every CI runner, and (today) every ``ssh`` session onto one of those. This is
deliberately the *only* signal auto-detection consults (no terminal-size guessing), so
a small window on the desktop is never mistaken for the device.
"""
try:
raw = _DEVICE_TREE_MODEL_PATH.read_bytes()
except OSError:
return None
return raw.decode("utf-8", "replace").rstrip("\x00").strip()
def _lookup(name: str, *, source: str) -> Platform:
"""Resolve a platform name to its :class:`Platform`, or raise a clear ``ValueError``."""
try:
return _BY_NAME[name.strip().lower()]
except KeyError:
choices = ", ".join(sorted(_BY_NAME))
# Plain ASCII: this can surface through Typer/Click's own error path (raised
# before make_console() has reconfigured stdout/stderr to UTF-8), which mangles
# non-ASCII on a legacy Windows console the way make_console's docstring warns of.
raise ValueError(
f"unknown platform {name!r} (from {source}) - choose from: {choices}"
) from None
def resolve(flag: Optional[str] = None) -> Resolution:
"""Decide which platform is active, and record what the decision looked at.
Resolution order: the explicit ``--platform`` flag, then the ``MESHTERM_PLATFORM``
environment variable, then device-tree auto-detection, then :data:`REGULAR`. Only the
input that actually decides the outcome is name-validated — an env var typo is
reported when the flag isn't set, but never blocks a session that passed an explicit
(correct) flag.
Args:
flag: The ``--platform`` command-line value, if given.
Returns:
The full :class:`Resolution`, for both immediate use (``.platform``) and the
``meshterm platform`` diagnostic's report.
Raises:
ValueError: The deciding input (flag or env var) named an unknown platform.
"""
env = os.environ.get("MESHTERM_PLATFORM") or None
model = _read_device_tree_model()
if flag:
return Resolution(flag, env, model, "--platform flag", _lookup(flag, source="--platform"))
if env:
return Resolution(
flag, env, model, "MESHTERM_PLATFORM env var",
_lookup(env, source="MESHTERM_PLATFORM"),
)
if model == _LYRA_MODEL:
return Resolution(flag, env, model, "auto-detect", PICOCALC)
return Resolution(flag, env, model, "default", REGULAR)
__all__ = [
"Platform",
"REGULAR",
"PICOCALC",
"PLATFORM",
"get_platform",
"set_platform",
"Resolution",
"resolve",
]
+6 -2
View File
@@ -25,6 +25,7 @@ from rich.text import Text
from .. import __version__
from ..context import AppContext
from ..persistence.logging import get_logger
from ..platforms import get_platform
from ..tools import all_tools
from .surface import TuiUi
from .braillechart import activity_peak, activity_sparkline
@@ -325,8 +326,11 @@ async def run_menu(ctx: AppContext) -> None:
"""
# Measure how this terminal renders emoji and align Rich to it, before
# prompt_toolkit takes over the screen. This keeps every panel border — and
# every chat bubble — aligned regardless of the terminal's emoji widths.
calibrate_emoji_width()
# every chat bubble — aligned regardless of the terminal's emoji widths. Skipped
# outright on a platform that never draws emoji (PicoCalc): there is nothing to
# calibrate, and the probe writes escape sequences the console font can't shape.
if get_platform().emoji:
calibrate_emoji_width()
header_cache: dict = {}
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
+30 -16
View File
@@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
from ...platforms import get_platform
from . import frame
from .overlay import BusyOverlay
from .progress import TuiProgress
@@ -178,18 +179,31 @@ def _read_clipboard() -> str:
return ""
#: Reclaim the terminal's final column. Some terminals (and prompt_toolkit's size probe on
#: them) report the window one column narrower than it really is, so the frame is drawn to
#: ``columns - 1`` and the true last column sits unused — visibly selectable to the right of
#: the border. When this is on, :class:`_WidthExtendedOutput` tells both the renderer and the
#: frame compositor the window is one column wider, and that final column gets drawn.
#:
#: This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a
#: terminal whose width probe is already right, the extra column falls off the real screen and
#: the frame would wrap and tear, so it can be switched off with ``MESHTERM_FULL_WIDTH=0``.
#: Kept as an env-var gate for now so it can graduate to a user setting once confirmed across
#: terminals (TODO: thread through ``settings`` and the config editor).
_RECLAIM_LAST_COLUMN = os.environ.get("MESHTERM_FULL_WIDTH", "1") != "0"
def _reclaim_last_column() -> bool:
"""Whether to reclaim the terminal's final column.
Some terminals (and prompt_toolkit's size probe on them) report the window one column
narrower than it really is, so the frame is drawn to ``columns - 1`` and the true last
column sits unused — visibly selectable to the right of the border. When this is on,
:class:`_WidthExtendedOutput` tells both the renderer and the frame compositor the
window is one column wider, and that final column gets drawn.
This is a gate, not a certainty: it is *correct* only when the probe under-reports. On a
terminal whose width probe is already right, the extra column falls off the real screen and
the frame would wrap and tear — which is exactly PicoCalc's exact-width console, so
:data:`~meshterm.platforms.PICOCALC` defaults this off. ``MESHTERM_FULL_WIDTH=0``/``=1``
remains an explicit override on top of the platform default for any terminal that needs
to disagree with its platform's usual verdict (TODO: thread through ``settings`` and the
config editor once confirmed further).
Read fresh on every call (never cached at import time) so it reflects whichever platform
:func:`~meshterm.platforms.set_platform` installed for this process — see that module's
docstring for why a cached/imported copy of the platform would go stale.
"""
override = os.environ.get("MESHTERM_FULL_WIDTH")
if override is not None:
return override != "0"
return get_platform().width_reclaim
#: How many stacked dialog layers the layout can float over the background at once. A fixed
#: pool of centered-box floats (see :meth:`TuiSession._build_app`), sized well past the deepest
@@ -231,7 +245,7 @@ class _WidthExtendedOutput:
adds a column. Because the whole render pipeline — prompt_toolkit's differential renderer
and MeshTerm's own frame compositor (via :meth:`TuiSession._size`) — keys off
``output.get_size()``, this single override makes both use the reclaimed column in
lock-step. See :data:`_RECLAIM_LAST_COLUMN` for when this is right (and when it isn't).
lock-step. See :func:`_reclaim_last_column` for when this is right (and when it isn't).
"""
def __init__(self, inner: Any) -> None:
@@ -1107,11 +1121,11 @@ class TuiSession:
"""The output the app renders to — optionally widened to reclaim the last column.
Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its
own output) is wrapped, and only when :data:`_RECLAIM_LAST_COLUMN` is on: a test that
supplies its own output keeps the exact size it set, so headless rendering stays
own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test
that supplies its own output keeps the exact size it set, so headless rendering stays
deterministic. See :class:`_WidthExtendedOutput` for what the wrap does.
"""
if self._output is not None or not _RECLAIM_LAST_COLUMN:
if self._output is not None or not _reclaim_last_column():
return self._output
from prompt_toolkit.output.defaults import create_output
+15
View File
@@ -18,6 +18,7 @@ from typing import Iterable, Iterator, Union
import pytest
from meshterm.platforms import REGULAR, set_platform
from meshterm.ui.termfont import powerline_support
#: ANSI SGR escapes — the colour runs a rendered line carries between its characters.
@@ -42,3 +43,17 @@ def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
powerline_support.cache_clear()
yield
powerline_support.cache_clear()
@pytest.fixture(autouse=True)
def _reset_platform() -> Iterator[None]:
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
A test that calls ``set_platform(PICOCALC)`` (directly, or via the gallery harness)
can't leak that choice into whatever runs next — regular is the suite's baseline, the
same way it is the default for a process that never passes ``--platform``/
``MESHTERM_PLATFORM`` (see :mod:`meshterm.platforms`).
"""
set_platform(REGULAR)
yield
set_platform(REGULAR)
+497
View File
@@ -0,0 +1,497 @@
"""The dual-platform gallery: every screen the interactive menu can open, checked for
width discipline under both flavours this app runs as.
Each entry below builds one full-screen :class:`~meshterm.ui.tui.screen.Screen` against
hand-built (simulator-shaped) data — the same "fake session, real screen" approach every
individual screen's own test file already uses — then renders it, both directly
(``render_body``) and through the real frame compositor (``compose_base``), at REGULAR's
72x24 and at PICOCALC's two live/lux row counts (53x26, the actual on-device floor; 53x40,
the boot-font/6x8-font-B case). No rendered line may exceed its terminal's width.
This is the harness described in .claude/plans/picocalc-platform.md's "Parity" section: a
screen added here without surviving both platforms is meant to fail CI by default, so the
suite polices new work automatically instead of relying on someone remembering to check
by eye. It intentionally starts already covering every screen the interactive menu can
open (not growing empty) — a screen that doesn't yet fit 53 columns is marked ``xfail`` in
:data:`_KNOWN_WIDE` instead of being left out, so that list *is* the width-reduction
worklist P6 works through screen by screen; a screen graduates off it the moment its case
starts reporting XPASS.
"""
from __future__ import annotations
import logging
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
import pytest
from rich.cells import cell_len
from rich.text import Text
from meshterm.core.courier_store import CourierStore
from meshterm.core.models import (
ChatMessage,
Contact,
Conversation,
Hop,
Observation,
TraceResult,
TraceStats,
TxLevelResult,
utcnow,
)
from meshterm.core.watch_store import WatchStore
from meshterm.persistence.repository import DiscoveredPath
from meshterm.platforms import PICOCALC, REGULAR, Platform, set_platform
from meshterm.services.courier import CourierService
from meshterm.services.message_paths import Arrival
from meshterm.services.monitor_service import ACTIVITY_BUCKETS
from meshterm.services.records import CATEGORY_BY_ID
from meshterm.services.topology import MeshTopology, build_topology
from meshterm.ui.chat import ChatScreen
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING
from meshterm.ui.contacts_screen import ContactsScreen
from meshterm.ui.courier_screen import CourierOutboxScreen
from meshterm.ui.dashboard_screen import DashboardScreen
from meshterm.ui.livefeed_screen import LiveFeedScreen
from meshterm.ui.map_render import MapMarker
from meshterm.ui.map_screen import MapScreen
from meshterm.ui.message_paths_screen import MessagePathsScreen
from meshterm.ui.node_detail_screen import NodeDetailScreen, _Action, _RoutesView, _Tab
from meshterm.ui.packet_viewer import PacketEntry, PacketViewer
from meshterm.ui.path_composer import PathComposerScreen
from meshterm.ui.records_screen import RecordDialog
from meshterm.ui.remote_cli import RemoteCliScreen
from meshterm.ui.timemachine_screen import TimeMachineScreen
from meshterm.ui.trace_screen import TraceScreen
from meshterm.ui.tui import Screen, frame
from meshterm.ui.tx_screen import TxSweepScreen
from meshterm.ui.walk_screen import WalkScreen
from meshterm.ui.widgets import ContactsSort, highlighted_hash
from tests.conftest import plain as _plain
_HUB_KEY = "3d63c6429436" + "0" * 52
_FAR_KEY = "f2c24f54551e" + "0" * 52
class _GallerySession:
"""A minimal TuiSession stand-in every gallery screen is happy with (the same shape
each screen's own test file rolls independently as ``_FakeSession``/``_StubSession``).
"""
def __init__(self, cols: int = 80, rows: int = 24) -> None:
self.repaints = 0
self.stack: list = []
self._cols, self._rows = cols, rows
def invalidate(self) -> None:
self.repaints += 1
def push(self, screen) -> None: # noqa: ANN001
self.stack.append(screen)
def pop(self, screen=None) -> None: # noqa: ANN001
if screen is None:
self.stack.pop()
elif screen in self.stack:
self.stack.remove(screen)
def base_body_size(self) -> tuple[int, int]:
return self._cols, self._rows
async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001
return None
@dataclass
class _Entry:
"""One gallery specimen: a name, and how to build it at a given terminal size."""
name: str
factory: Callable[[int, int], Screen]
#: Whether this screen ends in the app's standard exit group (a blank line then the
#: bare word "Back") — true only for the ``SelectScreen``-family list screens; the rest
#: exit via Esc (their ``footer_hint`` says so) with no dedicated exit row to find.
has_exit_group: bool = False
# --- factories: one per screen the interactive menu can open ---------------------------
def _dashboard(cols: int, rows: int) -> Screen:
return DashboardScreen(
session=_GallerySession(cols, rows),
resolve=lambda h: {"a1b2c3d4": "Alice", "3d63c642": "YUL-Cartierville"}.get(h, ""),
window=[
Observation(node="a1b2c3d4", name="a1b2c3d4", kind="advert", snr=5.0, rssi=-90.0,
observed_at=utcnow()),
Observation(node="3d63c642", name="3d63c642", kind="packet", snr=-2.0, rssi=-104.0,
observed_at=utcnow()),
],
activity=lambda: (2.0,) * ACTIVITY_BUCKETS,
activity_flags=lambda: (True,) * ACTIVITY_BUCKETS,
kind_counts=lambda: {"advert": 5, "ack": 2, "packet": 3},
)
def _contacts(cols: int, rows: int) -> Screen:
sort = ContactsSort.from_name("name", SORT_COLUMNS, SORT_OPENS_ASCENDING)
contacts = [
Contact(name="Alice", public_key="aa" * 32),
Contact(name="A Rather Long Repeater Name For Width", public_key=_HUB_KEY),
]
return ContactsScreen("Homestead", "cc" * 32, contacts, 1, {"aa" * 6: 7}, sort)
def _node_detail_header() -> Text:
header = Text("▲ ", style="#a78bfa")
header.append("YUL-Cartierville", style="accent")
header.append(" repeater", style="muted")
return header
def _node_detail(cols: int, rows: int) -> Screen:
return NodeDetailScreen(
title="Node — YUL-Cartierville",
header=_node_detail_header(),
info_rows=[
("key", highlighted_hash(_HUB_KEY, 1)),
("heard", Text("5m ago")),
("packets", Text("42")),
],
tabs=[_Tab("Info", "info"), _Tab("Routes", "routes")],
minimap=None,
map_caption=None,
routes=_RoutesView(note="no route observed yet — trace to discover one"),
info_actions=[_Action("timemachine", "⏳", "", "Time machine — 42 receptions")],
trace_action=_Action("trace", "\U0001f3af", "", "Trace — auto route …"),
tail_actions=[_Action("back", "", "", "Back")],
)
class _StubTileSource:
"""An always-offline tile source: the gallery renders markers only, no network."""
available = False
max_zoom = 14
def load_tile(self, z: int, x: int, y: int): # noqa: ANN001
return None
def _map(cols: int, rows: int) -> Screen:
session = _GallerySession(cols, rows)
markers = [
MapMarker("Homestead", 45.50, -73.60, is_self=True),
MapMarker("YUL-Cartierville", 45.40, -73.50, is_repeater=True),
MapMarker("A Rather Long Node Name For Width", 45.55, -73.65),
]
return MapScreen(session, markers, _StubTileSource(), 14)
def _chat(cols: int, rows: int) -> Screen:
conv = Conversation(
label="Alice", is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
)
messages = [
ChatMessage(text="on my way, should be there soon", outbound=False, peer="d4e5f6a7"),
ChatMessage(text="sounds good, see you shortly", outbound=True, peer="d4e5f6a7", acked=True),
]
return ChatScreen(
conv, messages, send=None, names={"d4e5f6a7": "Alice"}, session=_GallerySession(cols, rows),
)
def _livefeed(cols: int, rows: int) -> Screen:
return LiveFeedScreen(
session=_GallerySession(cols, rows),
resolve=lambda h: {"a1b2c3d4": "Alice"}.get(h, ""),
seed=[
Observation(node="a1b2c3d4", name="a1b2c3d4", kind="advert", snr=5.0, rssi=-90.0,
observed_at=utcnow()),
],
hub_active=lambda: True,
)
def _walk_topo() -> tuple[MeshTopology, dict[str, Contact]]:
hub = Contact(name="YUL-Cartierville", public_key=_HUB_KEY, key_prefix="3d63c6429436")
far = Contact(name="Alice", public_key=_FAR_KEY, key_prefix="f2c24f54551e")
topo = MeshTopology("aa" * 6, contacts=[hub, far])
hub_id, far_id = topo.canonical(hub.public_key), topo.canonical(far.public_key)
when = utcnow()
topo.add_walk([topo.self_id, hub_id], snrs=[6.0], when=when, source="trace")
topo.add_walk([hub_id, far_id], snrs=[-2.0], when=when, source="packet")
return topo, {hub_id: hub, far_id: far}
def _walk(cols: int, rows: int) -> Screen:
topo, contacts = _walk_topo()
return WalkScreen(
session=_GallerySession(cols, rows), topo=topo, contacts=contacts, self_label="Homestead",
)
def _timemachine(cols: int, rows: int) -> Screen:
def build(window, width): # noqa: ANN001
return [Text("YUL-Cartierville 5m ago advert"), Text("Alice 12m ago packet")]
return TimeMachineScreen(
session=_GallerySession(cols, rows), label="YUL-Cartierville", build=build,
)
def _message_paths(cols: int, rows: int) -> Screen:
message = ChatMessage(text="on my way, should be there soon", is_channel=True,
created_at=utcnow())
arrivals = [
Arrival(when=utcnow(), hops=("3d63c6",), snr=4.0),
Arrival(when=utcnow(), hops=("a1b2c3", "77aabb"), snr=-2.0),
]
return MessagePathsScreen(
message, arrivals, matched=True, resolve=lambda h: h, prefix_bytes=1,
self_name="Homestead", summary="heard twice", source="Alice",
)
def _remote_cli(cols: int, rows: int) -> Screen:
return RemoteCliScreen(
node_label="YUL-Cartierville",
history=["get name", "get name -> YUL-Cartierville"],
send=lambda c: None, session=_GallerySession(cols, rows),
)
def _path_composer(cols: int, rows: int) -> Screen:
hub = Contact(name="YUL-Cartierville", public_key=_HUB_KEY, key_prefix="3d63c6429436")
far = Contact(name="Alice", public_key=_FAR_KEY, key_prefix="f2c24f54551e")
topo = build_topology(
self_id="aaaaaaaaaaaa" + "0" * 52, contacts=[hub, far],
trace_paths=[], packet_paths=[], neighbour_links=[],
)
return PathComposerScreen(
device_label="Homestead", device_hash="aaaaaaaaaaaa" + "0" * 52, topology=topo,
width_bytes=1, hops=["3d63c6429436", "f2c24f54551e"],
)
class _CourierStubDevice:
async def get_contacts(self) -> list:
return []
class _CourierStubChat:
def __init__(self) -> None:
self.sent: list = []
async def send_direct(self, contact, text): # noqa: ANN001
return None
class _CourierStubContext:
"""The minimal AppContext surface :class:`CourierOutboxScreen` actually reads."""
def __init__(self, config_dir: Path) -> None:
self.courier_store = CourierStore(config_dir / "courier.json")
self.watch_store = WatchStore(config_dir / "watchtower.json")
self.chat = _CourierStubChat()
self.is_connected = True
self.log = logging.getLogger("test.gallery.courier")
self._device = _CourierStubDevice()
async def device(self) -> _CourierStubDevice:
return self._device
def _courier_outbox(cols: int, rows: int) -> Screen:
config_dir = Path(tempfile.mkdtemp(prefix="meshterm-gallery-courier-"))
ctx = _CourierStubContext(config_dir)
ctx.courier = CourierService(ctx)
ctx.courier_store.queue("aa" * 6, "YUL-Cartierville", "battery reading requested please")
ctx.courier_store.queue("bb" * 6, "A Rather Long Contact Name For Width", "hello there")
return CourierOutboxScreen(ctx)
def _record(**over) -> DiscoveredPath:
defaults = dict(
id=1, category="grand_tour", width_bytes=1, spec="3d63c6429436,f2c24f54551e",
route=("3d63c6429436", "f2c24f54551e"), score=2.0,
stats={
"hop_count": 2, "distinct_nodes": 2, "repeats": False, "min_snr": 6.0,
"km_travelled": 3.2, "km_complete": True, "far_km": 1.5, "rtt_ms": 250.0,
},
app_version="0.1.0",
discovered_at=datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc),
)
defaults.update(over)
return DiscoveredPath(**defaults)
def _record_dialog(cols: int, rows: int) -> Screen:
record = _record()
return RecordDialog(
record, CATEGORY_BY_ID[record.category], 1,
resolve=lambda h: {"3d63c6429436": "YUL-Cartierville", "f2c24f54551e": "Alice"}.get(h, h),
device_label="Homestead", device_hash=None,
)
def _packet_viewer(cols: int, rows: int) -> Screen:
entry = PacketEntry(
when=utcnow(), kind="packet", node="3d63c6429436", path="3d63c6429436f2c24f54551e",
raw={"payload_typename": "GRP_TXT", "route_typename": "FLOOD"},
)
return PacketViewer(
[entry], 0, resolve=lambda h: {"3d63c6429436": "YUL-Cartierville"}.get(h, h),
)
def _trace(cols: int, rows: int) -> Screen:
async def _noop_flow(current): # noqa: ANN001
return current
async def _trace_call(path_spec, on_trace): # noqa: ANN001
pass # never invoked — the screen is seeded directly via _on_trace below
screen = TraceScreen(
"Alice", mode="target", device_label="Homestead", device_hash="aa" * 32,
resolve=lambda label: label, session=_GallerySession(cols, rows),
trace=_trace_call, compose_path=_noop_flow, explore=_noop_flow,
pick_width=_noop_flow, pick_samples=_noop_flow,
width_bytes=lambda: 2, sample_count=lambda: 1, pace_s=0.0,
previous=None, auto_spec=lambda: "", auto_source="",
)
screen._on_trace(TraceResult(
target="Alice", success=True,
hops=[Hop(0, "3d63c6", 5.0), Hop(1, None, 2.0)],
round_trip_ms=210.0, path_hash_bytes=2,
))
return screen
def _tx_sweep(cols: int, rows: int) -> Screen:
screen = TxSweepScreen(
admin_label="YUL-Cartierville", target_label="Alice", device_label="Homestead",
device_hash="00" * 32, resolve=lambda h: h, session=_GallerySession(cols, rows),
tx_min=12, tx_max=28, step=3, samples=3,
run_sweep=lambda: None, apply_winner=lambda: None,
)
screen.on_phase("coarse")
screen.on_level(1, 6, TxLevelResult(
tx_power=19, samples=3, successes=3, target_snr=8.8, score=8.8,
stats=TraceStats.from_traces("Alice", []),
))
return screen
_ENTRIES: list[_Entry] = [
_Entry("dashboard", _dashboard),
_Entry("contacts", _contacts, has_exit_group=True),
_Entry("node_detail", _node_detail),
_Entry("map", _map),
_Entry("chat", _chat),
_Entry("livefeed", _livefeed),
_Entry("walk", _walk),
_Entry("timemachine", _timemachine),
_Entry("message_paths", _message_paths),
_Entry("remote_cli", _remote_cli),
_Entry("path_composer", _path_composer),
_Entry("courier_outbox", _courier_outbox, has_exit_group=True),
_Entry("record_dialog", _record_dialog),
_Entry("packet_viewer", _packet_viewer),
_Entry("trace", _trace),
_Entry("tx_sweep", _tx_sweep),
]
#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its
#: live floor (53x26, the on-device measurement — see the plan's P0 appendix) and the lux
#: case (53x40, today's boot fbcon font / a future 6x8 font). Regular is unchanged by this
#: seam's arrival, so it stays the existing 72x24 standard.
_COMBOS: list[tuple[Platform, int, int]] = [
(REGULAR, REGULAR.readable_cols, REGULAR.readable_rows),
(PICOCALC, PICOCALC.readable_cols, PICOCALC.readable_rows),
(PICOCALC, PICOCALC.readable_cols, 40),
]
#: Entries that overflow PICOCALC's 53 columns today (a width overflow doesn't depend on
#: row count, so one entry here covers both picocalc combos). This is P6's worklist, not a
#: TODO comment — pull an entry out the moment its case reports XPASS instead of xfail.
_KNOWN_WIDE: set[str] = {
"contacts",
"map",
"walk",
"livefeed",
"remote_cli",
"path_composer",
}
#: Entries whose footer_hint already overflowed the *existing* 72-column standard before
#: this platform seam existed. Not this phase's to fix: CLAUDE.md's 72-col rule predates
#: the seam, and per standing guidance old chrome that already broke it is left for a
#: dedicated pass rather than retrofitted as a drive-by here (map's footer grows an
#: "offline — no basemap" suffix that was already too long without it).
_PREEXISTING_REGULAR_OVERFLOW: set[str] = {"map"}
def _cases():
for entry in _ENTRIES:
for platform, cols, rows in _COMBOS:
case_id = f"{entry.name}-{platform.name}-{cols}x{rows}"
marks = []
if platform.name == "picocalc" and entry.name in _KNOWN_WIDE:
marks.append(pytest.mark.xfail(
reason=(
f"{entry.name} overflows picocalc's {PICOCALC.readable_cols} cols "
"today -- P6 worklist (.claude/plans/picocalc-platform.md)"
),
strict=False,
))
if platform is REGULAR and entry.name in _PREEXISTING_REGULAR_OVERFLOW:
marks.append(pytest.mark.xfail(
reason=(
f"{entry.name} already overflows the existing 72-col standard, "
"pre-dating the platform seam -- not retrofitted here, see "
"_PREEXISTING_REGULAR_OVERFLOW"
),
strict=False,
))
yield pytest.param(entry, platform, cols, rows, id=case_id, marks=marks)
def _assert_fits(lines: list[str], cols: int, where: str) -> None:
"""Assert every line of already-rendered output is within ``cols`` display cells."""
for i, line in enumerate(lines):
width = cell_len(_plain(line))
assert width <= cols, f"{where} line {i} is {width} cells, over {cols} allowed: {line!r}"
@pytest.mark.parametrize("entry,platform,cols,rows", list(_cases()))
def test_gallery_screen_fits_its_platform(
entry: _Entry, platform: Platform, cols: int, rows: int,
) -> None:
"""Every gallery specimen renders within its platform's width, raw and framed alike."""
set_platform(platform)
screen = entry.factory(cols, rows)
screen.note_viewport(max(1, rows - 4)) # mirrors compose_base's own viewport math
_assert_fits(screen.render_body(cols), cols, "render_body")
# footer_hint may itself carry Rich markup (e.g. map's "[warn]offline[/warn]" tag), the
# same way compose_base renders it (`Text.from_markup(f"[muted]{footer_hint}[/muted]")")
# — so it must be parsed, not just ANSI-stripped, before measuring.
footer_plain = Text.from_markup(screen.footer_hint).plain
assert cell_len(footer_plain) <= cols, (
f"footer_hint renders to {cell_len(footer_plain)} cells, over {cols}: {footer_plain!r}"
)
composed = frame.compose_base(Text(""), screen, screen.footer_hint, cols, rows)
_assert_fits(composed.split("\n"), cols, "compose_base")
if entry.has_exit_group:
lines = _plain(screen.render_body(cols)).splitlines()
assert lines[-1].strip() == "Back", f"{entry.name}: exit row missing, got {lines[-1]!r}"
assert lines[-2].strip() == "", f"{entry.name}: no blank separator before Back"
+143
View File
@@ -0,0 +1,143 @@
"""The platform seam: resolution order, the active-platform singleton, and the two
platform specs' own sanity (PicoCalc is strictly the narrower, plainer flavour).
"""
from __future__ import annotations
from pathlib import Path
import pytest
import meshterm.platforms as platforms_mod
from meshterm.platforms import PICOCALC, REGULAR, get_platform, resolve, set_platform
def test_regular_is_todays_behaviour() -> None:
"""REGULAR is the desktop terminal exactly as it behaves without this seam."""
assert REGULAR.name == "regular"
assert REGULAR.width_reclaim is True
assert REGULAR.emoji is True
assert REGULAR.truecolor is True
assert REGULAR.frame_border is True
assert REGULAR.readable_cols == 72
def test_picocalc_is_the_narrower_plainer_flavour() -> None:
"""PICOCALC never claims a capability the console can't back up."""
assert PICOCALC.name == "picocalc"
assert PICOCALC.readable_cols < REGULAR.readable_cols
assert PICOCALC.width_reclaim is False # a phantom column would tear the exact-width frame
assert PICOCALC.emoji is False
assert PICOCALC.truecolor is False
assert PICOCALC.name_colour == "type"
def test_get_platform_defaults_to_regular() -> None:
"""Every test starts on REGULAR (see the autouse fixture in conftest.py)."""
assert get_platform() is REGULAR
def test_set_platform_is_visible_through_get_platform() -> None:
"""set_platform takes effect immediately for any caller reading via get_platform()."""
set_platform(PICOCALC)
assert get_platform() is PICOCALC
set_platform(REGULAR)
assert get_platform() is REGULAR
def test_set_platform_reaches_a_dotted_import_too() -> None:
"""A consumer that imports the module (not the name) sees the change immediately.
This is the exact footgun the module docstring warns about: ``from meshterm.platforms
import PLATFORM`` at a module's top level would NOT see this, but module-qualified
access (``platforms_mod.PLATFORM``) — and every real binding point in this codebase,
which reads through :func:`get_platform` — does.
"""
set_platform(PICOCALC)
assert platforms_mod.PLATFORM is PICOCALC
def test_resolve_defaults_to_regular_off_device(monkeypatch: pytest.MonkeyPatch) -> None:
"""No flag, no env, no device tree (every desktop/CI machine) → REGULAR, source 'default'."""
monkeypatch.delenv("MESHTERM_PLATFORM", raising=False)
monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model"))
r = resolve()
assert r.platform is REGULAR
assert r.source == "default"
assert r.flag is None and r.env is None and r.detected_model is None
def test_resolve_flag_wins_over_everything(monkeypatch: pytest.MonkeyPatch) -> None:
"""An explicit --platform overrides both the env var and auto-detection."""
monkeypatch.setenv("MESHTERM_PLATFORM", "picocalc")
monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model"))
r = resolve("regular")
assert r.platform is REGULAR
assert r.source == "--platform flag"
assert r.env == "picocalc" # recorded even though it didn't decide the outcome
def test_resolve_env_wins_when_no_flag(monkeypatch: pytest.MonkeyPatch) -> None:
"""MESHTERM_PLATFORM decides when --platform wasn't passed."""
monkeypatch.setenv("MESHTERM_PLATFORM", "picocalc")
monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", Path("/nonexistent/model"))
r = resolve(None)
assert r.platform is PICOCALC
assert r.source == "MESHTERM_PLATFORM env var"
def test_resolve_matching_is_case_insensitive_and_trims_space() -> None:
"""A flag/env value matches regardless of case or stray whitespace."""
assert resolve(" PicoCalc ").platform is PICOCALC
assert resolve("REGULAR").platform is REGULAR
def test_resolve_unknown_flag_raises_with_the_choices(monkeypatch: pytest.MonkeyPatch) -> None:
"""A typo'd --platform is a clear ValueError, not a silent fallback to REGULAR."""
monkeypatch.delenv("MESHTERM_PLATFORM", raising=False)
with pytest.raises(ValueError) as exc:
resolve("laptop")
message = str(exc.value)
assert "laptop" in message and "--platform" in message
assert "picocalc" in message and "regular" in message
def test_resolve_unknown_env_raises_only_when_it_would_decide(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A bad env var is only an error when nothing else (the flag) overrides it."""
monkeypatch.setenv("MESHTERM_PLATFORM", "laptop")
# A valid flag wins outright — the bad env var is recorded but never validated.
assert resolve("regular").platform is REGULAR
with pytest.raises(ValueError) as exc:
resolve(None)
assert "MESHTERM_PLATFORM" in str(exc.value)
def test_resolve_auto_detects_the_lyra_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""The exact on-device string (P0, 2026-08-01) — trailing space and NUL included."""
monkeypatch.delenv("MESHTERM_PLATFORM", raising=False)
class _FakePath:
def read_bytes(self) -> bytes:
return b"Luckfox Lyra \x00"
monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", _FakePath())
r = resolve()
assert r.platform is PICOCALC
assert r.source == "auto-detect"
assert r.detected_model == "Luckfox Lyra" # trailing space/NUL stripped
def test_resolve_auto_detect_is_conservative(monkeypatch: pytest.MonkeyPatch) -> None:
"""A device tree that exists but names different hardware never guesses picocalc."""
monkeypatch.delenv("MESHTERM_PLATFORM", raising=False)
class _FakePath:
def read_bytes(self) -> bytes:
return b"Raspberry Pi 4 Model B\x00"
monkeypatch.setattr(platforms_mod, "_DEVICE_TREE_MODEL_PATH", _FakePath())
r = resolve()
assert r.platform is REGULAR
assert r.detected_model == "Raspberry Pi 4 Model B"
+29 -7
View File
@@ -4,6 +4,10 @@ Some terminals (and prompt_toolkit's size probe on them) report the window one c
narrower than it really is, so the frame's right border lands one short and the true last
column sits unused. The session can report one extra column to close that gap. The wrapping
output is pure and the resolver is a small branch, so both are assertable without a terminal.
The default comes from the active :class:`~meshterm.platforms.Platform` (on, desktop; off,
PicoCalc — reclaiming a phantom column on its exact-width console would tear the frame), and
``MESHTERM_FULL_WIDTH`` remains an explicit override on top of that default either way.
"""
from __future__ import annotations
@@ -12,7 +16,7 @@ from types import SimpleNamespace
from prompt_toolkit.data_structures import Size
import meshterm.ui.tui.session as session_mod
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui.session import TuiSession, _WidthExtendedOutput
@@ -36,17 +40,35 @@ def test_session_leaves_a_supplied_output_untouched() -> None:
assert session._resolve_output() is dummy
def test_session_wraps_the_real_terminal_when_enabled(monkeypatch) -> None:
"""No supplied output + gate on → the real terminal output is width-extended."""
def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None:
"""No supplied output + REGULAR (today's default) → the real terminal is width-extended."""
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
monkeypatch.setattr(session_mod, "_RECLAIM_LAST_COLUMN", True)
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
set_platform(REGULAR)
out = TuiSession()._resolve_output()
assert isinstance(out, _WidthExtendedOutput)
assert out.get_size() == Size(rows=30, columns=101)
def test_session_gate_off_uses_the_bare_terminal(monkeypatch) -> None:
"""Gate off → the real terminal output is used untouched (pt builds its own)."""
monkeypatch.setattr(session_mod, "_RECLAIM_LAST_COLUMN", False)
def test_session_leaves_the_bare_terminal_on_picocalc(monkeypatch) -> None:
"""PICOCALC's exact-width console → no reclaim: a phantom column would tear the frame."""
monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False)
set_platform(PICOCALC)
assert TuiSession()._resolve_output() is None
def test_env_override_forces_reclaim_on_despite_picocalc(monkeypatch) -> None:
"""``MESHTERM_FULL_WIDTH=1`` wins over PICOCALC's off-by-default."""
fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100))
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake)
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "1")
set_platform(PICOCALC)
assert isinstance(TuiSession()._resolve_output(), _WidthExtendedOutput)
def test_env_override_forces_reclaim_off_despite_regular(monkeypatch) -> None:
"""``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default."""
monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0")
set_platform(REGULAR)
assert TuiSession()._resolve_output() is None