Dialogs: float confirms/prompts over their backdrop, never replace the screen

Enforce the UX standard "modal popups over the pushed backdrop" across three
flows that were drawing a full-frame replacement instead of a floating popup:

- Trophy case: keep the record browser pushed as a static backdrop while its
  delete-record / delete-discipline / delete-all confirms float over it, and
  run any trace hand-off after popping.
- Device picker: pass the picker's rows through to the removal confirm so it
  floats over a redraw of the list (backdrop_items/backdrop_default on
  confirm_startup), with the row being removed left highlighted.
- Remote-admin password: add an opt-in floating flag to Ui.text so a mid-flow
  password prompt draws as a centered popup over a blank base instead of the
  full frame; apply it to repeater_admin and tx_optimize logins. Tool-entry
  text prompts (Trace/tx target fallbacks) stay full-frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-16 20:07:41 -04:00
1 parent 115fe6895d
commit 59b26ae616
8 files changed
+395 -76

No files matched your search

+1 -1
View File
@@ -309,7 +309,7 @@ class TxOptimizeTool(Tool):
"interactively to store it."
)
entered = await ctx.ui.text(
f"Admin password for {admin_node.name}:", password=True
f"Admin password for {admin_node.name}:", password=True, floating=True
)
if not entered:
raise DeviceCommandError("an admin password is required to tune a remote node.")
+20 -6
View File
@@ -296,9 +296,12 @@ async def prompt_device(
# Preselect the remembered "last known good" device when it is currently attached/in range.
default = next((d for d in listed if remembered and remembered.matches(d)), None)
# Build the rows once so the same list can be redrawn as the backdrop behind a
# removal confirm (so it floats over the picker rather than replacing it).
items = _build_items(listed, remembered, registry)
chosen = await ui.select_startup(
"Select a companion device",
_build_items(listed, remembered, registry),
items,
default=default,
banner=load_logo(),
footnote=copyright_notice(),
@@ -318,8 +321,11 @@ async def prompt_device(
if isinstance(chosen, DeleteRequest):
# Delete was pressed on a removable (network) row: confirm, forget, and re-draw
# the list — the row's disappearance is the visible feedback.
await _remove_network_device(ui, store, registry, chosen.value)
# the list — the row's disappearance is the visible feedback. The list is passed
# through so the confirm floats over it (the row it removes stays highlighted).
await _remove_network_device(
ui, store, registry, chosen.value, backdrop_items=items
)
continue
name = _display_name(chosen, registry)
@@ -426,20 +432,26 @@ async def _remove_network_device(
store: DeviceStore,
registry: dict[str, RememberedDevice],
device: DiscoveredDevice,
*,
backdrop_items: list,
) -> None:
"""Confirm and forget a remembered network (TCP) device, dropping it from the picker.
Removal is offered only on network rows: a TCP companion is listed solely from its
remembered endpoint, so forgetting it is what makes it leave the picker — a scanned serial
or BLE device would just reappear on the next scan. Opens a cautionary Cancel/Remove
confirm on the splash; on Remove the record is pruned from the store, on Cancel/Esc nothing
changes. Either way the caller re-opens the list, so the row's absence is the feedback.
or BLE device would just reappear on the next scan. Opens the reserved-red Cancel/Remove
confirm as a modal popup floating over the device list (``backdrop_items``, with the row
being removed left highlighted), so it reads as a dialog on top of the picker rather than a
splash that replaces it. On Remove the record is pruned from the store, on Cancel/Esc
nothing changes. Either way the caller re-opens the list, so the row's absence is the
feedback.
Args:
ui: The interactive surface for the confirm dialog.
store: The confirmed-device registry to prune.
registry: The current registry, for naming the device in the prompt.
device: The network device the user asked to remove.
backdrop_items: The picker's rows, redrawn behind the floating confirm.
"""
if not device.is_tcp:
return # defensive: only network rows opt into deletion (see _build_items)
@@ -450,6 +462,8 @@ async def _remove_network_device(
confirm_label="Remove",
banner=load_logo(),
footnote=copyright_notice(),
backdrop_items=backdrop_items,
backdrop_default=device,
)
if confirmed:
store.forget(device.stable_id)
+54 -43
View File
@@ -671,49 +671,60 @@ async def open_records(ctx: "AppContext") -> dict:
value=("del_all", None, 0, None),
))
items.extend(back_rows(("back", None, 0, None)))
picked = await session.run_screen(
SelectScreen(
"Trophy case",
items,
footer_hint="↑↓ move · Enter open · Esc back",
wrap=False,
)
browser = SelectScreen(
"Trophy case",
items,
footer_hint="↑↓ move · Enter open · Esc back",
wrap=False,
)
picked = await session.run_screen(browser)
if picked is CANCEL or picked is None or picked[0] == "back":
return {"records": total}
verb = picked[0]
if verb == "del_cat":
await delete_category_flow()
continue
if verb == "del_all":
if not total:
continue
if await session.typed_confirm(
f"This deletes all {total} records — every discipline, every width. "
"They can only be re-earned by walking them again.",
"delete",
title="Delete all records",
):
ctx.repo.delete_discoveries()
continue
_verb, category, rank, record = picked
far_label, far_id, shape = walk_drawing(record)
action = await session.run_screen(RecordDialog(
record, category, rank,
resolve=resolve, device_label=device_label, device_hash=device_hash,
far_label=far_label, shape=shape,
reliability=walk_reliability(far_id, far_label),
type_of=lambda node_id: node_geo(node_id)[1],
))
if action == "trace":
await open_trace_path(ctx, spec=record.spec)
elif action == "delete":
sure = await ctx.ui.dialog(
f"Delete this {category.title} record?",
[("Cancel", False), ("Delete", True)],
title="Delete record",
default=1,
destructive=True,
)
if sure:
ctx.repo.delete_discovery(record.id)
# Every pick opens a dialog that belongs *over* the trophy case — the discipline
# picker, the delete confirms, a record's floating story. run_screen just popped the
# browser, so re-push it as the static backdrop: it stays drawn full-frame behind the
# dialog (which, run over the bare stack, would otherwise draw over a blank frame — or,
# alone and floating, become the frame). This is the device picker's trick
# (confirm_startup) and what RecordDialog's "floating over the screen beneath" assumes.
session.push(browser)
trace_spec: Optional[str] = None
try:
verb = picked[0]
if verb == "del_cat":
await delete_category_flow()
elif verb == "del_all":
if total and await session.typed_confirm(
f"This deletes all {total} records — every discipline, every width. "
"They can only be re-earned by walking them again.",
"delete",
title="Delete all records",
):
ctx.repo.delete_discoveries()
else:
_verb, category, rank, record = picked
far_label, far_id, shape = walk_drawing(record)
action = await session.run_screen(RecordDialog(
record, category, rank,
resolve=resolve, device_label=device_label, device_hash=device_hash,
far_label=far_label, shape=shape,
reliability=walk_reliability(far_id, far_label),
type_of=lambda node_id: node_geo(node_id)[1],
))
if action == "trace":
trace_spec = record.spec
elif action == "delete":
sure = await ctx.ui.dialog(
f"Delete this {category.title} record?",
[("Cancel", False), ("Delete", True)],
title="Delete record",
default=1,
destructive=True,
)
if sure:
ctx.repo.delete_discovery(record.id)
finally:
session.pop(browser)
# Tracing a path is a full hand-off to the Trace screen, not a dialog over the
# trophy case, so it runs only once the browser backdrop is down.
if trace_spec is not None:
await open_trace_path(ctx, spec=trace_spec)
+1
View File
@@ -116,6 +116,7 @@ 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,
floating=True, # a modal step between the node picker and the admin menu
)
if not password:
return False
+23 -2
View File
@@ -144,8 +144,14 @@ class Ui:
confirm_label: str = "Remove",
banner: Any = None,
footnote: Optional[str] = None,
backdrop_items: Optional[list] = None,
backdrop_default: Any = None,
) -> bool:
"""Confirm a destructive action on the startup splash; ``True`` only if committed."""
"""Confirm a destructive action on the startup splash; ``True`` only if committed.
``backdrop_items`` (the picker's rows) floats the confirm over a redrawn device list;
``backdrop_default`` pre-highlights the row it acts on.
"""
raise NotImplementedError
async def notify_startup(
@@ -210,11 +216,15 @@ class Ui:
validate: Optional[Validator] = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> Optional[str]:
"""Prompt for a line of text; return it or ``None`` if cancelled.
Keep ``title`` short (it heads the popup's border) and put the question/instruction
in ``prompt`` (drawn above the field), so a text popup reads like the button dialogs.
``floating`` forces the prompt to draw as a centered popup even with nothing beneath
it (a mid-flow modal such as a remote-admin password) instead of filling the frame;
surfaces without a screen stack ignore it.
"""
raise NotImplementedError
@@ -408,6 +418,8 @@ class PlainUi(Ui):
confirm_label: str = "Remove",
banner: Any = None,
footnote: Optional[str] = None,
backdrop_items: Optional[list] = None,
backdrop_default: Any = None,
) -> bool:
"""Unsupported in scripted CLI mode — the picker splash is interactive-only."""
raise self._no_prompt()
@@ -478,12 +490,15 @@ class PlainUi(Ui):
validate: Optional[Validator] = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> Optional[str]:
"""Prompt on the terminal (line editor / getpass), re-asking until valid.
A few tools (e.g. a remote-admin password) can legitimately prompt from a scripted
run when no flag was supplied, so this stays functional on the CLI. An in-body
``prompt`` (used by the interactive popups) is printed once as a lead-in line here.
``floating`` is a full-screen-popup nicety with no meaning on the plain terminal, so
it is accepted and ignored.
Returns:
The entered string, or ``None`` on EOF / interrupt.
@@ -652,14 +667,18 @@ class TuiUi(Ui):
confirm_label: str = "Remove",
banner: Any = None,
footnote: Optional[str] = None,
backdrop_items: Optional[list] = None,
backdrop_default: Any = None,
) -> bool:
"""Delegate to the session's chromeless startup confirm dialog."""
"""Delegate to the session's startup confirm dialog (floated over the picker)."""
return await self.session.confirm_startup(
prompt,
title=title,
confirm_label=confirm_label,
banner=banner,
footnote=footnote,
backdrop_items=backdrop_items,
backdrop_default=backdrop_default,
)
async def notify_startup(
@@ -738,6 +757,7 @@ class TuiUi(Ui):
validate: Optional[Validator] = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> Optional[str]:
"""Delegate to the session's text screen."""
return await self.session.text(
@@ -747,6 +767,7 @@ class TuiUi(Ui):
validate=validate,
help_text=help_text,
password=password,
floating=floating,
)
async def confirm(self, title: str, *, default: bool = True) -> Optional[bool]:
+63 -20
View File
@@ -368,16 +368,26 @@ class TuiSession:
confirm_label: str = "Remove",
banner: Optional[Any] = None,
footnote: Optional[str] = None,
backdrop_items: Optional[list] = None,
backdrop_default: Any = None,
backdrop_title: str = "Select a companion device",
footer_hint: str = "←→ choose · Enter select · Esc cancel",
) -> bool:
"""Confirm a destructive splash action with a Cancel/verb dialog (chromeless).
The startup-splash sibling of :meth:`button_dialog`: the same platform-dialog layout
— the safe *Cancel* on the left and the committing verb on the right and default, so
Enter commits and Esc backs out — but drawn without the status bars and centered under
``banner``, so it reads as part of the device-selection flow it floats over. Themed
as data loss (the reserved red prompt and border), since it only ever gates forgetting
a remembered device — the splash sibling of a ``destructive`` :meth:`button_dialog`.
Enter commits and Esc backs out. Themed as data loss (the reserved red prompt and
border), since it only ever gates forgetting a remembered device — the splash sibling
of a ``destructive`` :meth:`button_dialog`.
When ``backdrop_items`` is given, the device list they describe is redrawn as the
chromeless base and the red confirm *floats over it* as a centred box (a modal popup
over the pushed backdrop, the way every other dialog behaves) — so removing a device
reads as a popup on top of the picker rather than a splash that replaces it. The
``backdrop_default`` row is pre-highlighted so the confirm reads as being about it.
Without ``backdrop_items`` the confirm draws as its own chromeless splash under
``banner`` (the fallback for a caller with no list to float over).
Args:
prompt: The question shown above the buttons.
@@ -385,12 +395,16 @@ class TuiSession:
confirm_label: Label for the committing button (e.g. ``"Remove"``).
banner: Wordmark rows drawn above the box (as on the other startup splashes).
footnote: Muted line drawn below the box.
backdrop_items: The picker's rows to redraw behind the confirm; ``None`` falls
back to a standalone chromeless confirm splash.
backdrop_default: The row value to pre-highlight in the backdrop list.
backdrop_title: Heading for the backdrop list (the picker's own title).
footer_hint: Footer key hint.
Returns:
``True`` only when the user chose the committing button; ``False`` on Cancel/Esc.
"""
screen = ButtonDialog(
dialog = ButtonDialog(
prompt,
[("Cancel", False), (confirm_label, True)],
title=title,
@@ -399,11 +413,31 @@ class TuiSession:
prompt_style="err",
border_style="err",
)
screen.chrome = False
screen.banner = banner
screen.footnote = footnote
result = await self.run_screen(screen)
return result is True
if backdrop_items is None:
# No list to float over: draw the confirm as its own chromeless splash.
dialog.chrome = False
dialog.banner = banner
dialog.footnote = footnote
return await self.run_screen(dialog) is True
# Keep the picker on screen as the chromeless base and float the red confirm over it,
# so the removal confirm sits *on top of* the device list it acts on. The backdrop is
# a static redraw of the same rows (it never takes a key — the dialog above owns input).
backdrop = SelectScreen(
backdrop_title,
backdrop_items,
default=backdrop_default,
footer_hint="↑↓ move · Enter select · Esc quit",
delete_hint="Del remove",
filterable=False,
)
backdrop.chrome = False
backdrop.banner = banner
backdrop.footnote = footnote
self.push(backdrop)
try:
return await self.run_screen(dialog) is True
finally:
self.pop(backdrop)
async def notify_startup(
self,
@@ -567,18 +601,27 @@ class TuiSession:
validate: Optional[Validator] = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> Optional[str]:
"""Show a text prompt; return the string or ``None`` if cancelled."""
result = await self.run_screen(
TextScreen(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
password=password,
)
"""Show a text prompt; return the string or ``None`` if cancelled.
``floating`` guarantees the prompt draws as a centered popup even on an empty stack
— a modal step mid-flow (a remote-admin password) rather than a tool's full-frame
entry screen. It then floats over a blank base the way :meth:`button_dialog` and
:meth:`typed_confirm` do (see :meth:`_run_dialog_screen`); with a screen already
beneath it there is no difference, so leave it ``False`` for a prompt that is itself
a tool's primary screen (the Trace target's typed fallback).
"""
screen = TextScreen(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
password=password,
)
runner = self._run_dialog_screen if floating else self.run_screen
result = await runner(screen)
return None if result is CANCEL else result
async def confirm(self, title: str, *, default: bool = True) -> Optional[bool]:
+133 -2
View File
@@ -7,12 +7,27 @@ are all assertable without a terminal.
from __future__ import annotations
import asyncio
import io
import re
from datetime import datetime, timezone
from pathlib import Path
from meshterm.persistence.repository import DiscoveredPath
import pytest
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.persistence.repository import DiscoveredPath, Repository
from meshterm.services.records import CATEGORY_BY_ID
from meshterm.ui.records_screen import RecordDialog, WalkVertex
from meshterm.ui.records_screen import RecordDialog, WalkVertex, open_records
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.prompt import TypedConfirmDialog
from meshterm.ui.tui.screen import CANCEL
from meshterm.ui.tui.select import SelectScreen
from meshterm.ui.tui.session import TuiSession
from meshterm.ui.widgets import node_marker, self_marker
HUB_ID, FAR_ID = "3d63c6429436", "f2c24f54551e"
@@ -197,3 +212,119 @@ def test_record_dialog_arrows_follow_the_action_cursor() -> None:
assert dialog._follow
dialog.render_body(60) # the frame renders before reading the cursor
assert dialog.cursor_line() is not None
# --- the browser loop: dialogs float over the trophy case, never a blank frame ------
@pytest.fixture()
def tui_ctx(tmp_path: Path) -> AppContext:
"""A mock-backed context wired to a headless TUI session (no prompt_toolkit app)."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "records.db")
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
ctx.ui = TuiUi(TuiSession())
yield ctx
ctx.repo.close()
async def _step_until(predicate, *, limit: int = 200):
"""Yield to the event loop until ``predicate()`` is truthy; return it (or its last value)."""
value = predicate()
for _ in range(limit):
if value:
return value
await asyncio.sleep(0)
value = predicate()
return value
def _trophy_case(session, *, other_than=None):
"""The trophy-case browser on top of the stack, if it is the frontmost screen."""
top = session.top
if isinstance(top, SelectScreen) and top.title == "Trophy case" and top is not other_than:
return top
return None
async def test_delete_all_confirm_floats_over_the_browser(tui_ctx) -> None:
"""The trophy case stays drawn full-frame behind the "Delete all records" confirm.
Regression: the browser was run *then popped* before the confirm opened, so the confirm
floated over a blank frame — the background "cleared". Re-pushing the browser as the
backdrop keeps it the full-frame base while the red typed-delete gate floats over it.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour", 1, "3d,f2", (HUB_ID, FAR_ID),
score=2.0, stats={"hop_count": 2, "distinct_nodes": 2}, app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None, "the trophy case list never opened"
browser.resolve(("del_all", None, 0, None)) # choose "Delete all records"
confirm = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
)
assert isinstance(confirm, TypedConfirmDialog) # the red typed-delete gate
assert session._base_screen() is browser # the browser is still the full-frame base
confirm.resolve(CANCEL) # Esc — back out without deleting
again = await _step_until(lambda: _trophy_case(session, other_than=browser))
assert again is not None, "the browser never reopened after the confirm"
again.resolve(("back", None, 0, None)) # leave the trophy case
result = await task
finally:
if not task.done():
task.cancel()
assert result == {"records": 1} # backed out — the record still stands
async def test_delete_a_disciplines_records_is_a_popup_over_the_browser(tui_ctx) -> None:
"""Picking a discipline to delete floats a popup over the browser, not a full-screen list.
The discipline picker is a floating :class:`SelectScreen`; with the browser kept on the
stack it draws as a centered popup over the visible trophy case (``_base_screen`` stays
the browser) instead of replacing the whole frame.
"""
ctx = tui_ctx
session = ctx.ui.session
ctx.repo.record_discovery(
"grand_tour", 1, "3d,f2", (HUB_ID, FAR_ID),
score=2.0, stats={"hop_count": 2, "distinct_nodes": 2}, app_version="0.1.0",
)
task = asyncio.ensure_future(open_records(ctx))
try:
browser = await _step_until(lambda: _trophy_case(session))
assert browser is not None
browser.resolve(("del_cat", None, 0, None)) # "Delete a discipline's records…"
picker = await _step_until(
lambda: session._float_layers()[0] if session._has_float() else None
)
assert isinstance(picker, SelectScreen) # a floating popup, not the frame
assert picker.title == "Delete a discipline's records"
assert session._base_screen() is browser # the browser stays behind it
picker.resolve(CANCEL) # Esc — abandon the pick
again = await _step_until(lambda: _trophy_case(session, other_than=browser))
assert again is not None
again.resolve(("back", None, 0, None))
result = await task
finally:
if not task.done():
task.cancel()
assert result == {"records": 1}
+100 -2
View File
@@ -965,8 +965,13 @@ def test_device_picker_removes_network_device_on_delete(tmp_path) -> None:
return _QUIT
async def confirm_startup(self, prompt, *, title="", confirm_label="Remove",
banner=None, footnote=None):
banner=None, footnote=None, backdrop_items=None,
backdrop_default=None):
confirmed_prompts.append(prompt)
# The confirm floats over the picker: it's handed the rows to redraw behind it,
# with the row being removed pre-highlighted.
assert backdrop_items is not None
assert getattr(backdrop_default, "is_tcp", False)
return True # the user confirms the removal
async def _never(_device, _pin=None):
@@ -1006,7 +1011,8 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
return _QUIT
async def confirm_startup(self, prompt, *, title="", confirm_label="Remove",
banner=None, footnote=None):
banner=None, footnote=None, backdrop_items=None,
backdrop_default=None):
return False # the user backs out (Cancel / Esc)
async def _never(_device, _pin=None):
@@ -1018,6 +1024,46 @@ def test_device_picker_keeps_network_device_when_removal_cancelled(tmp_path) ->
assert remembered is not None and remembered.node_name == "WifiNode"
def test_confirm_startup_floats_red_over_the_picker_backdrop() -> None:
"""The removal confirm floats as a red popup over a redrawn picker, not a full splash."""
from meshterm.core.discovery import tcp_device
session = TuiSession()
device = tcp_device("192.168.1.50", 5000, name="WifiNode")
items = [Choice("WifiNode (192.168.1.50:5000)", device, deletable=True)]
async def main() -> None:
task = asyncio.ensure_future(
session.confirm_startup(
"Remove WifiNode?",
title="Remove network device",
banner=["MESHTERM"],
backdrop_items=items,
backdrop_default=device,
)
)
# Let confirm_startup push the backdrop list and float the confirm over it.
for _ in range(3):
await asyncio.sleep(0)
base = session._base_screen()
floats = session._float_layers()
# The picker is redrawn as the chromeless base; the confirm floats over it (not a
# full-screen splash that replaces the list).
assert isinstance(base, SelectScreen) and base.chrome is False
assert len(floats) == 1
dialog = floats[0]
assert isinstance(dialog, ButtonDialog)
assert dialog.border_style == "err" # the reserved data-loss red
dialog.resolve(True) # commit the removal
result = await task
assert result is True
assert session._stack == [] # the backdrop is torn down with the dialog
asyncio.run(main())
class _PickerUi:
"""A fake splash UI that always selects the first device, then dismisses messages."""
@@ -1413,6 +1459,58 @@ def test_session_background_is_the_topmost_full_frame_screen() -> None:
assert session._float_layers() == [dialog]
def test_floating_text_prompt_is_a_popup_over_a_blank_base() -> None:
"""``text(floating=True)`` floats as a centered popup even on an empty stack.
A mid-flow modal — a remote-admin password, between the node picker and the admin menu —
must float like the button dialogs, so a blank base is slipped beneath it rather than
letting the prompt fill the frame the way a tool's primary entry screen does.
"""
session = TuiSession()
async def main() -> None:
task = asyncio.ensure_future(
session.text("Password", password=True, floating=True)
)
for _ in range(5):
await asyncio.sleep(0)
if session._has_float():
break
floats = session._float_layers()
assert len(floats) == 1 and isinstance(floats[0], TextScreen)
assert session._base_screen() is not floats[0] # a blank base sits beneath it
floats[0].resolve("hunter2")
assert await task == "hunter2"
assert session._stack == [] # the blank base is torn down with the prompt
asyncio.run(main())
def test_default_text_prompt_is_the_full_frame_base() -> None:
"""A default ``text`` prompt on an empty stack *is* the frame — a tool's primary entry.
The Trace target's typed fallback stands in for the select picker, so it fills the frame
rather than floating over a blank base (the floating popup is opt-in, see above).
"""
session = TuiSession()
async def main() -> None:
task = asyncio.ensure_future(session.text("Target node"))
for _ in range(5):
await asyncio.sleep(0)
if session.top is not None:
break
assert isinstance(session.top, TextScreen)
assert not session._has_float() # no float — the prompt is the background
assert session._base_screen() is session.top
session.top.resolve("YUL")
assert await task == "YUL"
asyncio.run(main())
# --- busy skeleton card ------------------------------------------------------