Courier recipient picker: the shared node list, Enter commits

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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-19 01:13:34 -04:00
1 parent 98aa6ef7dc
commit 93c1937de7
2 files changed
+124 -31

No files matched your search

+69 -31
View File
@@ -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}",
+55
View File
@@ -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]