diff --git a/meshterm/tools/tx_optimize.py b/meshterm/tools/tx_optimize.py index c13aeba..a6870d9 100644 --- a/meshterm/tools/tx_optimize.py +++ b/meshterm/tools/tx_optimize.py @@ -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.") diff --git a/meshterm/ui/device_picker.py b/meshterm/ui/device_picker.py index 4092418..4e5604a 100644 --- a/meshterm/ui/device_picker.py +++ b/meshterm/ui/device_picker.py @@ -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) diff --git a/meshterm/ui/records_screen.py b/meshterm/ui/records_screen.py index 7e4eb6d..ac8464e 100644 --- a/meshterm/ui/records_screen.py +++ b/meshterm/ui/records_screen.py @@ -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) diff --git a/meshterm/ui/repeater_admin.py b/meshterm/ui/repeater_admin.py index f9e0059..fe30fdc 100644 --- a/meshterm/ui/repeater_admin.py +++ b/meshterm/ui/repeater_admin.py @@ -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 diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 793e621..a67085c 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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]: diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 316ce69..88e131c 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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]: diff --git a/tests/test_records_screen.py b/tests/test_records_screen.py index 935a415..9a0a15f 100644 --- a/tests/test_records_screen.py +++ b/tests/test_records_screen.py @@ -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} diff --git a/tests/test_tui.py b/tests/test_tui.py index d36c296..06f39a0 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 ------------------------------------------------------