Write the stack down: CLAUDE.md, the About page, and the survey's ledger

The rules the last three commits established, stated where they bind: a
Navigation section in CLAUDE.md covering the strict stack, `stay`/`Visit` for a
hub, `replace_items` for a list whose content is data, and the modal-vs-floating
distinction ^W turns on.

The two global chords get their one statement on the About page, since neither
has a screen to be advertised on. It also says what happens when the host takes
Ctrl+W first (VS Code's integrated terminal reads it as close editor) — the key
is a convenience, and nothing depends on it.

docs/survey-navigation.md gets its ledger updated: A, B and C are closed, with
what was actually done to each. D and G stay open, and D is now recorded as the
reason Esc Esc was rejected as the pop-all key — on the map, the mesh walk and
chat, Esc Esc is already an ordinary peel-then-leave.

Also drops an asyncio import the watchtower no longer needs, and an escape
sequence in a new docstring that made ast.parse warn.

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 13:45:07 -04:00
1 parent f2a49c8e2d
commit c31ada0c8b
4 files changed
+74 -18

No files matched your search

+31 -2
View File
@@ -58,6 +58,37 @@ sortable list you pick from is `contactlist.py` (`ContactListScreen`/`ContactRow
Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now ago"),
`_format_age` for aligned columns ("now", "5m").
### Navigation — the stack
Navigation is a **strict stack**: entering a screen or a dialog pushes one frame, Esc pops
exactly one, and the frame you land back on is the one you left — same object, so its
cursor, sort, scroll and typed filter are simply still there. Exceptions are added
deliberately, one at a time, and say why in the code.
- A screen that **owns a loop** is a hub, and a hub *stays pushed* for the whole visit:
`async with session.stay(screen) as visit:` / `while True: x = await visit.result()`.
One push, one pop, however many rounds. Never pop-and-re-push a screen to give a dialog
a backdrop — a screen that never left the stack already is one. `run_screen` remains the
one-shot form: push, await, pop, for a dialog or a prompt.
- A **sub-view nests**; nothing flattens the stack to spare the reader a climb. Two peers
may open each other (trace ↔ trophy case) and that cycle is fine — ^W is the climb.
- A list whose **content is data** (an editor's staged values, a queue that lost a row)
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.
- **^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.
- `modal` is *owning the keyboard* (a prompt, progress, a busy splash); `floating` is only
*drawn as a box*. They are not the same flag: a select list floats and is not modal, the
busy splash is modal and does not float. ^W declines to unwind past anything modal.
- A caller tests one thing for "the user left": `CANCEL` (or the `None` that `ui.select`
folds it to). `POP_ALL` never reaches a caller — the navigation boundary turns it into
`PopToMenu`, which derives from `BaseException` so it crosses the app's `except Exception`
tool guards; only the menu loop catches it.
### Screens and lists
- **No screen carries an exit row.** Esc leaves — it is on both platforms' keyboards and
@@ -73,8 +104,6 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a
shape in `ReorderScreen`. A **Quit** row is likewise kept (main menu, device splash):
it *initiates* the app's terminal action behind a confirm, and on the splash it is the
only statement that the app can be left at all.
- A caller therefore tests one thing for "the user left": `CANCEL` (or the `None` that
`ui.select` folds it to) — never a `_BACK`/`"__back__"` sentinel of its own.
- Grouped-list section headings use `section_heading("Label")` → `── Label ──` accent.
That is also what makes a heading *sticky* (it pins to the top row while its section
scrolls, and the ^PgUp/^PgDn jumps step by it), so build them through it — a hand-rolled
+40 -12
View File
@@ -7,8 +7,36 @@ makes it a question.
**Resolved since:** findings **E** and **§10** — 2026-08-29. Exit rows were retired
app-wide: `back_rows()` is gone, `exit_rows()` draws nothing while clean, and every
caller's three-way Back test collapsed to `is CANCEL` / `is None`. What is kept is stated
in CLAUDE.md. Everything else below still stands, **D included** — Esc's peel/leave split
is now the only thing left telling a user what Esc does.
in CLAUDE.md.
**Resolved 2026-08-30 — A, B, C, and the shortcut that made B possible.** Navigation is
a strict stack now; the rules are in CLAUDE.md under *Navigation — the stack*.
- **A1/A2/A3.** A2 is the rule, and it grew a second half. A hub stays pushed for the
whole visit (`session.stay` / `Visit.result`), so the *object* — not a `default=`
restore — carries the cursor, the sort, the scroll and the filter. Where the rows are
genuinely data (the editors, the outbox, the watchtower) they are swapped in place with
`SelectScreen.replace_items`, which follows the highlighted row by value and clamps to
its position when that row is gone. A1's vanishing-row problem is answered by that
clamp, everywhere, rather than by watchtower's one hand-written case. A screen is
rebuilt only when the rows it was holding a place in no longer exist — a purge, a
removal, a record count that moved — and each such site says so.
- **B1/B2/B3.** They no longer flatten. Both hand-built escapes existed because a deep
stack had no way out; **^W** is the way out, so trace ↔ trophy case and node detail's
peers all nest, and Esc from any of them is one pop back onto the screen that opened
them. The trace screen takes an `open_trophy_case` callback instead of resolving a
sentinel its opener had to interpret, which is also what lets the two open each other.
- **C.** All five backdrop re-pushes are gone, and so are the six hand-rolled
push/await/pop loops the survey paired them with — the same need, spelled one way.
Repeater admin's was the worst of them: a second, identical `SelectScreen` drawn as a
static stand-in for the real one. `admin_node_visit` keeps the real list up instead.
- **F.** Still the one place the stack is cleared rather than unwound, and it now also
disarms a pending ^W: a disconnect has already abandoned the flow that unwind was for.
**Still open: D** (Esc's peel/leave split) and **G** (`popup` declared by hand). D is now
the only thing left telling a reader what Esc does, and it is the reason `Esc Esc` was
rejected as the pop-all key — on the map, the mesh walk and chat, Esc-Esc is already an
ordinary peel-then-leave.
The point of the survey is that MeshTerm's navigation is *almost* uniform. The exceptions
are few enough to list exhaustively, which means they can be decided one at a time rather
@@ -358,13 +386,13 @@ Every other `Cancel` in the app is one half of a real two-way choice.
## 11. Summary — the shortlist
| # | Finding | Sites | Weight |
|---|---|---|---|
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit |
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result |
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code |
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated |
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium |
| G | `popup` declared once by hand while the result presenter infers it | 1 | low |
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low |
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none |
| # | Finding | Sites | Weight | Status |
|---|---|---|---|---|
| A3 | Screen rebuilt per round, losing cursor (and node detail's tab) | 3 | **high** — felt on every visit | **done** 08-30 |
| D | Esc peels the filter on 3 screens, leaves on 2 | 5 | **high** — same keys, opposite result | open |
| E | Six spellings of the Back value; callers test three at once | 19 | medium — invisible to users, costly in code | done 08-29 |
| C | Pop-then-re-push backdrop workaround | 5 | medium — an API gap, repeated | **done** 08-30 |
| B | Peer hand-offs all flatten, but the rule is unwritten and node detail pays for it | 3 | medium | **done** 08-30 |
| G | `popup` declared once by hand while the result presenter infers it | 1 | low | open |
| A1 | Cursor restore doesn't handle a vanishing row (except watchtower) | 7 | low | **done** 08-30 |
| F | `session.reset()` on disconnect — correct, noted for completeness | 1 | none | kept |
+3 -3
View File
@@ -35,9 +35,9 @@ POP_ALL = object()
class PopToMenu(BaseException):
"""Raised through every awaiting navigation frame to unwind to the main menu.
The whole realized path is a stack of ``await``\ s — a screen's caller awaiting its
result, that caller's caller awaiting *it* — so the one way to leave every frame at
once is to raise through them all. Each frame pops its own screen in a ``finally`` on
The whole realized path is a stack of pending ``await`` expressions — a screen's caller
awaiting its result, that caller's caller awaiting *it* — so the one way to leave every
frame at once is to raise through them all. Each frame pops its own screen in a ``finally`` on
the way out, which is exactly the strict single-pop discipline applied N times: no
screen is ever dropped without its owner unwinding.
-1
View File
@@ -15,7 +15,6 @@ is opened before that ever happened). Nothing transmits.
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, Callable, Optional
from rich.text import Text