tui2: add onboarding flow and session parity affordances

This commit is contained in:
thePR0M3TH3AN committed 2026-03-02 09:44:25 -05:00
1 parent 5c524b2086
commit 8f7075c39b
6 files changed
+326 -29

No files matched your search

+6 -1
View File
@@ -66,7 +66,7 @@ Use these docs as inputs, but treat this file as the decision layer:
1. Continue feature-development parity polish in TUI v2 with user-driven UX gaps.
: prioritized gaps from side-by-side legacy vs v2 run:
: guided first-run onboarding, stats/ops visibility, explicit lock/session affordances.
: onboarding + stats/ops + lock/session affordances now landed; continue bug-driven UX polish.
2. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`).
3. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites.
4. Execute a tagged release integrity run and attach evidence URLs in readiness checklist.
@@ -128,6 +128,11 @@ Progress update (2026-03-02):
- added full palette command reference (`help-commands`, alias `commands`)
- `help` now also renders in-app command reference in entry detail pane
- focused TUI gate remains green with improved app coverage (`79.97%`)
- Session affordance parity improved in TUI v2:
- added `session-status`, `lock`, and `unlock <password>` palette commands
- left pane now displays explicit session lock state and managed-session indicator
- lock state blocks open/reveal/QR workflows until unlock succeeds
- interaction + matrix tests extended for usage, success, and failure paths
## 6) Working Rule
+5 -7
View File
@@ -51,19 +51,17 @@ Purpose: map legacy interactive workflows to Textual TUI v2 equivalents and isol
| Export TOTP codes | Settings | `totp-export` | Implemented | |
| Backup/reveal parent seed | Settings | `parent-seed-backup [path] [password]` | Implemented | |
| Stats view | Settings > Stats | `stats` (palette) | Implemented | In-app dashboard in detail pane. |
| First-run onboarding guidance | Startup path/menu guidance | Empty-vault onboarding panel + `onboarding` (alias: `welcome`) + `quickstart` | Implemented | Guided steps for first entry, inspect/reveal, and operations. |
| Display npub | Main utility flow | `npub` (alias: `nostr-pubkey`) | Implemented | Displays active profile npub and QR payload in the sensitive panel. |
| View archived-only list | Settings/List flow | `h` cycle archive scope or `archive-filter <active|all|archived>` | Implemented | Supports active-only/default, all, and archived-only views. |
| Reset Nostr sync state | Settings > Nostr | `nostr-reset-sync-state` | Implemented | Added to TUI v2 palette + tests. |
| Start fresh Nostr namespace | Settings > Nostr | `nostr-fresh-namespace` | Implemented | Added to TUI v2 palette + tests. |
| Load managed-account session | Retrieve managed account action | `managed-load (optional: entry_id)` | Implemented | Added in TUI v2 palette + tests. |
| Exit managed-account session | Main loop when in child profile | `managed-exit` | Implemented | Added in TUI v2 palette + tests. |
| Session visibility | Operational/session affordance | `session-status` + left-pane session lines | Implemented | Shows lock state + managed session state. |
| Lock vault | Operational/session affordance | `lock` | Implemented | Locks session, clears selected view, blocks reveal/open actions. |
| Unlock vault | Operational/session affordance | `unlock <password>` | Implemented | Restores unlocked state; usage + failure paths covered in tests. |
## Prioritized Gap Queue
1. Medium: guided first-run onboarding parity in TUI v2 (legacy has explicit startup path).
2. Medium: explicit lock/session affordances and status visibility parity in TUI v2.
## Suggested implementation order
1. Land guided onboarding panel/flow parity improvements.
2. Add lock/session visibility commands and test coverage.
No active medium/high parity gaps are currently identified in this matrix.
+59
View File
@@ -825,3 +825,62 @@
- `docs/dev_control_center.md`
- `docs/tui_v2_legacy_parity_matrix.md`
(remaining medium gap: explicit lock/session affordances).
## 2026-03-02 Parity UX slice: explicit session lock/unlock/status affordances
- Implemented in `src/seedpass/tui_v2/app.py`:
- new palette commands:
- `session-status`
- `lock`
- `unlock <password>`
- left panel now displays:
- `Session: locked|unlocked`
- `Managed: #<entry_id>|(none)`
- managed session visibility wired:
- `managed-load` sets active managed-session marker
- `managed-exit` clears active managed-session marker
- lock guard behavior:
- `open`, `reveal`, and `qr` actions are blocked while locked
- selected entry/view state is cleared on lock
- unlock restores normal browse/reveal behavior
- Command discoverability updates:
- palette placeholder/help summary/reference now include session commands.
- Tests added/updated:
- `src/tests/test_tui_v2_textual_interactions.py`
- `test_tui2_textual_palette_session_status_lock_and_unlock`
- managed session test now asserts filter-panel state line updates
- validation suite now covers `session-status`, `lock`, and `unlock` usage/unavailable paths
- `src/tests/test_tui_v2_action_matrix.py`
- matrix command paths now exercise session command usage + runtime cases
- Validation:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- Result: `28 passed`.
- Planning docs updated:
- `docs/tui_v2_legacy_parity_matrix.md` now marks session lock/unlock/status rows as implemented.
- `docs/dev_control_center.md` priority list now reflects onboarding as the primary remaining UX parity item.
## 2026-03-02 Parity UX slice: first-run onboarding flow parity
- Implemented explicit onboarding guidance in `src/seedpass/tui_v2/app.py`:
- new palette command: `onboarding`
- alias: `welcome`
- `quickstart` remains supported and now renders the same onboarding guide for backward compatibility.
- Empty-vault startup behavior now renders the onboarding panel and status guidance:
- status text updated to: run `onboarding` (or `quickstart`) to begin.
- Onboarding guide content now provides a structured first-run path:
- create first entry
- inspect/reveal/QR verification
- operate with stats/help/linking commands.
- Command discoverability updates:
- palette placeholder, help summary, and reference include onboarding.
- left action hints now show onboarding/quickstart/stats path.
- Tests updated:
- `src/tests/test_tui_v2_textual_interactions.py`
- quickstart/stats test now includes onboarding command assertions
- empty-vault test now validates onboarding panel rendering
- `src/tests/test_tui_v2_action_matrix.py`
- matrix coverage now includes onboarding usage and success paths
- Validation:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- Result: `28 passed`.
- Planning docs updated:
- `docs/tui_v2_legacy_parity_matrix.md` now marks onboarding parity as implemented and clears active medium/high parity gaps.
- `docs/dev_control_center.md` now reflects that the tracked onboarding/stats/session UX gaps are landed.
+156 -19
View File
@@ -282,7 +282,7 @@ def launch_tui2(
yield Input(
placeholder=(
"Command palette: help | open <id> | search <q> | "
"help-commands | quickstart | stats | "
"help-commands | onboarding | quickstart | stats | session-status | lock | unlock <password> | "
"filter <kind> | archive | restore | "
"archive-filter <active|all|archived> | "
"add-password <label> <length> [username] [url] | "
@@ -306,7 +306,7 @@ def launch_tui2(
"sync-now | sync-bg | "
"checksum-verify | checksum-update | db-export <path> | db-import <path> | "
"totp-export <path> | parent-seed-backup [path] [password] | "
"managed-load [entry_id] | managed-exit | "
"managed-load [entry_id] | managed-exit | session-status | lock | unlock <password> | "
"doc-export [output_path] | "
"link-add <target> [relation] [note] | "
"link-rm <target> [relation] | "
@@ -382,6 +382,8 @@ def launch_tui2(
self._last_status_message = ""
self._time_now = time.time
self._totp_rows: list[dict[str, Any]] = []
self._session_locked = False
self._managed_session_entry_id: int | None = None
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
try:
self._service = (
@@ -491,32 +493,41 @@ def launch_tui2(
self.query_one("#secret-detail", Static).update(text)
def _quickstart_text(self) -> str:
return self._onboarding_text()
def _onboarding_text(self) -> str:
return "\n".join(
[
"Quick Start",
"-----------",
"Onboarding Quick Start",
"----------------------",
"",
"Welcome to SeedPass TUI v2.",
"Your vault currently has no active entries.",
"",
"Try these first actions in the palette (Ctrl+P):",
'- add-password "Site" 16 user https://site.example',
'- add-totp "Authenticator" 30 6',
'- add-document "Runbook" md "starter notes"',
"Step 1: Create your first entry (Ctrl+P then run one command):",
'- add-password "Site" 16 user https://site.example (login)',
'- add-totp "Authenticator" 30 6 (2FA)',
'- add-document "Runbook" md "starter notes" (KB)',
"",
"Step 2: Inspect and verify",
"- open <id> to inspect an entry",
"- v to reveal a selected secret, g to render QR when supported",
"",
"Step 3: Operate and scale",
"- stats for vault overview",
"- help-commands for full command reference",
"- link-add / tag-add / notes-set to build knowledge graph context",
"",
"Useful navigation:",
"- / search, j jump, f kind filter, h archive scope",
"- 1/2/3 focus panes, ? keyboard help",
"",
"Operational shortcuts:",
"- stats (vault summary)",
"- help-commands (full palette reference)",
]
)
def _palette_help_summary(self) -> str:
return (
"Palette commands: help, help-commands, open, search, filter, "
"quickstart, stats, "
"onboarding, quickstart, stats, session-status, lock/unlock, "
"archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, "
"link-add/link-rm/link-filter/link-next/link-prev/link-open, "
"add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, "
@@ -532,7 +543,7 @@ def launch_tui2(
"-----------------",
"",
"Core: help | help-commands | open <id> | jump <id> | search <q>",
"Core Ops: quickstart | stats",
"Core Ops: onboarding | quickstart | stats | session-status | lock | unlock <password>",
"Filters: filter <kind|all> | archive-filter <active|all|archived>",
"Pages: page-next | page-prev | page <n>",
"",
@@ -549,7 +560,7 @@ def launch_tui2(
"Settings: setting-secret | setting-offline | setting-quick-unlock | setting-timeout | setting-kdf-iterations | setting-kdf-mode",
"Nostr: relay-list | relay-add | relay-rm | relay-reset | npub | nostr-reset-sync-state | nostr-fresh-namespace",
"Sync/Utility: sync-now | sync-bg | checksum-verify | checksum-update | db-export | db-import | totp-export | parent-seed-backup",
"Sessions: managed-load [entry_id] | managed-exit",
"Sessions: managed-load [entry_id] | managed-exit | session-status | lock | unlock <password>",
"",
"Tip: use '?' for compact keyboard help overlay.",
]
@@ -661,6 +672,12 @@ def launch_tui2(
if fingerprint
else "Fingerprint: (default)"
)
session_state = "locked" if self._session_locked else "unlocked"
managed_state = (
f"#{self._managed_session_entry_id}"
if self._managed_session_entry_id is not None
else "(none)"
)
text = "\n".join(
[
"SeedPass ◈ TUI v2",
@@ -670,6 +687,8 @@ def launch_tui2(
f"Filter : {self.filter_kind}",
f"Archive: {self.archive_scope}",
f"Links : {self.link_relation_filter}",
f"Session: {session_state}",
f"Managed: {managed_state}",
(
f"Results: {len(self._all_results)} | "
f"Page: {self._result_page + 1}/{self._total_pages()}"
@@ -692,7 +711,7 @@ def launch_tui2(
"- Ctrl+S save doc",
"- Ctrl+P command palette",
"- add-* create entries",
"- quickstart/stats via palette",
"- onboarding/quickstart/stats via palette",
"- tags/notes/fields via palette",
"- docs: edit/save/export",
"- 6 toggle dedicated 2FA board",
@@ -700,6 +719,7 @@ def launch_tui2(
"- npub + nostr reset/fresh namespace",
"- checksum/db/totp/seed backup",
"- managed-load/managed-exit session",
"- session-status, lock, unlock",
"- l cycle link relation",
"- [ / ] select link",
"- o open link target",
@@ -820,7 +840,7 @@ def launch_tui2(
)
if is_empty_vault:
self.query_one("#entry-detail", Static).update(
self._quickstart_text()
self._onboarding_text()
)
else:
self.query_one("#entry-detail", Static).update("No entries match.")
@@ -834,7 +854,7 @@ def launch_tui2(
self._current_link_cursor = 0
if is_empty_vault:
self._set_status(
"Vault is empty. Run 'quickstart' or add your first entry."
"Vault is empty. Run 'onboarding' (or 'quickstart') to begin."
)
else:
self._set_status("No entries match current filter/search")
@@ -931,6 +951,9 @@ def launch_tui2(
self._render_current_page(preserve_selected=not reset_page)
def _show_entry(self, entry_index: int) -> None:
if self._session_locked:
self._set_status("Vault is locked. Run: unlock <password>")
return
if self._service is None:
return
try:
@@ -1499,6 +1522,8 @@ def launch_tui2(
self._refresh_doc_edit_help()
def _selected_entry_payload(self) -> dict[str, Any]:
if self._session_locked:
raise ValueError("Vault is locked. Run: unlock <password>")
if self._service is None:
raise ValueError("Entry service unavailable")
if self._selected_entry_id is None:
@@ -1604,11 +1629,18 @@ def launch_tui2(
self._set_status(f"Applied search: {query}")
return
if cmd in {"onboarding", "welcome"}:
if args:
self._set_status("Usage: onboarding")
return
self.query_one("#entry-detail", Static).update(self._onboarding_text())
self._set_status("Displayed onboarding guide")
return
if cmd == "quickstart":
if args:
self._set_status("Usage: quickstart")
return
self.query_one("#entry-detail", Static).update(self._quickstart_text())
self.query_one("#entry-detail", Static).update(self._onboarding_text())
self._set_status("Displayed quick start guide")
return
@@ -1619,6 +1651,103 @@ def launch_tui2(
self.query_one("#entry-detail", Static).update(self._stats_text())
self._set_status("Displayed vault stats")
return
if cmd == "session-status":
if args:
self._set_status("Usage: session-status")
return
lock_state = "locked" if self._session_locked else "unlocked"
managed_state = (
f"entry #{self._managed_session_entry_id}"
if self._managed_session_entry_id is not None
else "none"
)
vault_state = (
"connected" if self._vault_service is not None else "unavailable"
)
self.query_one("#entry-detail", Static).update(
"\n".join(
[
"Session Status",
"--------------",
"",
f"Vault lock state: {lock_state}",
f"Managed account session: {managed_state}",
f"Vault service: {vault_state}",
]
)
)
self._update_filters_panel()
self._set_status("Displayed session status")
return
if cmd == "lock":
if args:
self._set_status("Usage: lock")
return
if self._vault_service is None:
self._set_status("Vault service unavailable")
return
locker = getattr(self._vault_service, "lock", None)
if not callable(locker):
self._set_status("Vault service does not support lock")
return
try:
locker()
self._session_locked = True
self._clear_failure()
self._selected_entry_id = None
self._selected_entry = None
self._current_links = []
self._current_link_cursor = 0
self._all_results = []
self._result_page = 0
self._render_current_page(preserve_selected=False)
self._set_secret_panel(
"Vault locked. Sensitive data hidden.\nRun: unlock <password>"
)
self._update_filters_panel()
self._set_status("Vault locked")
except Exception as exc:
self._record_failure(
"lock failed",
exc,
retry=lambda: self._run_palette_command(raw),
hint="Press 'x' to retry.",
)
return
if cmd == "unlock":
if len(args) != 1:
self._set_status("Usage: unlock <password>")
return
if self._vault_service is None:
self._set_status("Vault service unavailable")
return
unlocker = getattr(self._vault_service, "unlock", None)
if not callable(unlocker):
self._set_status("Vault service does not support unlock")
return
try:
from seedpass.core.api import UnlockRequest
response = unlocker(UnlockRequest(password=args[0]))
self._session_locked = False
self._load_entries(self._last_query, reset_page=False)
self._set_secret_panel(
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
)
self._clear_failure()
duration = getattr(response, "duration", None)
if isinstance(duration, (int, float)):
self._set_status(f"Vault unlocked in {float(duration):.2f}s")
else:
self._set_status("Vault unlocked")
except Exception as exc:
self._record_failure(
"unlock failed",
exc,
retry=lambda: self._run_palette_command(raw),
hint="Press 'x' to retry.",
)
return
if cmd == "filter":
if len(args) != 1:
@@ -3155,6 +3284,7 @@ def launch_tui2(
return
try:
loader(entry_id)
self._managed_session_entry_id = int(entry_id)
self._load_entries(query="", reset_page=True)
self._clear_failure()
self._set_status(
@@ -3184,6 +3314,7 @@ def launch_tui2(
return
try:
exiter()
self._managed_session_entry_id = None
self._load_entries(query="", reset_page=True)
self._clear_failure()
self._set_status("Exited managed account session")
@@ -3544,9 +3675,15 @@ def launch_tui2(
self._set_status("Palette closed")
def action_reveal_selected(self, confirm: bool = False) -> None:
if self._session_locked:
self._set_status("Vault is locked. Run: unlock <password>")
return
self._show_sensitive_panel(include_qr=False, confirm=confirm)
def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None:
if self._session_locked:
self._set_status("Vault is locked. Run: unlock <password>")
return
self._show_sensitive_panel(include_qr=True, qr_mode=mode, confirm=confirm)
def action_toggle_totp_board(self) -> None:
+8
View File
@@ -505,6 +505,7 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("page abc")
app._run_palette_command("page 0")
app._run_palette_command("help-commands now")
app._run_palette_command("onboarding now")
app._run_palette_command("quickstart now")
app._run_palette_command("stats now")
app._run_palette_command("link-filter")
@@ -572,6 +573,9 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("managed-load 1 2")
app._run_palette_command("managed-load nope")
app._run_palette_command("managed-exit now")
app._run_palette_command("session-status now")
app._run_palette_command("lock now")
app._run_palette_command("unlock")
app._run_palette_command("doc-export one two")
app._run_palette_command("filter")
app._run_palette_command("archive-filter")
@@ -581,8 +585,12 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("help")
app._run_palette_command("help-commands")
app._run_palette_command("onboarding")
app._run_palette_command("quickstart")
app._run_palette_command("stats")
app._run_palette_command("session-status")
app._run_palette_command("lock")
app._run_palette_command("unlock hunter2")
app._run_palette_command("search Doc")
app._run_palette_command("filter document")
app._run_palette_command("archive-filter all")
+92 -2
View File
@@ -522,6 +522,9 @@ class FakeVaultService:
self.exported: list[Path] = []
self.imported: list[Path] = []
self.parent_seed_backups: list[tuple[Path | None, str | None]] = []
self.lock_calls = 0
self.unlock_passwords: list[str] = []
self.locked = False
def export_vault(self, req) -> None:
path = Path(getattr(req, "path"))
@@ -542,6 +545,22 @@ class FakeVaultService:
path.write_text("seed-backup", encoding="utf-8")
self.parent_seed_backups.append((path, password))
def lock(self) -> None:
self.lock_calls += 1
self.locked = True
def unlock(self, req):
password = str(getattr(req, "password", ""))
self.unlock_passwords.append(password)
if password != "hunter2":
raise ValueError("invalid password")
self.locked = False
class _Resp:
duration = 0.42
return _Resp()
def _build_app(
service: FakeEntryService,
@@ -773,9 +792,15 @@ async def test_tui2_textual_quickstart_and_stats_commands() -> None:
async with app.run_test() as pilot:
await pilot.pause()
await _run_palette(app, pilot, "onboarding")
detail = _widget_text(app, "#entry-detail")
assert "Onboarding Quick Start" in detail
assert "Step 1: Create your first entry" in detail
assert "Displayed onboarding guide" in _status_text(app)
await _run_palette(app, pilot, "quickstart")
detail = _widget_text(app, "#entry-detail")
assert "Quick Start" in detail
assert "Onboarding Quick Start" in detail
assert "add-password" in detail
assert "Displayed quick start guide" in _status_text(app)
@@ -794,7 +819,7 @@ async def test_tui2_textual_empty_vault_shows_quickstart() -> None:
async with app.run_test() as pilot:
await pilot.pause()
detail = _widget_text(app, "#entry-detail")
assert "Quick Start" in detail
assert "Onboarding Quick Start" in detail
assert "add-password" in detail
assert "Vault is empty." in _status_text(app)
@@ -1571,11 +1596,13 @@ async def test_tui2_textual_managed_account_session_palette_commands() -> None:
await pilot.pause()
assert "Loaded managed account session from entry #1" in _status_text(app)
assert service.managed_load_calls == [1]
assert "Managed: #1" in _filters_text(app)
app._run_palette_command("managed-exit")
await pilot.pause()
assert "Exited managed account session" in _status_text(app)
assert service.managed_exit_calls == 1
assert "Managed: (none)" in _filters_text(app)
app._run_palette_command("open 2")
app._run_palette_command("managed-load")
@@ -1614,6 +1641,14 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
await pilot.pause()
assert "Vault service unavailable" in _status_text(app)
app._run_palette_command("lock")
await pilot.pause()
assert "Vault service unavailable" in _status_text(app)
app._run_palette_command("unlock secret")
await pilot.pause()
assert "Vault service unavailable" in _status_text(app)
app._run_palette_command("totp-export")
await pilot.pause()
assert "Usage: totp-export <path>" in _status_text(app)
@@ -1744,3 +1779,58 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
app._run_palette_command("managed-exit now")
await pilot.pause()
assert "Usage: managed-exit" in _status_text(app)
app._run_palette_command("session-status now")
await pilot.pause()
assert "Usage: session-status" in _status_text(app)
app._run_palette_command("lock now")
await pilot.pause()
assert "Usage: lock" in _status_text(app)
app._run_palette_command("unlock")
await pilot.pause()
assert "Usage: unlock <password>" in _status_text(app)
@pytest.mark.anyio
async def test_tui2_textual_palette_session_status_lock_and_unlock() -> None:
service = FakeEntryService(
[{"id": 1, "kind": "password", "label": "Demo", "username": "a"}]
)
vault = FakeVaultService()
app = _build_app(service, vault_service=vault)
async with app.run_test() as pilot:
await pilot.pause()
app._run_palette_command("session-status")
await pilot.pause()
assert "Displayed session status" in _status_text(app)
assert "Vault lock state: unlocked" in _widget_text(app, "#entry-detail")
assert "Session: unlocked" in _filters_text(app)
app._run_palette_command("lock")
await pilot.pause()
assert "Vault locked" in _status_text(app)
assert vault.lock_calls == 1
assert "Session: locked" in _filters_text(app)
app._run_palette_command("open 1")
await pilot.pause()
assert "Vault is locked. Run: unlock <password>" in _status_text(app)
app._run_palette_command("reveal")
await pilot.pause()
assert "Vault is locked. Run: unlock <password>" in _status_text(app)
app._run_palette_command("unlock wrong")
await pilot.pause()
assert "unlock failed: invalid password" in _status_text(app)
assert vault.unlock_passwords[-1] == "wrong"
app._run_palette_command("unlock hunter2")
await pilot.pause()
assert "Vault unlocked in 0.42s" in _status_text(app)
assert vault.unlock_passwords[-1] == "hunter2"
assert "Session: unlocked" in _filters_text(app)