Spin the delivery glyph while a direct message awaits its ack

Replace the static ⏳ hourglass on an in-flight direct message with the
reusable tui.Spinner, animated for the whole wait-for-ack window:

- _delivery_glyph is now a method returning the spinner's current frame when
  acked is None (✅/❌ once resolved); the _SENDING constant is gone.
- _spin_while wraps the awaited send and retry, running a background ticker
  that advances the shared spinner and repaints, then cancels-and-awaits it
  (same no-stray-task discipline as the startup splash). Only one send or
  retry is ever in flight (both gated by _sending), so one spinner suffices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-08 18:32:29 -04:00
1 parent 101b48bc71
commit fb3c7a15e2
2 files changed
+101 -17

No files matched your search

+59 -15
View File
@@ -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}"
+42 -2
View File
@@ -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