The live feed's SCOPE column never collapses

It came and went with the terminal's width: hidden between 70 and 83 columns, where the class label won, and always on the PicoCalc. It is now drawn at every width, and the class label is what gives way on a narrow screen, the icon still saying what the packet is. Beside the subject, the lane is on screen even at 53 columns; there it is the readings after it that scroll off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-25 00:24:49 -04:00
1 parent fcd70c2e2e
commit bdf166ce6d
4 files changed
+53 -73

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ Every interactive screen is listed here with its scripted equivalent, where one
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it, and the region a scoped flood was sent into where the terminal is wide enough. Enter opens any row in the packet viewer, route graph and all. | — |
| **📰 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it, and the region a scoped flood was sent into. Enter opens any row in the packet viewer, route graph and all. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
| **⏳ Time machine** | Everything the recorder ever heard, as braille charts: reception volume, the median-SNR band, hour-of-day rhythm, packets and nodes per day, and first-ever arrivals — over a switchable 24h / 7d / 30d / all-time window (on the PicoCalc the windows stop at 30d), per node or mesh-wide. | — |
| **🎧 Monitor** | Passive recording is always on in the app. On the CLI, capture a bounded foreground window, tailing each overheard packet and summarising when it ends. It transmits nothing — it only listens. | `meshterm monitor --seconds 60` |
+34 -51
View File
@@ -11,11 +11,11 @@ to the packet viewer, which has the room to draw it as a wrapped path line over
graph. The feed's job is to say what arrived and how well it was heard; Enter says how
it got here.
Where the width allows, a **scope** lane sits between the subject and the readings: the
region a flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a
muted ``-`` for a plain flood, and nothing for a direct frame, which no repeater
region-filters (see :func:`_lanes_for` for which widths hold it, and why the PicoCalc's
never does).
A **scope** lane sits between the subject and the readings, at every width: the region a
flood was sent into (``harbour``), ``? 3fa1`` for a region nobody here has named, a muted
``-`` for a plain flood, and nothing for a direct frame, which no repeater region-filters.
It never collapses; the class label beside the icon is what gives way on a narrow terminal
(see :func:`_lanes_for`).
The subject lane is contextual: it holds whatever the *class* of packet is about (see
:meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node
@@ -145,11 +145,10 @@ _SNR_LANE = _READING_W + len(" dB")
#: `` -61 dBm`` — the lane gap, the number field, then its unit.
_RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
#: Terminal width below which the feed drops the textual class label and keeps only the
#: two-cell icon. It is exactly the width the full row occupies — with no route to make
#: room for, every lane fits a 72-column screen, so the label (the row's most informative
#: field now that it names the real class) is only ever dropped when it truly can't fit.
_FEED_LABEL_MIN_WIDTH = (
#: The full row's width without its scope lane: every lane the row always carries plus the
#: class label — see :data:`_FEED_LABEL_MIN_WIDTH` for the width the label needs once the
#: scope is added.
_FEED_ROW_WIDTH = (
2
+ _TIME_LANE
+ _ICON_LANE
@@ -162,20 +161,19 @@ _FEED_LABEL_MIN_WIDTH = (
)
#: The scope lane's width. Room for an unnamed scope's ``? 3fa1`` and a short region name
#: whole; a longer name ellipsizes — its whole name is on the viewer's ``route`` row, one
#: whole; a longer name ellipsizes — its whole name is on the viewer's ``scope`` row, one
#: keypress away, like everything else a lane cuts.
_FEED_SCOPE_WIDTH = 10
#: The scope lane with the gap that sets it off from the readings after it.
_SCOPE_LANE = _FEED_SCOPE_WIDTH + _LANE_GAP
#: The most cells the subject lane lends the scope lane when the row is that close to
#: holding it. On a 72-column terminal the framed body is 68 cells and the icon-only row 54,
#: so the scope lane fits there with room to spare today; the lend is what keeps it at the
#: width the app is designed to if either lane ever grows by a cell. Never more than that:
#: the subject is what the row is about, and a lane that shrank to make room for another
#: would stop naming it.
_SUBJECT_LEND = 1
#: Terminal width below which the feed drops the textual class label and keeps only the
#: two-cell icon: the whole row, scope lane included. The scope never collapses (JP,
#: 2026-09-25) — a lane that came and went with the terminal's width hid the one reading
#: that says where a flood was allowed to go — so on a narrower screen it is the label that
#: gives way, the icon beside it still saying what the packet is.
_FEED_LABEL_MIN_WIDTH = _FEED_ROW_WIDTH + _SCOPE_LANE
def _feed_scope(scope: Scope | None) -> Text:
@@ -194,37 +192,24 @@ class _Lanes(NamedTuple):
Attributes:
label: The class lane's text label beside its icon.
scope: The scope lane between the subject and the readings.
subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`, less whatever it
lent the scope lane).
subject: The subject lane's width (:data:`_FEED_SUBJECT_WIDTH`).
"""
label: bool
scope: bool
subject: int
def _lanes_for(width: int) -> _Lanes:
"""The lanes a row can carry at ``width``, most informative first.
"""The lanes a row can carry at ``width``.
The class label comes first — it says what a packet *is*, on every row — and keeps
the threshold it always had (:data:`_FEED_LABEL_MIN_WIDTH`). The scope lane then takes
whatever the row has left, borrowing up to :data:`_SUBJECT_LEND` cells off the subject
to fit. That makes the scope appear at two widths with a gap between them: at 72
columns (the label already dropped, the icon row leaving room), and again once a wide
terminal has room for both. In between the label wins, because a scope is a dash on
most rows and a class never is.
The PicoCalc's 53 columns never carry it: the icon-only row is 54 cells before any
scope, so it already scrolls sideways there, and a lane only reachable by ``←→`` on the
highlighted row would be a lane nobody reads. The viewer's ``route`` row states it.
Every lane but one is always drawn, the scope lane included. The class label is the
one that gives way: it needs the whole row (:data:`_FEED_LABEL_MIN_WIDTH`), and below
that the icon alone says what the packet is. On a 72-column terminal (a 68-cell body)
that is the icon row with its scope, 66 cells; on the PicoCalc's 53 the icon row is
wider than the screen before any scope, so there the row reads by scrolling the
highlighted one sideways (``←→``), as it already did.
"""
label = width >= _FEED_LABEL_MIN_WIDTH
row = _FEED_LABEL_MIN_WIDTH - (0 if label else _FEED_CLASS_WIDTH + _LANE_GAP)
short = row + _SCOPE_LANE - width
if short > _SUBJECT_LEND:
return _Lanes(label, False, _FEED_SUBJECT_WIDTH)
return _Lanes(label, True, _FEED_SUBJECT_WIDTH - max(0, short))
return _Lanes(width >= _FEED_LABEL_MIN_WIDTH, _FEED_SUBJECT_WIDTH)
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
@@ -617,8 +602,7 @@ class LiveFeedScreen(Screen):
else " " * _ICON_LANE
)
header.append(fit_cells("SUBJECT", lanes.subject + _LANE_GAP))
if lanes.scope:
header.append(fit_cells("SCOPE", _SCOPE_LANE))
header.append(fit_cells("SCOPE", _SCOPE_LANE))
# The two readings right-align their number, so their labels do too — each sits
# over the digits it names rather than over the sign column ahead of them.
header.append(fit_cells("SNR", _READING_W, align="right"))
@@ -700,15 +684,14 @@ class LiveFeedScreen(Screen):
subject.truncate(lanes.subject, overflow="ellipsis", pad=True)
body.append_text(subject)
body.append(" " * _LANE_GAP)
if lanes.scope:
# Beside the subject, ahead of the readings: where a flood was allowed to go is
# a fact about the packet, as its subject is, and the readings are about how it
# reached us. A plain flood — most rows — is a muted dash rather than the word,
# so the lane is quiet until a row is actually scoped.
scope = _feed_scope(self._entry_scope(entry))
scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True)
body.append_text(scope)
body.append(" " * _LANE_GAP)
# Beside the subject, ahead of the readings: where a flood was allowed to go is a
# fact about the packet, as its subject is, and the readings are about how it
# reached us. A plain flood — most rows — is a muted dash rather than the word, so
# the lane is quiet until a row is actually scoped.
scope = _feed_scope(self._entry_scope(entry))
scope.truncate(_FEED_SCOPE_WIDTH, overflow="ellipsis", pad=True)
body.append_text(scope)
body.append(" " * _LANE_GAP)
body.append(
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None else " " * _SNR_LANE,
style=snr_style(entry.snr) if entry.snr is not None else "muted",
+8 -8
View File
@@ -123,16 +123,16 @@ def test_livefeed_rows_leave_the_relay_path_to_the_viewer() -> None:
"""A row says what arrived and how well it was heard; the route is Enter's job.
Whatever the fixed lanes left a route was never enough to draw one in — it arrived
elided to a stub — so the feed carries none, and the whole row fits a 72-column
screen with its class label intact.
elided to a stub — so the feed carries none: the whole row, scope lane and all, fits a
72-column screen, and a wide one keeps its class label in words too.
"""
screen = _screen()
screen.on_event(MeshEvent.observation_event(_obs(kind="packet", path="3d63,a1b2", snr=1.0)))
row = _rows(screen, 72)[0]
row = _rows(screen, 68)[0] # a 72-column terminal's framed body
assert "via" not in row and "Hub" not in row # no route, not even a stub of one
assert "📦 packet" in row # the class still reads in words at 72
assert "+1.0 dB" in row and "-90 dBm" in row # …as does the reception it was heard at
assert len(row.rstrip()) <= 72
assert "+1.0 dB" in row and "-90 dBm" in row # …the reception it was heard at reads
assert len(row.rstrip()) <= 68
assert "📦 packet" in _rows(screen, 90)[0] # the class label, where there is room
def test_livefeed_class_lane_names_the_payload_class_once() -> None:
@@ -263,8 +263,8 @@ def test_livefeed_tokened_classes_are_about_their_token() -> None:
def test_livefeed_column_header_sits_over_the_lanes_it_names() -> None:
"""The header names each lane, and every label lands on the values beneath it."""
screen = _screen(seed=[_obs(node="3d63", kind="telemetry", snr=12.8, rssi=-105.0)])
header, row = _stripped(screen.render_body(72))[:2]
assert header.split() == ["TIME", "CLASS", "SUBJECT", "SNR", "RSSI"]
header, row = _stripped(screen.render_body(90))[:2]
assert header.split() == ["TIME", "CLASS", "SUBJECT", "SCOPE", "SNR", "RSSI"]
# Measured in display cells, not characters — the class icon is one character wide
# but two cells, so a character index would report every later lane one column early.
# The clock is read off the row itself: a literal needle would only match during the
+10 -13
View File
@@ -200,21 +200,18 @@ def test_the_feed_carries_a_scope_lane_on_a_72_column_terminal() -> None:
assert all(row.rstrip().endswith("dBm") for row in rows)
def test_the_feed_keeps_the_class_label_ahead_of_the_scope() -> None:
"""Between the two widths that hold it, the label wins: a class is never ``unscoped``."""
screen = _feed(_scoped("harbour"))
header = _stripped(screen.render_body(72))[0]
assert "CLASS" in header and "SCOPE" not in header
header, row = _stripped(screen.render_body(100))[:2]
assert "CLASS" in header and "SCOPE" in header
assert _col(header, "SCOPE") == _col(row, "harbour")
def test_the_feed_never_collapses_the_scope_lane() -> None:
"""The scope is drawn at every width; the class label is what gives way when narrow.
def test_the_feed_leaves_the_scope_to_the_viewer_on_the_picocalc() -> None:
"""At 53 the row already scrolls sideways; a lane only ←→ reaches is not drawn."""
Beside the subject, the lane is on screen even at the PicoCalc's 53, where it is the
readings after it that run off the edge.
"""
screen = _feed(_scoped("harbour"))
body = _plain(screen.render_body(53))
assert "SCOPE" not in body and "harbour" not in body
for width in (53, 68, 72, 80, 100):
header, row = _stripped(screen.render_body(width))[:2]
assert _col(header, "SCOPE") == _col(row, "harbour"), width
assert "CLASS" not in _stripped(screen.render_body(80))[0]
assert "CLASS" in _stripped(screen.render_body(100))[0]
def test_the_feed_hands_its_scope_reader_to_the_viewer() -> None: