diff --git a/CLAUDE.md b/CLAUDE.md index bc44d25..6e6143a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,11 @@ deliberately, one at a time, and say why in the code. refreshes in place with `SelectScreen.replace_items`, which follows the highlighted row by value and keeps the filter. Rebuilding the screen is for when the rows it was holding a place in are genuinely gone (a purge, a delete) — and then say so. +- **An entry chain is a stack too.** A flow that asks two or more things in a row runs + through `menus.run_steps`: each step gets the answers so far (to label itself, and to + offer its own previous answer as its default) and returns `None` to step back, so Esc + undoes one step instead of the whole flow. A trailing Cancel/verb confirm is still a + decision, not a step — its Cancel abandons. - **Esc peels before it leaves.** A screen carrying a find-as-you-type filter treats the typed query as the most recent thing the reader entered: the first Esc clears it and the screen stays, the second leaves. One rule on all four (`SelectScreen` and everything diff --git a/meshterm/ui/channels.py b/meshterm/ui/channels.py index bf2976c..e8ab927 100644 --- a/meshterm/ui/channels.py +++ b/meshterm/ui/channels.py @@ -62,7 +62,7 @@ from ..core.connection import Device from ..core.models import Conversation from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS from .braillechart import activity_peak, activity_sparkline -from .menus import command_label, fit_cells, menu_rows, section_heading +from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading from .qr import share_popup from .tui import CANCEL, Choice, SelectScreen, Separator from .widgets import _age_seconds, _format_age, channel_glyph, format_ago @@ -815,18 +815,30 @@ async def _add_public( async def _join_with_key( ctx: "AppContext", device: Device, slots: list[ChannelSlot], capacity: int ) -> int: - """Join an existing private channel by entering its name and 16-byte key.""" + """Join an existing private channel by entering its name and 16-byte key. + + The two prompts are a stack (:func:`~meshterm.ui.menus.run_steps`): Esc on the key + steps back to the name with what was typed still in the field, rather than throwing + both away — a 32-hex key is a long thing to mistype. + """ idx = await _pick_free_slot(ctx, slots, capacity) if idx is None: return 0 - name = await ctx.ui.text("Channel name:", validate=_nonblank) - if not name: - return 0 - key = await ctx.ui.text( - "Channel key (32 hex characters / 16 bytes):", validate=_valid_secret + answers = await run_steps( + [ + lambda vals: ctx.ui.text( + "Channel name:", default=vals[0] or "", validate=_nonblank + ), + lambda vals: ctx.ui.text( + "Channel key (32 hex characters / 16 bytes):", + default=vals[1] or "", + validate=_valid_secret, + ), + ] ) - if not key: + if answers is None: return 0 + name, key = answers secret = normalize_secret(key) await write_channel(ctx, device, idx, name.strip(), secret) ctx.ui.note(f"[ok]✓[/ok] joined [brand]{name.strip()}[/brand]") @@ -856,18 +868,36 @@ async def _import_link( async def _edit(ctx: "AppContext", device: Device, slot: ChannelSlot) -> bool: - """Rename and/or re-key an existing channel; return whether it changed.""" - name = await ctx.ui.text("Channel name:", default=slot.name, validate=_nonblank) - if name is None: - return False - name = name.strip() - key = await ctx.ui.text( - "Channel key (32 hex chars; blank to derive from the name):", - default="" if slot.is_name_derived else slot.secret.hex(), - validate=_optional_secret, + """Rename and/or re-key an existing channel; return whether it changed. + + Name then key, as a stack (:func:`~meshterm.ui.menus.run_steps`): Esc on the key steps + back to the name rather than dropping the rename with it. Each step opens on what it + was last given — the slot's current value the first time through, whatever was typed + when it is come back to (a committed blank key included, since blank means *derive it + from the name*). + """ + answers = await run_steps( + [ + lambda vals: ctx.ui.text( + "Channel name:", + default=slot.name if vals[0] is None else vals[0], + validate=_nonblank, + ), + lambda vals: ctx.ui.text( + "Channel key (32 hex chars; blank to derive from the name):", + default=( + ("" if slot.is_name_derived else slot.secret.hex()) + if vals[1] is None + else vals[1] + ), + validate=_optional_secret, + ), + ] ) - if key is None: + if answers is None: return False + name, key = answers + name = name.strip() secret = normalize_secret(key) if key.strip() else None await write_channel(ctx, device, slot.idx, name, secret) ctx.ui.note(f"[ok]✓[/ok] updated channel [brand]{name}[/brand]") diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index d3e5cea..feb008b 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -60,6 +60,7 @@ from .menus import ( lane_header, lane_row, menu_rows, + run_steps, section_heading, ) from .tui import Choice, Separator @@ -628,26 +629,49 @@ async def _stage_custom_var( Known variable names are offered as suggestions so an existing one can be recalled without retyping it; any new name is accepted as free text. + + Name then value, as a stack (:func:`~meshterm.ui.menus.run_steps`): Esc on the value + steps back to the name it is for, rather than dropping both and starting over. """ + answers = await run_steps( + [ + lambda vals: _ask_custom_name(ctx, custom, vals[0]), + lambda vals: ctx.ui.text( + "Custom variable", + prompt=f"Value for {vals[0]}:", + default=custom.get(vals[0], "") if vals[1] is None else vals[1], + ), + ] + ) + if answers is None: + return + key, value = answers + extra_ops.append(("set_custom", key, value)) + + +async def _ask_custom_name( + ctx: "AppContext", custom: dict[str, str], previous: Optional[str] +) -> Optional[str]: + """The variable-name step: suggestions where there are any, free text otherwise. + + Args: + ctx: Shared application context. + custom: The variables the device already reports, offered for recall. + previous: What this step last returned, so coming back to it opens on it. + + Returns: + The trimmed name, or ``None`` if it was left blank or cancelled. + """ + prompt = "Name of the firmware variable to set:" if custom: - key = await ctx.ui.autocomplete( - "Custom variable", - sorted(custom), - prompt="Name of the firmware variable to set:", + typed = await ctx.ui.autocomplete( + "Custom variable", sorted(custom), prompt=prompt, default=previous or "" ) else: - key = await ctx.ui.text( - "Custom variable", prompt="Name of the firmware variable to set:" + typed = await ctx.ui.text( + "Custom variable", prompt=prompt, default=previous or "" ) - if not key or not key.strip(): - return - key = key.strip() - value = await ctx.ui.text( - "Custom variable", prompt=f"Value for {key}:", default=custom.get(key, "") - ) - if value is None: - return - extra_ops.append(("set_custom", key, value)) + return typed.strip() if typed and typed.strip() else None # --- device actions (run immediately) ----------------------------------------- diff --git a/meshterm/ui/courier_screen.py b/meshterm/ui/courier_screen.py index b13f962..9c861fa 100644 --- a/meshterm/ui/courier_screen.py +++ b/meshterm/ui/courier_screen.py @@ -29,7 +29,7 @@ from rich.text import Text from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage from ..core.models import Contact, is_direct_messageable, utcnow -from .menus import command_label, marked_label, section_heading +from .menus import command_label, marked_label, run_steps, section_heading from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow from .theme import glyph from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator @@ -352,7 +352,14 @@ class CourierRecipientScreen(ContactListScreen): async def _queue_flow(ctx: "AppContext", contacts: list[Contact]) -> None: - """Float the queueing flow: recipient, message, schedule.""" + """Float the queueing flow: recipient, message, schedule — as a stack. + + Three prompts, so Esc means "back one step", not "throw the whole thing away": from the + schedule to the message with what was written still in the field, from the message to + the recipient list (which stays pushed the whole time, so it is still on the row the + message was being written to), and from there out to the outbox. Nothing typed is lost + to a single keypress — see :func:`~meshterm.ui.menus.run_steps`. + """ from .timemachine_screen import _routing_prefix_bytes session = ctx.ui.session @@ -381,19 +388,23 @@ async def _queue_flow(ctx: "AppContext", contacts: list[Contact]) -> None: counts=counts, sort=ContactsSort.from_name("name", SORT_COLUMNS, SORT_OPENS_ASCENDING), ) - contact = await session.run_screen(picker) - if not isinstance(contact, Contact): # Esc (CANCEL) or anything else backs out - return - text = await session.text( - f"Message for {contact.name}", - prompt="Delivered as a normal direct message when its moment comes.", - byte_limit=DM_BYTE_LIMIT, - ) - if not text: - return - not_before = await _pick_schedule(ctx, contact.name) - if not_before is CANCEL_SCHEDULE: + async with session.stay(picker) as visit: + answers = await run_steps( + [ + lambda vals: _next_recipient(visit), + lambda vals: session.text( + f"Message for {vals[0].name}", + prompt="Delivered as a normal direct message when its moment comes.", + byte_limit=DM_BYTE_LIMIT, + default=vals[1] or "", + ), + lambda vals: _schedule_step(ctx, vals[0].name), + ] + ) + if answers is None: # Esc off the recipient list — out to the outbox return + contact, text, when = answers + not_before = None if when is WHEN_HEARD else when key = contact_watch_key(contact) if key is None: await session.message_dialog( @@ -404,6 +415,26 @@ async def _queue_flow(ctx: "AppContext", contacts: list[Contact]) -> None: ctx.courier_store.queue(key, contact.name, text, not_before=not_before) +async def _next_recipient(visit: Any) -> Optional[Contact]: + """One round of the visited recipient list: the picked contact, or ``None`` on Esc.""" + chosen = await visit.result() + return chosen if isinstance(chosen, Contact) else None + + +async def _schedule_step(ctx: "AppContext", name: str) -> Optional[object]: + """The schedule step, in the shape :func:`~meshterm.ui.menus.run_steps` reads. + + A chain step signals *step back* with ``None``, which is exactly what + :func:`_pick_schedule` returns for the real answer "no schedule — send on the next sign + of life". So the two are swapped here: that answer travels as :data:`WHEN_HEARD` (its + own row's value) and the cancel becomes the ``None``. + """ + picked = await _pick_schedule(ctx, name) + if picked is CANCEL_SCHEDULE: + return None + return WHEN_HEARD if picked is None else picked + + #: Sentinel: the schedule picker was cancelled (distinct from "no schedule"). CANCEL_SCHEDULE = object() @@ -420,40 +451,45 @@ async def _pick_schedule(ctx: "AppContext", name: str): ``None`` means "no schedule — send on the next sign of life" (the explicit *When it's next heard* row); an aware UTC datetime holds until then; :data:`CANCEL_SCHEDULE` means the user backed out and nothing should queue. + + The *At a time…* row opens a second prompt, and Esc there steps back to these rungs + rather than out of the queueing flow — the chain rule one level down (see + :func:`~meshterm.ui.menus.run_steps`). """ session = ctx.ui.session - now = utcnow() - tomorrow_7 = parse_clock("07:00", now) - items = [ - Choice("When it's next heard (recommended)", WHEN_HEARD), - Choice("In 1 h", now + timedelta(hours=1)), - Choice("In 3 h", now + timedelta(hours=3)), - Choice("In 8 h", now + timedelta(hours=8)), - Choice(f"Next 07:00 ({_local_stamp(tomorrow_7)})", tomorrow_7), - Choice("At a time… (HH:MM, next occurrence)", "custom"), - ] - picked = await session.select( - f"When should {name} get it?", items, filterable=False, - footer_hint="↑↓ move · Enter select · Esc cancel", - ) - if picked is None: - # Esc on the picker cancels the queueing; "when next heard" is its own explicit - # row (WHEN_HEARD below), so backing out never silently queues something. - return CANCEL_SCHEDULE - if picked is WHEN_HEARD: - return None # no schedule constraint — delivered on the next pass - if picked == "custom": + while True: + now = utcnow() + tomorrow_7 = parse_clock("07:00", now) + items = [ + Choice("When it's next heard (recommended)", WHEN_HEARD), + Choice("In 1 h", now + timedelta(hours=1)), + Choice("In 3 h", now + timedelta(hours=3)), + Choice("In 8 h", now + timedelta(hours=8)), + Choice(f"Next 07:00 ({_local_stamp(tomorrow_7)})", tomorrow_7), + Choice("At a time… (HH:MM, next occurrence)", "custom"), + ] + picked = await session.select( + f"When should {name} get it?", items, filterable=False, + footer_hint="↑↓ move · Enter select · Esc cancel", + ) + if picked is None: + # Esc on the picker steps back out of the schedule; "when next heard" is its + # own explicit row (WHEN_HEARD), so backing out never silently queues anything. + return CANCEL_SCHEDULE + if picked is WHEN_HEARD: + return None # no schedule constraint — delivered on the next pass + if picked != "custom": + return picked while True: typed = await session.text( "Send at (local HH:MM)", prompt="A time already past today means tomorrow.", ) if not typed: - return CANCEL_SCHEDULE + break # Esc on the time: back to the rungs it was reached from when = parse_clock(typed) if when is not None: return when - return picked async def _entry_actions(ctx: "AppContext", ident: int) -> None: diff --git a/meshterm/ui/menus.py b/meshterm/ui/menus.py index 10fb569..945341f 100644 --- a/meshterm/ui/menus.py +++ b/meshterm/ui/menus.py @@ -18,6 +18,8 @@ construction: :func:`lane_header`, the ready-made header for :func:`lane_row`'s lanes. * :func:`changes_phrase` — ``"1 staged change"`` / ``"3 staged changes"``. * :func:`confirm_discard` — the shared are-you-sure dialog for leaving staged changes. +* :func:`run_steps` — an entry flow of two or more prompts, run as a stack: Esc on one + step goes back to the step before it, with what was answered there still in hand. House rules the helpers encode (see the UX standards in ``CLAUDE.md``): a list carries no exit row — Esc leaves, and a row repeating it earned nothing for its two lines; the @@ -29,7 +31,16 @@ styled ``ok``/``err``. from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Iterable, Sequence, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Iterable, + Optional, + Sequence, + Union, +) from rich.cells import cell_len from rich.text import Text @@ -337,6 +348,42 @@ async def confirm_discard( return choice == "discard" +async def run_steps(steps: "Sequence[Callable[[list], Awaitable[Any]]]") -> "Optional[list]": + """Run a chain of prompts as a stack — Esc on a step goes back to the one before it. + + An entry flow that asks two or more things in a row (pick a slot, then name the + channel; pick a recipient, write the message, choose when it goes) is a stack like any + other: Esc undoes the last thing you did, not the whole flow. Gathering the answers as + one straight run of awaits instead made every Esc abandon everything, so a mistyped + 32-hex channel key also cost the name typed before it. + + Each step is awaited with the answers gathered so far — the same list each time, so a + step can label itself with an earlier answer *and* offer its own previous answer as its + default when it is come back to — and returns its value, or ``None`` to step back. + ``None`` from the first step ends the flow: there is nothing behind it. A step whose + real answer can be ``None`` (Courier's "when it's next heard") returns a sentinel + instead and the caller reads it back. + + Args: + steps: The prompts, in order. Index ``i`` reads and writes ``values[i]``. + + Returns: + The answers, positionally, or ``None`` if the flow was abandoned. + """ + values: list = [None] * len(steps) + index = 0 + while index < len(steps): + answer = await steps[index](values) + if answer is None: + index -= 1 + if index < 0: + return None + continue + values[index] = answer + index += 1 + return values + + def section_heading(label: str) -> Separator: """A ``── Label ──`` accent section heading for a grouped select list. diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 12f5eea..3bbf666 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -423,6 +423,66 @@ async def test_the_path_composer_peels_its_typed_entry_first() -> None: assert screen.future.result() is CANCEL +# --- entry chains step back -------------------------------------------------- + + +async def test_a_cancelled_step_goes_back_to_the_one_before_it() -> None: + """Esc in a multi-prompt flow undoes one step, keeping what earlier steps hold. + + A chain of prompts used to be a straight run of awaits, so Esc anywhere abandoned the + whole flow — a mistyped 32-hex channel key cost the name typed before it too. + """ + from meshterm.ui.menus import run_steps + + asked: list[str] = [] + key_attempts = iter([None, "cafe"]) # first Esc, then a good key on the way forward + + async def name_step(values: list): + asked.append("name") + return values[0] or "Backyard" # opens on what it last returned + + async def key_step(values: list): + asked.append("key") + return next(key_attempts) + + answers = await run_steps([name_step, key_step]) + + assert answers == ["Backyard", "cafe"] + assert asked == ["name", "key", "name", "key"], "the name was asked again, not skipped" + + +async def test_backing_out_of_the_first_step_ends_the_flow() -> None: + """There is nothing behind step one, so Esc there leaves — as it always did.""" + from meshterm.ui.menus import run_steps + + async def only_step(values: list): + return None + + assert await run_steps([only_step]) is None + + +async def test_a_step_keeps_its_own_answer_to_offer_as_a_default() -> None: + """Coming back to a step, it is handed what it returned last time (its field's value).""" + from meshterm.ui.menus import run_steps + + seen: list = [] + + async def first(values: list): + return "one" + + async def second(values: list): + seen.append(list(values)) + return "two" + + async def third(values: list): + return None if len(seen) == 1 else "three" # Esc the first time through + + assert await run_steps([first, second, third]) == ["one", "two", "three"] + # Asked again after the third step backed out, the second step is handed what it + # answered before — which is how a re-opened field comes back already filled in. + assert seen == [["one", None, None], ["one", "two", None]] + + # --- ^W, the pop-all ---------------------------------------------------------