Courier outbox: live rows, live sections
The outbox used to freeze at whatever it showed when it opened — "retry in ~4 m" would still read 4 m ten minutes later, and a delivery only moved to Finished after some unrelated keypress rebuilt the list. Entry rows become callable titles recomputed on every repaint (the 1 s app baseline), so countdowns and ages tick for real; a new CourierOutboxScreen fingerprints the store's shape once a second from an opener ticker and recomposes the sections in place only when an entry appeared, finished, or was cleared — keeping the highlight on its entry, the node-list rebuild idiom. No store callback needed: the courier's own pass cadence is 30 s, so a 1 s shape check lands every transition promptly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4622d529a
commit
98aa6ef7dc
2 files changed
+159
-13
No files matched your search
@@ -7,6 +7,12 @@ retries) — the finished history (delivered / given-up), and the queueing flow:
|
||||
contact, write the message, choose when. Enter on a waiting entry offers *Send now*
|
||||
(one forced attempt, outcome in a dialog) and *Cancel*.
|
||||
|
||||
The outbox is **live** while it sits open: every row is a callable title recomputed on
|
||||
each repaint (so "retry in ~N m" counts down for real), and a once-a-second ticker
|
||||
compares the store's shape — entry set and statuses — rebuilding the sections in place
|
||||
when something moved (a delivery lands its row in *Finished* within a second, no
|
||||
keypress needed), keeping the highlight on its entry.
|
||||
|
||||
Delivery itself happens in the background service (:mod:`meshterm.services.courier`),
|
||||
which the menu starts with the other always-on services — this screen never needs to
|
||||
stay open for a queued message to go out.
|
||||
@@ -24,7 +30,7 @@ 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, Separator
|
||||
from .tui import DM_BYTE_LIMIT, Choice, SelectScreen, Separator
|
||||
from .watchtower_screen import contact_watch_key
|
||||
from .widgets import (
|
||||
_DEFAULT_GLYPH,
|
||||
@@ -42,6 +48,9 @@ if TYPE_CHECKING:
|
||||
_QUEUE = ("queue",)
|
||||
_CLEAR = ("clear",)
|
||||
|
||||
#: Seconds between the open outbox's refresh ticks (shape check + repaint).
|
||||
_REFRESH_S = 1.0
|
||||
|
||||
#: The widest a message body renders in a row before it is ellipsized.
|
||||
_TEXT_W = 36
|
||||
|
||||
@@ -96,7 +105,7 @@ 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, SelectScreen
|
||||
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")
|
||||
@@ -114,16 +123,18 @@ async def open_courier(ctx: "AppContext") -> Optional[dict[str, Any]]:
|
||||
loop = asyncio.get_running_loop()
|
||||
cursor: Any = None
|
||||
while True:
|
||||
items = _menu_items(ctx, store.entries())
|
||||
menu = SelectScreen(
|
||||
"Courier — store-and-forward outbox",
|
||||
items,
|
||||
default=cursor,
|
||||
wrap=False,
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
)
|
||||
menu = CourierOutboxScreen(ctx, default=cursor)
|
||||
menu.future = loop.create_future()
|
||||
session.push(menu)
|
||||
|
||||
async def tick(screen: "CourierOutboxScreen" = menu) -> None:
|
||||
"""Fold store changes in and repaint, once a second, while the list is open."""
|
||||
while True:
|
||||
await asyncio.sleep(_REFRESH_S)
|
||||
screen.refresh()
|
||||
session.invalidate()
|
||||
|
||||
ticker = asyncio.ensure_future(tick())
|
||||
try:
|
||||
choice = await menu.future
|
||||
if choice in (None, CANCEL):
|
||||
@@ -146,14 +157,74 @@ async def open_courier(ctx: "AppContext") -> Optional[dict[str, Any]]:
|
||||
elif isinstance(choice, tuple) and choice[0] == "msg":
|
||||
await _entry_actions(ctx, int(choice[1]))
|
||||
finally:
|
||||
ticker.cancel()
|
||||
try:
|
||||
await ticker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001 - teardown must never surface a tick hiccup
|
||||
pass
|
||||
session.pop(menu)
|
||||
|
||||
|
||||
# --- the menu ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CourierOutboxScreen(SelectScreen):
|
||||
"""The live outbox list: row text recomputes per repaint, sections per tick.
|
||||
|
||||
Rows are callable titles (see :attr:`~meshterm.ui.tui.select.Choice.title`), so
|
||||
every repaint re-reads each entry's live state — the retry countdown, the fresh/
|
||||
waiting note, a finished row's age. Structure changes (an entry moving waiting →
|
||||
finished, a new queue, a cleared history) can't be expressed by a row re-rendering
|
||||
itself, so the opener's ticker calls :meth:`refresh`: it fingerprints the store's
|
||||
shape and recomposes the sections in place only when that changed, keeping the
|
||||
highlight on its entry (the :class:`~meshterm.ui.nodelist.NodeListScreen` rebuild
|
||||
idiom).
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: "AppContext", *, default: Any = None) -> None:
|
||||
self._ctx = ctx
|
||||
self._shape = self._fingerprint()
|
||||
super().__init__(
|
||||
"Courier — store-and-forward outbox",
|
||||
_menu_items(ctx, ctx.courier_store.entries()),
|
||||
default=default,
|
||||
wrap=False,
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
)
|
||||
|
||||
def _fingerprint(self) -> tuple:
|
||||
"""The store's shape: which entries exist and what status each is in."""
|
||||
return tuple((m.ident, m.status) for m in self._ctx.courier_store.entries())
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Recompose the sections if the store's shape changed, keeping the highlight."""
|
||||
shape = self._fingerprint()
|
||||
if shape == self._shape:
|
||||
return
|
||||
self._shape = shape
|
||||
current = self._current_choice()
|
||||
keep = current.value if current is not None else None
|
||||
self._items = _menu_items(self._ctx, self._ctx.courier_store.entries())
|
||||
self._reselect(keep)
|
||||
|
||||
def _reselect(self, value: Any) -> None:
|
||||
"""Move the highlight back onto the choice with ``value`` (else clamp in range)."""
|
||||
choices = self._choices()
|
||||
for i, choice in enumerate(choices):
|
||||
if choice.value == value:
|
||||
self._index = i
|
||||
return
|
||||
self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0
|
||||
|
||||
|
||||
def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
|
||||
"""Build the screen's rows: the waiting outbox, then the finished history."""
|
||||
"""Build the screen's rows: the waiting outbox, then the finished history.
|
||||
|
||||
Entry rows are zero-arg callables so their live state re-renders every repaint;
|
||||
the fixed action rows stay plain strings.
|
||||
"""
|
||||
waiting = [m for m in entries if m.status == QUEUED]
|
||||
done = [m for m in entries if m.status != QUEUED]
|
||||
|
||||
@@ -161,7 +232,9 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
|
||||
if not waiting:
|
||||
items.append(Separator(" empty — queued messages wait here for their moment"))
|
||||
for message in waiting:
|
||||
items.append(Choice(_waiting_row(ctx, message), ("msg", message.ident)))
|
||||
items.append(
|
||||
Choice(lambda m=message: _waiting_row(ctx, m), ("msg", message.ident))
|
||||
)
|
||||
items.append(Separator(" ")) # space the action off the outbox rows above it
|
||||
items.append(Choice("📨 Queue a message…", _QUEUE))
|
||||
|
||||
@@ -169,7 +242,7 @@ def _menu_items(ctx: "AppContext", entries: list[QueuedMessage]) -> list:
|
||||
items.append(Separator(" "))
|
||||
items.append(section_heading("Finished"))
|
||||
for message in done[:15]:
|
||||
items.append(Choice(_done_row(message), ("msg", message.ident)))
|
||||
items.append(Choice(lambda m=message: _done_row(m), ("msg", message.ident)))
|
||||
items.append(Choice("🗑 Clear finished", _CLEAR))
|
||||
|
||||
items.extend(back_rows()) # a visible exit beside Esc
|
||||
|
||||
@@ -395,3 +395,76 @@ async def test_pick_schedule_a_fixed_delay_holds_until_its_time() -> None:
|
||||
result = await _pick_schedule(ctx, "YUL")
|
||||
assert result is not None and result is not CANCEL_SCHEDULE
|
||||
assert result > utcnow()
|
||||
|
||||
|
||||
# --- the live outbox screen --------------------------------------------------------------
|
||||
|
||||
|
||||
def _outbox_ctx(tmp_path: Path) -> _StubContext:
|
||||
ctx = _StubContext(tmp_path, [])
|
||||
ctx.courier = CourierService(ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
def _outbox_plain(screen, width: int = 100) -> str:
|
||||
import re
|
||||
|
||||
return "\n".join(re.sub(r"\x1b\[[0-9;]*m", "", ln) for ln in screen.render_body(width))
|
||||
|
||||
|
||||
def test_outbox_refresh_moves_a_delivered_entry_without_a_keypress(tmp_path: Path) -> None:
|
||||
"""A delivery lands its row in Finished on refresh(), no action required."""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
entry = ctx.courier_store.queue(NODE, "YUL", "hold this")
|
||||
screen = CourierOutboxScreen(ctx)
|
||||
before = _outbox_plain(screen)
|
||||
assert "⏳ YUL" in before and "Finished" not in before
|
||||
|
||||
ctx.courier_store.mark_delivered(entry.ident)
|
||||
screen.refresh()
|
||||
after = _outbox_plain(screen)
|
||||
assert "Finished" in after and "✓ YUL" in after and "⏳" not in after
|
||||
|
||||
|
||||
def test_outbox_refresh_keeps_the_highlight_on_its_entry(tmp_path: Path) -> None:
|
||||
"""A row finishing above the cursor doesn't drag the highlight off its entry."""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
first = ctx.courier_store.queue(NODE, "YUL", "first")
|
||||
second = ctx.courier_store.queue(NODE, "YUL", "second")
|
||||
screen = CourierOutboxScreen(ctx, default=("msg", second.ident))
|
||||
assert screen._current_choice().value == ("msg", second.ident)
|
||||
|
||||
ctx.courier_store.mark_delivered(first.ident)
|
||||
screen.refresh()
|
||||
assert screen._current_choice().value == ("msg", second.ident)
|
||||
|
||||
|
||||
def test_outbox_refresh_without_change_recomposes_nothing(tmp_path: Path) -> None:
|
||||
"""An unchanged store shape leaves the row objects alone (no churn per tick)."""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
ctx.courier_store.queue(NODE, "YUL", "steady")
|
||||
screen = CourierOutboxScreen(ctx)
|
||||
items = screen._items
|
||||
screen.refresh()
|
||||
assert screen._items is items
|
||||
|
||||
|
||||
def test_outbox_rows_recompute_live_state_per_repaint(tmp_path: Path) -> None:
|
||||
"""The waiting row's text is a callable: a state change shows on the next paint."""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
entry = ctx.courier_store.queue(NODE, "YUL", "patience")
|
||||
screen = CourierOutboxScreen(ctx)
|
||||
assert "waiting to hear the node" in _outbox_plain(screen)
|
||||
|
||||
# An attempt just happened: the same row now shows the retry countdown —
|
||||
# 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)
|
||||
Reference in new issue
Block a user