Files
MeshTerm/meshterm/ui/channels.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

1057 lines
45 KiB
Python
Raw Permalink 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 interactive channel manager, rendered in the full-screen session.
This is the user-facing channel-management experience for the ``channels`` tool: a live
list of the device's channel slots with, for each, a detail view that shows the sharable
QR code and key, renames or re-keys it, opens it in chat, mutes its notifications, or clears
it. New channels are created four ways — a fresh private channel (random key), a public ``#``
channel (key derived from the name), joining by pasting a key, or importing a scanned
``meshcore://`` link. Every change is written to the device immediately (like a phone app), so
the list you see always reflects the radio.
Muting is the one per-channel setting that is a local *preference* rather than device
configuration: a muted channel's new messages stop raising the unread badge (its inbound
messages no longer accrue unread, and muting zeros whatever it had), while still being
recorded to history. The mute lives in :class:`~meshterm.core.mute_store.MuteStore`, keyed by
the channel's intrinsic identity so it follows the channel across slot moves, and the list
row shows a ``🔕`` in its (then always-empty) unread lane to mark it.
The list is laid out like the config editor: fixed, column-aligned lanes under one header
line — the openness glyph and name, unread badge, total messages, last-message age, and a
braille sparkline of the trailing two hours' traffic — so a glance shows not just *which*
channels exist but which ones are alive. Openness beyond the glyph, and the hash, live in
the detail views (the title line and Show key), keeping the list lean enough that the
activity lane survives a 72-column terminal. The message statistics come from
:meth:`~meshterm.persistence.repository.Repository.channel_stats` (read through a small
TTL cache) and the unread counts from the live chat service, and each row is a callable
title re-resolved on repaint, so a message arriving while the list sits open updates its
row in place — the same trick the conversation picker uses. The menus also follow the
config editor's persistent-backdrop pattern: the list stays pushed while every sub-prompt
floats over it as a modal popup, rather than replacing the screen.
The module sits in the UI layer but, like the config editor and chat screen, is allowed to
depend on the context and services; it owns no persistence of its own.
"""
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING
from rich.console import Group
from rich.text import Text
from ..core.channels import (
CHANNEL_SLOT_EMPTY_RUN,
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
MAX_CHANNELS,
channel_hash,
channel_identity,
derive_secret,
full_channel_hash,
is_name_derived,
is_public_channel,
normalize_secret,
parse_share_url,
random_secret,
share_url,
)
from ..core.connection import Device
from ..core.models import Conversation
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
from .braillechart import activity_peak, activity_sparkline
from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading
from .qr import share_popup
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
if TYPE_CHECKING:
from ..context import AppContext
from ..persistence.repository import ChannelStats
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
_CREATE = "__create__"
_PUBLIC = "__public__"
_DEFAULT_PUBLIC = "__default_public__"
_JOIN = "__join__"
_IMPORT = "__import__"
_REORDER = "__reorder__"
# Channel-detail action sentinels.
_QR = "qr"
_KEY = "key"
_CHAT = "chat"
_MUTE = "mute"
_EDIT = "edit"
_CLEAR = "clear"
@dataclass(slots=True)
class ChannelSlot:
"""One configured channel slot read back from the device.
Attributes:
idx: The 0-based slot index.
name: The channel's name.
secret: The channel's 16-byte shared secret.
"""
idx: int
name: str
secret: bytes
@property
def is_name_derived(self) -> bool:
"""Whether this channel's key is reproducible from its name (so it needn't be stored)."""
return is_name_derived(self.name, self.secret)
@property
def is_public(self) -> bool:
"""Whether this channel is public (shared meshwide) rather than a private one.
Covers both name-derived channels and the firmware's fixed-key default ``Public``.
"""
return is_public_channel(self.name, self.secret)
@property
def hash(self) -> str:
"""The channel's two-character hash fingerprint (the leading byte MeshCore shows)."""
return channel_hash(self.secret)
@property
def full_hash(self) -> str:
"""The channel's complete ``sha256(secret)`` digest; its first byte is :attr:`hash`."""
return full_channel_hash(self.secret)
@property
def identity(self) -> str:
"""The channel's slot-independent identity, used to key its chat history."""
return channel_identity(self.name, self.secret)
@property
def conversation(self) -> Conversation:
"""A :class:`~meshterm.core.models.Conversation` for opening this channel in chat."""
return Conversation(
label=self.name,
is_channel=True,
channel_idx=self.idx,
channel_id=self.identity,
secret=self.secret,
)
async def manage_channels(ctx: AppContext) -> int:
"""Run the interactive channel manager until the user backs out.
Args:
ctx: Shared application context (provides the connected device and UI surface).
Returns:
The number of channels created, changed, or cleared during the session.
"""
device = await ctx.device()
# The firmware's slot count is fixed for the session, so discover it once (a read-only
# probe) rather than assuming a hard-coded 8; it drives the free-slot check and the
# used/total display below. Read through the session cache: on firmware that never
# rejects an out-of-range index the probe walks every slot (slow), and the count never
# changes for a connection, so it is warmed once (prewarm/first open) and reused on every
# later open. A device that can't report any slots falls back to the standard count so the
# manager stays usable instead of showing zero capacity.
capacity = await ctx.devstate.channel_capacity() or MAX_CHANNELS
stats = _LiveStats(ctx)
changes = 0
highlight: object | None = None
slots: list = []
async def reload() -> tuple[str, list]:
"""Re-probe the slot layout and rebuild the list's title and rows.
Through the session cache — the slot probe walks every index and is one of the
slowest reads on a screen open, so a screen opened after this one (or this one after
another) reuses the one probe. A local copy so the mutation helpers below can work a
working list; each mutation invalidates the cache (see ``handle``), so this re-probes
the fresh layout rather than trusting the stale copy.
"""
nonlocal slots
slots = list(await ctx.devstate.channel_slots())
return _menu_items(ctx, slots, capacity, stats)
async def handle(choice: object) -> bool:
"""Dispatch one menu choice (over the still-pushed list); ``False`` exits."""
nonlocal changes, highlight
if choice is None: # Esc
return False
highlight = choice
before = changes
if choice == _CREATE:
changes += await _create_private(ctx, device, slots, capacity)
elif choice == _DEFAULT_PUBLIC:
changes += await _add_default_public(ctx, device, slots, capacity)
elif choice == _PUBLIC:
changes += await _add_public(ctx, device, slots, capacity)
elif choice == _JOIN:
changes += await _join_with_key(ctx, device, slots, capacity)
elif choice == _IMPORT:
changes += await _import_link(ctx, device, slots, capacity)
elif choice == _REORDER:
changes += await _reorder_channels(ctx, device, slots)
else: # an existing slot index
slot = next((s for s in slots if s.idx == choice), None)
if slot is not None:
changes += await _channel_detail(ctx, device, slot, stats)
if changes > before:
# A slot's occupant changed (created, re-keyed, cleared, or moved). Drop the
# session cache's channel list so the dashboard (and anything else reading it)
# re-reads the new layout instead of a stale copy.
ctx.devstate.invalidate_channels()
# Inbound messages carry only a slot index, which the chat service maps to a
# channel identity through a cache keyed by slot; refresh it now so a message
# on a reused/re-keyed slot is filed under the channel that's actually there
# and not the one that used to be — otherwise its transcript surfaces in the
# wrong chat.
await _refresh_chat_channels(ctx)
return True
# The list stays pushed for the whole visit: every sub-flow — creating a channel,
# importing a link, a slot's detail page — floats over it, and the rows are re-probed and
# swapped in place afterwards, so the highlight (and any typed filter) survives a layout
# that changed under it. A surface with no session — the plain CLI, scripted tests — has
# no stack to stay on and runs the same dispatcher a round at a time.
title, items = await reload()
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
if not await _menu_round(ctx, title, items, default=highlight, handle=handle):
return changes
title, items = await reload()
menu = SelectScreen(title, items)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
if not await handle(None if choice is CANCEL else choice):
return changes
title, items = await reload()
menu.replace_items(items, title=title)
async def _menu_round(
ctx: AppContext,
title: str,
items: list,
*,
handle: Callable[[object], Awaitable],
default: object = None,
) -> object:
"""Select once and dispatch the choice — what a surface with no screen stack does.
Both channel menus keep one screen for the whole visit in the full-screen session
(``session.stay``, rows swapped in place), which is the only way the cursor, the sort
and a typed filter survive an action. A surface without a stack — the plain CLI, a
scripted test — has nothing to keep, so it selects, dispatches, and is called again;
``default`` is all it can carry between rounds, and the summary line has nowhere to be
drawn at all.
Args:
ctx: Shared application context.
title: The menu's border heading.
items: The menu's :class:`Choice`/:class:`Separator` rows.
handle: Async dispatcher awaited with the chosen value (``None`` for Esc).
default: A choice value to re-highlight, so the menu reopens where it was left.
Returns:
Whatever ``handle`` returns.
"""
return await handle(await ctx.ui.select(title, items, default=default))
async def _refresh_chat_channels(ctx: AppContext) -> None:
"""Rebuild the chat service's slot→identity cache after a channel mutation.
Best-effort: a device read hiccup here must never break the channel manager, and the
cache also self-heals on the next miss, so a failure is only logged.
"""
try:
await ctx.chat.refresh_channels()
except Exception as exc: # noqa: BLE001 - refresh is best-effort; never fatal here
ctx.log.debug("channels: chat cache refresh failed: %s", exc)
# --- reading -----------------------------------------------------------------
async def read_channel_slots(device: Device) -> list[ChannelSlot]:
"""Probe the channel slots and return the configured ones, in index order.
The scan stops as soon as the firmware rejects a slot index, so it reads exactly the
slots a well-behaved device has regardless of its capacity. Firmware that never rejects
an out-of-range index (it answers every slot with an empty payload instead of raising)
would otherwise walk all :data:`CHANNEL_SLOT_PROBE_CAP` slots on every read; a run of
:data:`CHANNEL_SLOT_EMPTY_RUN` consecutive empty slots ends the scan on such firmware.
That is safe because the manager packs channels from slot 0 up, so an unbroken empty run
that long means every configured channel has already been seen (see the constant's note).
Args:
device: The connected device to query.
Returns:
One :class:`ChannelSlot` per configured slot (an empty slot is skipped).
"""
slots: list[ChannelSlot] = []
empty_run = 0
for idx in range(CHANNEL_SLOT_PROBE_CAP):
try:
payload = await device.get_channel(idx)
except Exception: # noqa: BLE001 - firmware may not support channel reads
break
if payload and payload.get("channel_name"):
empty_run = 0
slots.append(
ChannelSlot(
idx=idx,
name=str(payload["channel_name"]),
secret=bytes(payload.get("channel_secret") or b"\x00" * 16),
)
)
else:
empty_run += 1
if empty_run >= CHANNEL_SLOT_EMPTY_RUN:
break # off the end of a never-rejecting firmware; nothing more to find
return slots
def _next_free_slot(slots: list[ChannelSlot], capacity: int) -> int | None:
"""Return the lowest unused slot index, or ``None`` when every slot is full."""
used = {s.idx for s in slots}
return next((i for i in range(capacity) if i not in used), None)
# --- writing -----------------------------------------------------------------
async def write_channel(
ctx: AppContext, device: Device, idx: int, name: str, secret: bytes | None
) -> None:
"""Write a channel to a device slot, remembering it so a forgetful device can be restored.
The single boundary every channel mutation goes through — create, join, import, edit,
reorder, and clear all land here — so the channel store stays a faithful record of what
MeshTerm wrote (see :mod:`meshterm.core.channel_store`). Clearing a slot (an empty ``name``)
forgets it; any other write remembers the channel under the device's own public key, storing
a name-derived channel's derived key so it can later be replayed as-is. Remembering is
best-effort: it never blocks or fails the actual device write.
Args:
ctx: Shared application context (for the channel store and cached self-info).
device: The connected device to write to.
idx: The slot to write.
name: The channel name (empty clears the slot).
secret: The 16-byte secret, or ``None`` to let the firmware derive it from the name.
"""
await device.set_channel(idx, name, secret)
store = ctx.channel_store
if store is None:
return
try:
pubkey = (await ctx.devstate.self_info()).get("public_key", "")
except Exception as exc: # noqa: BLE001 - identity probe is best-effort; skip remembering
ctx.log.debug("channels: could not read self key to remember channel: %s", exc)
return
if not pubkey:
return
if name.strip():
store.remember(pubkey, idx, name, secret if secret is not None else derive_secret(name))
else:
store.forget(pubkey, idx)
def _slot_label(slot: ChannelSlot) -> str:
"""Format a channel for a compact row (the reorder screen): just its glyph and name.
The reorder screen is about *position*, not vitals — the openness, hash, and message
lanes of the manager list would only widen the popup — so each row is the channel
exactly as the manager's first lane shows it: glyph, gap, name.
"""
return f"{channel_glyph(slot.name, slot.secret)} {slot.name}"
# --- notifications -------------------------------------------------------------
def _is_muted(ctx: AppContext, slot: ChannelSlot) -> bool:
"""Whether this channel's new-message notifications are muted (see :class:`MuteStore`)."""
return ctx.mute_store.is_muted(slot.identity)
def _toggle_mute(ctx: AppContext, slot: ChannelSlot) -> None:
"""Flip a channel's notification mute, zeroing its unread the moment it is muted.
Muting is a remembered per-channel preference (keyed by the channel's intrinsic
identity, so it follows the channel across slot moves) that stops its new messages from
raising the unread badge. Muting also clears whatever unread it had accrued this session,
so the badge drops immediately rather than lingering until the next open — the reverse
(unmuting) simply lets future messages start counting again.
"""
now_muted = not _is_muted(ctx, slot)
ctx.mute_store.set_muted(slot.identity, now_muted)
if now_muted:
ctx.chat.clear_unread(slot.conversation.key)
# --- message statistics --------------------------------------------------------
#: Floor for the shared channel-activity peak: with every channel quiet, a lone message
#: draws against at least this many per bucket, so a single stray stays a small nub
#: rather than filling its column. Low, since channel chatter is sparse to begin with.
_ACTIVITY_FLOOR = 2.0
class _LiveStats:
"""A self-refreshing view of every channel's stored-message statistics.
The conversation picker's ``_LiveLasts`` pattern applied to
:meth:`~meshterm.persistence.repository.Repository.channel_stats`: the list rows read
through this on every repaint (their titles are callables), so a message arriving while
the manager sits open updates that channel's counts, age, and sparkline in place —
but the repository is re-queried at most once per ``ttl`` seconds rather than once per
row per repaint, so a full slot table stays cheap at the session's ~1 Hz repaint.
The TTL sits *above* that repaint period on purpose: at exactly the tick rate every
idle frame would still land one repository query — the throttle would gate nothing.
"""
def __init__(self, ctx: AppContext, *, ttl: float = 3.0) -> None:
"""Bind to a context; the first read populates the cache."""
self._ctx = ctx
self._ttl = ttl
self._cache: dict[str, ChannelStats] | None = None
self._peak: float | None = None
self._at = 0.0
def _snapshot(self) -> dict[str, ChannelStats]:
"""Every channel's stats, re-reading the repository at most once per ``ttl``."""
now = time.monotonic()
if self._cache is None or now - self._at >= self._ttl:
try:
self._cache = self._ctx.repo.channel_stats()
except Exception: # noqa: BLE001 - keep the last good snapshot on a read error
self._cache = self._cache or {}
self._peak = None # derived from the snapshot; recompute on the next read
self._at = now
return self._cache
def get(self, channel_id: str) -> ChannelStats | None:
"""Return the stats for one channel identity, refreshing once the TTL lapses."""
return self._snapshot().get(channel_id)
def peak(self) -> float:
"""The shared sparkline scale across *every* channel, cached per snapshot.
A steady ceiling over the pooled per-channel histograms (see
:func:`~meshterm.ui.braillechart.activity_peak`): outlier-robust so one busy
burst doesn't flatten the column, floored so a lull's stray message stays a nub,
and steadied by pooling a window deeper than the rows draw. Handing this one
value to every row's :func:`~meshterm.ui.braillechart.activity_sparkline` scales the whole
activity column against the busiest channel on screen, so the rows' bar heights
are comparable at a glance instead of each self-scaling to its own ceiling.
Read live through the same cache/TTL as :meth:`get`, and memoised alongside that
snapshot so a full slot table doesn't recompute the pooled peak once per row.
"""
snapshot = self._snapshot()
if self._peak is None:
self._peak = activity_peak(
*(st.histogram for st in snapshot.values()),
floor=_ACTIVITY_FLOOR,
)
return self._peak
#: Widest the name lane grows (longer names are ellipsized so the lanes stay put).
_NAME_WIDTH_MAX = 18
#: Width of the unread-badge lane (fits ``● 999``), matching the conversation picker's.
_BADGE_WIDTH = 5
#: Width of the right-aligned total-messages lane.
_COUNT_WIDTH = 5
#: Width of the right-aligned last-message-age lane (fits ``never``-length ages).
_AGE_WIDTH = 5
@lru_cache(maxsize=32)
def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
"""The channel's braille activity sparkline over the trailing two hours, now at the right.
The shared :func:`~meshterm.ui.braillechart.activity_sparkline` over the newest
:data:`ACTIVITY_DRAWN_BUCKETS` five-minute buckets of the repository histogram,
scaled to ``peak`` — the shared ceiling across every channel (see
:meth:`_LiveStats.peak`) — so the whole activity column shares one scale and the
rows' bars are comparable at a glance. The histogram runs deeper than is drawn; the
tail past the drawn buckets shapes ``peak`` but isn't charted.
Memoized on its (hashable) inputs: the row callables rebuild every repaint, but the
histogram snapshot only moves once per :class:`_LiveStats` TTL, so between refreshes
every slot's sparkline is a cache hit. Callers treat the returned Text as read-only.
"""
return activity_sparkline(histogram, ACTIVITY_DRAWN_BUCKETS, peak=peak)
# --- menus -------------------------------------------------------------------
def _lanes_header(name_w: int) -> str:
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
lane (3 cells), so each header lands exactly over its column. ``UNREAD`` borrows its
lane's trailing gap — the badge lane itself is one cell too narrow for the word — which
still leaves a space before the message count. (No TYPE or HASH lane: the glyph already
carries the openness and the hash lives in Show key, which buys the activity sparkline
its room on a 72-column terminal.)
"""
return (
" "
+ "CHANNEL".ljust(name_w + 2)
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
+ f"{'MSGS':>{_COUNT_WIDTH}}"
+ " "
+ f"{'LAST':>{_AGE_WIDTH}}"
+ " ACTIVITY"
)
def _slot_row(
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int
) -> Callable[[], Text]:
"""Return a list-row title *callable* the select screen re-renders on each repaint.
The unread badge, counts, age, and activity sparkline are all read live (see
:class:`_LiveStats`), so a message arriving while the list sits open updates the row on
the next repaint — exactly the conversation picker's behavior.
"""
return lambda: _slot_text(ctx, slot, stats, name_w)
def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int) -> Text:
"""Build one channel's list row as fixed-width, colour-coded lanes.
Alignment carries the readability — glyph, name, unread badge, total messages,
last-message age, and the activity sparkline each sit in their own lane under the
:func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
red with its count in warn (the conversation picker's language), and the sparkline
draws in the ok green over a faint flatline. A muted channel shows a muted ``🔕`` in
the unread lane instead of a count — muting zeros its unread and stops it accruing, so
that lane is always free to carry the state. The row is always a Rich
:class:`~rich.text.Text` so those spans survive under the select screen's row highlight.
"""
st = stats.get(slot.identity)
muted = _is_muted(ctx, slot)
unread = ctx.chat.unread(slot.conversation.key)
text = Text(no_wrap=True, overflow="ellipsis")
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
text.append(fit_cells(slot.name, name_w))
text.append(" ")
if muted:
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
text.append(" " * (_BADGE_WIDTH - 2))
elif unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
text.append(" " * _BADGE_WIDTH)
text.append(" ")
total = st.total if st is not None else 0
if total:
# Clamped so a pathological backlog can't push the row out of its lanes.
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
else:
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
text.append(" ")
age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else ""
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
# The shared peak across all channels, so every row's sparkline uses one scale.
text.append_text(_activity_sparkline(st.histogram if st is not None else (), stats.peak()))
return text
def _menu_items(
ctx: AppContext, slots: list[ChannelSlot], capacity: int, stats: _LiveStats
) -> tuple[str, list]:
"""Build the channel manager's title and rows for the current slot table.
Returns the ``(title, items)`` for one :func:`_menu_round`: the channel rows in their
aligned lanes under a column-header line (the config editor's presentation), then the
Organize and Add-a-channel action sections. The slot usage lives in the title, so the
header line is free to be pure column labels.
"""
items: list = []
if slots:
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
# The lane names are this block's only landmark (its section carries no ── heading ──),
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
items.append(Separator(_lanes_header(name_w), heading=True))
for slot in slots:
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
else:
items.append(Separator(" no channels configured yet"))
if len(slots) > 1:
items.append(section_heading("Organize"))
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
# label's text column-aligned with theirs. Both go together where the platform
# draws no icon lane at all.
items.append(Choice(title=command_label("↕ Reorder channels"), value=_REORDER))
items.append(section_heading("Add a channel"))
rows = []
# The firmware's built-in fixed-key Public channel has one well-known secret, so it's the
# same channel on every slot — offer to restore it only while no slot already holds it.
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
rows.append(
("🌐 Standard Public channel", "MeshCore's built-in meshwide channel", _DEFAULT_PUBLIC)
)
rows.extend(
[
("+ New private channel…", "A fresh random key", _CREATE),
("# Public channel…", "Key derived from its name", _PUBLIC),
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
]
)
items.extend(menu_rows(rows))
return f"Channels — {len(slots)}/{capacity} slots", items
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
st = stats.get(slot.identity)
unread = ctx.chat.unread(slot.conversation.key)
muted = _is_muted(ctx, slot)
if st is None or not st.total:
base = f"Slot {slot.idx} · no messages recorded yet"
return f"{base} · muted" if muted else base
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
if unread:
parts.append(f"{unread} unread")
if muted:
parts.append("muted")
if st.last_at is not None:
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
return " · ".join(parts)
def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
"""Build the channel-detail rows: label and description in two aligned lanes.
The Device actions presentation (menu-style lanes, no header line — these are commands,
not tabular data), padded in display cells so the double-width emoji can't skew the
description column. Open-in-chat carries the channel's live unread badge, the
notifications row reads as a toggle whose glyph and verb reflect the current mute state
(an immediate action, so no trailing ``…``), and the one destructive row keeps an
err-tinted label so it reads as such.
"""
unread = ctx.chat.unread(slot.conversation.key)
chat_label = Text("💬 Open in chat")
if unread:
chat_label.append(" ●", style="err")
chat_label.append(f" {unread}", style="warn")
if _is_muted(ctx, slot):
mute_row = (
"🔔 Unmute notifications",
"Show new messages here in the unread badge",
_MUTE,
)
else:
mute_row = (
"🔕 Mute notifications",
"Hide new messages here from the unread badge",
_MUTE,
)
items = menu_rows(
[
("📱 Show QR code", "Share this channel as a scannable code", _QR),
("🔑 Show key", "The name, key, hash, and share link", _KEY),
(chat_label, "Read and send messages on this channel", _CHAT),
mute_row,
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
(
Text("🗑 Clear this slot…", style="err"),
"Remove the channel from this device",
_CLEAR,
),
]
)
return items
async def _channel_detail(
ctx: AppContext, device: Device, slot: ChannelSlot, stats: _LiveStats
) -> int:
"""Show one channel's actions (QR, key, chat, rename, clear); return changes made.
The screen leads with the channel's vital signs (see :func:`_detail_summary`) above the
action rows, and — like the main list — is one screen for the whole visit: it stays
pushed while each action's prompts float over it, and the rows and the vital-signs line
are swapped in place afterwards rather than the screen being drawn again. Both are live
(the mute row is a toggle; the summary counts unread and ages the last message), so both
are re-read each round, and the highlight stays on the row that was just used. A
rename/re-key or clear closes the detail (the slot's occupant changed, so the caller
re-reads the device); the read-only actions loop back here.
"""
kind = "public" if slot.is_public else "private"
title = f"{slot.name} ({kind}, hash {slot.hash})"
async def handle(choice: object) -> int | None:
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
if choice is None: # Esc
return 0
if choice == _QR:
await _show_share(ctx, slot.name, slot.secret)
elif choice == _KEY:
await _show_key(ctx, slot)
elif choice == _CHAT:
await _open_chat(ctx, slot)
elif choice == _MUTE:
# A preference, not a slot-config change: toggle in place and loop back to the
# detail (whose rows re-render to the new state) without counting a channel change.
_toggle_mute(ctx, slot)
elif choice == _EDIT and await _edit(ctx, device, slot):
return 1
elif choice == _CLEAR and await _clear(ctx, device, slot):
return 1
return None
# A surface with no session (the plain CLI, scripted tests) has no stack to stay on and
# runs the same dispatcher a round at a time, as the manager above does.
session = getattr(ctx.ui, "session", None)
if session is None:
while True:
result = await _menu_round(ctx, title, _detail_items(ctx, slot), handle=handle)
if result is not None:
return result
menu = SelectScreen(
title,
_detail_items(ctx, slot),
prompt=_detail_summary(ctx, slot, stats),
)
async with session.stay(menu) as visit:
while True:
choice = await visit.result()
result = await handle(None if choice is CANCEL else choice)
if result is not None:
return result
menu.replace_items(_detail_items(ctx, slot), prompt=_detail_summary(ctx, slot, stats))
# --- create / join flows -----------------------------------------------------
async def _create_private(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a private channel with a fresh random key on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
name = await ctx.ui.text("Channel name:", validate=_nonblank)
if not name:
return 0
secret = random_secret()
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] created private channel [brand]{name.strip()}[/brand]")
await _show_share(ctx, name.strip(), secret, intro="Share this channel:")
return 1
async def _add_default_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Add MeshCore's built-in fixed-key ``Public`` channel on the next free slot."""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
await write_channel(ctx, device, idx, "Public", DEFAULT_PUBLIC_SECRET)
ctx.ui.note("[ok]✓[/ok] added the standard [brand]Public[/brand] channel")
return 1
async def _add_public(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Create a public channel whose key is derived from its (``#``-prefixed) name."""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
raw = await ctx.ui.text(
"Public channel name:",
help_text="A leading # is added automatically; the key is derived from the name",
validate=_nonblank,
)
if not raw:
return 0
name = raw.strip()
if not name.startswith("#"):
name = f"#{name}"
secret = derive_secret(name) # what the firmware will compute; kept for the QR/share
await write_channel(ctx, device, idx, name, None) # None => firmware derives the key from name
ctx.ui.note(f"[ok]✓[/ok] added public channel [brand]{name}[/brand]")
await _show_share(ctx, name, secret, intro="Share this channel:")
return 1
async def _join_with_key(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Join an existing private channel by entering its name and 16-byte key.
The two prompts are a stack (:func:`~meshterm.ui.menus.run_steps`): Esc on the key
steps back to the name with what was typed still in the field, rather than throwing
both away — a 32-hex key is a long thing to mistype.
"""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
answers = await run_steps(
[
lambda vals: ctx.ui.text("Channel name:", default=vals[0] or "", validate=_nonblank),
lambda vals: ctx.ui.text(
"Channel key (32 hex characters / 16 bytes):",
default=vals[1] or "",
validate=_valid_secret,
),
]
)
if answers is None:
return 0
name, key = answers
secret = normalize_secret(key)
await write_channel(ctx, device, idx, name.strip(), secret)
ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]")
return 1
async def _import_link(
ctx: AppContext, device: Device, slots: list[ChannelSlot], capacity: int
) -> int:
"""Import a channel from a pasted ``meshcore://channel/add`` link."""
idx = await _pick_free_slot(ctx, slots, capacity)
if idx is None:
return 0
url = await ctx.ui.text("Paste a meshcore:// channel link:", validate=_valid_link)
if not url:
return 0
parsed = parse_share_url(url)
if parsed is None: # pragma: no cover - guarded by the validator
ctx.ui.note("[err]not a valid channel link[/err]")
return 0
name, secret = parsed
await write_channel(ctx, device, idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] imported [brand]{name}[/brand]")
return 1
async def _edit(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
"""Rename and/or re-key an existing channel; return whether it changed.
Name then key, as a stack (:func:`~meshterm.ui.menus.run_steps`): Esc on the key steps
back to the name rather than dropping the rename with it. Each step opens on what it
was last given — the slot's current value the first time through, whatever was typed
when it is come back to (a committed blank key included, since blank means *derive it
from the name*).
"""
answers = await run_steps(
[
lambda vals: ctx.ui.text(
"Channel name:",
default=slot.name if vals[0] is None else vals[0],
validate=_nonblank,
),
lambda vals: ctx.ui.text(
"Channel key (32 hex chars; blank to derive from the name):",
default=(
("" if slot.is_name_derived else slot.secret.hex())
if vals[1] is None
else vals[1]
),
validate=_optional_secret,
),
]
)
if answers is None:
return False
name, key = answers
name = name.strip()
secret = normalize_secret(key) if key.strip() else None
await write_channel(ctx, device, slot.idx, name, secret)
ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]")
return True
async def _clear(ctx: AppContext, device: Device, slot: ChannelSlot) -> bool:
"""Clear a channel slot after confirmation; return whether it was cleared.
A red data-loss dialog (Cancel left, the verb right and default, ``destructive``
theming — clearing a slot drops its key) rather than a bare yes/no, so it reads
like every other delete confirm.
"""
choice = await ctx.ui.dialog(
f"Clear {slot.name}? This removes the channel from this device.",
[("Cancel", None), ("Clear", "clear")],
title="Clear channel",
default=1,
destructive=True,
)
if choice != "clear":
return False
ctx.chat.set_active(slot.conversation.key) # drop its unread before the slot goes away
ctx.chat.set_active(None)
await write_channel(ctx, device, slot.idx, "", None) # empty name => the slot reads as unused
ctx.ui.note(f"[warn]cleared channel {slot.name}[/warn]")
return True
# --- reordering --------------------------------------------------------------
async def _write_slot(ctx: AppContext, device: Device, idx: int, slot: ChannelSlot) -> None:
"""Write ``slot``'s contents into slot ``idx`` (name-derived channels re-derive their key)."""
secret = None if slot.is_name_derived else slot.secret
await write_channel(ctx, device, idx, slot.name, secret)
async def _apply_order(
ctx: AppContext, device: Device, slots: list[ChannelSlot], order: list[int]
) -> int:
"""Rewrite the channel slots so they display in ``order``; return the writes made.
``slots`` is the current list in slot-index order and ``order`` is a permutation of its
positions (as returned by the reorder screen). The channels are re-laid across the same
physical slot indices, lowest first, so their on-device order matches the new display
order. Only slots whose occupant actually changes are written. Reads come from the
in-memory snapshot, so the interleaved writes never clobber a not-yet-placed channel.
"""
indices = sorted(s.idx for s in slots) # the physical slots to fill, ascending
changes = 0
for target_idx, pos in zip(indices, order, strict=True):
slot = slots[pos]
if slot.idx == target_idx:
continue # already in place; no write needed
await _write_slot(ctx, device, target_idx, slot)
changes += 1
return changes
async def _reorder_channels(ctx: AppContext, device: Device, slots: list[ChannelSlot]) -> int:
"""Let the user drag channels into a new order with the arrows; return writes made."""
if len(slots) < 2: # pragma: no cover - the menu only offers reorder with 2+ channels
return 0
labels = [_slot_label(slot) for slot in slots]
order = await ctx.ui.reorder("Reorder channels", labels)
if not order or order == list(range(len(slots))):
return 0 # cancelled or left unchanged
# Chat history is keyed by each channel's intrinsic identity, not its slot, so it follows
# the channels automatically — reordering needs no history migration. The slot→identity
# cache the chat service resolves inbound messages through is refreshed centrally by
# manage_channels once any change lands (see :func:`_refresh_chat_channels`).
return await _apply_order(ctx, device, slots, order)
# --- shared views ------------------------------------------------------------
async def _show_share(
ctx: AppContext, name: str, secret: bytes, *, intro: str = "Scan to add this channel:"
) -> None:
"""Show the channel's QR code and its share URL in a dismissable window."""
await share_popup(ctx, name=name, url=share_url(name, secret), intro=intro)
async def _show_key(ctx: AppContext, slot: ChannelSlot) -> None:
"""Show a channel's name, type, hash, key, and share link as labelled blocks.
Deliberately not a table: a bordered grid inside a popup is visual noise and wastes
the very columns the values need. Each field is instead a muted column-header-style
label with its value on the line beneath, and the long values — the hash, key, and
link exist to be copied out whole — *wrap* to the popup's width (``overflow="fold"``,
since they are single unbreakable words) rather than being chopped at an ellipsis.
The hash's first byte keeps the brand highlight: it is the two-character fingerprint
the MeshCore companion app reports.
"""
if slot.is_name_derived:
kind = "public (key from name)"
elif slot.is_public:
kind = "public (default channel)"
else:
kind = "private"
hash_text = Text(overflow="fold")
hash_text.append(slot.full_hash[:2], style="brand")
hash_text.append(slot.full_hash[2:], style="muted")
fields: list[tuple[str, Text]] = [
("NAME", Text(slot.name, style="brand", overflow="fold")),
("TYPE", Text(kind)),
("HASH", hash_text),
("KEY", Text(slot.secret.hex(), style="warn", overflow="fold")),
("LINK", Text(share_url(slot.name, slot.secret), style="accent", overflow="fold")),
]
blocks: list[Text] = []
for label, value in fields:
if blocks:
blocks.append(Text())
blocks.append(Text(label, style="muted"))
blocks.append(value)
await ctx.ui.view(Group(*blocks), title=f"Key — {slot.name}", footer_hint="Esc close")
async def _open_chat(ctx: AppContext, slot: ChannelSlot) -> None:
"""Open this channel in the live chat screen."""
from .chat import open_chat
await open_chat(ctx, slot.conversation)
async def _pick_free_slot(ctx: AppContext, slots: list[ChannelSlot], capacity: int) -> int | None:
"""Return the next free slot, warning (and returning ``None``) if all are full."""
idx = _next_free_slot(slots, capacity)
if idx is None:
await ctx.ui.view(
Text.from_markup(
"[warn]All channel slots are full.[/warn] Clear one first, then try again."
),
title="No free slot",
)
return idx
# --- validators --------------------------------------------------------------
def _nonblank(text: str) -> bool | str:
"""Require a non-empty name."""
return bool(text.strip()) or "Enter a channel name."
def _valid_secret(text: str) -> bool | str:
"""Require a valid 16-byte hex key."""
try:
normalize_secret(text)
return True
except ValueError as exc:
return str(exc)
def _optional_secret(text: str) -> bool | str:
"""Accept a blank key (derive from name) or a valid 16-byte hex key."""
return True if not text.strip() else _valid_secret(text)
def _valid_link(text: str) -> bool | str:
"""Require a parseable ``meshcore://channel/add`` link."""
return parse_share_url(text) is not None or "Not a valid meshcore:// channel link."