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:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-30 18:12:13 -04:00
1 parent deea9ebdd2
commit 71dee82fd3
6 files changed
+273 -71

No files matched your search

+5
View File
@@ -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
View File
@@ -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]")
+39 -15
View File
@@ -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) -----------------------------------------
+73 -37
View File
@@ -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
View File
@@ -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.
+60
View File
@@ -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 ---------------------------------------------------------