^Q asks before it quits, and Esc at the main menu stays put

Esc-mashing back to the menu used to land on the quit confirm, and ^Q, one
key from ^W, quit with no question at all. Now Esc at the main menu only
clears a typed filter, and ^Q floats the same quit confirm the Quit row
opens, from anywhere and without stopping what runs underneath; a second
^Q while it asks leaves at once. The main menu's hint names ^Q quit, the
only place the chord is shown. Option D from issue #22.

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-09-29 01:31:03 -04:00
1 parent 0e9f41fb2a
commit 7071214660
6 files changed
+305 -81

No files matched your search

+14 -6
View File
@@ -148,11 +148,18 @@ deliberately, one at a time, and say why in the code.
for as long as it is true. Chat is the same shape with a different peelable thing — its
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
outcomes behind the same keystroke on the same affordance.
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
screen to belong to, and the F-key lane has only three free slots per screen. They are
stated once, on the About page. `test_navigation` walks every string literal in the
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
floats the same confirm the menu's Quit row opens, detached over whatever is up so
nothing under it stops, and a second ^Q while it asks leaves at once
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
belong to, and the F-key lane has only three free slots per screen — except `^Q quit`
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
`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
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
behind the confirm. ^W arms **every frame on the stack**, not just the
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
navigation frame awaits, so an unwind sent only to the top died there while the hub
@@ -265,7 +272,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit` instead ·
`Esc bye` only on the device
splash, which is the door rather than a screen — nothing has been started there to
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
because that is what the press does then.
+88 -49
View File
@@ -39,7 +39,6 @@ from .menus import (
from .surface import TuiUi
from .theme import make_console
from .tui import (
CANCEL,
Choice,
PopToMenu,
ReconnectDialog,
@@ -398,6 +397,8 @@ async def run_menu(ctx: AppContext) -> None:
header_cache: dict = {}
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
ctx.ui = TuiUi(session)
# The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere.
session.set_quit_confirm(lambda: _confirm_quit(ctx, session))
async def main() -> None:
try:
@@ -482,11 +483,81 @@ async def _unpair_on_exit(ctx: AppContext) -> None:
#: can show, the same as any tool's.
_QUIT_ICON = "🚪"
#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit
#: confirm (see :func:`_menu_round`).
#: The value the Quit row resolves with, which opens the quit confirm (see
#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`).
_QUIT_VALUE = "__quit__"
async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
"""Float the quit confirm over whatever is up; return whether to leave.
One dialog for both doors — the menu's Quit row and the quit chord, which asks from
anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc)
sits left of Quit (Enter), and Quit starts highlighted so Enter commits it.
It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth
bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't
unpair. That button sits between Cancel and Quit and is never the default, so it takes a
deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see
:func:`run_menu`), because the link has to be disconnected first; the remembered-device
record is intentionally kept, so the device just asks for its PIN again.
Args:
ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*).
session: The running TUI session.
Returns:
``True`` if the reader chose Quit or Unpair & quit.
"""
buttons = [("Cancel", "cancel")]
if await _can_unpair(ctx):
buttons.append(("Unpair & quit", "unpair"))
buttons.append(("Quit", "quit"))
choice = await session.button_dialog(
"Are you sure you want to quit?",
buttons,
title="Quit MeshTerm",
default=len(buttons) - 1, # highlight Quit
# The hint stays the dialog default: Enter commits *the highlighted button*,
# which is Quit only until ←→ moves it — and with a third button in the row,
# naming the key that moves it is the point.
prompt_style="warn",
button_style="selected",
button_idle_style="muted",
border_style="warn",
)
if choice == "unpair":
ctx.unpair_on_exit = True
return choice in ("unpair", "quit")
class _MainMenu(SelectScreen):
"""The main menu's list, on which Esc peels a typed filter and otherwise does nothing.
Pressing Esc over and over is the natural climb back to the menu, and when Esc at the
top opened the quit confirm, the presses that landed after the climb opened it too — the
reader asked for the menu and got a question about leaving (issue #22). The menu is the
bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made
on the Quit row, or with the quit chord, both of which ask first.
The footer therefore ends on the quit chord rather than on Esc, until a filter is
standing and ``Esc clear`` joins it last, because that is what the press does then —
the same atom every filtering list shows.
"""
def handle(self, action: str, data: str = "") -> None:
"""Answer every key as a select list does, except a bare Esc, which is inert."""
if action == "escape" and not self._filter:
return
super().handle(action, data)
@property
def footer_hint(self) -> str:
"""The list's own hint, gaining ``Esc clear`` only while a filter is standing."""
base = super().footer_hint
return f"{base} · Esc clear" if self._filter else base
def _menu_lane(tools: Sequence[Any]) -> int:
"""The main menu's icon column in cells: every tool's mark *and* the Quit row's.
@@ -614,18 +685,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
# Drive the menu list ourselves (rather than via session.select) so it stays on the
# stack while the quit dialog floats over it: the confirm is drawn as a centered box
# on top of the still-visible menu, not as a screen that replaces it.
menu = SelectScreen(
menu = _MainMenu(
"What would you like to do?",
items,
footer_hint="↑↓ move · type to filter · Enter select · Esc quit",
# The one place the quit chord is named: the menu is where a reader looks for the
# way out, and with Esc inert here the footer would otherwise name none. Everywhere
# else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS).
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit",
)
session.set_root(menu)
loop = asyncio.get_running_loop()
while True:
try:
await _menu_round(ctx, session, menu, tools, loop)
except _Quit as chosen:
ctx.unpair_on_exit = chosen.unpair
except _Quit:
return
except PopToMenu:
# ^W from somewhere deep. Every frame between there and here has already
@@ -635,18 +708,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
class _Quit(Exception):
"""The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too.
"""The user confirmed the quit dialog from the menu's Quit row.
A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled
as a ``return`` — the loop would just show the menu again. It is raised instead, and
caught in the one frame that owns the decision to leave.
caught in the one frame that owns the decision to leave. Whether to drop the OS bond too
is already recorded on the context by the confirm (see :func:`_confirm_quit`).
"""
def __init__(self, *, unpair: bool) -> None:
"""Record whether the OS pairing should be dropped on the way out."""
super().__init__("quit")
self.unpair = unpair
async def _menu_round(
ctx: AppContext,
@@ -677,41 +746,11 @@ async def _menu_round(
ran_over_menu = False
try:
selection = await menu.future
if selection is CANCEL: # Esc at the top level
selection = None
# Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating
# over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel
# (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it.
if selection in (None, _QUIT_VALUE):
# Offer to drop the OS pairing on the way out, but only when there is a live
# Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a
# platform we can't unpair. The extra button sits between Cancel and Quit and is
# never the default, so it takes a deliberate choice, not a stray Enter.
unpairable = await _can_unpair(ctx)
buttons = [("Cancel", "cancel")]
if unpairable:
buttons.append(("Unpair & quit", "unpair"))
buttons.append(("Quit", "quit"))
choice = await session.button_dialog(
"Are you sure you want to quit?",
buttons,
title="Quit MeshTerm",
default=len(buttons) - 1, # highlight Quit
# The hint stays the dialog default: Enter commits *the highlighted
# button*, which is Quit only until ←→ moves it — and with a third
# button in the row, naming the key that moves it is the point.
prompt_style="warn",
button_style="selected",
button_idle_style="muted",
border_style="warn",
)
# "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen
# first, so the actual unpair is deferred to teardown (see run_menu). The
# remembered-device record is intentionally kept — the device just asks for its
# PIN again. Cancel (button or Esc → None) ends the round and redraws the menu,
# whose cursor never moved off the row they came from.
if choice in ("unpair", "quit"):
raise _Quit(unpair=choice == "unpair")
# The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel
# ends the round and redraws the menu, whose cursor never moved off the row.
if selection == _QUIT_VALUE:
if await session.confirm_quit():
raise _Quit()
return
# A popup tool runs while the menu is still pushed, so its prompts and
# result float over it as modal dialogs instead of replacing the screen.
+6 -5
View File
@@ -212,7 +212,7 @@ _BASE_BOX_CACHE: tuple[tuple, list[str]] | None = None
#: The screen's own way out, lifted off the end of its footer hint. The grammar is
#: mandated — "navigation keys, then action keys, **Esc last**" — so the last atom is the
#: Esc clause wherever a screen has one, and its verb is the true one for that surface
#: (``back`` on a screen, ``quit`` at the main menu, ``keep`` in a value picker).
#: (``back`` on a screen, ``keep`` in a value picker; the main menu names none).
_ESC_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+)\s*$")
#: Rule cells the title keeps on each side before the bar gives the Esc hint back. Below
@@ -274,10 +274,11 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) ->
its five slots do — so nothing on the screen said that Esc leaves, which is the one key
every screen answers to and the reason no screen spends a row on a *Back* item. The
verb is the screen's own (:func:`_esc_hint` lifts the atom off its footer hint, so a
value picker says ``keep`` and the main menu says ``quit``); a screen whose hint names
no Esc gets nothing. Compact by construction: where a long title would be crowded, the
atom drops to a bare ``Esc`` and then out altogether — the title is what the reader
came for, and the arrows are two cells nothing else can spend.
value picker says ``keep``); a screen whose hint names no Esc gets nothing — the
main menu, where Esc is inert until a filter is typed. Compact by construction: where
a long title would be crowded, the atom drops to a bare ``Esc`` and then out
altogether — the title is what the reader came for, and the arrows are two cells
nothing else can spend.
"""
border = "accent"
head_span = 3 # the arrow pair, plus the space parting it from the rule
+85 -19
View File
@@ -68,10 +68,12 @@ from .spinner import spinner_interval
#: walk, because what it names on both is our own node.
#:
#: Two of these are app-wide rather than a screen's: ``to_menu`` (^W) unwinds the whole
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) leaves
#: the app from anywhere. Neither is advertised in a footer hint or an F-key chip — the lane
#: has three free slots per screen and a global verb would claim one on every screen forever,
#: so both are stated once on the About page's key list instead (JP, 2026-08-30). ^W was
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) asks
#: to leave the app from anywhere — a second press while it asks is the leaving (see
#: :meth:`TuiSession.request_quit`). Neither is advertised in a footer hint or an F-key
#: chip — the lane has three free slots per screen and a global verb would claim one on
#: every screen forever (JP, 2026-08-30) — save ``^Q quit`` on the main menu's own hint,
#: where Esc is inert and the reader looks for the way out (issue #22). ^W was
#: picked for the close-this-whole-thing reflex; both were verified deliverable on the
#: Canadian Multilingual layout (^W ``U+0017``, ^Q ``U+0011``) and neither is eaten by the
#: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here.
@@ -515,6 +517,11 @@ class TuiSession:
# the top rather than pointlessly rebuilding it — and what a popup floats over when
# nothing else is pushed (see :meth:`_floated`). ``None`` until the menu declares it.
self._root: Screen | None = None
# The quit confirm ^Q raises from anywhere (see :meth:`request_quit`), declared by
# the app with :meth:`set_quit_confirm`; and whether it is up right now, which is
# what turns the next ^Q into the leaving itself.
self._quit_confirm: Callable[[], Awaitable[bool]] | None = None
self._quit_asking = False
# --- stack ---------------------------------------------------------------
@@ -596,6 +603,74 @@ class TuiSession:
"""The declared navigation root (the main menu), or ``None`` before it is declared."""
return self._root
def set_quit_confirm(self, ask: Callable[[], Awaitable[bool]] | None) -> None:
"""Declare the question the quit chord asks before it leaves.
The app declares it once, because only the app knows what the question offers —
the main menu's confirm adds *Unpair & quit* over a Bluetooth bond, which needs the
application context the session never holds. ``ask`` floats its dialog and returns
whether to leave; it owns any side effect of the answer (recording an unpair).
Args:
ask: The confirm, or ``None`` to have the quit chord leave without asking.
"""
self._quit_confirm = ask
async def confirm_quit(self) -> bool:
"""Ask the declared quit confirm, as the main menu's Quit row does.
The same question ^Q asks, and the same dialog: while it is up the chord's next
press leaves outright (see :meth:`request_quit`), whichever door opened it.
Returns:
Whether the reader chose to leave — ``True`` with no confirm declared.
"""
self._quit_asking = True
return await self._asking_quit()
async def _asking_quit(self) -> bool:
"""Run the declared confirm with :attr:`_quit_asking` already raised, then lower it."""
try:
return self._quit_confirm is None or bool(await self._quit_confirm())
finally:
self._quit_asking = False
def request_quit(self) -> None:
"""Answer the quit chord (^Q, and ^C beside it): ask first, leave on the second press.
^Q sits one key from ^W, the chord that climbs back to the menu, so a slip on the way
to the menu used to close the app — and a running capture or a courier queue with
it. It now floats the quit confirm over whatever is up, from anywhere (issue #22,
option D). The confirm is a detached flow (:meth:`run_detached`): nothing under it
is interrupted, a capture keeps capturing behind the box, and Esc puts the reader
back exactly where they were.
While the confirm is up, the chord leaves at once, so the fastest way out is still
two keys and still works when a screen is wedged or a device read hangs — the
second press never waits on the dialog to paint or take a key. The flag is raised
here, synchronously, so two presses arriving in one input batch are still a quit
rather than two dialogs.
Leaving is clean either way: :meth:`run` cancels the coroutine driving the app and
waits for its unwind, so the history and chat runs close, the services stop, and the
exit watchdog is armed, the same as quitting from the menu.
"""
if self._quit_asking or self._quit_confirm is None:
self._exit_app()
return
self._quit_asking = True
async def ask() -> None:
if await self._asking_quit():
self._exit_app()
self.run_detached(ask())
def _exit_app(self) -> None:
"""Exit the running application, from under whatever flow is driving it."""
if self._app is not None and self._app.is_running:
self._app.exit()
def request_pop_all(self) -> bool:
"""Arm the unwind to the navigation root (the ^W key). Returns whether it fired.
@@ -1603,7 +1678,8 @@ class TuiSession:
# touches the screen. Errors from ``main`` still propagate via ``driver``/``box``.
await self._app.run_async(pre_run=pre_run, set_exception_handler=False)
# The app can also exit from *under* the driver: the quit chords (^Q/^C) call
# ``Application.exit`` straight from the key handler, so ``run_async`` returns while
# ``Application.exit`` from their detached confirm, or straight from the key handler
# on the second press (see request_quit), so ``run_async`` returns while
# ``main`` is still parked on whatever screen was up. Left alone, that coroutine is
# simply abandoned mid-await and its ``finally`` never runs — which is where the
# history and chat runs are closed, the background services stopped, and the exit
@@ -2030,20 +2106,10 @@ class TuiSession:
self.invalidate()
return
if action == "quit":
# Straight out (^Q/^C), with no confirmation — deliberately, and not an
# oversight to be tidied up later. The quit dialog exists to catch *one Esc too
# many* at the main menu, where a single reflexive keystroke on the way out of
# something would otherwise end the session (JP, 2026-08-30). Neither of these
# chords can be pressed by accident, so there is nothing for a confirm to catch
# — and both are the escape hatch: a screen that is wedged, or a device read
# that is hanging, is exactly when a confirm dialog (itself a screen, which has
# to paint and take a key) would be the thing in the way. Leaving is still
# clean: ``run`` cancels the coroutine driving the app and waits for its unwind,
# so the history and chat runs close, the services stop, and the exit watchdog
# is armed, the same as quitting from the menu. What this route does *not* offer
# is the dialog's "Unpair & quit" — that stays a deliberate, menu-only choice.
if self._app is not None:
self._app.exit()
# Asks first, from anywhere; a second press while it asks leaves (see
# request_quit). "Unpair & quit" rides the confirm the menu declared.
self.request_quit()
self.invalidate()
return
if action == "paste_clipboard":
# Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste
+111 -2
View File
@@ -866,6 +866,110 @@ async def test_quit_from_a_nested_screen_still_runs_the_apps_teardown() -> None:
assert torn_down == ["services closed"]
def _asking_session(inp, asked: list[str], answer: str | None = None):
"""A session whose quit chord floats a Cancel/Quit confirm, the way the menu declares it.
``answer`` resolves the dialog as soon as it is pushed (``None`` leaves it up for the
test to drive); ``asked`` records each time the question is put.
"""
session = _session(inp)
async def ask() -> bool:
asked.append("asked")
dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], default=1)
if answer is not None:
asyncio.get_running_loop().call_soon(dialog.resolve, answer)
return await session.run_dialog(dialog) == "quit"
session.set_quit_confirm(ask)
return session
async def test_quit_asks_first_and_cancelling_leaves_the_flow_where_it_was() -> None:
"""One ^Q floats the confirm over whatever is up; Cancel puts the reader back.
The screen under it is never resolved or popped — a capture behind the box keeps
capturing — which is the whole difference from the old straight-out chord (issue #22).
"""
with create_pipe_input() as inp:
asked: list[str] = []
session = _asking_session(inp, asked, answer="cancel")
screen = ScrollScreen("a capture in progress")
async def main() -> None:
session.push(screen)
session._dispatch("quit")
for _ in range(5):
await asyncio.sleep(0)
assert asked == ["asked"]
assert session.top is screen, "Cancel lands back on the screen it floated over"
assert not session._quit_asking
session.pop(screen)
await asyncio.wait_for(session.run(main()), timeout=5)
assert screen.future is None or not screen.future.done()
async def test_a_second_quit_while_the_confirm_is_up_leaves_at_once() -> None:
"""^Q ^Q is the fast way out, and it never waits on the dialog to take a key."""
with create_pipe_input() as inp:
asked: list[str] = []
session = _asking_session(inp, asked)
torn_down: list[str] = []
async def main() -> None:
try:
await session.run_screen(ScrollScreen("deep"))
finally:
torn_down.append("services closed")
# Both presses in one batch: the flag is raised synchronously, so the second is
# the leaving rather than a second dialog.
inp.send_text(QUIT_KEY + QUIT_KEY)
await asyncio.wait_for(session.run(main()), timeout=5)
assert torn_down == ["services closed"]
assert len(asked) <= 1
async def test_quit_confirmed_from_anywhere_exits_the_app() -> None:
"""Choosing Quit in the floated confirm exits, and the flow's teardown still runs."""
with create_pipe_input() as inp:
asked: list[str] = []
session = _asking_session(inp, asked, answer="quit")
torn_down: list[str] = []
async def main() -> None:
try:
await session.run_screen(ScrollScreen("deep"))
finally:
torn_down.append("services closed")
inp.send_text(QUIT_KEY)
await asyncio.wait_for(session.run(main()), timeout=5)
assert asked == ["asked"]
assert torn_down == ["services closed"]
def test_esc_at_the_main_menu_does_nothing_but_peel_a_filter() -> None:
"""Esc-mashing up to the menu settles there instead of opening the quit confirm."""
from meshterm.ui.menu import _MainMenu
menu = _MainMenu(
"menu",
[Choice("alpha", 1), Choice("beta", 2)],
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit",
)
menu.future = asyncio.new_event_loop().create_future()
menu.handle("escape")
assert not menu.future.done(), "a bare Esc at the menu resolves nothing"
assert not menu.footer_hint.endswith("Esc clear")
menu.handle("text", "b")
assert menu.footer_hint.endswith("^Q quit · Esc clear")
menu.handle("escape")
assert menu._filter == "" and not menu.future.done()
assert menu.footer_hint.endswith("^Q quit")
# --- the bindings themselves -------------------------------------------------
@@ -885,10 +989,12 @@ def test_both_global_chords_are_bound_on_both_ctrl_keys() -> None:
def test_neither_global_chord_is_advertised_anywhere() -> None:
"""Both are deliberately undiscoverable in the UI — the About page states them instead.
"""Both are deliberately undiscoverable in the UI, bar the menu's own way out.
A global verb has no screen to belong to, so a footer atom or an F-key chip would have to
ride on *every* screen; the lane only has three free slots per screen to begin with.
ride on *every* screen; the lane only has three free slots per screen to begin with. The
one exception is the main menu's hint, which names ^Q because Esc is inert there and the
menu is where a reader looks for the way out (issue #22) — exactly one string, there.
"""
import ast
import re
@@ -919,6 +1025,9 @@ def test_neither_global_chord_is_advertised_anywhere() -> None:
and chord.search(node.value)
):
offenders.append(f"{path.name}:{node.lineno}: {node.value.strip()[:70]}")
menu_hint = [o for o in offenders if o.startswith("menu.py:") and o.endswith("· ^Q quit")]
assert len(menu_hint) == 1, "the main menu's hint must name the quit chord, once"
offenders.remove(menu_hint[0])
assert not offenders, "the global chords must not appear in any hint or chip:\n" + "\n".join(
offenders
)
+1
View File
@@ -2327,6 +2327,7 @@ def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None:
class FakeApp:
def __init__(self) -> None:
self.exited = False
self.is_running = True
def exit(self) -> None:
self.exited = True