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

+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)