From 56cc220ee319aa04cf15e022639b86d255e7ad7c Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 19 Jul 2026 00:43:07 -0400 Subject: [PATCH] Lexicon: a lane shows the key, the hash is the lit slice The node lists' HASH column header becomes KEY: the lane renders as full a key as the caller resolved, and the hash is only the short path-hash-mode prefix highlighted_hash lights inside it. CLAUDE.md's key/hash lexicon rows now carry the sharper definitions so UX text never again calls a truncated key a hash. The "hash" sort-ring id stays, for saved-sort compatibility. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 14 ++++---- meshterm/ui/nodelist.py | 66 +++++++++++++++++++------------------ meshterm/ui/nodes_screen.py | 4 +-- tests/test_device_config.py | 4 +-- tests/test_timemachine.py | 14 ++++---- 5 files changed, 53 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 78fb80c..831570b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ them instead of hand-rolling: - `ui/menus.py` — `back_rows`, `exit_rows`, `menu_rows`, `lane_row`, `section_heading`, `confirm_discard`, `fit_cells`. -- `ui/widgets.py` — `highlighted_hash` (THE hash widget), `format_ago` (prose ages), +- `ui/widgets.py` — `highlighted_hash` (THE key widget — shows a key, lights its hash), + `format_ago` (prose ages), `_format_age` (column ages), `channel_glyph`, `_NODE_GLYPHS`, heard-age heat colouring. - `ui/theme.py` — `name_style`/`node_style` (per-node hues, hash-derived), `snr_style`, the `you` white. @@ -33,8 +34,8 @@ them instead of hand-rolling: | node | any mesh participant | | contact | a node the device knows (has a key) | | heard | received from ("last heard", "first heard") — never "seen" in UX text | -| key | a full public key / channel secret | -| hash | the displayed key prefix (shown via `highlighted_hash`) | +| key | the full fixed-length value — a node's public key, a channel secret | +| hash | the short derived id — a key's first path-hash-mode bytes (the slice `highlighted_hash` lights), a channel hash, a path hop | | path | an ordered hop spec you compose or force (`a1,3d,…`) | | route | the concrete node sequence a trace walked or will walk | | via | prefix for a packet/message's relay chain | @@ -109,9 +110,10 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a node's key (its first byte — any known prefix agrees) so a rename keeps the colour; the name's characters seed it only when no key exists (a channel sender). Our own node is the pure-white `you` style; a context colouring (chart quality) may still win. - Recency heat colours heard/first-heard ages, never names. A hash's addressed prefix - (via `highlighted_hash`) lights in the same key-derived hue; the rest of the hash, and - any hash standing in as a name, stays muted. + Recency heat colours heard/first-heard ages, never names. A key lane (via + `highlighted_hash`) lights its hash in the same key-derived hue; the rest of the key, + and any key standing in as a name, stays muted. UX text says "key" for the lane and + "hash" only for the short derived id — never "hash" for a truncated key. - SNR always through `snr_style`; timelines oldest→now left-to-right, grey baseline = 0, drawn via `ui/braillechart` only. diff --git a/meshterm/ui/nodelist.py b/meshterm/ui/nodelist.py index 2ab5a24..fda6f80 100644 --- a/meshterm/ui/nodelist.py +++ b/meshterm/ui/nodelist.py @@ -1,10 +1,10 @@ """The shared node list: one sortable, filterable lane layout for every screenful of nodes. Extracted from the Time Machine's subject picker so the app has exactly one way to draw "a -full-screen list of nodes": aligned ``NAME · HEARD · PKTS · HASH`` lanes under a sort-aware +full-screen list of nodes": aligned ``NAME · HEARD · PKTS · KEY`` lanes under a sort-aware column header, our own node pinned first, the sort riding the Ctrl+arrows (plain arrows keep the highlight, letters keep type-to-filter), and the name lane sized to its content so the -columns anchor left while the hash lane soaks up the rest of the terminal. The Time Machine +columns anchor left while the key lane soaks up the rest of the terminal. The Time Machine picker and the Nodes screen both build on :class:`NodeListScreen`; each hands its rows over as :class:`NodeRow` values — however it learned them (stored history, the device's contact table) — so the two lists render, sort, and steer identically without sharing a data source. @@ -37,17 +37,17 @@ from .widgets import ( #: label and its sort triangle always have somewhere to sit. _NAME_MIN = 6 -#: The hash lane's floor in cells — four leading bytes plus an ellipsis, still a recognisable +#: The key lane's floor in cells — four leading bytes plus an ellipsis, still a recognisable #: prefix on a very narrow terminal. The lane otherwise flexes to fill whatever width the #: (content-sized) name lane leaves, so a longer key — our own 64-hex node key, or a full key #: resolved from a contact — shows as many whole bytes as fit, ellipsized past that (see #: :func:`~meshterm.ui.widgets.highlighted_hash`). _HASH_MIN = 8 -#: Everything in a row *besides* the name and hash lanes, in cells: the select pointer (2), +#: Everything in a row *besides* the name and key lanes, in cells: the select pointer (2), #: the type glyph and its space (2), then the two gapped fixed lanes — heard (2 gap + 5) and -#: packets (2 gap + 5) — and the 3-cell gap before the hash. The name lane is content-sized -#: and the hash lane takes the rest (see :meth:`NodeListScreen._lane_widths`). +#: packets (2 gap + 5) — and the 3-cell gap before the key. The name lane is content-sized +#: and the key lane takes the rest (see :meth:`NodeListScreen._lane_widths`). _LEAD = 2 + 2 + (2 + 5) + (2 + 5) + 3 #: The muted ``(you)`` tag on the own-node lane (see :func:`_lane`), its width folded into @@ -56,6 +56,8 @@ _YOU_TAG = " (you)" #: The sort ring and each column's natural opening direction — name A→Z, most-recently-heard #: first, most packets first, and ``hash`` on the node's key (ascending = ``0`` → ``f``). +#: The ``hash`` ring id predates the lexicon and stays for saved-sort compatibility; its +#: column header reads ``KEY`` — the lane shows the key, with the hash lit inside it. #: Callers build their :class:`~meshterm.ui.widgets.NodesSort` over these so the Ctrl+arrows #: walk the same four columns on every node list. SORT_COLUMNS: tuple[str, ...] = ("name", "heard", "packets", "hash") @@ -83,7 +85,7 @@ class NodeRow: name: The display name, drawn in the node's hash-derived palette hue; ``None`` renders a muted ``unknown`` (an own-node row falls back to a bare ``you`` instead). - key: The hex the hash lane shows — as full a key as the caller could resolve; + key: The hex the key lane shows — as full a key as the caller could resolve; also the seed of the name's palette hue. Empty renders a muted ``?``. node_type: The node's type for the leading glyph (``None`` = the plain-node ``●``; ignored on the own-node row, which always leads with the yellow ``★``). @@ -107,7 +109,7 @@ class NodeRow: def _header(name_w: int, sort: NodesSort) -> Text: """Column labels over the node lanes (see :func:`_lane`). - The lanes read ``NAME · HEARD · PKTS · HASH``, matching the row builder. The four + The lanes read ``NAME · HEARD · PKTS · KEY``, matching the row builder. The four leading spaces cover the select screen's pointer column (2 cells) plus the one-cell type glyph and its gap, so each label lands over its lane. @@ -136,10 +138,10 @@ def _header(name_w: int, sort: NodesSort) -> Text: header.append(" ", style="muted") # gap to the HEARD lane column(header, "HEARD", "heard") column(header, f"{'PKTS':>5}", "packets") - # HASH sits one cell further out than the other lanes so a packets-sort triangle — - # packets being the lane just before it — never abuts the hash label. + # KEY sits one cell further out than the other lanes so a packets-sort triangle — + # packets being the lane just before it — never abuts the key label. header.append(" ", style="muted") - column(header, "HASH", "hash") + column(header, "KEY", "hash") return header @@ -147,12 +149,12 @@ def _you_lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text """Our own node's lane — laid out exactly like :func:`_lane`'s regular rows. Drawn like the map and the static table draw us: the ``★`` self marker (yellow), the - name in the pure-white ``you`` style with a muted ``(you)`` tag, and our key hash lit - at the routing width, filling the flexing hash lane — our 64-hex key is longer than - any lane, so it shows as many digits as fit and ellipsizes. The heard and packet lanes - read a faint ``—``: we never overhear ourselves, so there is no reception age or count - to show. With no reachable device to name us, the name falls back to a bare ``you`` - and the hash to ``?``. + name in the pure-white ``you`` style with a muted ``(you)`` tag, and our key with its + hash lit at the routing width, filling the flexing key lane — our 64-hex key is longer + than any lane, so it shows as many digits as fit and ellipsizes. The heard and packet + lanes read a faint ``—``: we never overhear ourselves, so there is no reception age or + count to show. With no reachable device to name us, the name falls back to a bare + ``you`` and the key to ``?``. """ text = Text(no_wrap=True, overflow="ellipsis") text.append(_SELF[0], style=_SELF[1]) @@ -172,7 +174,7 @@ def _you_lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text text.append(f"{'—':>5}", style="faint") # heard: we don't hear ourselves text.append(" ") text.append(f"{'—':>5}", style="faint") # packets: nothing to count - text.append(" ") # the wider gap the header's HASH lane keeps (see _header) + text.append(" ") # the wider gap the header's KEY lane keeps (see _header) if row.key: text.append_text(highlighted_hash(row.key, prefix_bytes, width=hash_w)) else: @@ -189,16 +191,16 @@ def _lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text: muted — colour marks a name, and the hash lane already carries the identity) and the flexing name lane (``name_w`` cells). The last-heard age follows, glowing with recency heat (brighter = fresher) so a freshly heard node still reads hot at a glance; then the - packet count, right-aligned — a node never overheard reads a faint ``—``; the hash - closes the row in the shared hash widget, its path-hash prefix lit at the device's - routing width, or a muted ``?`` when no key is known at all. An own-node row - (:attr:`NodeRow.you`) takes its own drawing — see :func:`_you_lane`. + packet count, right-aligned — a node never overheard reads a faint ``—``; the key + closes the row in the shared key widget, its hash lit at the device's routing width, + or a muted ``?`` when no key is known at all. An own-node row (:attr:`NodeRow.you`) + takes its own drawing — see :func:`_you_lane`. Args: row: The node's lane data. name_w: The name lane's width in cells (content-sized across the whole list). - prefix_bytes: Path-hash width in bytes to light in the hash. - hash_w: The flexing hash lane's width in cells (a short key pads out to it; see + prefix_bytes: The hash width in bytes to light at the head of the key. + hash_w: The flexing key lane's width in cells (a short key pads out to it; see :func:`~meshterm.ui.widgets.highlighted_hash`). """ if row.you: @@ -219,7 +221,7 @@ def _lane(row: NodeRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text: text.append(f"{'—':>5}", style="faint") else: text.append(f"{min(row.count, 99999):>5}", style="muted") - text.append(" ") # the wider gap the header's HASH lane keeps (see _header) + text.append(" ") # the wider gap the header's KEY lane keeps (see _header) if row.key: text.append_text(highlighted_hash(row.key, prefix_bytes, width=hash_w)) else: @@ -259,10 +261,10 @@ class NodeListScreen(SelectScreen): """A full-screen, sortable, filterable node list in the shared lane layout. The rows run in aligned lanes — name (in the node's hash-derived palette hue), - last-heard age (coloured by recency heat), packet count, and key hash — own-node rows + last-heard age (coloured by recency heat), packet count, and key — own-node rows first, then the rest in the active sort order. The lanes anchor to the left: the name lane is sized to its widest name (not the terminal), so the columns stay put as the window widens and the freed width flows - to the hash lane, which shows each key as fully as it fits (see :meth:`_lane_widths`). + to the key lane, which shows each key as fully as it fits (see :meth:`_lane_widths`). It is a full-screen list, not a floating popup. The sort rides the Ctrl+arrows, leaving the plain arrows for the highlight and the @@ -294,7 +296,7 @@ class NodeListScreen(SelectScreen): title: Short heading shown in the border. rows: Every node's lane data; :attr:`NodeRow.you` rows are pinned first (in the order given), the rest re-sorted here per ``sort``. - prefix_bytes: Path-hash width in bytes to light in each hash. + prefix_bytes: The hash width in bytes to light at the head of each key. sort: The sort state, mutated in place by the Ctrl+arrows — pass the same instance across re-opens so the chosen order persists. Its ring should span :data:`SORT_COLUMNS` for the hash column to be reachable. @@ -371,11 +373,11 @@ class NodeListScreen(SelectScreen): return max(widths) def _lane_widths(self, width: int) -> tuple[int, int]: - """The name and hash lane widths for a terminal ``width`` cells wide. + """The name and key lane widths for a terminal ``width`` cells wide. The name lane is content-sized (see :meth:`_widest_name`) so it stays put as the - window grows; it only yields when a very long name would starve the hash lane past - its :data:`_HASH_MIN` floor. The hash lane then takes all the width the fixed + window grows; it only yields when a very long name would starve the key lane past + its :data:`_HASH_MIN` floor. The key lane then takes all the width the fixed lanes and the name lane leave, so keys show as fully as they fit — a heard id's 12 hex digits with room to spare, a 64-hex key ellipsized to the lane. """ @@ -385,7 +387,7 @@ class NodeListScreen(SelectScreen): return name_w, hash_w def render_body(self, width: int) -> list[str]: - """Size the name lane to content and flex the hash lane, then render the list.""" + """Size the name lane to content and flex the key lane, then render the list.""" name_w, hash_w = self._lane_widths(width) if (name_w, hash_w) != (self._name_w, self._hash_w): self._name_w, self._hash_w = name_w, hash_w diff --git a/meshterm/ui/nodes_screen.py b/meshterm/ui/nodes_screen.py index 0c6fb36..f2c3a4e 100644 --- a/meshterm/ui/nodes_screen.py +++ b/meshterm/ui/nodes_screen.py @@ -1,8 +1,8 @@ """The interactive Nodes screen: the device's contacts in the shared sortable node list. The Time Machine picker's presentation pointed at the companion's contact table: the same -aligned ``NAME · HEARD · PKTS · HASH`` lanes, our own node pinned first, the same Ctrl+arrow -sort (now including the hash column) and type-to-filter — one node list app-wide, whatever +aligned ``NAME · HEARD · PKTS · KEY`` lanes, our own node pinned first, the same Ctrl+arrow +sort (now including the key column) and type-to-filter — one node list app-wide, whatever the data source (see :mod:`~meshterm.ui.nodelist`). Enter is deliberately inert for now: the highlight is a cursor, not yet a selection — a per-node action will land on it later. The one-shot CLI (``meshterm nodes --sort …``) still renders the static diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 10bdb0e..fe2027d 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -238,7 +238,7 @@ def test_nodes_screen_ctrl_arrows_steer_the_sort() -> None: def test_nodes_screen_lists_contacts_in_the_shared_lanes() -> None: - """Contacts render in the shared NAME/HEARD/PKTS/HASH lanes, our node pinned first; + """Contacts render in the shared NAME/HEARD/PKTS/KEY lanes, our node pinned first; plain arrows only move the highlight, and Enter is inert (selection comes later).""" import re @@ -255,7 +255,7 @@ def test_nodes_screen_lists_contacts_in_the_shared_lanes() -> None: body = "\n".join(re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(72)) # The shared column header (recorded sticky, so it pins once scrolled past)... - assert "NAME" in body and "HEARD" in body and "PKTS" in body and "HASH" in body + assert "NAME" in body and "HEARD" in body and "PKTS" in body and "KEY" in body assert screen._sticky_headers # ...our own node leading the lanes, then the contacts with their tallies. choices = screen._choices() diff --git a/tests/test_timemachine.py b/tests/test_timemachine.py index 67d1bac..24fc9ac 100644 --- a/tests/test_timemachine.py +++ b/tests/test_timemachine.py @@ -578,7 +578,7 @@ def test_picker_row_lanes_align_under_the_header() -> None: span.style == "muted" and span.start <= unknown_at < span.end for span in row.spans ) - # The hash's routing prefix (2 bytes here) is lit in the key's hue, the tail muted. + # The key's 2-byte hash is lit in the key's hue, the tail muted. from meshterm.ui.theme import node_style hash_at = plain.index("3d" * 6) @@ -588,11 +588,11 @@ def test_picker_row_lanes_align_under_the_header() -> None: for span in row.spans ) # Header labels land over their lanes (+2 covers the select pointer column); the lanes - # now read NAME · HEARD · PKTS · HASH, with the hash closing the row. + # now read NAME · HEARD · PKTS · KEY, with the key closing the row. header = _header(10, NodesSort.from_name("heard")).plain assert header.index("NAME") == plain.index("unknown") + 2 - assert header.index("HASH") == plain.index("3d" * 6) + 2 - assert header.index("HEARD") < header.index("PKTS") < header.index("HASH") + assert header.index("KEY") == plain.index("3d" * 6) + 2 + assert header.index("HEARD") < header.index("PKTS") < header.index("KEY") def test_picker_header_marks_the_active_sort_column() -> None: @@ -607,9 +607,9 @@ def test_picker_header_marks_the_active_sort_column() -> None: # A different active column moves the mark; packets opens descending. packets = _header(12, NodesSort.from_name("packets")).plain assert "PKTS ▼" in packets and "HEARD" in packets and "HEARD ▼" not in packets - # The hash column is sortable now too, so it carries the mark when it's active. + # The key column (ring id "hash") is sortable too, so it carries the mark when active. hash_sorted = _header(12, NodesSort("hash", ascending=True)).plain - assert "HASH ▲" in hash_sorted and "PKTS ▲" not in hash_sorted + assert "KEY ▲" in hash_sorted and "PKTS ▲" not in hash_sorted def test_picker_header_highlights_only_the_active_sort_column() -> None: @@ -621,7 +621,7 @@ def test_picker_header_highlights_only_the_active_sort_column() -> None: lit = [header.plain[s.start:s.end] for s in header.spans if s.style == _SORT_ACTIVE] # Exactly the active PKTS lane (label + triangle) carries the highlight. assert any("PKTS" in seg and "▼" in seg for seg in lit) - assert not any(any(other in seg for other in ("NAME", "HEARD", "HASH")) for seg in lit) + assert not any(any(other in seg for other in ("NAME", "HEARD", "KEY")) for seg in lit) def _picker(listed, *, prefix_bytes=0, sort=None, type_of=None, resolve_key=None, width=80):