Files
MeshTerm/meshterm/ui/admin_picker.py
T
Jean-Pierre MartineauandClaude Opus 5 f2a49c8e2d Editors keep their place too: rows change, the reader doesn't move
Six lists rebuilt themselves as a whole new screen every round, because their
content is data: an editor's rows carry a staged `current → new` value, its
title counts what is staged, the outbox loses the message it just sent, the
watchtower's log rewrites the row you acknowledged. Each approximated the cost
with `default=cursor`, which recovers only the cursor, and only while the row it
names still exists — the typed filter went every time.

SelectScreen.replace_items swaps the rows (and optionally the title) in place
instead. The place is kept by value, so the highlight lands back on its row
wherever that row moved to, and falls back to the same position in the list when
the row is gone entirely — which is what you want after deleting the row you
were sitting on. The filter rides along.

So the config editor, device actions, the channel manager, the courier outbox,
the watchtower and the repeater-admin editor all hold one screen for the whole
visit now, refreshed after each action. The courier's ticker is started once for
the visit rather than per round, and folds a flow's effect in immediately rather
than waiting up to a second for the next tick.

The admin node picker gains a `stay` form. Repeater admin used to draw a second,
identical SelectScreen as a static backdrop so the password prompt had something
behind it, then drop the list before the session; the real list stays up through
both now — so cancelling the password lands back on the node list rather than
bowing out of the flow, and leaving a node's admin session lands there too.
Administering two repeaters in a row is an Esc and a pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:38:26 -04:00

187 lines
7.4 KiB
Python

"""The admin-node picker: choose a remote node you hold (or will enter) credentials for.
Shared by the tools that administer remote nodes over the mesh — TX optimize and the
repeater admin screen — so "pick the node to manage" reads identically everywhere.
Credentialed nodes lead the list in their own 🔑 section (those are the nodes previously
administered, the likeliest picks), repeaters and room servers follow, then everything
else; each section orders by how recently the node was heard. Any contact with a public
key is offerable, since holding a password is a fact about the *user*, not the node.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any, AsyncIterator, Optional
from rich.text import Text
from ..core.models import NODE_TYPE_REPEATER, NODE_TYPE_ROOM, Contact
if TYPE_CHECKING:
from ..context import AppContext
def admin_picker_rows(
ctx: "AppContext", contacts: list[Contact]
) -> tuple[list, list[Contact]]:
"""Build the admin-node picker's grouped rows and the contacts they map to.
Shared so the pick and any later redraw are guaranteed identical: :func:`pick_admin_node`
runs these rows, and a caller can rebuild the same list as a static backdrop to float a
follow-up dialog (the admin login) over the very list the node was picked from. Rows carry
``value = contact.name``, so a picked name (or a backdrop's ``default``) resolves through
``candidates``.
Args:
ctx: Shared application context (for the admin store, which sorts remembered nodes up).
contacts: The device's known contacts.
Returns:
``(rows, candidates)`` — the grouped select rows and the offerable contacts
(those holding a key). Both are empty when nothing is offerable; the caller
notes that and never opens a list at all.
"""
from .menus import section_heading
from .theme import name_style
from .tui import Choice
from .widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
candidates = [c for c in contacts if (c.public_key or c.key_prefix).strip()]
if not candidates:
return [], candidates
def row(contact: Contact) -> Choice:
# The type mark keeps its own fixed hue; the *name* takes the node's key-derived
# colour like every other list of nodes (a style on the Text itself would be the
# row's base and would paint the name the type's colour too).
glyph, glyph_style = _NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
label = Text()
label.append(f"{glyph} ", style=glyph_style)
label.append(
contact.name,
style=name_style(contact.name, contact.public_key or contact.key_prefix),
)
return Choice(title=label, value=contact.name)
def recency(contact: Contact) -> float:
return -(contact.last_seen.timestamp() if contact.last_seen else 0.0)
remembered = [c for c in candidates if ctx.admin_store.get(c) is not None]
infrastructure = [
c for c in candidates
if c not in remembered and c.node_type in (NODE_TYPE_REPEATER, NODE_TYPE_ROOM)
]
others = [c for c in candidates if c not in remembered and c not in infrastructure]
items: list = []
if remembered:
items.append(section_heading("Remembered admins"))
items.extend(row(c) for c in sorted(remembered, key=recency))
if infrastructure:
items.append(section_heading("Repeaters & rooms"))
items.extend(row(c) for c in sorted(infrastructure, key=recency))
if others:
items.append(section_heading("Other contacts"))
items.extend(row(c) for c in sorted(others, key=recency))
return items, candidates
async def pick_admin_node(
ctx: "AppContext",
contacts: list[Contact],
*,
title: str,
prompt: str,
) -> Optional[Contact]:
"""Pick a remote node to administer, credentialed and infrastructure nodes first.
The one-shot form: the list comes down as soon as a node is picked. A caller whose next
step belongs *over* the list — the admin login, which asks about the node just picked —
wants :func:`admin_node_visit` instead.
Args:
ctx: Shared application context (for the UI surface and the admin store).
contacts: The device's known contacts.
title: The select screen's heading (names the calling feature).
prompt: One line above the list saying what the pick is for.
Returns:
The chosen contact, or ``None`` if cancelled (or there is nothing to pick).
"""
items, candidates = admin_picker_rows(ctx, contacts)
if not candidates:
await _note_nothing_to_pick(ctx, title)
return None
choice = await ctx.ui.select(title, items, prompt=prompt, wrap=False)
return _resolve(candidates, choice)
@asynccontextmanager
async def admin_node_visit(
ctx: "AppContext",
contacts: list[Contact],
*,
title: str,
prompt: str,
) -> "AsyncIterator[Optional[_AdminNodePicker]]":
"""Keep the node picker pushed for a whole visit, yielding a picker to draw from.
The list stays on the stack for the duration of the block, so everything the caller does
with a picked node — the password prompt, a rejection, the admin session itself — nests
*above* the very list the pick came from, with the picked node still highlighted, instead
of floating over a blank frame or being drawn again as a lookalike backdrop. Esc from any
of it is one pop back onto the list.
Yields ``None`` when there is nothing to pick (the caller has already been told).
Args:
ctx: Shared application context (for the UI surface and the admin store).
contacts: The device's known contacts.
title: The select screen's heading (names the calling feature).
prompt: One line above the list saying what the pick is for.
"""
from .surface import TuiUi
from .tui import SelectScreen
items, candidates = admin_picker_rows(ctx, contacts)
if not candidates or not isinstance(ctx.ui, TuiUi):
if not candidates:
await _note_nothing_to_pick(ctx, title)
yield None
return
screen = SelectScreen(title, items, prompt=prompt, wrap=False)
async with ctx.ui.session.stay(screen) as visit:
yield _AdminNodePicker(visit, candidates)
class _AdminNodePicker:
"""The visited node list: :meth:`pick` is one round of it, resolved to a contact."""
__slots__ = ("_visit", "_candidates")
def __init__(self, visit: Any, candidates: list[Contact]) -> None:
"""Bind the picker to the list's visit and the contacts its rows stand for."""
self._visit = visit
self._candidates = candidates
async def pick(self) -> Optional[Contact]:
"""Await one pick: the chosen contact, or ``None`` on Esc."""
from .tui.screen import CANCEL
choice = await self._visit.result()
return _resolve(self._candidates, None if choice is CANCEL else choice)
async def _note_nothing_to_pick(ctx: "AppContext", title: str) -> None:
"""Say there is no offerable node and show it — no list is opened at all."""
ctx.ui.note("[err]no contacts with a key — receive an advert first[/err]")
await ctx.ui.present(title=title)
def _resolve(candidates: list[Contact], choice: Any) -> Optional[Contact]:
"""The contact a picked row's name stands for, or ``None`` when nothing was picked."""
if choice is None:
return None
return next((c for c in candidates if c.name == choice), None)