The repeater admin's node picker is a popup, gone before the admin page opens

The same shape TX optimize just lost, with one popup instead of two: the node list was
kept pushed as a hub under the password prompt and the admin page, so Esc from the page
landed on the list it was picked from — two places where there is one. A popup asks and
is gone: the pick is now a floating one-shot (session.select gains text's floating
flag, so a lead-in question draws as a box even as the only frame on the stack), the
admin page opens over the main menu, and a refused login re-asks the question with the
same node highlighted. admin_node_visit is deleted; nothing keeps a popup pushed now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5.1 committed 2026-09-12 02:05:21 -04:00
1 parent 222679c5a3
commit a8b69efa73
6 files changed
+89 -122

No files matched your search

+7
View File
@@ -87,6 +87,13 @@ of the two pickers never looked like a question at all. `stay(dialog=True)` now
blank base under a popup's visit that `_run_dialog_screen` puts under a one-shot. Trace's
picker is a full-screen contact list (a place), and stays as it is.
*Same day — Repeater admin.* The same shape, one popup instead of two: the node picker was
a hub under the password prompt and the admin page. It is now a floating one-shot
(`session.select(floating=True)`, `admin_picker.pick_admin_node`), gone once answered; the
admin page opens over the main menu and Esc from it lands there. A refused login re-asks
the question with the same node highlighted. `admin_node_visit` is deleted — nothing keeps
a popup pushed any more.
**I. An entry chain of prompts was not a stack.** Five flows asked two or more things in a
row, and Esc on any of them abandoned the whole flow rather than stepping back one — a
mistyped 32-hex channel key cost the name typed before it. `menus.run_steps` now runs a
+22 -67
View File
@@ -6,12 +6,15 @@ Credentialed nodes lead the list in their own 🔑 section (those are the nodes
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.
The pick is a **popup**: a question asked on the way into a tool, drawn as a box and gone
the moment it is answered — never a frame kept under the tool's own page for Esc to land
back on. It used to be held pushed as a hub for the whole visit, so leaving the admin page
landed on the list it was picked from; that read as two places where there is one.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from rich.text import Text
@@ -25,11 +28,10 @@ if TYPE_CHECKING:
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``.
Shared so every surface that offers the pick draws the same list: :func:`pick_admin_node`
runs these rows as a one-shot popup, and TX optimize turns them as the first page of its
stepped dialog. Rows carry ``value = contact.name``, so a picked name (or a ``default``)
resolves through ``candidates``.
Args:
ctx: Shared application context (for the admin store, which sorts remembered nodes up).
@@ -92,18 +94,21 @@ async def pick_admin_node(
*,
title: str,
prompt: str,
default: Contact | None = None,
) -> Contact | None:
"""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.
A floating popup that comes down as soon as a node is picked. A caller that asks again
— the login it tried was refused — hands the last pick back as ``default`` so the list
reopens on it, the way a step of :func:`~meshterm.ui.menus.run_steps` offers its
previous answer.
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.
default: The node to open highlighted on, if any.
Returns:
The chosen contact, or ``None`` if cancelled (or there is nothing to pick).
@@ -113,66 +118,16 @@ async def pick_admin_node(
await _note_nothing_to_pick(ctx, title)
return None
choice = await ctx.ui.select(title, items, prompt=prompt)
choice = await ctx.ui.select(
title,
items,
prompt=prompt,
default=default.name if default is not None else None,
floating=True,
)
return _resolve(candidates, choice)
@asynccontextmanager
async def admin_node_visit(
ctx: AppContext,
contacts: list[Contact],
*,
title: str,
prompt: str,
) -> AsyncIterator[_AdminNodePicker | None]:
"""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)
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) -> Contact | None:
"""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]")
+23 -28
View File
@@ -174,21 +174,24 @@ class AdminMenu(SelectScreen):
async def open_repeater_admin(ctx: AppContext) -> dict[str, Any] | None:
"""Run the repeater-admin flow: pick a node, log in, and administer it.
The picker stays up for the whole flow, so leaving a node's admin session lands back on
the list rather than on the main menu — administering two repeaters in a row is one Esc
and a pick, not a round trip through the menu.
The pick is a popup — a question on the way in, gone once answered — so the admin page
opens over the main menu and Esc from it lands there: the page is the whole visit. A
login the node refuses (or never answers) re-asks the question with the same node
highlighted, so a mistyped password is one Enter and a retry, not a round trip. The
list used to stay pushed under the page as a hub, which read as two places where
there is one.
Args:
ctx: The shared application context (must be running the interactive TUI surface).
Returns:
A summary of the last node administered (for the tool's log), or ``None`` if the
reader left the picker without ever getting into a session.
A summary of the node administered (for the tool's log), or ``None`` if the reader
left the picker without ever getting into a session.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .admin_picker import admin_node_visit
from .admin_picker import pick_admin_node
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
@@ -200,27 +203,19 @@ async def open_repeater_admin(ctx: AppContext) -> dict[str, Any] | None:
# :class:`~meshterm.services.device_state.DeviceState`); the device handle below is still
# needed for the admin login and the CLI session that follow.
contacts = await ctx.devstate.contacts()
# The node list stays pushed for the whole flow, so the password prompt (and any
# rejection) floats over the very list the pick came from, and the admin session opens
# above it: Esc out of the session lands back on the list, ready to manage another node,
# and Esc again leaves. It used to draw a second, identical SelectScreen as a static
# backdrop for the login and then drop the list entirely before the session.
async with admin_node_visit(
ctx,
contacts,
title="Repeater admin — node to manage",
prompt="The remote node to set up (you need its admin password):",
) as picker:
if picker is None: # nothing offerable; the reader has been told
node: Contact | None = None
while True:
node = await pick_admin_node(
ctx,
contacts,
title="Repeater admin — node to manage",
prompt="The remote node to set up (you need its admin password):",
default=node,
)
if node is None:
return None
summary: dict[str, Any] | None = None
while True:
node = await picker.pick()
if node is None:
return summary
if not await _login(ctx, device, node):
continue
summary = await _admin_session(ctx, device, node)
if await _login(ctx, device, node):
return await _admin_session(ctx, device, node)
async def _login(ctx: AppContext, device: Device, node: Contact) -> bool:
@@ -241,8 +236,8 @@ async def _login(ctx: AppContext, device: Device, node: Contact) -> bool:
f"Admin password for {node.name}",
prompt="The node ignores admin commands without a login.",
password=True,
# Floats over the picker backdrop the caller keeps pushed; the flag is the
# belt-and-suspenders so it never fills the frame even if that backdrop is absent.
# The picker popup is gone by now, so this is the only frame: the flag is what
# keeps it a box over a blank base rather than a full-frame prompt.
floating=True,
)
if not password:
+6 -1
View File
@@ -163,13 +163,15 @@ class Ui:
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Prompt the user to choose one item; return its value or ``None`` if cancelled.
``prompt`` draws an instruction inside the popup, above the list. Pass
``filterable=False`` for a short, fixed list so a stray key can't narrow (and
resize) it. ``delete_hint`` (with rows marked deletable) enables the Delete
key's remove flow — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
key's remove flow, and ``floating`` keeps a lead-in question drawn as a box even
with nothing under it — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
"""
raise NotImplementedError
@@ -458,6 +460,7 @@ class PlainUi(Ui):
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -712,6 +715,7 @@ class TuiUi(Ui):
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Delegate to the session's select screen."""
return await self.session.select(
@@ -721,6 +725,7 @@ class TuiUi(Ui):
default=default,
filterable=filterable,
delete_hint=delete_hint,
floating=floating,
)
async def select_startup(
+7 -2
View File
@@ -871,6 +871,7 @@ class TuiSession:
filterable: bool = True,
footer_hint: str | None = None,
delete_hint: str = "",
floating: bool = False,
) -> Any:
"""Show a select screen; return the chosen value or ``None`` if cancelled.
@@ -879,7 +880,10 @@ class TuiSession:
want no type-to-filter and a tailored hint. ``delete_hint`` (with rows marked
:attr:`~meshterm.ui.tui.select.Choice.deletable`) surfaces the Delete key's atom
while the highlight sits on such a row; Delete then resolves a
:class:`~meshterm.ui.tui.select.DeleteRequest` the caller unwraps.
:class:`~meshterm.ui.tui.select.DeleteRequest` the caller unwraps. ``floating``
is :meth:`text`'s: the list is a *question* asked on the way into a tool (which
node to administer) rather than the tool's own page, so it must draw as a box
even when it is the only frame on the stack — see :meth:`_floated`.
"""
kwargs: dict[str, Any] = dict(
prompt=prompt,
@@ -889,7 +893,8 @@ class TuiSession:
)
if footer_hint is not None:
kwargs["footer_hint"] = footer_hint
result = await self.run_screen(SelectScreen(title, items, **kwargs))
runner = self._run_dialog_screen if floating else self.run_screen
result = await runner(SelectScreen(title, items, **kwargs))
return None if result is CANCEL else result
async def select_startup(
+24 -24
View File
@@ -387,30 +387,30 @@ def test_picker_rows_hue_each_name_by_its_own_key(tui_ctx) -> None:
assert len(hues) == 2 # two repeaters, two identities — not one violet block
async def test_login_password_floats_over_the_node_picker(tui_ctx) -> None:
"""The admin-login password prompt floats over the picker itself, not a lookalike.
async def test_the_node_picker_is_a_popup_gone_before_the_login(tui_ctx) -> None:
"""The node pick is a floating question, answered and gone; a refused login re-asks it.
Regression: ``_login`` ran on an empty stack (the picker was popped when it returned),
so ``session.text`` pushed a *blank* base and the password box floated over an erased
frame. That was patched by redrawing a second, identical ``SelectScreen`` as a static
backdrop; the picker now simply never leaves the stack, so the base under the prompt is
the list the pick came from — the same object, still highlighted on the row the reader
chose — and Esc out of the prompt is one pop back onto it rather than out of the flow.
The picker used to stay pushed as a hub under the password prompt and the admin page,
so Esc from the page landed on the list it was picked from — two places where there
is one. Now it draws as a box over a blank base (a popup that is the only frame on the
stack would otherwise be painted full-frame), comes down on Enter, the password prompt
floats on its own, and Esc out of the prompt asks the question again with the same node
highlighted rather than dropping the reader on a list that never left.
"""
ctx = tui_ctx
session = ctx.ui.session
pick_title = "Repeater admin — node to manage"
def picker_up() -> SelectScreen | None:
top = session.top
return top if isinstance(top, SelectScreen) and top.title == pick_title else None
task = asyncio.ensure_future(open_repeater_admin(ctx))
try:
picker = await _step_until(
lambda: (
session.top
if isinstance(session.top, SelectScreen) and session.top.title == pick_title
else None
)
)
picker = await _step_until(picker_up)
assert picker is not None, "the node picker never opened"
assert picker.floating and session._has_float(), "drawn as a box…"
assert not session._base_screen().floating, "…over a blank base"
# Walk to the repeater's row and commit it, the way a reader does.
for _ in range(len(picker._items)):
@@ -420,24 +420,24 @@ async def test_login_password_floats_over_the_node_picker(tui_ctx) -> None:
picker.handle("down")
picker.handle("enter")
prompt = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
lambda: session.top if isinstance(session.top, TextScreen) else None
)
assert isinstance(prompt, TextScreen) # the password box floats
assert session._base_screen() is picker # the very list, not a redrawn stand-in
current = picker._current_choice() # still highlighted on the node being logged into
assert current is not None and current.value == "Yagi-Repeater"
assert prompt.floating and session._has_float(), "the password box floats"
assert picker not in session._stack, "the pick is answered, so the popup is gone"
prompt.resolve(CANCEL) # Esc — abandon the login…
again = await _step_until(lambda: session.top if session.top is picker else None)
assert again is picker, "…and land back on the list, one pop, place kept"
picker.handle("escape") # Esc again leaves the flow
again = await _step_until(picker_up)
assert again is not picker, "…and the question is asked again, a fresh popup"
current = again._current_choice()
assert current is not None and current.value == "Yagi-Repeater", "same node highlighted"
again.handle("escape") # Esc on the question leaves the flow
result = await task
finally:
if not task.done():
task.cancel()
assert result is None # no node was ever administered
assert session._stack == []
# --- reading and applying against the simulator ----------------------------------------