feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no caller — the startup session picker (design-brief §4) was never built; bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors AgentPickerApp) and resolve bare mode in _resolve_then_run. - Bare TUI mode (no --session/--new) now valid → session picker. Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per §4 "no in-app creation, --new only"); exactly 1 -> auto-resume (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick (Esc/Ctrl-D -> exit 0). - cli._parse: bare TUI valid; --send still requires one flag; --agent forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive. - Contract #6 amended (SessionPickerApp + bare-mode resolution) + validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli validation tests. Suite 528 green; touched code ruff-clean. Design note: bare + 0 sessions errors (honors §4's no-in-app-creation clause); the friendlier auto-fall-through-to-new is deferred pending operator preference.
This commit is contained in:
@@ -372,3 +372,64 @@ test layer.
|
||||
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
|
||||
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
|
||||
distinction (different error layers entirely).
|
||||
|
||||
## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2)
|
||||
|
||||
The v1 coverage-audit found `list_sessions` had **no caller** — the startup
|
||||
session picker (design-brief §4: "single-session-per-launch, with a startup
|
||||
picker invoked when more than one session exists ... plus flags `--session`/
|
||||
`--new` to skip it") was never built. Bare TUI mode (neither `--session` nor
|
||||
`--new`) was a hard usage error. This adds the picker as a pre-alt-screen
|
||||
resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`.
|
||||
|
||||
**Locked design (design-brief §4):** the picker is **resume-only** (§4 negative
|
||||
clause "no in-app session creation — `--new` flag only"); shown only when **>1**
|
||||
session exists (exactly 1 auto-resumes; the launch intent is "resume the last
|
||||
session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare
|
||||
mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to
|
||||
`--new` (honors the "no in-app creation" clause; the friendlier
|
||||
auto-fall-through-to-new alternative is deferred pending operator confirmation).
|
||||
|
||||
### `_parse` validation relaxation (ratatoskr.cli._parse)
|
||||
|
||||
- Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID
|
||||
→ triggers the picker. (Previously `raise UsageError("pass exactly one of
|
||||
--session or --new")` unconditionally.)
|
||||
- `--send` mode still requires exactly one of `--session`/`--new` (non-
|
||||
interactive: no picker can open) → `UsageError("--send requires --session or
|
||||
--new")`.
|
||||
- `--session` + `--new` stays mutually exclusive.
|
||||
- `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`).
|
||||
|
||||
```contract
|
||||
FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None
|
||||
BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001).
|
||||
PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker)
|
||||
POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None}
|
||||
STEPS:
|
||||
1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp).
|
||||
2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer.
|
||||
3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss.
|
||||
4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None).
|
||||
TESTS:
|
||||
pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id.
|
||||
dismiss_returns_none [happy]: press escape → run_async() returns None.
|
||||
ctrl_d_dismisses [adversarial]: press ctrl+d → None.
|
||||
|
||||
FN _resolve_then_run(args) — bare-mode extension (session picker)
|
||||
BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals.
|
||||
STEPS (inserted at the top of the `async with client` block):
|
||||
1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id.
|
||||
2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode
|
||||
a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21.
|
||||
b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent <id>` + return 14.
|
||||
c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1
|
||||
d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit).
|
||||
3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`.
|
||||
TESTS (in the `_resolve_then_run` block):
|
||||
bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker.
|
||||
bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown.
|
||||
bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id.
|
||||
bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed.
|
||||
bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20.
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user