The author chip of the selected message's group now turns its label white, as the message's text does, and goes back when the selection leaves the group. A mention of an unknown node turns white too: its grey fill already says the node is unknown, and white reads better on it than black. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2095 lines
98 KiB
Python
2095 lines
98 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The live chat screen and the function that opens it.
|
||
|
||
This is the interactive, full-screen chat: a transcript that scrolls, with an input line
|
||
pinned at the bottom. The messages that you send and the messages that come in over the
|
||
mesh show together in real time. This module is in the UI layer, the same as the device
|
||
picker and the config editor, but it can depend on the context and the services. It
|
||
connects the send path of :class:`~meshterm.services.chat_service.ChatService` and the
|
||
event hub, which always runs, to a :class:`~meshterm.ui.tui.screen.Screen`.
|
||
|
||
While the screen is open, it subscribes to the hub. Thus the inbound messages for the
|
||
current conversation are appended live. Separately,
|
||
:class:`~meshterm.services.chat_service.ChatService` stores each inbound message. Thus
|
||
the history is complete, also when the screen is not open.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from rich.cells import cell_len
|
||
from rich.console import Group, RenderableType
|
||
from rich.text import Text
|
||
|
||
from ..core import links
|
||
from ..core.channels import MENTION, split_channel_sender
|
||
from ..core.connection import ContactNotOnDeviceError
|
||
from ..core.events import EventKind, MeshEvent
|
||
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
|
||
from ..core.regions import WILDCARD
|
||
from ..platforms import Platform, on_platform
|
||
from .compose import MentionEditor
|
||
from .pathline import PATH_INK, PathHop
|
||
from .qr import QrScreen, qr_strip
|
||
from .theme import snr_style
|
||
from .tui.prompt import (
|
||
CHANNEL_BYTE_LIMIT,
|
||
DM_BYTE_LIMIT,
|
||
byte_counter,
|
||
)
|
||
from .tui.render import render_hanging, render_lines, right_aligned_tail
|
||
from .tui.screen import CANCEL, Screen
|
||
from .tui.spinner import Spinner, spinner_interval
|
||
from .widgets import (
|
||
DESTINATION,
|
||
ORIGIN,
|
||
author_chip,
|
||
hop_chip,
|
||
mention_hop,
|
||
mention_mark,
|
||
picked_hop,
|
||
self_chip,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from ..context import AppContext
|
||
from .map_render import MapMarker
|
||
from .map_screen import Locator
|
||
from .node_detail_screen import ContactPages
|
||
|
||
|
||
#: The delivery marks for a *resolved* outbound direct message, at the end of its line:
|
||
#: acknowledged, or transmitted but not acknowledged (^R can retry it). These are the
|
||
#: ``✓``/``✗`` status marks of the app in their ok/err styles, not the ✅/❌ emoji, which
|
||
#: are for the lane of packet-class icons. While the ack is still pending, the line shows an
|
||
#: animated spinner instead (refer to :meth:`ChatScreen._delivery_glyph`).
|
||
_DELIVERED = ("✓", "ok")
|
||
_FAILED = ("✗", "err")
|
||
|
||
#: A word between whitespace. The wrap moves a word only as a whole. Thus the run that a
|
||
#: wrap must not cut a URL in is the word that has the URL, with a bracket or a full stop
|
||
#: next to it.
|
||
_WORD = re.compile(r"\S+")
|
||
|
||
#: The column where a URL starts when it is too long to hang under its body: the column
|
||
#: after the ``❯`` that is in front of the first line of the selected message. A message
|
||
#: that names us has its mark in this column, and the timestamp follows it.
|
||
_URL_GUTTER = 2
|
||
|
||
#: Whether the URLs of a message are drawn as QR codes under it (``Platform.url_codes``,
|
||
#: only on the PicoCalc). Bound for each platform, never asked at each frame.
|
||
_URL_CODES = False
|
||
|
||
|
||
@on_platform
|
||
def _bind(platform: Platform) -> None:
|
||
"""Bind the transcript to the platform (now and at each switch).
|
||
|
||
The platform says whether codes hang under URLs.
|
||
"""
|
||
global _URL_CODES
|
||
_URL_CODES = platform.url_codes
|
||
|
||
|
||
#: The maximum width of a footer hint, in cells (refer to the UX standards).
|
||
_HINT_CELLS = 72
|
||
|
||
|
||
def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
|
||
"""Join the hint atoms that are not empty, and remove ``spare`` atoms until the hint fits.
|
||
|
||
The ``spare`` atoms go in the given order, and only while the hint is wider than
|
||
:data:`_HINT_CELLS`. Thus a key that the line can hold stays on it.
|
||
"""
|
||
kept = [atom for atom in atoms if atom]
|
||
for atom in spare:
|
||
if cell_len(" · ".join(kept)) <= _HINT_CELLS:
|
||
break
|
||
if atom in kept:
|
||
kept.remove(atom)
|
||
return " · ".join(kept)
|
||
|
||
|
||
#: The long words of a hint atom, and the short words that the same atom has when the hint
|
||
#: needs the room. The two forms count as one atom. The short form leaves out a note or a
|
||
#: second key, as the hint already does for ^U, ^L, and ^O.
|
||
_SHORT_ATOMS = {"Enter reply (@mention)": "Enter reply", "^End/Esc cancel": "Esc cancel"}
|
||
|
||
|
||
def _atom_set(hint: str) -> set[str]:
|
||
"""The atoms of a hint, each in its short form, to compare two hints by their atoms."""
|
||
return {_SHORT_ATOMS.get(atom, atom) for atom in hint.split(" · ")}
|
||
|
||
|
||
class ChatScreen(Screen):
|
||
"""A live conversation: a transcript that scrolls, above a pinned input line.
|
||
|
||
The transcript automatically stays on the newest message (and goes back to the bottom
|
||
each time you type or send). ↑ selects a message. The selection moves with
|
||
↑↓/PgUp/PgDn, and the viewport moves with it. ^End (or Esc) puts the focus on the
|
||
compose line again. Enter sends the current line, or does an action on a selected
|
||
message: in a channel, it starts a reply with an ``@mention``, and in a direct chat, it
|
||
opens the delivery paths of the message. In the two types of chat, ^P opens the paths
|
||
of the selected message. A path belongs to one message, so when no message is
|
||
selected, there is nothing to show. ^U shows the links of the selected message as QR
|
||
codes, on the share screen. ^L opens the map on the node of the author of the selected
|
||
message, when that node has a location. ^K locks that author against the archive
|
||
sweep, or unlocks it. When no message is selected, Esc leaves the chat.
|
||
|
||
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
|
||
sends a message again *unscoped*: the selected message, or the newest when no message is
|
||
selected. This is the solution for a scoped message that no repeater in range relays.
|
||
It comes after an amber confirm, because an unscoped flood gets to each repeater that
|
||
the scope kept it from.
|
||
"""
|
||
|
||
floating = False
|
||
#: Home and End move the caret of the compose line here. Thus edge scroll does not use
|
||
#: them.
|
||
home_end_jumps = False
|
||
|
||
@property
|
||
def picocalc_lyra_lane(self):
|
||
"""The PicoCalc lane of a conversation: the same lane in a channel, a DM, and a room.
|
||
|
||
The plain bank of F1 to F3 has the verbs that all three screens share, in the order
|
||
of the hint: ``Paths`` (^P), ``URL→QR`` (^U), and ``Map`` (^L). The Shift bank of F1
|
||
and F2 has the verbs of one screen only. ``Retry`` (^R) is on the Shift half of F1 in
|
||
a DM and in a room, and ``Resend`` (the same ^R, unscoped) is there in a channel. A
|
||
room adds ``Log in`` (^G) on the Shift half of F2 (refer to
|
||
:class:`~meshterm.ui.room.RoomScreen`). Thus a key does the same thing on each of
|
||
the three screens, and the user finds a verb in the same place (JP, 2026-10-10).
|
||
|
||
The shared verbs act *on* the selected message. ``Paths`` is lit while a message is
|
||
selected. ``URL→QR`` shows the codes of the links of the selection on a share
|
||
screen, one code at a time with its full URL, and is lit while the selection has a
|
||
URL (refer to :meth:`_picked_urls`). On the Cardputer it is the only way to a code.
|
||
On the PicoCalc, the transcript already draws each code under its message in the
|
||
same size, so there the chip only shows one code alone. ``Map`` is lit while the
|
||
author of the selection has a location (refer to :meth:`_locate_target`).
|
||
|
||
``Author`` (^O, the node page of the author of the selection) is the fourth shared
|
||
verb, and the plain bank has only three slots. Thus it is on the Shift half of F3,
|
||
behind ``Map``, the other verb about the author. It is lit while the author has a
|
||
page (refer to :meth:`_author_page`). Each of the two halves of F3 is empty when
|
||
the screen has nothing to find that node with.
|
||
|
||
``Lock`` (^K) is the fifth verb about the author, and it is on the Shift half of F2.
|
||
It toggles the lock of the author, so it reads ``Unlock`` on a locked author, and
|
||
it is lit while the author can have a lock (refer to :meth:`_lock_target`). That
|
||
slot is free in a channel and in a DM. In a room, ``Log in`` has it, so there the
|
||
user locks from the ``Author`` page, which a conversation opens with its lock row
|
||
(JP, 2026-10-19). The slot is empty where MeshTerm cannot store a lock.
|
||
|
||
The Shift half of F1 follows the two rules of the lane. In a channel with no
|
||
resend connected, it is empty, because a channel message is never acknowledged and
|
||
there is nothing to retry. In a DM with nothing outstanding, it is only dim.
|
||
``Resend`` sends the selected message (or the newest) again, unscoped (refer to
|
||
:meth:`_retry_target`), and it is dim until that message is one of ours that went
|
||
out under a region.
|
||
|
||
The day jump (``ctrl_pageup``/``ctrl_pagedown``) has no chip. ``Day ↑`` and
|
||
``Day ↓`` were on F1 and F2, and JP removed them (2026-10-10). The desktop keeps
|
||
the jump on ^PgUp and ^PgDn.
|
||
|
||
F4/F5 keep the shared paging pair. The Shift bank of the shared lane sends the plain
|
||
``end``/``home`` actions. But here the cursor of the compose line already uses them
|
||
(refer to :meth:`handle`). Thus, without a change, the two keyboard keys go to the
|
||
same fallback ("clear the selection, stay at the tail"), and each one seems to
|
||
scroll to the bottom. Instead, the two Shift slots send ``ctrl_end``/``ctrl_home``,
|
||
and their labels say what these actions do to a conversation: go back to the
|
||
*latest* message and the compose line, or go back to the *oldest*. Each jump is
|
||
still behind the page key that goes in the same direction (refer to
|
||
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). From the outside, the transcript moves
|
||
by one screen, also when it is the selection that moves.
|
||
"""
|
||
from .tui.fkeys import FPair, default_lane
|
||
|
||
lane = list(default_lane(nav=bool(self._messages)))
|
||
live = bool(self._messages)
|
||
if not self._is_channel:
|
||
again = ("Retry", "retry")
|
||
elif self._resend_unscoped is not None:
|
||
again = ("Resend", "retry")
|
||
else:
|
||
again = ("", "")
|
||
lane[0] = FPair(
|
||
"Paths",
|
||
"paths",
|
||
*again,
|
||
enabled=self._selected is not None and self._paths is not None,
|
||
opp_enabled=self._retry_target() is not None,
|
||
)
|
||
lock = self._lock_target()
|
||
if self._pages is not None and self._pages.can_lock:
|
||
toggle = ("Unlock" if lock is not None and lock[1] else "Lock", "lock")
|
||
else:
|
||
toggle = ("", "")
|
||
lane[1] = FPair(
|
||
"URL→QR",
|
||
"url_code",
|
||
*toggle,
|
||
enabled=bool(self._picked_urls()),
|
||
opp_enabled=lock is not None,
|
||
)
|
||
if self._locator is not None or self._pages is not None:
|
||
where = ("Map", "locate") if self._locator is not None else ("", "")
|
||
who = ("Author", "author_page") if self._pages is not None else ("", "")
|
||
lane[2] = FPair(
|
||
*where,
|
||
*who,
|
||
enabled=self._locate_target() is not None,
|
||
opp_enabled=self._author_page() is not None,
|
||
)
|
||
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
|
||
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
|
||
return lane
|
||
|
||
def __init__(
|
||
self,
|
||
conversation: Conversation,
|
||
messages: list[ChatMessage],
|
||
*,
|
||
send: Callable[[str], Awaitable[ChatMessage | None]],
|
||
names: dict[str, str],
|
||
session, # noqa: ANN001 - a TuiSession, imported late to prevent an import cycle
|
||
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
|
||
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
|
||
key_of: Callable[[str], str | None] | None = None,
|
||
scope: str | None = None,
|
||
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
|
||
locator: Locator | None = None,
|
||
pages: ContactPages | None = None,
|
||
self_name: str | None = None,
|
||
self_key: str | None = None,
|
||
) -> None:
|
||
"""Build the chat screen.
|
||
|
||
Args:
|
||
conversation: The thread that the screen shows (its label is the title of the
|
||
screen).
|
||
messages: The initial transcript (history), the oldest first.
|
||
send: An async callable that sends a line and returns the stored outbound
|
||
message (or ``None`` if nothing was sent).
|
||
names: A map from contact key prefix to friendly name, for the labels of
|
||
inbound direct messages.
|
||
session: The running :class:`~meshterm.ui.tui.session.TuiSession`. The screen
|
||
uses it to ask for a paint when messages arrive or a send completes.
|
||
resend: An async callable that tries again to deliver a direct message that
|
||
is not acknowledged, and changes the message in place (direct chats only,
|
||
``None`` for channels).
|
||
paths: An async callable that shows the delivery paths of the selected
|
||
message (the ^P view). ``None`` makes this action do nothing, with no
|
||
message.
|
||
key_of: Finds the key of the node of a sender from its display name (refer to
|
||
:func:`~meshterm.services.trace_runner.make_name_key_resolver`). The key
|
||
is the source of the hue of the sender. With ``None`` (or for a name that
|
||
it cannot find), the sender gets the grey of an unknown node
|
||
(``node.unknown``), because colour is only for identities that have a key.
|
||
scope: The send scope of the channel, shown in the title as a bare ``·`` atom
|
||
(``#ops · yul``: the title of a channel has no other atom that the user
|
||
can think is a region name). ``None`` for a channel that sends under the
|
||
default scope of the device, and always ``None`` for a direct chat.
|
||
resend_unscoped: An async callable that sends the text of a channel message
|
||
again, unscoped, and returns the stored message (channels only. With
|
||
``None``, ^R does nothing there).
|
||
locator: The located nodes, for ^L: the map on the author of the selected
|
||
message (refer to :class:`~meshterm.ui.map_screen.Locator`). With ``None``,
|
||
^L does nothing.
|
||
pages: The contacts, for ^O: the node page of the author of the selected
|
||
message (refer to :class:`~meshterm.ui.node_detail_screen.ContactPages`).
|
||
With ``None``, ^O does nothing.
|
||
self_name: The name of our node (refer to :func:`our_node`). Our messages and
|
||
the mentions of our name show it (:func:`~meshterm.ui.widgets.self_chip`).
|
||
self_key: Our public key, which gives the hue of that chip.
|
||
"""
|
||
super().__init__()
|
||
self._self_name = self_name
|
||
self._self_key = self_key
|
||
self._scope = scope if conversation.is_channel else None
|
||
self.title = f"{conversation.label} · {self._scope}" if self._scope else conversation.label
|
||
self._is_channel = conversation.is_channel
|
||
# Many voices or one. The user reads a channel and a room in the same way: each
|
||
# message under its author, and Enter on a message starts a reply with an
|
||
# @mention. A direct chat has two persons, and Enter on a message opens its paths.
|
||
self._multiparty = conversation.is_channel or conversation.is_room
|
||
# Whether typing goes to the compose line. It is off where no typed text can be
|
||
# sent (a room that keeps nothing from us). Thus the keyboard keys never fill a
|
||
# line that the user cannot see.
|
||
self._composing = True
|
||
self._key_of: Callable[[str], str | None] = key_of or (lambda name: None)
|
||
# The one remote sender of a direct thread is the peer. Its key gives the colour of
|
||
# the header, also when the resolver cannot find the display name.
|
||
contact = conversation.contact
|
||
self._peer_key = (
|
||
""
|
||
if conversation.is_channel or contact is None
|
||
else (contact.public_key or contact.key_prefix or "")
|
||
)
|
||
self._messages = list(messages)
|
||
self._send = send
|
||
self._resend = resend
|
||
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
|
||
self._paths = paths
|
||
self._locator = locator
|
||
self._pages = pages
|
||
self._names = names
|
||
self._session = session
|
||
self._editor = MentionEditor()
|
||
self._sending = False
|
||
# It turns while a direct message is in flight, so that the glyph at the end of the
|
||
# message spins (instead of a static hourglass) until the ack resolves. All messages
|
||
# share it: only one send or retry is ever in flight at a time (``_sending``
|
||
# controls the two).
|
||
self._spinner = Spinner()
|
||
self._status = ""
|
||
self._stick = True # keep the newest message visible until the user scrolls up
|
||
self._paths_open = False # one paths dialog at a time
|
||
self._map_open = False # one map at a time
|
||
self._page_open = False # one node page at a time
|
||
self._codes_open = False # one share screen of links at a time
|
||
self._paste_open = False # one paste-confirm dialog at a time
|
||
self._resend_open = False # one resend-unscoped confirm at a time
|
||
# The selection: the index of the highlighted message (or None when the compose
|
||
# line has the focus), and the body line where it rendered, so that the frame keeps
|
||
# it visible.
|
||
self._selected: int | None = None
|
||
self._selected_line: int | None = None
|
||
# One line after the last rendered row of the selected message, so that its full
|
||
# block stays visible, not only its head (refer to render_body).
|
||
self._selected_end: int | None = None
|
||
# The cache of the output of _render_grouped for each message. Thus a paint that
|
||
# something outside the transcript causes (a key press, the 1 s idle tick, an
|
||
# animation step of a spinner) renders again only the rows that changed. Refer to
|
||
# _render_grouped.
|
||
self._render_cache: dict | None = None
|
||
# Strong references to the sends, resends, and spinner tickers that a key handler
|
||
# starts. The event loop holds only a *weak* reference. Thus the garbage collector
|
||
# can remove a task that nothing else names while it runs: the result is a message
|
||
# that is only half transmitted, or a spinner that stops. Each task removes itself
|
||
# from the set when it finishes.
|
||
self._tasks: set[asyncio.Task] = set()
|
||
|
||
def _spawn(self, coro) -> asyncio.Task: # noqa: ANN001 - any coroutine of this screen
|
||
"""Start ``coro`` on the loop and keep a reference to it until it finishes."""
|
||
task = asyncio.ensure_future(coro)
|
||
self._tasks.add(task)
|
||
task.add_done_callback(self._tasks.discard)
|
||
return task
|
||
|
||
@property
|
||
def footer_hint(self) -> str:
|
||
"""The key hint. It shows whether a message is selected and what Enter does to it.
|
||
|
||
Only the keys that do something show. ``^P paths`` must have a selected message to
|
||
show the paths *of*. ``^U QR`` must have a selected message with a URL in it
|
||
(refer to :meth:`_picked_urls`). ``^L map`` must have a selected message whose
|
||
author has a location (refer to :meth:`_locate_target`). ``^O author`` must have a
|
||
selected message whose author has a node page (refer to :meth:`_author_page`).
|
||
``^R retry failed`` must have something to retry (refer to :meth:`_retry_target`).
|
||
The F-key lane uses the same rules to dim its slots.
|
||
|
||
``^K lock`` (or ``^K unlock``) must have a selected message whose author can have a
|
||
lock (refer to :meth:`_lock_target`). It goes in only when there is room for it
|
||
(JP, 2026-10-19: "If there's room, put it."): the hint with it must stay in 72 cells
|
||
and keep each atom of the hint without it. It can make the words of an atom shorter
|
||
(:data:`_SHORT_ATOMS`), as ^U, ^L, and ^O already do. When it does not go in, the
|
||
status line still tells what the key did.
|
||
"""
|
||
hint = self._hint_with("")
|
||
lock = self._lock_target()
|
||
if lock is not None:
|
||
atom = "^K unlock" if lock[1] else "^K lock"
|
||
longer = self._hint_with(atom)
|
||
if (
|
||
cell_len(longer) <= _HINT_CELLS
|
||
and atom in longer.split(" · ")
|
||
and _atom_set(hint) <= _atom_set(longer)
|
||
):
|
||
return longer
|
||
return hint
|
||
|
||
def _hint_with(self, lock: str) -> str:
|
||
"""The key hint, with the ``lock`` atom after ``^O author`` (or no atom for ``""``)."""
|
||
if self._selected is not None:
|
||
code = "^U QR" if self._picked_urls() else ""
|
||
where = "^L map" if self._locate_target() is not None else ""
|
||
who = "^O author" if self._author_page() is not None else ""
|
||
if self._is_channel and self._retry_target() is not None:
|
||
# A selected scoped message of ours can be sent again unscoped, and the hint
|
||
# must name that key. The "(@mention)" of the reply and ^End are removed to
|
||
# keep the line inside 72 cells (Esc still cancels the selection, and Enter
|
||
# still says what it does). If the line is still too long, ↑↓ goes first,
|
||
# then ^P, which the hint of each other selected message names, then ^O.
|
||
# The message is ours here, so ^O only opens our own page.
|
||
return _fit_hint(
|
||
[
|
||
"Enter reply",
|
||
"^P paths",
|
||
code,
|
||
where,
|
||
who,
|
||
lock,
|
||
"^R resend unscoped",
|
||
"↑↓ pick",
|
||
"Esc cancel",
|
||
],
|
||
spare=("↑↓ pick", "^P paths", "^O author"),
|
||
)
|
||
if self._replies:
|
||
# With all the keys of a selection, the long words do not fit in 72 cells.
|
||
# Then the hint uses the short words of the resend hint above.
|
||
hint = _fit_hint(
|
||
[
|
||
"Enter reply (@mention)",
|
||
"^P paths",
|
||
code,
|
||
where,
|
||
who,
|
||
lock,
|
||
"↑↓ pick",
|
||
"^End/Esc cancel",
|
||
],
|
||
spare=("↑↓ pick",),
|
||
)
|
||
if cell_len(hint) <= _HINT_CELLS:
|
||
return hint
|
||
return _fit_hint(
|
||
["Enter reply", "^P paths", code, where, who, lock, "↑↓ pick", "Esc cancel"],
|
||
spare=("↑↓ pick",),
|
||
)
|
||
return _fit_hint(
|
||
["Enter paths", code, where, who, lock, "↑↓ pick", "^End/Esc cancel"],
|
||
spare=("↑↓ pick",),
|
||
)
|
||
if self._retry_target() is not None:
|
||
if self._is_channel:
|
||
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
|
||
return "Enter send · ↑ pick a message · ^R retry failed · Esc back"
|
||
return "Enter send · ↑ pick a message · Esc back"
|
||
|
||
# --- live updates --------------------------------------------------------
|
||
|
||
def append(self, message: ChatMessage) -> None:
|
||
"""Append an inbound message to the transcript and paint."""
|
||
self._messages.append(message)
|
||
# Do not pull the viewport to the tail while the user selects a message to reply to.
|
||
if self._selected is None:
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
# --- rendering -----------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the transcript, a divider, the input line, and the status, if there is one."""
|
||
self._selected_line = None
|
||
self._selected_end = None
|
||
if not self._messages:
|
||
lines = render_lines(Text(self._empty_text, style="muted"), width)
|
||
else:
|
||
# Direct threads and channel threads use the same grouped transcript, in the
|
||
# style of Discord or Slack: consecutive messages from one sender share a
|
||
# coloured header, with day dividers between them. Because the render is for
|
||
# each message, we can also store where the selected message is (refer to
|
||
# _selected_line / cursor_line). The user can select in the two types of thread.
|
||
# The only difference is what Enter then does with the selection: a reply in a
|
||
# channel, or the delivery paths in a direct thread.
|
||
if self._selected is not None:
|
||
self._selected = max(0, min(self._selected, len(self._messages) - 1))
|
||
lines = self._render_grouped(width)
|
||
|
||
compose = self._compose_line(width)
|
||
footer_parts: list[RenderableType] = [
|
||
Text("─" * width, style="muted"),
|
||
compose,
|
||
]
|
||
if self._selected is not None:
|
||
footer_parts.append(self._pick_banner())
|
||
if self._status:
|
||
footer_parts.append(Text(self._status, style="muted"))
|
||
lines += render_lines(Group(*footer_parts), width)
|
||
|
||
# Stay at the bottom: give the frame an offset that is too large, so that it clamps
|
||
# the viewport to the last lines (input and latest messages). A scroll up clears
|
||
# the stick.
|
||
if self._stick:
|
||
self.scroll = len(lines)
|
||
elif self._selected_end is not None:
|
||
# The selection keeps its *full* message visible, with the codes under its text.
|
||
# The frame keeps only the one line that cursor_line names, the head of the
|
||
# message. With only that line, a selection that moves down stops the head on
|
||
# the bottom row, and the codes are below the viewport. Scroll far enough to
|
||
# show the last row of the message (one extra line for the day divider pinned
|
||
# at the top). The frame then moves the viewport up again only if that pushes
|
||
# the head off the top.
|
||
self.scroll = max(self.scroll, self._selected_end - self._scroll_viewport + 1)
|
||
return lines
|
||
|
||
def cursor_line(self) -> int | None:
|
||
"""Keep the selected reply target visible.
|
||
|
||
When nothing is selected, the scroll is free (the stick controls it).
|
||
"""
|
||
return self._selected_line
|
||
|
||
#: The text of an empty transcript.
|
||
_empty_text = "No messages yet — say hello!"
|
||
|
||
@property
|
||
def _replies(self) -> bool:
|
||
"""Whether Enter on a selected message replies to it (or else opens its paths).
|
||
|
||
This is true where there are many voices and an @mention says which one gets the
|
||
answer, and only while there is a compose line for the reply.
|
||
"""
|
||
return self._multiparty and self._composing
|
||
|
||
def _compose_line(self, width: int) -> RenderableType:
|
||
"""The compose line under the transcript: the input, with its byte limit.
|
||
|
||
The byte counter is pinned to the right edge of the *last* line of the input. Thus,
|
||
when the text wraps onto a second line, the counter stays in the bottom-right
|
||
corner and does not follow the cursor down the wrap. Only when the last line is
|
||
already full to the edge does the counter go onto its own right-aligned line.
|
||
|
||
Each complete mention in the line shows as its chip, the same chip as in the
|
||
transcript (refer to :class:`~meshterm.ui.compose.MentionEditor`). A chip never
|
||
breaks across two lines.
|
||
"""
|
||
limit = self._byte_limit()
|
||
input_line = self._editor.render_line(
|
||
self._mention_hop, overflow_at=self._overflow_at(limit)
|
||
)
|
||
whole = [(s.start, s.end) for s in input_line.spans if str(s.style) == PATH_INK]
|
||
return right_aligned_tail(input_line, self._byte_counter(limit), width, whole=whole)
|
||
|
||
# --- outgoing byte limit -------------------------------------------------
|
||
|
||
def _byte_limit(self) -> int:
|
||
"""The UTF-8 byte limit for a message in this conversation (channel or direct)."""
|
||
return CHANNEL_BYTE_LIMIT if self._is_channel else DM_BYTE_LIMIT
|
||
|
||
def _used_bytes(self) -> int:
|
||
"""The UTF-8 byte length of the current compose buffer: what counts against the limit."""
|
||
return len(self._editor.text.encode("utf-8"))
|
||
|
||
def _overflow_at(self, limit: int) -> int | None:
|
||
"""The index of the first compose character with bytes past ``limit``, or ``None``.
|
||
|
||
The count goes by character (not by byte), so multibyte input stays complete: an
|
||
emoji or an accented letter is fully under or fully over the line, never divided
|
||
in the middle of its sequence.
|
||
"""
|
||
total = 0
|
||
for i, ch in enumerate(self._editor.text):
|
||
total += len(ch.encode("utf-8"))
|
||
if total > limit:
|
||
return i
|
||
return None
|
||
|
||
def _byte_counter(self, limit: int) -> Text:
|
||
"""The inline ``used/limit`` counter: the shared compose gauge (:func:`byte_counter`)."""
|
||
return byte_counter(self._used_bytes(), limit)
|
||
|
||
def _name(self, peer: str | None) -> str | None:
|
||
"""Find the contact name for a sender key prefix (exact match, then prefix match)."""
|
||
if not peer:
|
||
return None
|
||
needle = peer.lower()
|
||
if needle in self._names:
|
||
return self._names[needle]
|
||
for prefix, name in self._names.items():
|
||
if prefix.startswith(needle) or needle.startswith(prefix):
|
||
return name
|
||
return None
|
||
|
||
# --- grouped rendering ---------------------------------------------------
|
||
|
||
def _render_grouped(self, width: int) -> list[str]:
|
||
"""Render the transcript to ANSI lines, and keep the row of the selected message.
|
||
|
||
This serves direct threads and channel threads. Consecutive messages from the same
|
||
sender on the same day share one coloured header, with each message body indented
|
||
below it. A divider marks each new day, and a blank line separates two different
|
||
sender groups. The render goes one message at a time (not as one Group). Thus we
|
||
can store the body line of the selected message in :attr:`_selected_line`, so that
|
||
the frame can scroll it into the viewport. The user can select in the two types of
|
||
thread (refer to :meth:`handle`).
|
||
|
||
Things that change nothing here cause a paint all the time: the idle tick, a key
|
||
press, the ack spinner. Thus this method caches the rendered lines of each message
|
||
in :attr:`_render_cache`, and for each message it puts in the cached slice instead
|
||
of a new render. A message renders again only when this is necessary:
|
||
|
||
* it still waits for its ack (the spinner draws its last glyph again at each
|
||
tick),
|
||
* it is the selected message (the selection sets its style, not the message), or
|
||
* its identity or ``acked`` is different from the cache (it was appended, it was
|
||
replaced in place when a pending bubble resolved, or it was removed).
|
||
|
||
A change of the locks draws all of the transcript again, because the sender chip of
|
||
a locked contact has a padlock (refer to :meth:`_group_header`). The locks change
|
||
rarely: ^K here, or the lock row of the node page of an author.
|
||
|
||
Most importantly, this works for each message. It is not a cached prefix that
|
||
stops at the first such row. Thus a message stays cheap to draw again, however far
|
||
back it is in a long transcript. With a prefix that stops, a page up through the
|
||
history causes a full render again at each key press. With this method, it does
|
||
not. The grouping context (day and sender headers) is cheap to calculate again in
|
||
all cases. Thus it is calculated new each time, and never taken from the cache.
|
||
"""
|
||
messages = self._messages
|
||
total = len(messages)
|
||
cache = self._render_cache
|
||
spacing = self.section_spacing()
|
||
locks = self._pages.locked_keys() if self._pages is not None else frozenset()
|
||
if (
|
||
cache is not None
|
||
and cache["width"] == width
|
||
and cache["spacing"] == spacing
|
||
and cache["locks"] == locks
|
||
):
|
||
cached_ids = cache["ids"]
|
||
cached_acked = cache["acked"]
|
||
cached_selected = cache["selected"]
|
||
cached_boundaries = cache["boundaries"]
|
||
cached_lines = cache["lines"]
|
||
cache_count = len(cached_ids)
|
||
else:
|
||
cached_ids = cached_acked = cached_selected = cached_boundaries = ()
|
||
cached_lines = []
|
||
cache_count = 0
|
||
|
||
lines: list[str] = []
|
||
# Store each day divider as a sticky block of one row. Thus, when the divider of
|
||
# the top visible message scrolls off, it is pinned to the top row again. This is
|
||
# the same base Screen.sticky_block that the conversation picker uses for its
|
||
# section headings. A day has nothing to say except its date, so the block is only
|
||
# the divider. The heading of a select list can also have its description.
|
||
self._sticky_headers = []
|
||
ids: list[int] = []
|
||
ackeds: list[bool | None] = []
|
||
selecteds: list[bool] = []
|
||
boundaries: list[int] = []
|
||
prev_group: tuple[bool, str] | None = None
|
||
prev_day = None
|
||
# The first message of the group of the selected message. Its slice has the author
|
||
# chip of the group, which is white while the selection is in the group.
|
||
picked_head = self._picked_head()
|
||
|
||
for idx in range(total):
|
||
message = messages[idx]
|
||
stamp = message.created_at.astimezone()
|
||
day = stamp.date()
|
||
sender, body = self._sender_and_body(message)
|
||
# Group by (are-we-the-sender, display name), not by the name alone. Thus our
|
||
# messages never join with those of a remote sender that has the same name.
|
||
group = (message.outbound, sender)
|
||
new_day = day != prev_day
|
||
selected = idx == self._selected
|
||
# A slice that the selection changes renders again, now and on the next paint
|
||
# after the selection leaves it: the selected message, and the head of its group.
|
||
marked = selected or idx == picked_head
|
||
pending = message.acked is None and message.outbound and not message.is_channel
|
||
reusable = (
|
||
not marked
|
||
and not pending
|
||
and idx < cache_count
|
||
and not cached_selected[idx]
|
||
and cached_ids[idx] == id(message)
|
||
and cached_acked[idx] == message.acked
|
||
)
|
||
# A blank line closes the messages of an author and the messages of a day, where
|
||
# the preferences ask for it (:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`,
|
||
# the same test as the section headings of a list). It is the first line of the
|
||
# slice of the message that starts the next group, so a cached slice brings it
|
||
# back with it.
|
||
gap = spacing and bool(lines) and (new_day or group != prev_group)
|
||
if reusable:
|
||
start = cached_boundaries[idx - 1] if idx > 0 else 0
|
||
slice_lines = cached_lines[start : cached_boundaries[idx]]
|
||
if new_day:
|
||
at = int(gap) # the divider comes after the gap
|
||
self._sticky_headers.append((len(lines) + at, [slice_lines[at]]))
|
||
lines += slice_lines
|
||
else:
|
||
if gap:
|
||
lines.append("")
|
||
if new_day:
|
||
# A day divider is the section heading of this transcript: it pins to the
|
||
# top row while its day scrolls under it, and ^PgUp/^PgDn step by it.
|
||
# Thus it has the heading style of the app (``── Label ──`` in the bold
|
||
# ``heading`` grey, refer to menus.section_heading). The rule above the
|
||
# compose line is plain ``muted``, because that rule is chrome.
|
||
divider = render_lines(
|
||
Text(f"── {stamp:%a} {stamp:%b} {stamp.day} ──", style="heading"), width
|
||
)
|
||
self._sticky_headers.append((len(lines), [divider[0]]))
|
||
lines += divider
|
||
if new_day or group != prev_group:
|
||
header = self._group_header(sender, message, picked=idx == picked_head)
|
||
lines += render_lines(header, width)
|
||
if selected:
|
||
# The first message has the head of the transcript: nothing comes before
|
||
# its day divider. Thus its selection anchors at line 0, not at its own
|
||
# body. When the anchor was on the body, the viewport at the very top
|
||
# was scrolled down two lines: the divider was only pinned, the sender
|
||
# chip was not visible, and the ↑ of the title bar was lit over a
|
||
# transcript with nothing above it.
|
||
self._selected_line = 0 if idx == 0 else len(lines)
|
||
lines += self._body_lines(body, message, width, selected=selected)
|
||
if selected:
|
||
self._selected_end = len(lines)
|
||
prev_group, prev_day = group, day
|
||
ids.append(id(message))
|
||
ackeds.append(message.acked)
|
||
selecteds.append(marked)
|
||
boundaries.append(len(lines))
|
||
|
||
self._render_cache = {
|
||
"width": width,
|
||
"spacing": spacing,
|
||
"locks": locks,
|
||
"ids": ids,
|
||
"acked": ackeds,
|
||
"selected": selecteds,
|
||
"lines": lines,
|
||
"boundaries": boundaries,
|
||
}
|
||
return lines
|
||
|
||
def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]:
|
||
"""Return the display sender and the clean body of a message.
|
||
|
||
Our messages are ``you``. An inbound channel message has a ``Name: `` prefix, which
|
||
we move into the sender (``·`` when there is no prefix). An inbound direct message
|
||
gets the sender from the resolved contact name (or the raw key, or ``?``).
|
||
"""
|
||
if message.outbound:
|
||
return "you", message.text
|
||
if message.is_channel:
|
||
name, body = split_channel_sender(message.text)
|
||
return (name or "·"), body
|
||
return (self._name(message.peer) or message.peer or "?"), message.text
|
||
|
||
def _picked_head(self) -> int | None:
|
||
"""The index of the first message of the group of the selected message, or ``None``.
|
||
|
||
A group is a run of messages from one sender on one day, under one author chip
|
||
(refer to :meth:`_render_grouped`). The chip is drawn with the first message of the
|
||
run, so that message is the one to draw again when the selection is in the run.
|
||
"""
|
||
selected, messages = self._selected, self._messages
|
||
if selected is None or not 0 <= selected < len(messages):
|
||
return None
|
||
|
||
def group_of(message: ChatMessage) -> tuple:
|
||
day = message.created_at.astimezone().date()
|
||
return (day, message.outbound, self._sender_and_body(message)[0])
|
||
|
||
group = group_of(messages[selected])
|
||
head = selected
|
||
while head > 0 and group_of(messages[head - 1]) == group:
|
||
head -= 1
|
||
return head
|
||
|
||
def _group_header(self, sender: str, message: ChatMessage, *, picked: bool = False) -> Text:
|
||
"""Build the sender header that starts a group: the name, drawn as a chip.
|
||
|
||
The chip makes a *label* different from the prose under it. A name alone above its
|
||
messages then reads as a tag, not as a coloured word that a person typed. It is the
|
||
chip of a path line (refer to :func:`~meshterm.ui.widgets.name_chip`). Thus a
|
||
sender looks exactly like that node as a hop in a route.
|
||
|
||
Our messages are the exception. They go under our name, as ``★ Name`` on a chip
|
||
in white (refer to :func:`~meshterm.ui.widgets.self_chip`). A route
|
||
shows our end as the bare ``★``, but a chat is a conversation between names.
|
||
Our chip comes from the direction of the message (``outbound``), not from the
|
||
``"you"`` label. Thus a remote sender with the name ``you`` still gets the hue of
|
||
its key, and does not look like our node.
|
||
|
||
The sender is the origin of the route of each message under it. Thus the chip
|
||
closes on the point, the mark of a route that goes on. An ``@mention`` in a body is
|
||
the other end, and it opens on the notch (refer to :meth:`_render_mentions`).
|
||
|
||
A sender that is a locked contact has the padlock before its name, in the place of
|
||
the ``★`` of our chip (JP, 2026-10-19). Thus the chip shows the state that ^K
|
||
toggles.
|
||
|
||
With ``picked``, the selected message is in this group, and the label of the chip
|
||
is white, as the text of that message is (refer to
|
||
:func:`~meshterm.ui.widgets.picked_hop`, JP, 2026-10-19). Our chip already has a
|
||
white label.
|
||
"""
|
||
if message.outbound:
|
||
return self_chip(self._self_name, self._self_key, role=ORIGIN)
|
||
# ``·`` is a channel line that arrived with no sender prefix: there is no key to use.
|
||
key = None if sender == "·" else self._header_key(sender, message)
|
||
pages = self._pages
|
||
locked = key is not None and pages is not None and pages.lock_state(pages.find(key)) is True
|
||
return author_chip(sender, key, locked=locked, picked=picked)
|
||
|
||
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
|
||
"""The key that gives the colour of the sender chip of a group: the key of the name.
|
||
|
||
This is a hook, not a call, because a room knows more than a name. Each post has the
|
||
key prefix of its author, which gives the colour of the chip with no name lookup
|
||
(refer to :class:`~meshterm.ui.room.RoomScreen`).
|
||
"""
|
||
return self._sender_key(sender)
|
||
|
||
def _body_lines(
|
||
self, body: str, message: ChatMessage, width: int, *, selected: bool = False
|
||
) -> list[str]:
|
||
"""Render one message to ANSI lines, with its own time and a hanging indent.
|
||
|
||
The timestamp of each message is here (not on the group header), so that each
|
||
message shows the time when it was sent, also when one sender has many messages in
|
||
a group. Without this, a run of messages from the same sender seems to share one
|
||
time. A long body wraps with a hanging indent, so that the next lines align under
|
||
the body and not under the timestamp gutter. When ``selected`` is true, the line
|
||
is marked as the reply target (the same as the ``❯`` pointer and the highlight of
|
||
the select screen).
|
||
|
||
A message that mentions our name has a mark at the left of its time: the white
|
||
``★`` of our node (refer to :func:`~meshterm.ui.widgets.mention_mark`, JP,
|
||
2026-10-13). The chip of the mention in the body has our white label, but no star
|
||
(refer to :func:`~meshterm.ui.widgets.mention_hop`). Thus the mark of a message that
|
||
names us is in one column, where the eye finds it when it goes down the transcript.
|
||
Each time has the two cells of that column in front of it, so that all the times stay
|
||
in one column too.
|
||
|
||
A URL is never cut where the screen can hold it. A URL that is too long for the
|
||
body lane moves out to the column after the ``❯``, on its own line (refer to
|
||
:func:`~meshterm.ui.tui.render.render_hanging`). This is because a terminal opens
|
||
a link, and a user copies one, only while it is on one line.
|
||
|
||
Where the platform draws them (:data:`_URL_CODES`, the PicoCalc), each URL in the
|
||
body gets a QR code under it. The codes are side by side, at the indent of the
|
||
body. Thus the user can open a link from the handheld on a phone, and does not
|
||
have to type it.
|
||
"""
|
||
stamp = message.created_at.astimezone()
|
||
prefix = Text()
|
||
prefix.append("❯ " if selected else " ", style="cursor" if selected else None)
|
||
if self._names_us(body):
|
||
prefix.append_text(mention_mark(self._self_key))
|
||
prefix.append(" ")
|
||
else:
|
||
prefix.append(" ")
|
||
prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted")
|
||
body_text = self._body_text(body, message, selected=selected)
|
||
whole = [m.span() for m in _WORD.finditer(body_text.plain) if links.urls(m.group())]
|
||
# A mention is a chip, and a chip never breaks across two lines.
|
||
whole += [(s.start, s.end) for s in body_text.spans if str(s.style) == PATH_INK]
|
||
lines = render_hanging(
|
||
prefix, body_text, width, indent=prefix.cell_len, whole=whole, gutter=_URL_GUTTER
|
||
)
|
||
urls = links.urls(body) if _URL_CODES else []
|
||
if urls:
|
||
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
|
||
return lines
|
||
|
||
def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text:
|
||
"""Build the styled body of a message: mentions in colour, then the glyphs at the end."""
|
||
text = self._render_mentions(body, selected=selected)
|
||
if message.outbound:
|
||
# A direct message shows its own delivery (spinner → ✓/✗, and a retry is
|
||
# possible). A channel broadcast has no ack. Thus mark only a channel message
|
||
# that did not leave the companion.
|
||
if message.is_channel:
|
||
if message.acked is False:
|
||
text.append(" ⚠ no ack", style="warn")
|
||
text.append_text(self._scope_note(message))
|
||
else:
|
||
text.append(" ")
|
||
text.append_text(self._delivery_glyph(message.acked))
|
||
if message.snr is not None:
|
||
text.append(f" {message.snr:+.0f} dB", style=snr_style(message.snr))
|
||
return text
|
||
|
||
def _scope_note(self, message: ChatMessage) -> Text:
|
||
"""A muted note after a sent message that went out under a scope not in the title.
|
||
|
||
This note shows only in a channel whose title names a scope, and only for the
|
||
exceptions to that scope: an unscoped resend, or a message sent before the scope
|
||
was set or changed. On each other line, a note only repeats the title. In
|
||
a channel with no scope of its own, the default scope of its messages belongs to
|
||
the device. It is not news to show on each line.
|
||
"""
|
||
if not self._scope or not message.scope or message.scope == self._scope:
|
||
return Text()
|
||
if message.scope == WILDCARD:
|
||
return Text(" · unscoped", style="muted")
|
||
return Text(f" · scope {message.scope}", style="muted")
|
||
|
||
def _render_mentions(self, body: str, *, selected: bool) -> Text:
|
||
"""Render body text, and change each ``@[Name]`` token to the chip of that node.
|
||
|
||
The reply flow puts an ``@[Name]`` token in the compose line (refer to
|
||
:meth:`_begin_reply`). Here it shows as the name in a chip of the hue of that node.
|
||
Thus the user sees that a mention belongs to the node that it names. The text
|
||
around the mentions keeps the base style of the line (``cursor`` when the message
|
||
is the selected reply target, or else no style). A mention of our own name has our
|
||
chip, in white, and the line has our white ``★`` at the left of its time
|
||
(refer to :meth:`_body_lines`). Thus a message that names us is easy to see.
|
||
|
||
The chip opens on the notch, because the node that a mention names is the
|
||
destination of a route that came from the sender (refer to :meth:`_group_header`,
|
||
whose chip closes on the point). The chip has no ``@``, because the chip already
|
||
says that the name is a mention (JP, 2026-10-13). Arrow mode has no chip, so there a
|
||
mention keeps its ``@`` (refer to :func:`~meshterm.ui.widgets.mention_hop`).
|
||
|
||
On the selected message, the label of each chip is white, as the text around it is
|
||
(refer to :func:`~meshterm.ui.widgets.picked_hop`, JP, 2026-10-19).
|
||
"""
|
||
base = "cursor" if selected else None
|
||
text = Text()
|
||
pos = 0
|
||
for match in MENTION.finditer(body):
|
||
if match.start() > pos:
|
||
text.append(body[pos : match.start()], style=base)
|
||
hop = self._mention_hop(match.group(1))
|
||
if selected:
|
||
hop = picked_hop(hop) # the chips turn white with the text around them
|
||
text.append_text(hop_chip(hop, role=DESTINATION))
|
||
pos = match.end()
|
||
if pos < len(body):
|
||
text.append(body[pos:], style=base)
|
||
return text
|
||
|
||
def _mention_hop(self, name: str) -> PathHop:
|
||
"""The hop of a mention of ``name``: in the transcript, and in the compose line.
|
||
|
||
The two use this one method, so a chip in the compose line is the chip that the
|
||
message will show after the send. A mention of our name has our key and the chip
|
||
with the white label. Each other name has the key that the names of the senders
|
||
give (refer to :meth:`_sender_key`).
|
||
"""
|
||
if self._self_name and name == self._self_name:
|
||
return mention_hop(name, self._self_key, you=True)
|
||
return mention_hop(name, self._sender_key(name, mention=True))
|
||
|
||
def _names_us(self, body: str) -> bool:
|
||
"""Whether ``body`` has a mention of our name (it gets a mark before its time)."""
|
||
if not self._self_name:
|
||
return False
|
||
return any(match.group(1) == self._self_name for match in MENTION.finditer(body))
|
||
|
||
def _delivery_glyph(self, acked: bool | None) -> Text:
|
||
"""Map the ``acked`` state of an outbound direct message to the mark at its end.
|
||
|
||
A message that still waits for its ack (``acked is None``) shows the current
|
||
animation step of the spinner. :meth:`_spin_while` animates it while the send is in
|
||
flight. A resolved message shows the delivered ``✓`` (ok) or the unacknowledged
|
||
``✗`` (err).
|
||
"""
|
||
if acked is None:
|
||
return self._spinner.text()
|
||
glyph, style = _DELIVERED if acked else _FAILED
|
||
return Text(glyph, style=style)
|
||
|
||
def _pick_banner(self) -> Text:
|
||
"""A one-line note above the input while a message is selected.
|
||
|
||
In a channel, it starts with the reply (what Enter does there). In a direct chat,
|
||
it starts with the paths view (what Enter does there). The two say what the
|
||
selection is for. Thus the user never sees a highlight that has no explanation.
|
||
"""
|
||
message = self._messages[self._selected]
|
||
sender, _ = self._sender_and_body(message)
|
||
who = "this message" if message.outbound or sender == "·" else sender
|
||
if self._replies:
|
||
return Text(
|
||
f"↩ Enter to reply to {who} with an @mention · End to cancel",
|
||
style="accent",
|
||
)
|
||
return Text("Enter to see the paths this message took · End to cancel", style="accent")
|
||
|
||
def _sender_key(self, sender: str, *, mention: bool = False) -> str | None:
|
||
"""The key of a sender name, or ``None`` when nothing gives a key for it.
|
||
|
||
The contacts first, then the names that the recorder stored. In a *direct* thread,
|
||
a sender label with no key falls back to the key of the peer, because the only two
|
||
persons in the conversation are we and the peer. A mention names any node, so
|
||
that fallback must not apply to a mention. Thus a mention that is not resolved
|
||
stays grey in a direct chat, exactly as it does in a channel.
|
||
|
||
A name with no key is drawn in the *node* grey, never in ``muted`` (the chip does
|
||
this, refer to :func:`~meshterm.ui.widgets.name_chip`). An unidentified sender is
|
||
content that you can still do actions on (select the message, open its paths),
|
||
not chrome. On the console, ``muted`` is the dark slot and ``node.unknown`` is the
|
||
light one (JP, 2026-08-12).
|
||
|
||
The sender chip (:meth:`_group_header`) and the mention chips
|
||
(:meth:`_render_mentions`) share this method. Thus the two cannot disagree about
|
||
the identity of a name.
|
||
"""
|
||
key = self._key_of(sender)
|
||
if not key and not self._multiparty and not mention:
|
||
key = self._peer_key or None
|
||
return key
|
||
|
||
# --- input ---------------------------------------------------------------
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Dispatch a key: select messages and do actions on them, edit the compose line, or leave.
|
||
|
||
The two types of chat share one model. When no message is selected, Enter sends,
|
||
and typing edits the compose line. An amber dialog asks the user to confirm a paste
|
||
(Ctrl-V, or the bracketed paste of a terminal) before the paste goes into the line
|
||
(refer to :meth:`_begin_paste`). ↑ selects the newest message. The selection then
|
||
moves with ↑↓, PgUp/PgDn (one screen), Ctrl+Home (the very first message), and
|
||
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
|
||
message starts a reply with an ``@mention`` in a channel, and opens the delivery
|
||
paths in a direct chat. In the two types of chat, ^P opens the paths of the
|
||
selected message, ^U the QR codes of its links, and ^L the map on its author. ^K
|
||
locks or unlocks its author. These keys do nothing when no message is selected.
|
||
^End (or a move past the newest message) goes back to the compose line. The first
|
||
Esc clears the selection, and the second Esc leaves the screen.
|
||
"""
|
||
if action == "enter":
|
||
if self._selected is not None:
|
||
if self._replies:
|
||
self._begin_reply()
|
||
else:
|
||
self._open_paths(self._selected)
|
||
else:
|
||
self._submit()
|
||
elif action == "paste":
|
||
self._begin_paste(data)
|
||
elif action == "paths":
|
||
self._open_paths(self._selected)
|
||
elif action == "url_code":
|
||
self._open_codes()
|
||
elif action == "locate":
|
||
self._open_map()
|
||
elif action == "author_page":
|
||
self._open_author_page()
|
||
elif action == "lock":
|
||
self._toggle_lock()
|
||
elif action == "retry":
|
||
if self._is_channel:
|
||
self._begin_resend_unscoped()
|
||
else:
|
||
self._retry()
|
||
elif action == "escape":
|
||
if self._selected is not None:
|
||
self._clear_selection() # the first Esc clears the selection, the next leaves
|
||
self._session.invalidate()
|
||
else:
|
||
self.resolve(CANCEL)
|
||
elif action == "up":
|
||
self._move_selection(-1)
|
||
elif action == "pageup":
|
||
self._move_selection(-self._page_step)
|
||
elif action == "down":
|
||
self._move_selection(1)
|
||
elif action == "pagedown":
|
||
self._move_selection(self._page_step)
|
||
elif action == "ctrl_home":
|
||
if self._messages:
|
||
self._selected = 0
|
||
self._stick = False
|
||
elif action == "ctrl_pageup":
|
||
self._select_section(-1)
|
||
elif action == "ctrl_pagedown":
|
||
self._select_section(1)
|
||
elif action == "ctrl_end":
|
||
self._clear_selection()
|
||
self._stick = True # jump back to the live tail and the compose line
|
||
else:
|
||
if self._composing and self._editor.edit(action, data):
|
||
# An edit of the compose line puts the focus there. Nothing stays selected.
|
||
self._status = "" # an edit clears the "too long" notice
|
||
self._clear_selection()
|
||
self._stick = True
|
||
|
||
def _open_paths(self, index: int | None) -> None:
|
||
"""Float the delivery-paths view for the selected message (one dialog at a time).
|
||
|
||
``index`` is the selection. Thus ``None`` (nothing selected) opens nothing. A path
|
||
is a route that one *specific* message walked. When the code guessed the latest
|
||
message, it showed the paths of the message that was at the bottom of the
|
||
transcript at that time.
|
||
"""
|
||
if index is None or not self._messages or self._paths is None or self._paths_open:
|
||
return
|
||
message = self._messages[max(0, min(index, len(self._messages) - 1))]
|
||
self._paths_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
await self._paths(message)
|
||
finally:
|
||
self._paths_open = False
|
||
self._session.invalidate()
|
||
|
||
self._session.run_detached(run())
|
||
|
||
def _locate_target(self) -> MapMarker | None:
|
||
"""The marker of the author of the selected message, or ``None`` when ^L does nothing.
|
||
|
||
The author is the node whose key gives the colour of the chip of the message (refer
|
||
to :meth:`_header_key`). Our own message has our node as its author. A message with
|
||
no sender, or an author that has no location, has no marker.
|
||
"""
|
||
if self._locator is None or self._selected is None or not self._messages:
|
||
return None
|
||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||
if message.outbound:
|
||
return self._locator.you()
|
||
sender, _body = self._sender_and_body(message)
|
||
if sender == "·":
|
||
return None
|
||
return self._locator.find(self._header_key(sender, message))
|
||
|
||
def _open_map(self) -> None:
|
||
"""Open the map on the author of the selected message (^L). One map at a time.
|
||
|
||
The map is a screen, and it goes on the stack above the chat. Esc on the map comes
|
||
back to the chat, with the same message selected.
|
||
"""
|
||
target = self._locate_target()
|
||
if target is None or self._locator is None or self._map_open:
|
||
return
|
||
locator = self._locator
|
||
self._map_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
await locator.show(target)
|
||
finally:
|
||
self._map_open = False
|
||
self._session.invalidate()
|
||
|
||
self._session.run_detached(run())
|
||
|
||
def _author_page(self) -> Callable[[], Awaitable[None]] | None:
|
||
"""The node page that ^O opens for the selected message, or ``None`` when there is none.
|
||
|
||
The page belongs to the author of the message: our node for a message of ours, or
|
||
the contact that the key of the author finds (refer to :meth:`_header_key`, the
|
||
same key that gives the colour of the sender chip). A message with no sender, or an
|
||
author that is not a contact, has no page. The hint and the F-key lane ask this at
|
||
each paint, so that they name ^O only when it acts.
|
||
"""
|
||
pages = self._pages
|
||
if pages is None or self._selected is None or not self._messages:
|
||
return None
|
||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||
if message.outbound:
|
||
return lambda: pages.show(None)
|
||
contact = self._author_contact()
|
||
return None if contact is None else (lambda: pages.show(contact))
|
||
|
||
def _author_contact(self) -> Contact | None:
|
||
"""The contact that wrote the selected message, or ``None``.
|
||
|
||
The key of the author finds it (refer to :meth:`_header_key`, the same key that
|
||
gives the colour of the sender chip). A message of ours, a message with no sender,
|
||
and an author that is not a contact have none.
|
||
"""
|
||
pages = self._pages
|
||
if pages is None or self._selected is None or not self._messages:
|
||
return None
|
||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||
if message.outbound:
|
||
return None
|
||
sender, _body = self._sender_and_body(message)
|
||
if sender == "·":
|
||
return None
|
||
return pages.find(self._header_key(sender, message))
|
||
|
||
def _lock_target(self) -> tuple[Contact, bool] | None:
|
||
"""The author that ^K locks or unlocks, and whether it is locked now.
|
||
|
||
``None`` when ^K does nothing: no selected message, a message of ours, an author
|
||
that is not a contact, or a contact that cannot have a lock (refer to
|
||
:meth:`~meshterm.ui.node_detail_screen.ContactPages.lock_state`). The hint and the
|
||
F-key lane ask at each paint, and the store answers from memory.
|
||
"""
|
||
contact = self._author_contact()
|
||
if contact is None or self._pages is None:
|
||
return None
|
||
locked = self._pages.lock_state(contact)
|
||
return None if locked is None else (contact, locked)
|
||
|
||
def _toggle_lock(self) -> None:
|
||
"""Lock the author of the selected message, or unlock it when it is locked (^K).
|
||
|
||
There is no confirm, because the next ^K reverses it, as the row on the node page
|
||
does. The hint atom and the lane chip change to their opposite. The status line also
|
||
tells what the key did, because the hint has no room for the atom on each message.
|
||
"""
|
||
target = self._lock_target()
|
||
if target is None or self._pages is None:
|
||
return
|
||
contact, locked = target
|
||
self._pages.set_locked(contact, not locked)
|
||
name = contact.name or contact.key_prefix
|
||
self._status = f"Unlocked {name}" if locked else f"Locked {name} — no sweep archives it"
|
||
self._session.invalidate()
|
||
|
||
def _open_author_page(self) -> None:
|
||
"""Open the node page of the author of the selected message (^O). One page at a time.
|
||
|
||
The page is a screen, and it goes on the stack above the chat. Esc on the page comes
|
||
back to the chat, with the same message selected.
|
||
"""
|
||
page = self._author_page()
|
||
if page is None or self._page_open:
|
||
return
|
||
self._page_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
await page()
|
||
finally:
|
||
self._page_open = False
|
||
self._session.invalidate()
|
||
|
||
self._session.run_detached(run())
|
||
|
||
def _picked_urls(self) -> list[str]:
|
||
"""The URLs in the body of the selected message, in order. None if nothing is selected."""
|
||
if self._selected is None or not self._messages:
|
||
return []
|
||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||
return links.urls(self._sender_and_body(message)[1])
|
||
|
||
def _open_codes(self) -> None:
|
||
"""Show the links of the selected message as QR codes on the share screen (^U).
|
||
|
||
This is the share screen of a channel or of a contact card
|
||
(:class:`~meshterm.ui.qr.QrScreen`), full-frame. Thus a phone can read a link from
|
||
here and open it. Many links are on one screen that ←→ step through, in the order of
|
||
the message. The same as ^P, it acts on the *selection*: a link belongs to one
|
||
message, and when nothing is selected, the screen cannot know which message. One
|
||
share screen at a time.
|
||
"""
|
||
urls = self._picked_urls()
|
||
if not urls or self._codes_open:
|
||
return
|
||
self._codes_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
await self._session.run_screen(QrScreen(*urls, title="Links"))
|
||
finally:
|
||
self._codes_open = False
|
||
self._session.invalidate()
|
||
|
||
self._session.run_detached(run())
|
||
|
||
def _begin_paste(self, data: str) -> None:
|
||
"""Confirm a clipboard paste on an amber dialog, then insert it into the compose line.
|
||
|
||
A chat message goes out over the air. A paste can be much larger than a key press,
|
||
or it can have content that the user did not want to broadcast. Thus a paste must
|
||
go through a Cancel/Paste confirm (the amber ``danger`` tier: disruptive, not a loss
|
||
of data), and does not go directly into the line. Newlines and control characters
|
||
become spaces (a message is one line). An empty result never opens the dialog. One
|
||
paste dialog at a time, scheduled from the key handler, the same as the paths view.
|
||
"""
|
||
if self._paste_open or not self._composing:
|
||
return
|
||
clean = "".join(ch if ch.isprintable() else " " for ch in data).strip()
|
||
if not clean:
|
||
return
|
||
self._paste_open = True
|
||
count = len(clean)
|
||
|
||
async def run() -> None:
|
||
try:
|
||
confirmed = await self._session.button_dialog(
|
||
Text(
|
||
f"Paste {count} character{'s' if count != 1 else ''} into your message?",
|
||
style="warn",
|
||
),
|
||
[("Cancel", False), ("Paste", True)],
|
||
title="Paste",
|
||
default=1,
|
||
border_style="warn",
|
||
footer_hint="←→ choose · Enter select · Esc cancel",
|
||
)
|
||
finally:
|
||
self._paste_open = False
|
||
if confirmed:
|
||
# Put it in the compose line: clear the selection, go back to the tail, and
|
||
# insert the run through the shared editor. If the result is over the limit,
|
||
# the byte gauge marks it, exactly as it marks typing past the limit.
|
||
self._clear_selection()
|
||
self._stick = True
|
||
self._editor.edit("text", clean)
|
||
self._status = ""
|
||
self._session.invalidate()
|
||
|
||
self._spawn(run())
|
||
|
||
def _move_selection(self, delta: int) -> None:
|
||
"""Move the reply selection by ``delta`` messages (negative = toward older).
|
||
|
||
The selection starts from the compose line only on a move up (``delta < 0``). A
|
||
move past the newest message clears the selection and stays at the tail again.
|
||
"""
|
||
if not self._messages:
|
||
return
|
||
last = len(self._messages) - 1
|
||
if self._selected is None:
|
||
if delta < 0:
|
||
self._selected = last
|
||
self._stick = False
|
||
return
|
||
target = self._selected + delta
|
||
if target > last:
|
||
self._clear_selection()
|
||
self._stick = True
|
||
else:
|
||
self._selected = max(0, target)
|
||
self._stick = False
|
||
|
||
def _select_section(self, direction: int) -> None:
|
||
"""Move the reply selection to the first message of the previous or next day.
|
||
|
||
This is the day jump of the transcript (Ctrl+PageUp/PageDown), for the selection.
|
||
Down moves to the first message of the next day (or to the tail when there is no
|
||
later day). Up moves to the first message of the current day, or to the first
|
||
message of the previous day when the selection is already on a first message.
|
||
"""
|
||
if not self._messages:
|
||
return
|
||
starts = self._day_start_indices()
|
||
current = self._selected if self._selected is not None else len(self._messages) - 1
|
||
if direction > 0:
|
||
target = next((s for s in starts if s > current), None)
|
||
if target is None:
|
||
self._clear_selection()
|
||
self._stick = True
|
||
return
|
||
else:
|
||
governing = max((s for s in starts if s <= current), default=0)
|
||
target = (
|
||
governing
|
||
if governing < current
|
||
else max((s for s in starts if s < current), default=0)
|
||
)
|
||
self._selected = target
|
||
self._stick = False
|
||
|
||
def _day_start_indices(self) -> list[int]:
|
||
"""The indexes of the messages that start a new local day: the transcript dividers."""
|
||
starts: list[int] = []
|
||
prev_day = None
|
||
for i, message in enumerate(self._messages):
|
||
day = message.created_at.astimezone().date()
|
||
if day != prev_day:
|
||
starts.append(i)
|
||
prev_day = day
|
||
return starts
|
||
|
||
def _clear_selection(self) -> None:
|
||
"""Clear the reply selection (the focus goes back to the compose line)."""
|
||
self._selected = None
|
||
self._selected_line = None
|
||
|
||
def _begin_reply(self) -> None:
|
||
"""Put an ``@mention`` of the sender of the selected message at the cursor.
|
||
|
||
The mention goes in at the cursor of the compose line, and the text that the user
|
||
already typed stays. Thus a message can name more than one node: select a message,
|
||
press Enter, select another, and press Enter again. A space comes before the
|
||
mention when it would touch the word in front of it, and after it when nothing
|
||
follows or a word would touch it. The mention is a chip from that moment, and the
|
||
cursor goes after it (refer to :class:`~meshterm.ui.compose.MentionEditor`).
|
||
|
||
A message of ours, or one with no sender, gives no name to mention. There the
|
||
line gets a bare ``@``, and the user types the name.
|
||
"""
|
||
message = self._messages[self._selected]
|
||
sender, _ = self._sender_and_body(message)
|
||
named = not message.outbound and sender != "·"
|
||
text, cursor = self._editor.text, self._editor.cursor
|
||
before = " " if cursor > 0 and not text[cursor - 1].isspace() else ""
|
||
if named:
|
||
after = " " if cursor == len(text) or not text[cursor].isspace() else ""
|
||
self._editor.insert(f"{before}@[{sender}]{after}")
|
||
else:
|
||
self._editor.insert(f"{before}@")
|
||
self._clear_selection()
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
def _submit(self) -> None:
|
||
"""Send the current input line as a message (scheduled from the key handler).
|
||
|
||
A line over the limit is refused. The buffer stays the same (so that the user can
|
||
make it shorter), and the status shows the overage, the same as the red overflow
|
||
that the compose bar already shows.
|
||
"""
|
||
text = self._editor.text.strip()
|
||
if not text or self._sending:
|
||
return
|
||
limit = self._byte_limit()
|
||
over = self._used_bytes() - limit
|
||
if over > 0:
|
||
self._status = f"Too long by {over} byte{'s' if over != 1 else ''} — trim to send."
|
||
self._session.invalidate()
|
||
return
|
||
self._editor = MentionEditor()
|
||
self._sending = True
|
||
self._stick = True
|
||
if self._is_channel:
|
||
self._status = "sending…"
|
||
self._session.invalidate()
|
||
self._spawn(self._send_channel(text))
|
||
else:
|
||
# A direct chat shows an optimistic bubble. The mark at its end shows the
|
||
# delivery: a spinner now, then ✓/✗ when the ack resolves (or times out).
|
||
# acked=None ⇒ the spinner still turns.
|
||
pending = ChatMessage(text=text, outbound=True, created_at=utcnow())
|
||
self._messages.append(pending)
|
||
self._status = ""
|
||
self._session.invalidate()
|
||
self._spawn(self._send_direct(pending))
|
||
|
||
async def _send_channel(self, text: str) -> None:
|
||
"""Broadcast a channel message, and append it when the companion accepts it."""
|
||
try:
|
||
message = await self._send(text)
|
||
if message is not None:
|
||
self._messages.append(message)
|
||
self._status = ""
|
||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||
self._status = f"send failed: {exc}"
|
||
finally:
|
||
self._sending = False
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
async def _spin_while(self, coro: Awaitable[Any]) -> Any:
|
||
"""Await ``coro`` while the delivery spinner animates on the message in flight.
|
||
|
||
A background timer moves the shared spinner forward and paints every
|
||
:func:`~meshterm.ui.tui.spinner.spinner_interval` seconds (the spin rate of the
|
||
platform). Thus the glyph at the end of a pending message spins until the ack
|
||
resolves. Before the method returns, it always cancels the timer (and awaits it, so
|
||
that it cannot stay after the send as a stray pending task). The spinner is only
|
||
for the look. Thus the method ignores each problem in the animation, so that the
|
||
problem cannot break the send.
|
||
"""
|
||
self._spinner.reset()
|
||
|
||
async def animate() -> None:
|
||
while True:
|
||
await asyncio.sleep(spinner_interval())
|
||
self._spinner.tick()
|
||
self._session.invalidate()
|
||
|
||
ticker = self._spawn(animate())
|
||
try:
|
||
return await coro
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner problem must never break a send
|
||
pass
|
||
|
||
async def _send_direct(self, pending: ChatMessage) -> None:
|
||
"""Await the delivery of the optimistic ``pending`` bubble, then put in the stored row.
|
||
|
||
On success, the stored message replaces the pending bubble. The stored message has
|
||
its resolved ``acked`` state and its row id, so that the user can retry a ✗ later
|
||
in place. A hard failure (the companion refuses the send) removes the bubble and
|
||
shows the error inline, the same as a failed send always did.
|
||
"""
|
||
try:
|
||
message = await self._spin_while(self._send(pending.text))
|
||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||
self._discard(pending)
|
||
self._status = f"send failed: {exc}"
|
||
else:
|
||
if message is not None:
|
||
self._swap(pending, message)
|
||
else:
|
||
self._discard(pending)
|
||
self._status = ""
|
||
finally:
|
||
self._sending = False
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
def _retry_target(self) -> ChatMessage | None:
|
||
"""The message that ^R sends again, or ``None`` when there is nothing to retry.
|
||
|
||
In a direct chat, this is the newest outbound message that went out and was never
|
||
acknowledged. In a channel, nothing is ever acknowledged. There, it is a message
|
||
that *we* sent under a region, and ^R sends it again unscoped: the **selected**
|
||
message when a message is selected (the user pointed at it), or else the newest
|
||
message that we sent. With a selection, only the selection counts. If the selected
|
||
message is not a scoped message of ours, ^R offers nothing, and does not go past it
|
||
to another message without a sign. With no selection, only the newest counts.
|
||
When its unscoped copy has gone, that copy is the newest, and nothing remains to
|
||
offer. In the two cases, this is true only while a resend can start: no send is
|
||
already in flight, and a resend path is connected.
|
||
"""
|
||
if self._is_channel:
|
||
if self._sending or self._resend_unscoped is None:
|
||
return None
|
||
if self._selected is not None:
|
||
candidate: ChatMessage | None = self._messages[self._selected]
|
||
else:
|
||
candidate = next((m for m in reversed(self._messages) if m.outbound), None)
|
||
if (
|
||
candidate is None
|
||
or not candidate.outbound
|
||
or not candidate.scope
|
||
or candidate.scope == WILDCARD
|
||
):
|
||
return None
|
||
return candidate
|
||
if self._sending or self._resend is None:
|
||
return None
|
||
return next(
|
||
(m for m in reversed(self._messages) if m.outbound and m.acked is False),
|
||
None,
|
||
)
|
||
|
||
def _retry(self) -> None:
|
||
"""Try again to deliver the most recent unacknowledged direct message (Ctrl-R)."""
|
||
target = self._retry_target()
|
||
if target is None:
|
||
return
|
||
self._sending = True
|
||
target.acked = None # back to the spinner while the retry is in flight
|
||
self._status = "retrying…"
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
self._spawn(self._resend_message(target))
|
||
|
||
def _begin_resend_unscoped(self) -> None:
|
||
"""Confirm, then send a scoped channel message again, unscoped (^R).
|
||
|
||
The dialog is amber (``danger``). Nothing is lost, but each repeater that permits
|
||
an unscoped flood relays it: all the mesh that the scope kept the message out of.
|
||
Thus it is a choice made on purpose, and the dialog names the region that the
|
||
message leaves. The resend is a new message on the air (a channel message has no
|
||
identity to deliver again), so it is appended as a new message. A radio that
|
||
cannot send unscoped refuses before anything goes out, and the status line says
|
||
why.
|
||
"""
|
||
target = self._retry_target()
|
||
if target is None or self._resend_open:
|
||
return
|
||
self._resend_open = True
|
||
preview = target.text if len(target.text) <= 32 else target.text[:31] + "…"
|
||
|
||
async def run() -> None:
|
||
try:
|
||
confirmed = await self._session.button_dialog(
|
||
Text(
|
||
f"Send “{preview}” again, unscoped? Every repeater that relays "
|
||
f"unscoped floods will carry it, not only those in {target.scope}.",
|
||
style="warn",
|
||
),
|
||
[("Cancel", False), ("Resend", True)],
|
||
title="Resend unscoped",
|
||
default=1,
|
||
border_style="warn",
|
||
footer_hint="←→ choose · Enter select · Esc cancel",
|
||
)
|
||
finally:
|
||
self._resend_open = False
|
||
if not confirmed or self._sending or self._resend_unscoped is None:
|
||
self._session.invalidate()
|
||
return
|
||
self._sending = True
|
||
self._status = "resending unscoped…"
|
||
# The resend goes to the tail as a new message. The selection that chose it is
|
||
# done.
|
||
self._clear_selection()
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
try:
|
||
message = await self._resend_unscoped(target)
|
||
if message is not None:
|
||
self._messages.append(message)
|
||
self._status = ""
|
||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||
self._status = f"resend failed: {exc}"
|
||
finally:
|
||
self._sending = False
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
self._spawn(run())
|
||
|
||
async def _resend_message(self, message: ChatMessage) -> None:
|
||
"""Run a retry to its end, and refresh the delivery state of the message in place."""
|
||
assert self._resend is not None
|
||
try:
|
||
await self._spin_while(self._resend(message))
|
||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||
message.acked = False
|
||
self._status = f"retry failed: {exc}"
|
||
else:
|
||
self._status = "" if message.acked else "still no ack — ^R to retry"
|
||
finally:
|
||
self._sending = False
|
||
self._stick = True
|
||
self._session.invalidate()
|
||
|
||
def _swap(self, old: ChatMessage, new: ChatMessage) -> None:
|
||
"""Replace an optimistic bubble with its stored message, in place."""
|
||
try:
|
||
self._messages[self._messages.index(old)] = new
|
||
except ValueError: # pragma: no cover - the bubble is always still there
|
||
self._messages.append(new)
|
||
|
||
def _discard(self, message: ChatMessage) -> None:
|
||
"""Remove an optimistic bubble that never became a real message."""
|
||
try:
|
||
self._messages.remove(message)
|
||
except ValueError: # pragma: no cover - a defensive check
|
||
pass
|
||
|
||
|
||
async def our_node(ctx: AppContext) -> tuple[str | None, str | None]:
|
||
"""The name and the public key of our node, from the cache of the session.
|
||
|
||
A chat shows them on our messages (refer to :func:`~meshterm.ui.widgets.self_chip`),
|
||
and a direct chat names one end of each of its packets with the key. The read uses the
|
||
cache, so it does not ask the device again.
|
||
|
||
Returns:
|
||
``(name, key)``. An item is ``None`` when the device did not report it, or when the
|
||
read failed. Our node with no name only keeps the bare ``★`` in a chat.
|
||
"""
|
||
try:
|
||
info = await ctx.devstate.self_info()
|
||
except Exception: # noqa: BLE001 - our node with no name keeps the bare star
|
||
return None, None
|
||
return str(info.get("name") or "") or None, str(info.get("public_key") or "") or None
|
||
|
||
|
||
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
|
||
|
||
This function makes sure that the listener and the message recorder run, reads the
|
||
stored transcript, subscribes to the hub so that inbound messages for this thread are
|
||
appended live, and pushes the screen. At exit, it always removes the subscription and
|
||
the marker of the active conversation.
|
||
|
||
Args:
|
||
ctx: The shared application context (it must run the interactive TUI surface).
|
||
conversation: The channel or contact conversation to open.
|
||
|
||
Returns:
|
||
The number of messages in the transcript when the screen closed.
|
||
|
||
Raises:
|
||
RuntimeError: If the call is not from the interactive menu (no full-screen
|
||
session).
|
||
"""
|
||
from .surface import TuiUi
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the caller is only in the menu
|
||
raise RuntimeError("live chat is only available in the interactive menu")
|
||
if conversation.is_room:
|
||
from .room import open_room
|
||
|
||
return await open_room(ctx, conversation)
|
||
session = ctx.ui.session
|
||
|
||
device = await ctx.device()
|
||
try:
|
||
await ctx.chat.start() # start to store inbound messages, if it did not already
|
||
except Exception: # noqa: BLE001 - the hub may already run. The recorder is best-effort
|
||
pass
|
||
|
||
from ..services import trace_runner
|
||
|
||
history = ctx.repo.recent_chat_messages(
|
||
is_channel=conversation.is_channel,
|
||
channel_id=conversation.channel_id,
|
||
peer=conversation.peer,
|
||
limit=ctx.preferences.chat_history_limit,
|
||
)
|
||
contacts = await ctx.devstate.contacts()
|
||
names = _contact_names(contacts)
|
||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||
|
||
async def send(text: str) -> ChatMessage | None:
|
||
if conversation.is_channel:
|
||
assert conversation.channel_idx is not None
|
||
return await ctx.chat.send_channel(
|
||
conversation.channel_idx, text, label=conversation.label
|
||
)
|
||
assert conversation.contact is not None
|
||
return await _with_restore(ctx, lambda: ctx.chat.send_direct(conversation.contact, text))
|
||
|
||
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None
|
||
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None
|
||
scope: str | None = None
|
||
if conversation.is_channel:
|
||
scope = ctx.chat.channel_scope(conversation.channel_id)
|
||
|
||
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
|
||
assert conversation.channel_idx is not None
|
||
return await ctx.chat.send_channel(
|
||
conversation.channel_idx, message.text, label=conversation.label, scope=WILDCARD
|
||
)
|
||
|
||
else:
|
||
|
||
async def resend(message: ChatMessage) -> ChatMessage:
|
||
assert conversation.contact is not None
|
||
return await _with_restore(
|
||
ctx, lambda: ctx.chat.resend_direct(conversation.contact, message)
|
||
)
|
||
|
||
from .map_screen import Locator
|
||
from .node_detail_screen import ContactPages
|
||
|
||
paths = await _make_paths_presenter(ctx, conversation, device)
|
||
self_name, self_key = await our_node(ctx)
|
||
screen = ChatScreen(
|
||
conversation,
|
||
history,
|
||
send=send,
|
||
names=names,
|
||
session=session,
|
||
resend=resend,
|
||
paths=paths,
|
||
key_of=key_of,
|
||
scope=scope,
|
||
resend_unscoped=resend_unscoped,
|
||
locator=await Locator.gather(ctx),
|
||
pages=await ContactPages.gather(ctx, self_key=self_key or ""),
|
||
self_name=self_name,
|
||
self_key=self_key,
|
||
)
|
||
ctx.chat.set_active(conversation.key)
|
||
|
||
def on_event(event: MeshEvent) -> None:
|
||
message = event.message
|
||
if message is not None and _belongs(message, conversation):
|
||
peer_name = names.get((message.sender or "").lower())
|
||
screen.append(
|
||
ChatMessage.from_message(
|
||
message, peer_name=peer_name, channel_id=conversation.channel_id
|
||
)
|
||
)
|
||
|
||
unsubscribe = ctx.events.subscribe(on_event, EventKind.MESSAGE)
|
||
try:
|
||
await session.run_screen(screen)
|
||
finally:
|
||
unsubscribe()
|
||
ctx.chat.set_active(None)
|
||
return len(screen._messages)
|
||
|
||
|
||
async def _with_restore(ctx: AppContext, attempt: Callable[[], Awaitable[Any]]) -> Any:
|
||
"""Run a direct send. If the device forgot the contact, offer to restore it, then retry.
|
||
|
||
This is the one rejection from which a send can recover: the firmware has no contact
|
||
for the recipient (refer to :class:`~meshterm.core.connection.ContactNotOnDeviceError`),
|
||
and one write corrects it. The user is asked first (:func:`_restore_contact`). If the
|
||
user declines, the error is raised again. Thus the chat shows the refusal exactly as it
|
||
shows each other failed send.
|
||
|
||
Args:
|
||
ctx: The shared application context.
|
||
attempt: The send to run. It is called a second time, with no change, when the
|
||
contact is back.
|
||
|
||
Returns:
|
||
The return value of ``attempt``.
|
||
|
||
Raises:
|
||
Exception: Each error that ``attempt`` raises. A rejection for a forgotten contact
|
||
is raised again only when the user declined the offer to add it back.
|
||
"""
|
||
try:
|
||
return await attempt()
|
||
except ContactNotOnDeviceError as missing:
|
||
if not await _restore_contact(ctx, missing):
|
||
raise
|
||
return await attempt()
|
||
|
||
|
||
async def _restore_contact(ctx: AppContext, missing: ContactNotOnDeviceError) -> bool:
|
||
"""Offer to write a forgotten contact back to the device. ``True`` if it now has it.
|
||
|
||
A direct message is addressed through the contact entry of the *device*. Thus MeshTerm
|
||
cannot send a message to a contact that the firmware removed, also when MeshTerm still
|
||
lists it. (The list on a screen is the union of the table of the device and the
|
||
contacts that MeshTerm keeps for it: refer to :mod:`meshterm.core.contact_store`.) The
|
||
contact that we already have holds all the data that the entry must have. Thus one
|
||
write is the repair, and the send that got the rejection tries again after it.
|
||
|
||
The function offers the write and does not do it silently. The write changes what the
|
||
device stores, and a full contact table refuses it (the user must see that, and it must
|
||
not be hidden).
|
||
|
||
Args:
|
||
ctx: The shared application context.
|
||
missing: The rejection, with the contact that the device could not find.
|
||
|
||
Returns:
|
||
``True`` when the contact is on the device (retry the send). ``False`` if the user
|
||
declined. Then the caller raises the error again, so that the chat shows the
|
||
refusal.
|
||
|
||
Raises:
|
||
DeviceCommandError: If the device refused the write (most often, a full table).
|
||
"""
|
||
contact = missing.contact
|
||
add = await ctx.ui.dialog(
|
||
f"{contact.name} isn't in this device's contacts, so the radio can't address it. "
|
||
"Add it back and send?",
|
||
[("Cancel", False), ("Add & send", True)],
|
||
title="Contact not on device",
|
||
default=1,
|
||
)
|
||
if not add:
|
||
return False
|
||
device = await ctx.device()
|
||
await device.add_contact(contact)
|
||
# The table of the device changed under the session cache. The next read gets it again
|
||
# (with the route that the firmware learns for the contact from now on).
|
||
ctx.devstate.invalidate_contacts()
|
||
return True
|
||
|
||
|
||
def _contact_names(contacts: list[Contact]) -> dict[str, str]:
|
||
"""Build a map from key prefix to name, for the labels of inbound direct messages."""
|
||
names: dict[str, str] = {}
|
||
for contact in contacts:
|
||
for key in (contact.key_prefix, contact.public_key[:12]):
|
||
if key:
|
||
names[key.lower()] = contact.name
|
||
return names
|
||
|
||
|
||
def _belongs(message: Message, conversation: Conversation) -> bool:
|
||
"""Whether an inbound message belongs to the open conversation.
|
||
|
||
Args:
|
||
message: The received message.
|
||
conversation: The conversation that the screen shows now.
|
||
|
||
Returns:
|
||
``True`` if the message must be appended to this transcript.
|
||
"""
|
||
if conversation.is_channel:
|
||
return message.is_channel and message.channel == conversation.channel_idx
|
||
if message.is_channel:
|
||
return False
|
||
sender = (message.sender or "").lower()
|
||
peer = (conversation.peer or "").lower()
|
||
if not sender or not peer:
|
||
return False
|
||
return sender.startswith(peer) or peer.startswith(sender)
|
||
|
||
|
||
# --- message paths (the ^P view) -----------------------------------------------------
|
||
|
||
|
||
def _sent_scope_line(ctx: AppContext, message: ChatMessage, *, relayed: bool) -> Text | None:
|
||
"""The scope of a message that we sent, for the paths dialog, and why it can be lost.
|
||
|
||
The line comes from the scope stored on the message at send time
|
||
(:attr:`ChatMessage.scope`), never from the current scope of the channel, which may
|
||
have changed since then. A scoped message can have no stored relayed copy *and* no
|
||
repeater that was ever heard here to relay its region
|
||
(:meth:`~meshterm.core.region_store.RegionStore.carriers`). Then the line gives the
|
||
most probable reason in plain words: only repeaters that relay its region relay a
|
||
scoped flood, and no known repeater in range does.
|
||
|
||
Args:
|
||
ctx: The shared application context (for the region store).
|
||
message: The message whose paths are open.
|
||
relayed: Whether a copy of it was heard when it came back.
|
||
|
||
Returns:
|
||
The line, or ``None`` for a message with no stored scope (inbound, direct, or
|
||
sent before MeshTerm stored scopes).
|
||
"""
|
||
from ..core.regions import UNSCOPED, Scope
|
||
from .widgets import scope_text
|
||
|
||
if not message.outbound or not message.scope:
|
||
return None
|
||
line = Text("sent ", style="muted")
|
||
if message.scope == WILDCARD:
|
||
line.append_text(scope_text(UNSCOPED))
|
||
return line
|
||
line.append("under ", style="muted")
|
||
line.append_text(scope_text(Scope("scoped", message.scope)))
|
||
store = getattr(ctx, "region_store", None)
|
||
carriers = len(store.carriers(message.scope)) if store is not None else 0
|
||
if not relayed and not carriers:
|
||
line.append(" — no repeater known here carries it", style="warn")
|
||
elif carriers:
|
||
line.append(
|
||
f" · {carriers} known repeater{'s' if carriers != 1 else ''} carr"
|
||
f"{'y' if carriers != 1 else 'ies'} it",
|
||
style="muted",
|
||
)
|
||
return line
|
||
|
||
|
||
async def _make_paths_presenter(
|
||
ctx: AppContext,
|
||
conversation: Conversation,
|
||
device, # noqa: ANN001 - the core Device. Its type is set at the source
|
||
) -> Callable[[ChatMessage], Awaitable[None]]:
|
||
"""Build the async presenter for the ^P delivery-paths view of the chat.
|
||
|
||
It collects what the presenter must have, one time each time the chat opens:
|
||
|
||
* a resolver of hop names over the contacts and all the names that the recorder ever
|
||
heard (the rule of the app is that a node that has a name never shows as a bare
|
||
hash),
|
||
* the name of our node, for the white ``you``,
|
||
* the width of the routing prefix, for the lit hashes, and
|
||
* for a channel, its secret. The function reads it from the device when the
|
||
conversation did not have one (a conversation from the picker knows its identity,
|
||
but not always its key).
|
||
|
||
Args:
|
||
ctx: The shared application context.
|
||
conversation: The conversation that the chat screen opens.
|
||
device: The connected device (the caller already awaited it).
|
||
|
||
Returns:
|
||
An async callable that shows the paths of one message in a floating window.
|
||
"""
|
||
from ..services import trace_runner
|
||
from ..services.message_paths import (
|
||
channel_arrivals,
|
||
collapse,
|
||
direct_arrivals,
|
||
distinct_paths,
|
||
message_scope,
|
||
)
|
||
from .message_paths_screen import MessagePathsScreen
|
||
|
||
session = ctx.ui.session
|
||
contacts = await ctx.devstate.contacts()
|
||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||
resolve = trace_runner.make_node_resolver(contacts, stored_names)
|
||
type_of = trace_runner.make_node_type_resolver(contacts)
|
||
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
|
||
# Our key names one end of each direct packet in this conversation. The key of the
|
||
# contact names the other end (refer to :func:`direct_arrivals`). Our node with no name
|
||
# only gets no white highlight.
|
||
self_name, self_key = await our_node(ctx)
|
||
|
||
secret = conversation.secret
|
||
if conversation.is_channel and not secret and conversation.channel_idx is not None:
|
||
try:
|
||
payload = await device.get_channel(conversation.channel_idx)
|
||
raw = (payload or {}).get("channel_secret")
|
||
secret = bytes(raw) if raw else None
|
||
except Exception: # noqa: BLE001 - no key, no decrypt. The view says so correctly
|
||
secret = None
|
||
|
||
async def present(message: ChatMessage) -> None:
|
||
if conversation.is_channel and secret:
|
||
arrivals = channel_arrivals(
|
||
ctx.repo, message, channel_name=conversation.label, secret=secret
|
||
)
|
||
matched = True
|
||
summary = (
|
||
f"heard {len(arrivals)} time{'s' if len(arrivals) != 1 else ''}"
|
||
f" · {distinct_paths(arrivals)} distinct "
|
||
f"path{'s' if distinct_paths(arrivals) != 1 else ''}"
|
||
if arrivals
|
||
else "no copies in the packet log"
|
||
)
|
||
elif conversation.is_channel:
|
||
await session.scroll(
|
||
Text(
|
||
"This channel's key isn't at hand, so overheard frames can't be "
|
||
"matched to the message.",
|
||
style="muted",
|
||
),
|
||
title="Message paths",
|
||
)
|
||
return
|
||
else:
|
||
contact = conversation.contact
|
||
peer_key = conversation.peer or (
|
||
(contact.public_key or contact.key_prefix) if contact else None
|
||
)
|
||
arrivals, exact = direct_arrivals(
|
||
ctx.repo, message, self_key=self_key, peer_key=peer_key
|
||
)
|
||
# An exact match says the same as the channel view: these packets are this
|
||
# message. The shared MAC of the packets gives the match. Thus it is correct,
|
||
# also when MeshTerm cannot read one word of them.
|
||
matched = exact
|
||
arrivals = collapse(arrivals)
|
||
heard = sum(a.copies for a in arrivals)
|
||
if exact:
|
||
summary = (
|
||
f"heard {heard} time{'s' if heard != 1 else ''}"
|
||
f" · {len(arrivals)} distinct path{'s' if len(arrivals) != 1 else ''}"
|
||
if arrivals
|
||
else "no copies in the packet log"
|
||
)
|
||
else:
|
||
summary = (
|
||
"direct frames are encrypted — matched by address and time"
|
||
if peer_key and self_key
|
||
else "direct frames are encrypted — matched by time alone"
|
||
)
|
||
# The two ends of the path: the node that the message started from, and the node
|
||
# that it was addressed to. Our sends go from our node to the peer. An inbound
|
||
# channel message names its sender on the air and is addressed to all nodes, so it
|
||
# ends on our node. The origin of an inbound message in a direct chat is the peer of
|
||
# the conversation.
|
||
destination: str | None = self_name
|
||
if message.outbound:
|
||
source = self_name
|
||
# A channel broadcast is addressed to no specific node, and the copies that we
|
||
# log are its rebroadcasts that come back. Thus it really ends on our node. A
|
||
# direct send does not: it went to one node, and its route ends there.
|
||
if not conversation.is_channel:
|
||
destination = conversation.label
|
||
elif conversation.is_channel:
|
||
source, _body = split_channel_sender(message.text)
|
||
else:
|
||
source = conversation.label
|
||
await session.run_screen(
|
||
MessagePathsScreen(
|
||
message,
|
||
arrivals,
|
||
matched=matched,
|
||
resolve=resolve,
|
||
prefix_bytes=prefix_bytes,
|
||
self_name=self_name,
|
||
summary=summary,
|
||
source=source or None,
|
||
destination=destination or None,
|
||
type_of=type_of,
|
||
key_of=key_of,
|
||
scope=message_scope(arrivals, ctx.region_store.scope_of),
|
||
sent_scope=_sent_scope_line(ctx, message, relayed=bool(arrivals)),
|
||
)
|
||
)
|
||
|
||
return present
|