The quit confirm keeps the chip that asked: F3 again leaves

On the PicoCalc the main menu's middle chip, Quit?, opens the quit
confirm, and the confirm's own lane was empty, so leaving took a move
to the Quit button and Enter. The confirm now carries Quit! on that same
F3, sending the same quit, which with the question up is the leaving, as
a second ^Q is. A button dialog can take a lane for that one case; every
other prompt keeps its empty one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-02 03:11:20 -04:00
1 parent c988a2374e
commit b7fe257e2a
6 files changed
+91 -4

No files matched your search

+5 -2
View File
@@ -157,7 +157,9 @@ deliberately, one at a time, and say why in the code.
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane
puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking
(`quit_now`, the one door that skips the confirm, and only on the menu).
(`quit_now`, the one door that skips the confirm, and only on the menu). The confirm
keeps the chip that asked: its F3 reads `Quit!` and sends the same `quit`, which with
the question up is the leaving — one key pressed twice, as ^Q is.
`test_navigation` walks every string literal in the package to keep them out of the rest
of the UI.
- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of
@@ -654,7 +656,8 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
rides the Shift half of the very pager heading for it** (F10 Top behind F5 Page ↑, F9
Bottom behind F4 Page ↓), because Home and End *are* on this keyboard and only ever
wanted a chip for consistency. That leaves **F1–F3 free on every screen** for its own
verbs, and `EMPTY_LANE` for a screen with none at all (every dialog). **A chip names an
verbs, and `EMPTY_LANE` for a screen with none at all (every dialog but the quit
confirm, whose F3 is the chip that asked, pressed again). **A chip names an
action, never a key** — `Page ↑`, not `PgUp`; `Latest` on a transcript; `Region` on the
map, whose Home reframes and whose paging zooms. **A directional pair rises toward its
outer key**: where two adjacent chips are opposite ends of one axis, the *up · in ·
+8 -1
View File
@@ -48,7 +48,7 @@ from .tui import (
TuiSession,
)
from .tui.emoji_width import install as install_emoji_widths
from .tui.fkeys import FPair
from .tui.fkeys import EMPTY_LANE, FPair
from .tui.spinner import spinner_interval
from .widgets import battery_cell
@@ -514,6 +514,12 @@ async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
if await _can_unpair(ctx):
buttons.append(("Unpair & quit", "unpair"))
buttons.append(("Quit", "quit"))
# On a handheld the question keeps the chip that asked it: F3, where the menu's
# ``Quit?`` sat, reads ``Quit!`` here and sends the same ``quit`` — which, while this
# confirm is up, leaves at once (see TuiSession.request_quit), as a second ^Q does.
# So the way out from the menu is one key pressed twice (JP, 2026-10-02).
lane = list(EMPTY_LANE)
lane[2] = FPair("Quit!", "quit")
choice = await session.button_dialog(
"Are you sure you want to quit?",
buttons,
@@ -526,6 +532,7 @@ async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
button_style="selected",
button_idle_style="muted",
border_style="warn",
lane=lane,
)
if choice == "unpair":
ctx.unpair_on_exit = True
+15 -1
View File
@@ -8,7 +8,8 @@ prompt shares the framework's look, layering, resize, and Esc-to-cancel behavior
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.console import Group, RenderableType
@@ -19,6 +20,9 @@ from .render import render_lines, right_aligned_tail
from .screen import Screen
from .spinner import Spinner
if TYPE_CHECKING:
from .fkeys import FPair
#: A validator returns ``True`` when the input is acceptable, or an error message to show.
Validator = Callable[[str], "bool | str"]
@@ -506,6 +510,7 @@ class ButtonDialog(_KeylessDialog):
button_style: str = "selected",
button_idle_style: str = "muted",
border_style: str = "accent",
lane: Sequence[FPair | None] | None = None,
) -> None:
"""Build a button dialog.
@@ -525,8 +530,12 @@ class ButtonDialog(_KeylessDialog):
button_style: Rich style for the highlighted button.
button_idle_style: Rich style for the un-highlighted buttons.
border_style: Rich style for the dialog border (read by the frame compositor).
lane: An F-key lane for the one question that has a chip worth offering — the
quit confirm's, whose F3 is the chip that asked it, pressed again (see
``menu._confirm_quit``). ``None`` keeps a prompt's empty lane.
"""
super().__init__()
self._lane = tuple(lane) if lane is not None else None
self.title = title
self.footer_hint = footer_hint
self.border_style = border_style
@@ -550,6 +559,11 @@ class ButtonDialog(_KeylessDialog):
return list(self._prompt.split("\n")) or [Text("")]
return [Text(self._prompt, style=self._prompt_style)]
@property
def fkey_lane(self):
"""The caller's lane where one was given, else a prompt's empty one."""
return self._lane if self._lane is not None else super().fkey_lane
@property
def _button_row_width(self) -> int:
"""Display width of the button row (`` Label `` cells joined by 4-space gaps)."""
+2
View File
@@ -1358,6 +1358,7 @@ class TuiSession:
button_style: str = "selected",
button_idle_style: str = "muted",
border_style: str = "accent",
lane: Sequence[fkeys.FPair | None] | None = None,
) -> Any:
"""Show a centered button dialog; return the chosen value or ``None`` if cancelled.
@@ -1378,6 +1379,7 @@ class TuiSession:
button_style=button_style,
button_idle_style=button_idle_style,
border_style=border_style,
lane=lane,
)
result = await self.run_dialog(screen)
return None if result is CANCEL else result
+23
View File
@@ -123,3 +123,26 @@ def test_no_icon_lane_leaves_the_bare_word_flush_with_the_titles(tools: list) ->
assert items[-1].value == menu._QUIT_VALUE
assert items[-1].title.plain == "Quit"
assert set(starts.values()) == {0}
def test_the_quit_confirm_keeps_the_chip_that_asked(monkeypatch: pytest.MonkeyPatch) -> None:
"""The confirm's F3 reads ``Quit!`` and sends ``quit`` — the menu's own F3, again."""
import asyncio
from meshterm.ui.tui import fkeys
asked: dict = {}
class Session:
async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN202
asked.update(kwargs)
return "cancel"
async def no_bond(ctx) -> bool: # noqa: ANN001
return False
monkeypatch.setattr(menu, "_can_unpair", no_bond)
assert asyncio.run(menu._confirm_quit(SimpleNamespace(), Session())) is False
assert asked["lane"][2].label == "Quit!"
assert fkeys.action_for(asked["lane"], 3) == "quit"
assert [slot for i, slot in enumerate(asked["lane"]) if i != 2] == [None] * 4
+38
View File
@@ -1242,3 +1242,41 @@ async def test_the_busy_dialog_is_one_card_however_deeply_it_nests() -> None:
await asyncio.wait_for(session.run(main()), timeout=5)
assert depths == [2, 2, 2, 1] # pushed once, popped once, hub still underneath
async def test_the_chip_that_asked_to_quit_leaves_when_pressed_again() -> None:
"""On a handheld, F3 asks from the menu and the confirm keeps F3: a second press leaves.
The confirm's lane carries ``Quit!`` on the slot the menu's ``Quit?`` sat on, sending
the same ``quit`` — which, with the question up, is the leaving, as a second ^Q is.
"""
from meshterm.platforms import PICOCALC, set_platform
from meshterm.ui.tui.fkeys import EMPTY_LANE, FPair
set_platform(PICOCALC)
lane = list(EMPTY_LANE)
lane[2] = FPair("Quit!", "quit")
with create_pipe_input() as inp:
session = _session(inp)
async def ask() -> bool:
dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], lane=lane)
return await session.run_dialog(dialog) == "quit"
session.set_quit_confirm(ask)
torn_down: list[str] = []
async def main() -> None:
session.push(ScrollScreen("the menu"))
try:
session._dispatch("quit") # the menu's Quit? chip
for _ in range(5):
await asyncio.sleep(0)
assert isinstance(session.top, ButtonDialog), "the confirm is up"
session._dispatch("f3") # the same chip, on the confirm's lane
await asyncio.sleep(5) # the leaving cancels this
finally:
torn_down.append("services closed")
await asyncio.wait_for(session.run(main()), timeout=5)
assert torn_down == ["services closed"]