From 93c1937de7bda0f1f810c7141826b43f58299fde Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 19 Jul 2026 01:13:34 -0400 Subject: [PATCH] Courier recipient picker: the shared node list, Enter commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queueing flow's inline glyph rows give way to the app's one node list: full NAME/HEARD/PKTS/KEY lanes, key-hued names, recency-heat ages, PKTS from the recorder's overheard tallies, and the same Ctrl+arrow sort ring and type-to-filter as the Nodes screen and the Time Machine picker. It opens on the heard column freshest-first (the old fixed order, now just a default), name sorts A→Z, and Enter resolves the highlighted Contact into the flow; the footer spells the grammar out with Esc cancelling the step. Co-Authored-By: Claude Fable 5 --- meshterm/ui/courier_screen.py | 100 +++++++++++++++++++++++----------- tests/test_courier.py | 55 +++++++++++++++++++ 2 files changed, 124 insertions(+), 31 deletions(-) diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index 64b37da..c32122c 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -30,16 +30,10 @@ from rich.text import Text from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage from ..core.models import Contact, utcnow from .menus import back_rows, section_heading -from .tui import DM_BYTE_LIMIT, Choice, SelectScreen, Separator +from .nodelist import SORT_COLUMNS, SORT_OPENS_ASCENDING, NodeListScreen, NodeRow +from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator from .watchtower_screen import contact_watch_key -from .widgets import ( - _DEFAULT_GLYPH, - _NODE_GLYPHS, - _age_seconds, - _format_age, - _recency_style, - format_ago, -) +from .widgets import NodesSort, _age_seconds, _contact_pkts, format_ago if TYPE_CHECKING: from ..context import AppContext @@ -105,7 +99,6 @@ async def open_courier(ctx: "AppContext") -> Optional[dict[str, Any]]: RuntimeError: If called outside the interactive menu (no full-screen session). """ from .surface import TuiUi - from .tui import CANCEL if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller raise RuntimeError("the courier is only available in the menu") @@ -297,8 +290,62 @@ def _done_row(message: QueuedMessage) -> Text: # --- the flows -------------------------------------------------------------------------- +#: The recipient picker's footer: the shared node-list grammar with a committing Enter, +#: Esc cancelling the queueing step it sits in. +_PICK_HINT = "↑↓ move · ^←→↑↓ sort · type filter · Enter select · Esc cancel" + + +class CourierRecipientScreen(NodeListScreen): + """The recipient picker on the shared node list. + + The full ``NAME · HEARD · PKTS · KEY`` lanes, the Ctrl+arrow sort ring, and + type-to-filter, exactly as the Nodes screen and the Time Machine picker draw + contacts — names in their key-derived hue, heard ages in recency heat. Unlike the + Nodes screen, Enter *commits*: the shared list's Enter resolves the highlighted + row's value, which is the :class:`~meshterm.core.models.Contact` itself. + """ + + def __init__( + self, + *, + contacts: list[Contact], + prefix_bytes: int, + counts: dict[str, int], + sort: NodesSort, + ) -> None: + """Build the picker over the device's contacts. + + Args: + contacts: The candidate recipients (every contact — courier addresses any). + prefix_bytes: The hash width in bytes to light at the head of each key. + counts: Overheard-packet tallies keyed by lowercased 12-hex node id. + sort: The sort state (defaults open on ``heard``, freshest first). + """ + rows = [ + NodeRow( + value=c, + name=c.name, + key=c.public_key or c.key_prefix or "", + node_type=c.node_type, + last_seen=c.last_seen, + count=_contact_pkts(c, counts), + ) + for c in contacts + ] + super().__init__( + "Courier — recipient", + rows=rows, + prefix_bytes=prefix_bytes, + sort=sort, + prompt="The message waits in the outbox until this node can take it:", + footer_hint=_PICK_HINT, + ) + + async def _queue_flow(ctx: "AppContext", contacts: list[Contact]) -> None: """Float the queueing flow: recipient, message, schedule.""" + from .timemachine_screen import _routing_prefix_bytes + session = ctx.ui.session if not contacts: await session.message_dialog( @@ -310,28 +357,19 @@ async def _queue_flow(ctx: "AppContext", contacts: list[Contact]) -> None: ) return - def recency(contact: Contact) -> float: - seen = _age_seconds(contact.last_seen) - return seen if seen is not None else float("inf") - - # The picker's shared presentation: the per-type glyph in the app's marker - # palette, the name coloured by recency heat (hotter = heard more recently), - # exactly as the Nodes list and the Time Machine picker draw contacts. - items: list = [] - for contact in sorted(contacts, key=recency): - glyph, glyph_style = _NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH) - secs = _age_seconds(contact.last_seen) - row = Text() - row.append(glyph + " ", style=glyph_style) - row.append(contact.name, style=_recency_style(secs)) - row.append(f" heard {_format_age(secs)}", style="muted") - items.append(Choice(row, contact)) - contact = await session.select( - "Courier — recipient", - items, - prompt="The message waits in the outbox until this node can take it:", + # The shared node-list presentation (see CourierRecipientScreen), opened on the + # heard column so the most reachable candidates lead — the old fixed order, now + # just the default of a re-sortable list. + counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node} + prefix_bytes = await _routing_prefix_bytes(ctx) + picker = CourierRecipientScreen( + contacts=contacts, + prefix_bytes=prefix_bytes, + counts=counts, + sort=NodesSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING), ) - if contact is None: + contact = await session.run_screen(picker) + if not isinstance(contact, Contact): # Esc (CANCEL) or anything else backs out return text = await session.text( f"Message for {contact.name}", diff --git a/tests/test_courier.py b/tests/test_courier.py index c3d42be..0be96d6 100644 --- a/tests/test_courier.py +++ b/tests/test_courier.py @@ -468,3 +468,58 @@ def test_outbox_rows_recompute_live_state_per_repaint(tmp_path: Path) -> None: # no refresh() needed, the callable title re-reads the entry on repaint. ctx.courier_store.note_attempt(entry.ident) assert "try 1" in _outbox_plain(screen) + + +# --- the recipient picker ---------------------------------------------------------------- + + +def test_recipient_picker_rides_the_shared_node_list(tmp_path: Path) -> None: + """The picker draws the full lanes, opens freshest-heard first, and Enter commits.""" + from meshterm.ui.courier_screen import _PICK_HINT, CourierRecipientScreen + from meshterm.ui.nodelist import SORT_COLUMNS, SORT_OPENS_ASCENDING + from meshterm.ui.widgets import NodesSort + + fresh = Contact(name="Fresh", public_key="aa" * 32, last_seen=utcnow()) + stale = Contact( + name="Stale", public_key="bb" * 32, last_seen=utcnow() - timedelta(days=2) + ) + counts = {"aa" * 6: 7} + screen = CourierRecipientScreen( + contacts=[stale, fresh], + prefix_bytes=1, + counts=counts, + sort=NodesSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING), + ) + import re + + body = "\n".join( + re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(80) + ) + assert "NAME" in body and "HEARD" in body and "PKTS" in body and "KEY" in body + assert body.index("Fresh") < body.index("Stale") # heard opens freshest-first + assert " 7" in body # Fresh's overheard tally fills the PKTS lane + assert screen.footer_hint == _PICK_HINT + + # Enter resolves the highlighted row's value — the Contact itself. + resolved: list = [] + screen.resolve = resolved.append # type: ignore[method-assign] + screen.handle("enter") + assert resolved == [fresh] + + +def test_recipient_picker_sort_keys_walk_the_ring(tmp_path: Path) -> None: + """The same ^←→ keys the Nodes list uses re-sort the picker's columns.""" + from meshterm.ui.courier_screen import CourierRecipientScreen + from meshterm.ui.nodelist import SORT_COLUMNS, SORT_OPENS_ASCENDING + from meshterm.ui.widgets import NodesSort + + a = Contact(name="Alpha", public_key="aa" * 32, last_seen=utcnow()) + z = Contact(name="Zulu", public_key="bb" * 32) + sort = NodesSort.from_name("heard", SORT_COLUMNS, SORT_OPENS_ASCENDING) + screen = CourierRecipientScreen( + contacts=[z, a], prefix_bytes=0, counts={}, sort=sort + ) + screen.handle("ctrl_left") # heard -> name (opens ascending) + assert sort.column == "name" and sort.ascending + values = [c.value for c in screen._choices()] + assert values == [a, z]