An entry chain is a stack: Esc steps back, not out
Five flows asked two or more things in a row as a straight run of awaits, so Esc anywhere in one abandoned the lot. Mistype the second half of a 32-hex channel key and the name you typed first went with it; back out of the courier's schedule and the message you had just written was gone. menus.run_steps runs a chain as the stack it is. Each step is awaited with the answers gathered so far — the same list every 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 None to step back. None from the first step ends the flow; there is nothing behind it. Converted: joining a channel by key and editing one (name then key), the custom firmware variable (name then value), and the courier's queue flow (recipient, message, schedule). The courier's recipient list now stays pushed for the whole flow, so stepping back from the message lands on the row it was being written to, and the schedule's "At a time…" prompt steps back to its own rungs rather than out of the queueing. A step signals "back" with None, which is what _pick_schedule returns for the real answer "when it's next heard" — so _schedule_step swaps the two, carrying that answer as its own WHEN_HEARD sentinel. A trailing Cancel/verb confirm is left alone: its left button says Cancel and means "don't do this", which is a decision rather than a step. That line is now in CLAUDE.md with the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
deea9ebdd2
commit
71dee82fd3
6 files changed
+273
-71
No files matched your search
@@ -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
|
||||
|
||||
+48
-18
@@ -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]")
|
||||
|
||||
@@ -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) -----------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
+48
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user