Files
MeshTerm/meshterm/tools/chat.py
T
Jean-Pierre MartineauandClaude Opus 4.8 8906e4ed53 Enliven the conversation list and keep section headings in view
Color-code the chat picker rows: a contact's dot takes that person's
chat hue (filled once talked-to, hollow before), previews light their
sender names and @mentions in-hue, and a relative age column sits
between the unread badge and the message text.

Pin a grouped list's section heading to the top row once it scrolls off
(sticky header in the frame), add PageUp/PageDown to SelectScreen, and
drop arrow wraparound in the conversation picker so it doesn't jump
across the section dividers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:03:31 -04:00

676 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The ``chat`` tool: channel and direct messaging over the mesh.
Interactively it opens a conversation picker and then a live, full-screen chat (see
:mod:`meshterm.ui.chat`) where sent and received messages stream together. On the CLI it
exposes ``send``, ``history``, and ``list`` subcommands for scripted use. Channels are only
*listed* here for picking; creating and editing channel slots lives in the ``channels`` tool.
Every inbound message is recorded to history by the always-on
:class:`~meshterm.services.chat_service.ChatService`, and outbound messages are recorded on
send, so ``history`` reflects the full transcript regardless of which path produced it.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any, Callable, Optional, Union
import typer
from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
channel_identity,
)
from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, utcnow
from ..ui.chat import _MENTION, _sender_hue, _split_channel_sender
from ..ui.tui import Choice, Separator
from ..ui.widgets import channel_glyph
from .base import Tool, ToolResult, register
#: How many recent messages ``chat history`` prints by default.
_HISTORY_LIMIT = 50
@register
class ChatTool(Tool):
"""Send and receive channel and direct messages, with a live interactive chat."""
name = "chat"
help = "Channel and direct messaging — live chat, with stored history."
category = "Messaging"
order = 10
async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]:
"""Show the conversation picker and return the chosen thread to open.
Args:
ctx: Shared application context.
Returns:
``{"conversation": Conversation}`` for the chosen thread, or ``None`` if the
user backed out.
"""
conversation = await self._pick_conversation(ctx)
if conversation is None:
return None
return {"conversation": conversation}
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Open a live chat (menu) or perform a scripted messaging action (CLI).
Args:
ctx: Shared application context.
params: Either ``conversation`` (menu) or a ``cli_action`` with its arguments.
Returns:
A :class:`ToolResult` summarizing what happened.
"""
action = params.get("cli_action")
if action is not None:
return await self._run_cli(ctx, action, params)
conversation: Optional[Conversation] = params.get("conversation")
if conversation is None: # pragma: no cover - prompt_params returns None to cancel
return ToolResult(summary={})
from ..ui.chat import open_chat
# Loop picker ↔ chat so backing out of a conversation (Esc) steps back to the
# picker rather than all the way to the main menu; Esc from the picker ends the
# loop and returns to the menu. The just-closed conversation is pre-selected in
# the picker so the cursor lands where the user left.
opened = 0
shown = 0
while conversation is not None:
shown = await open_chat(ctx, conversation)
opened += 1
conversation = await self._pick_conversation(ctx, default_key=conversation.key)
return ToolResult(summary={"conversations": opened, "messages": shown})
# -- interactive picker -----------------------------------------------------
async def _pick_conversation(
self, ctx: AppContext, *, default_key: Optional[str] = None
) -> Optional[Conversation]:
"""Let the user pick a channel or contact to chat with.
Args:
ctx: Shared application context.
default_key: Conversation key to pre-highlight (e.g. the one just backed out
of), so the cursor lands there rather than at the top.
Returns:
The chosen :class:`~meshterm.core.models.Conversation`, or ``None`` if
cancelled.
"""
device = await ctx.device()
channels = await _read_channels(device)
contacts = await device.get_contacts()
# A stable snapshot orders the rows (so the list doesn't reshuffle under the cursor),
# while a self-refreshing view feeds each row's live preview (see _LiveLasts).
lasts = ctx.repo.last_chat_messages()
live = _LiveLasts(ctx, seed=lasts)
items: list = [Separator("── 📡 Channels ──")]
for conversation in channels:
items.append(Choice(title=_row_title(ctx, conversation, live), value=conversation))
items.append(Separator("── 👤 Direct ──"))
if contacts:
# List contacts by recency — those with messages first, newest exchange at the
# top — then the never-contacted ones alphabetically (see _recency_key).
direct = [
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
for conversation in direct:
items.append(Choice(title=_row_title(ctx, conversation, live), value=conversation))
else:
items.append(Separator(" (no contacts yet — receive an advert first)"))
items.append(Separator(" "))
items.append(Choice(title="Back", value="__back__"))
default = next(
(
it.value
for it in items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and it.value.key == default_key
),
None,
)
choice = await ctx.ui.select(
"Chat — pick a conversation", items, default=default, wrap=False
)
if choice in (None, "__back__"):
return None
return choice
# -- CLI --------------------------------------------------------------------
async def _run_cli(
self, ctx: AppContext, action: str, params: dict[str, Any]
) -> ToolResult:
"""Dispatch a scripted CLI action.
Args:
ctx: Shared application context.
action: One of ``send``, ``history``, ``list``.
params: The action's arguments.
Returns:
A :class:`ToolResult` for the action.
"""
if action == "send":
return await self._cli_send(ctx, params)
if action == "history":
return await self._cli_history(ctx, params)
if action == "listen":
return await self._cli_listen(ctx, params)
return await self._cli_list(ctx)
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Send a channel or direct message and report the outcome."""
device = await ctx.device()
text = str(params["text"])
channel = params.get("channel")
if channel is not None:
await ctx.chat.send_channel(int(channel), text, label=f"#{channel}")
ctx.ui.note(f"[ok]✓[/ok] sent to channel [brand]{channel}[/brand]")
return ToolResult(summary={"channel": channel, "sent": True})
contact = _resolve_contact(await device.get_contacts(), str(params["to"]))
message = await ctx.chat.send_direct(contact, text)
state = "[ok]✅ delivered[/ok]" if message.acked else "[warn]❌ no ack[/warn]"
ctx.ui.note(f"[ok]✓[/ok] sent to [brand]{contact.name}[/brand] — {state}")
return ToolResult(summary={"to": contact.name, "acked": bool(message.acked)})
async def _cli_history(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Print a conversation's stored transcript."""
limit = int(params.get("limit") or _HISTORY_LIMIT)
channel = params.get("channel")
to = params.get("to")
if channel is not None:
channel_id = await ctx.chat.channel_id_for(int(channel))
messages = ctx.repo.recent_chat_messages(
is_channel=True, channel_id=channel_id, limit=limit
)
label = f"#{channel}"
else:
device = await ctx.device()
contact = _resolve_contact(await device.get_contacts(), str(to))
messages = ctx.repo.recent_chat_messages(
is_channel=False,
peer=contact.key_prefix or contact.public_key[:12],
limit=limit,
)
label = contact.name
if not messages:
ctx.ui.note(f"[muted]no messages with {label} yet[/muted]")
else:
ctx.ui.show(_history_table(label, messages))
return ToolResult(summary={"conversation": label, "messages": len(messages)})
async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Tail inbound messages live in the console (and record them to history).
A plain-console receiver, outside the full-screen menu: it starts the always-on
listener and prints each message as it arrives. Doubles as a diagnostic — with the
pump's debug logging enabled (``--debug`` on the CLI) it shows whether messages are
being pulled from the companion at all.
Args:
ctx: Shared application context.
params: ``seconds`` — how long to listen (``0``/``None`` = until interrupted).
Returns:
A :class:`ToolResult` with how many messages were seen.
"""
seconds = params.get("seconds") or 0
await ctx.device()
await ctx.chat.start() # begins recording inbound to history too
ctx.console.print(
"[muted]listening for messages"
f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]"
)
seen = 0
def on_message(event: MeshEvent) -> None:
nonlocal seen
message = event.message
if message is None:
return
seen += 1
stamp = message.received_at.astimezone().strftime("%H:%M:%S")
if message.is_channel:
who = f"#{message.channel}"
else:
who = message.sender or "?"
snr = f" [muted]({message.snr:+.0f} dB)[/muted]" if message.snr is not None else ""
ctx.console.print(f"[muted]{stamp}[/muted] [accent]{who}[/accent]: {message.text}{snr}")
unsubscribe = ctx.events.subscribe(on_message, EventKind.MESSAGE)
try:
if seconds:
await asyncio.sleep(seconds)
else:
await asyncio.Event().wait() # until Ctrl-C / cancellation
except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive
pass
finally:
unsubscribe()
return ToolResult(
summary={"messages": seen},
message=f"[muted]stopped — saw {seen} message(s)[/muted]",
)
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""List channels, contacts, and their most recent message."""
device = await ctx.device()
channels = await _read_channels(device)
contacts = await device.get_contacts()
lasts = ctx.repo.last_chat_messages()
table = Table(title="Conversations", border_style="muted", expand=False)
table.add_column("Conversation")
table.add_column("Unread", justify="right")
table.add_column("Last message")
rows = [*channels] + [
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
]
for conversation in rows:
last = lasts.get(conversation.key)
snippet = ""
if last is not None:
who = "you: " if last.outbound else ""
snippet = f"{who}{last.text[:40]}"
unread = ctx.chat.unread(conversation.key)
table.add_row(conversation.label, str(unread) if unread else "·", snippet)
ctx.ui.show(table)
return ToolResult(summary={"conversations": len(rows)})
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``chat`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
chat_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
@chat_app.command("send", help="Send a message to a contact or channel.")
def _send_cmd(
text: str = typer.Argument(..., help="The message body."),
to: Optional[str] = typer.Option(None, "--to", help="Contact name or key prefix."),
channel: Optional[int] = typer.Option(None, "--channel", help="Channel slot index."),
) -> None:
if (to is None) == (channel is None):
raise typer.BadParameter("Pass exactly one of --to / --channel.")
run_tool_command(
self, {"cli_action": "send", "to": to, "channel": channel, "text": text}
)
@chat_app.command("history", help="Show a conversation's stored history.")
def _history_cmd(
to: Optional[str] = typer.Option(None, "--to", help="Contact name or key prefix."),
channel: Optional[int] = typer.Option(None, "--channel", help="Channel slot index."),
limit: int = typer.Option(_HISTORY_LIMIT, "--limit", help="Max messages to show."),
) -> None:
if (to is None) == (channel is None):
raise typer.BadParameter("Pass exactly one of --to / --channel.")
run_tool_command(
self,
{"cli_action": "history", "to": to, "channel": channel, "limit": limit},
)
@chat_app.command("list", help="List channels, contacts, and recent messages.")
def _list_cmd() -> None:
run_tool_command(self, {"cli_action": "list"})
@chat_app.command("listen", help="Tail inbound messages live in the console.")
def _listen_cmd(
seconds: int = typer.Option(
0, "--seconds", "-s", help="How long to listen (0 = until Ctrl-C)."
),
debug: bool = typer.Option(
False, "--debug", help="Log the message-pull activity (diagnostic)."
),
) -> None:
if debug:
_enable_receive_debug()
run_tool_command(self, {"cli_action": "listen", "seconds": seconds})
app.add_typer(chat_app, name=self.name)
# -- helpers ------------------------------------------------------------------
def _enable_receive_debug() -> None:
"""Route the message-pull and library debug logs to the console for diagnosis.
Raises the ``meshcore`` and connection-layer loggers to DEBUG and attaches a stderr
handler, so ``chat listen --debug`` shows whether inbound messages are being pulled
from the companion (the receive path) at all.
"""
import logging
import sys
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
for name in ("meshcore", "meshterm.core.connection"):
log = logging.getLogger(name)
log.setLevel(logging.DEBUG)
log.addHandler(handler)
async def _read_channels(device: Device) -> list[Conversation]:
"""Probe channel slots and return them as channel conversations.
Channel 0 (the default public channel) is always offered even when the firmware reports
no configured slots, so there is always somewhere to chat.
Args:
device: The connected device to probe.
Returns:
One :class:`~meshterm.core.models.Conversation` per configured channel (at least
channel 0).
"""
conversations: list[Conversation] = []
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
channel = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if channel:
name = str(channel.get("channel_name") or idx)
secret = bytes(channel.get("channel_secret") or b"\x00" * 16)
conversations.append(
Conversation(
label=name,
is_channel=True,
channel_idx=idx,
channel_id=channel_identity(name, secret),
secret=secret,
)
)
if not any(c.channel_idx == 0 for c in conversations):
conversations.insert(
0,
Conversation(
label="Public",
is_channel=True,
channel_idx=0,
channel_id="slot:0",
secret=DEFAULT_PUBLIC_SECRET,
),
)
return conversations
def _resolve_contact(contacts: list[Contact], needle: str) -> Contact:
"""Find a contact by exact name (case-insensitive) or key-prefix match.
Args:
contacts: The known contacts.
needle: A contact name or public-key prefix.
Returns:
The matching contact.
Raises:
typer.BadParameter: If no contact matches ``needle``.
"""
folded = needle.casefold()
for contact in contacts:
pub = (contact.public_key or "").lower().removeprefix("0x")
if contact.name.casefold() == folded or pub.startswith(folded):
return contact
raise typer.BadParameter(f"no contact matches {needle!r}")
def _recency_key(conversation: Conversation, lasts: dict) -> tuple:
"""Sort key ordering conversations by recency, then name.
Conversations that have been chatted with sort first, most-recent exchange at the top;
those never chatted with sort after them, alphabetically by label. The leading ``0``/``1``
keeps the two groups apart so their differently-typed tie-breakers never compare.
Args:
conversation: The conversation to rank.
lasts: Map of conversation key to its most recent message.
Returns:
A tuple usable as a ``sorted`` key.
"""
last: Optional[ChatMessage] = lasts.get(conversation.key)
if last is not None:
return (0, -last.created_at.timestamp())
return (1, conversation.label.casefold())
class _LiveLasts:
"""A self-refreshing view of each conversation's most recent message.
The picker snapshots :meth:`~meshterm.persistence.repository.Repository.last_chat_messages`
once to *order* the rows (so the list never reshuffles under the cursor), but the row
previews read through this so a message arriving while the list is open updates the
sender/text preview on the next repaint. It re-queries the repository at most a few times a
second (bounded by ``ttl``) rather than once per row per repaint, so a wide list stays cheap.
"""
def __init__(self, ctx: AppContext, *, seed: dict, ttl: float = 0.5) -> None:
"""Bind to a context, seeding the cache with the snapshot already loaded at open."""
self._ctx = ctx
self._ttl = ttl
self._cache = seed
self._at = time.monotonic()
def get(self, key: str) -> Optional[ChatMessage]:
"""Return the latest message for ``key``, refreshing the cache once its TTL lapses."""
now = time.monotonic()
if now - self._at >= self._ttl:
try:
self._cache = self._ctx.repo.last_chat_messages()
except Exception: # noqa: BLE001 - keep the last good snapshot on a read error
pass
self._at = now
return self._cache.get(key)
#: Column width (display cells) the conversation label is padded/ellipsized to, so the unread
#: badge and message preview line up in fixed lanes down the picker.
_LABEL_WIDTH = 22
#: Width of the unread-badge lane between the label and the age (fits ``● 999``).
_BADGE_WIDTH = 5
#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now``
#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column.
_AGE_WIDTH = 3
#: Longest message preview shown before it is ellipsized.
_PREVIEW_WIDTH = 40
def _row_title(
ctx: AppContext, conversation: Conversation, lasts: "_LiveLasts"
) -> Callable[[], Union[str, Text]]:
"""Return a picker-row title *callable* the select screen re-renders on each repaint.
Both the unread badge and the last-message preview are read live, so a message arriving
while the picker sits open updates that row's ``●`` count *and* its sender/text preview on
the next repaint (the session already repaints ~1×/s for the header).
Args:
ctx: Shared application context (for the live unread count).
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view feeding the preview.
Returns:
A zero-argument callable producing the current row title.
"""
return lambda: _title(ctx, conversation, lasts)
def _title(
ctx: AppContext, conversation: Conversation, lasts: "_LiveLasts"
) -> Union[str, Text]:
"""Build a picker row as fixed-width, colour-coded lanes.
Alignment carries the readability — marker, label, unread badge, relative age, and preview
each sit in their own lane, so every row's message text starts in the same column. Colour is
kept light and purposeful: the name stays in the base colour, a contact's leading dot is
tinted in that person's chat hue, the unread ``●`` badge is red, the age is muted, and the
preview mutes its body while lighting sender names and ``@mentions`` in their hue — the same
colours the live transcript uses. The row is always a Rich :class:`~rich.text.Text` so those
spans survive under the select screen's row highlight.
Args:
ctx: Shared application context (for the live unread count).
conversation: The conversation the row represents.
lasts: The self-refreshing latest-message view.
Returns:
The row title as a styled :class:`~rich.text.Text`.
"""
unread = ctx.chat.unread(conversation.key)
last = lasts.get(conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last)
text.append(_fit(conversation.label, _LABEL_WIDTH))
text.append(" ")
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
# the following lanes still line up on rows with nothing unread.
if unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
text.append(" " * _BADGE_WIDTH)
# Relative-age lane (right-aligned) sits between the badge and the message text, so the ages
# stack in one tidy column and the previews all start at the same place.
age = _ago(last.created_at) if last is not None else ""
text.append(" ")
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
if last is not None:
text.append_text(_preview_text(last))
return text
def _append_marker(text: Text, conversation: Conversation, last: Optional[ChatMessage]) -> None:
"""Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot.
A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle tinted in
that person's chat hue — filled (``●``) once we've exchanged messages, a hollow ring (``○``)
before any — so the colour identifies the person and the hollow-vs-filled shape marks whether
there's history, while the name itself stays in the base colour. The contact dot is padded to
the same width as a channel's double-cell glyph so the labels line up across both sections.
"""
if conversation.is_channel:
text.append(f"{channel_glyph(conversation.label, conversation.secret)} ")
else:
dot = "●" if last is not None else "○"
text.append(dot, style=_sender_hue(conversation.label))
text.append(" ")
def _preview_text(last: ChatMessage) -> Text:
"""A muted last-message preview with sender names and ``@mentions`` lit in their chat hue.
Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel
message's inline ``Name:`` sender is coloured in that sender's hue, and every ``@[Name]``
mention reads as a bare ``@Name`` in the mentioned person's hue — so the list and the chat
speak the same colour language. The result is clipped to :data:`_PREVIEW_WIDTH` cells.
"""
body_raw = last.text.replace("\n", " ")
text = Text()
if last.outbound:
text.append("you: ", style="accent")
_append_body(text, body_raw)
elif last.is_channel:
name, body = _split_channel_sender(body_raw)
if name is not None:
text.append(name, style=_sender_hue(name))
text.append(": ", style="muted")
_append_body(text, body)
else:
_append_body(text, body_raw)
else:
_append_body(text, body_raw)
text.truncate(_PREVIEW_WIDTH, overflow="ellipsis")
return text
def _append_body(text: Text, body: str) -> None:
"""Append ``body`` to ``text``, muted, with each ``@[Name]`` mention drawn in the name's hue."""
pos = 0
for match in _MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style="muted")
name = match.group(1)
text.append(f"@{name}", style=_sender_hue(name))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style="muted")
def _ago(when: Any) -> str:
"""A compact relative age for a message time — ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
Tolerates a naive timestamp (assumed UTC) so a stray one from the wire can't crash the
picker on the aware/naive subtraction.
"""
if getattr(when, "tzinfo", None) is None:
return ""
secs = max(0.0, (utcnow() - when).total_seconds())
if secs < 60:
return "now"
if secs < 3600:
return f"{int(secs // 60)}m"
if secs < 86400:
return f"{int(secs // 3600)}h"
if secs < 604800:
return f"{int(secs // 86400)}d"
return f"{int(secs // 604800)}w"
def _fit(text: str, width: int) -> str:
"""Left-justify ``text`` to ``width`` columns, ellipsizing anything that would overflow."""
if len(text) > width:
return text[: width - 1] + "…"
return text.ljust(width)
def _history_table(label: str, messages: list[ChatMessage]) -> Table:
"""Render a conversation's stored messages as a table.
Args:
label: The conversation's display name.
messages: The messages to show, oldest-first.
Returns:
A Rich :class:`Table` of time, sender, and text.
"""
table = Table(title=f"History — {label}", border_style="muted", expand=False)
table.add_column("Time")
table.add_column("From")
table.add_column("Message")
for message in messages:
stamp = message.created_at.astimezone().strftime("%m-%d %H:%M")
if message.outbound:
who = Text("you", style="accent")
else:
who = Text(message.peer_name or message.peer or "?", style="brand")
table.add_row(stamp, who, message.text)
return table