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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-19 00:43:07 -04:00
1 parent f7dcda0575
commit 56cc220ee3
5 files changed
+53 -49

No files matched your search

+8 -6
View File
@@ -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.
+34 -32
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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()
+7 -7
View File
@@ -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):