diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 9220738..c231233 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -17,7 +17,7 @@ from __future__ import annotations import asyncio import re -from typing import TYPE_CHECKING, Awaitable, Callable, Optional +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional from rich.console import Group, RenderableType from rich.text import Text @@ -28,6 +28,7 @@ from .theme import snr_style from .tui.prompt import _LineEditor from .tui.render import render_lines from .tui.screen import CANCEL, Screen +from .tui.spinner import Spinner if TYPE_CHECKING: from ..context import AppContext @@ -59,9 +60,14 @@ _SENDER_COLORS = ( _SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL) -#: Delivery-state indicators for an outbound direct message, shown at the end of its line: -#: awaiting the ack, acknowledged, or transmitted-but-unacknowledged (retryable via Ctrl-R). -_SENDING, _DELIVERED, _FAILED = "⏳", "✅", "❌" +#: Delivery-state indicators for a *resolved* outbound direct message, shown at the end of +#: its line: acknowledged, or transmitted-but-unacknowledged (retryable via Ctrl-R). While +#: the ack is still pending the line shows an animated spinner instead (see +#: :meth:`ChatScreen._delivery_glyph`). +_DELIVERED, _FAILED = "✅", "❌" + +#: Seconds between spinner frames on a message that is still awaiting its ack. +_SPINNER_INTERVAL = 0.12 #: How many UTF-8 bytes a single outgoing message may carry, by conversation kind. MeshCore's #: LoRa payload caps a direct message at 150 bytes and an (unscoped) channel broadcast at 130; @@ -79,13 +85,6 @@ _BYTES_YELLOW = "bold #fde047" _BYTES_ORANGE = "bold #ff9500" -def _delivery_glyph(acked: Optional[bool]) -> str: - """Map an outbound direct message's ``acked`` state to its delivery emoji.""" - if acked is None: - return _SENDING - return _DELIVERED if acked else _FAILED - - def _split_channel_sender(text: str) -> tuple[Optional[str], str]: """Split a channel message into ``(sender_name, body)`` when it carries a name prefix. @@ -152,6 +151,10 @@ class ChatScreen(Screen): self._session = session self._editor = _LineEditor() self._sending = False + # Cycled while a direct message is in flight, so its trailing glyph spins (rather than + # a static hourglass) until the ack resolves. Shared across messages: only one send or + # retry is ever in flight at a time (both gated by ``_sending``). + self._spinner = Spinner() self._status = "" self._stick = True # keep the newest message in view until the user scrolls up # Channel-only reply selection: index of the highlighted message (or None when the @@ -354,17 +357,29 @@ class ChatScreen(Screen): line.append(f"{stamp:%H:%M} ", style="brand" if selected else "muted") line.append(body, style="brand" if selected else None) if message.outbound: - # Direct messages track per-message delivery (⏳ → ✅/❌, retryable); channel + # Direct messages track per-message delivery (spin → ✅/❌, retryable); channel # broadcasts have no ack, so only flag one that failed to leave the companion. if message.is_channel: if message.acked is False: line.append(" ⚠ no ack", style="warn") else: - line.append(f" {_delivery_glyph(message.acked)}") + line.append(" ") + line.append(self._delivery_glyph(message.acked)) if message.snr is not None: line.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr)) return line + def _delivery_glyph(self, acked: Optional[bool]) -> Text: + """Map an outbound direct message's ``acked`` state to its trailing glyph. + + A message still awaiting its ack (``acked is None``) shows the current spinner frame, + animated by :meth:`_spin_while` for as long as the send is in flight; a resolved one + shows the delivered ✅ or the unacknowledged ❌. + """ + if acked is None: + return self._spinner.text() + return Text(_DELIVERED if acked else _FAILED) + def _reply_banner(self) -> Text: """One-line cue shown above the input when a message is picked to reply to.""" message = self._messages[self._selected] @@ -549,6 +564,35 @@ class ChatScreen(Screen): self._stick = True self._session.invalidate() + async def _spin_while(self, coro: Awaitable[Any]) -> Any: + """Await ``coro`` while animating the delivery spinner on the in-flight message. + + A background timer advances the shared spinner and repaints every + :data:`_SPINNER_INTERVAL` seconds, so a pending message's trailing glyph spins until + the ack resolves. The timer is always cancelled (and awaited, so it can't outlive the + send as a stray pending task) before returning. The spinner is cosmetic, so any hiccup + in the animation is swallowed rather than allowed to break the send. + """ + self._spinner.reset() + + async def animate() -> None: + while True: + await asyncio.sleep(_SPINNER_INTERVAL) + self._spinner.tick() + self._session.invalidate() + + ticker = asyncio.ensure_future(animate()) + try: + return await coro + finally: + ticker.cancel() + try: + await ticker + except asyncio.CancelledError: + pass + except Exception: # noqa: BLE001 - a spinner hiccup must never break a send + pass + async def _send_direct(self, pending: ChatMessage) -> None: """Await delivery of the optimistic ``pending`` bubble, swapping in the stored row. @@ -558,7 +602,7 @@ class ChatScreen(Screen): the error inline, matching how a failed send has always surfaced. """ try: - message = await self._send(pending.text) + message = await self._spin_while(self._send(pending.text)) except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive self._discard(pending) self._status = f"send failed: {exc}" @@ -594,7 +638,7 @@ class ChatScreen(Screen): """Drive a retry to completion, refreshing the message's delivery state in place.""" assert self._resend is not None try: - await self._resend(message) + await self._spin_while(self._resend(message)) except Exception as exc: # noqa: BLE001 - report inline, keep the chat alive message.acked = False self._status = f"retry failed: {exc}" diff --git a/tests/test_chat.py b/tests/test_chat.py index 1287226..4e4a696 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -486,6 +486,41 @@ async def test_chat_screen_enter_sends_and_appends() -> None: assert session.invalidations > 0 +async def test_direct_send_spins_until_the_ack_resolves(monkeypatch) -> None: + """A pending direct message spins while the send is in flight, then settles on ✅.""" + from meshterm.ui.tui.spinner import Spinner + + monkeypatch.setattr("meshterm.ui.chat._SPINNER_INTERVAL", 0.005) + session = _StubSession() + release = asyncio.Event() + + async def send(text: str) -> ChatMessage: + await release.wait() # hold the send open so we can watch the glyph spin + return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True) + + screen = _screen(session, send=send) + for ch in "hi": + screen.handle("text", ch) + screen.handle("enter") + + # While the send is held open the trailing glyph is a live spinner frame, and it advances. + await asyncio.sleep(0.03) + first = screen._spinner.frame + joined = "\n".join(screen.render_body(60)) + assert first in Spinner.BRAILLE and first in joined and "⏳" not in joined + await asyncio.sleep(0.03) + assert screen._spinner.frame != first # the animation is actually running + + # Once the ack lands, the spinner is gone and the message shows its delivered glyph. + release.set() + for _ in range(100): # let _send_direct unwind (ticker cancel + swap) before asserting + await asyncio.sleep(0.005) + if screen._messages[-1].acked is not None: + break + assert screen._messages[-1].acked is True + assert "✅" in "\n".join(screen.render_body(60)) + + def test_byte_counter_shows_used_over_limit_and_colors_only_used() -> None: """The compose bar shows ``used/limit`` with only the used count styled (the max is muted).""" from rich.text import Text @@ -561,7 +596,9 @@ async def test_over_limit_message_is_not_sent_and_buffer_is_kept() -> None: def test_chat_screen_shows_delivery_glyphs() -> None: - """Each outbound direct message ends with its delivery emoji (⏳ / ✅ / ❌).""" + """Outbound direct messages end with delivery glyphs: a spinner, then ✅ / ❌.""" + from meshterm.ui.tui.spinner import Spinner + messages = [ ChatMessage(text="delivered", outbound=True, peer="d4e5f6a7", acked=True), ChatMessage(text="dropped", outbound=True, peer="d4e5f6a7", acked=False), @@ -570,7 +607,10 @@ def test_chat_screen_shows_delivery_glyphs() -> None: screen = _screen(_StubSession(), send=None, messages=messages) joined = "\n".join(screen.render_body(60)) - assert "✅" in joined and "❌" in joined and "⏳" in joined + # A message still awaiting its ack spins (a Braille frame) rather than showing the old + # static hourglass; resolved ones show ✅ / ❌. + assert "✅" in joined and "❌" in joined + assert Spinner.BRAILLE[0] in joined and "⏳" not in joined # A failed message advertises the retry shortcut in the footer hint. assert "Ctrl-R" in screen.footer_hint