diff --git a/scripts/interactive_agent_tui_test.py b/scripts/interactive_agent_tui_test.py index 838cf316..5fc49081 100644 --- a/scripts/interactive_agent_tui_test.py +++ b/scripts/interactive_agent_tui_test.py @@ -3,8 +3,8 @@ SeedPass TUI Interactive Agent Test Suite (v2 and v3 Support) ============================================================= -This script provides a "Human-in-the-loop" style automated test for the -SeedPass TUI. It bypasses real encryption and filesystem requirements +This script provides a "Human-in-the-loop" style automated test for the +SeedPass TUI. It bypasses real encryption and filesystem requirements by injecting a MockService layer. Usage: @@ -25,45 +25,55 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from seedpass.tui_v2.app import launch_tui2 from seedpass.tui_v3 import launch_tui3 + class AgentMockService: """ A comprehensive mock of all SeedPass core services. Bypasses real encryption and state. """ + def __init__(self): self.entries = {} self.next_id = 1 self.archived = set() - self.links = [] # list of dicts: {source, target, relation, note} + self.links = [] # list of dicts: {source, target, relation, note} self.is_managed = False self.nostr_index = 0 self.offline = False self._manager = SimpleNamespace( - parent_seed="MOCK_PARENT_SEED", - current_fingerprint="EFBE51E70ED1", - profile_stack=[] + parent_seed="MOCK_PARENT_SEED", + current_fingerprint="EFBE51E70ED1", + profile_stack=[], ) # --- EntryService Interface --- - def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False): + def search_entries( + self, query="", kinds=None, include_archived=False, archived_only=False + ): out = [] q = (query or "").lower() for eid, entry in self.entries.items(): is_archived = eid in self.archived - if archived_only and not is_archived: continue - if not include_archived and is_archived: continue - if q and q not in entry["label"].lower(): continue + if archived_only and not is_archived: + continue + if not include_archived and is_archived: + continue + if q and q not in entry["label"].lower(): + continue kind = entry["kind"] - if kinds and kind not in kinds: continue - - out.append(( - eid, - entry["label"], - entry.get("username"), - entry.get("url"), - is_archived, - SimpleNamespace(value=kind) - )) + if kinds and kind not in kinds: + continue + + out.append( + ( + eid, + entry["label"], + entry.get("username"), + entry.get("url"), + is_archived, + SimpleNamespace(value=kind), + ) + ) return sorted(out, key=lambda x: x[0]) def retrieve_entry(self, entry_id): @@ -75,7 +85,15 @@ class AgentMockService: def add_entry(self, label, length, username=None, url=None, **kwargs): eid = self.next_id - self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "url": url, "length": length, **kwargs} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "password", + "username": username, + "url": url, + "length": length, + **kwargs, + } self.next_id += 1 return eid @@ -87,7 +105,14 @@ class AgentMockService: def add_document(self, label, content, file_type="txt", **kwargs): eid = self.next_id - self.entries[eid] = {"id": eid, "label": label, "kind": "document", "content": content, "file_type": file_type, **kwargs} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "document", + "content": content, + "file_type": file_type, + **kwargs, + } self.next_id += 1 return eid @@ -96,11 +121,21 @@ class AgentMockService: if eid in self.entries: self.entries[eid].update(kwargs) - def archive_entry(self, entry_id): self.archived.add(int(entry_id)) - def restore_entry(self, entry_id): self.archived.discard(int(entry_id)) + def archive_entry(self, entry_id): + self.archived.add(int(entry_id)) + + def restore_entry(self, entry_id): + self.archived.discard(int(entry_id)) def add_link(self, source_id, target_id, relation="related_to", note=""): - self.links.append({"source": int(source_id), "target": int(target_id), "relation": str(relation), "note": str(note)}) + self.links.append( + { + "source": int(source_id), + "target": int(target_id), + "relation": str(relation), + "note": str(note), + } + ) def get_links(self, entry_id): eid = int(entry_id) @@ -110,49 +145,71 @@ class AgentMockService: def get(self, key): return {"inactivity_timeout": 300, "secret_mode_enabled": True}.get(key) - def unlock(self, req): return SimpleNamespace(status="ok", duration=0.1) - def lock(self): pass + def unlock(self, req): + return SimpleNamespace(status="ok", duration=0.1) + + def lock(self): + pass + def stats(self): return {"total_entries": len(self.entries), "archived": len(self.archived)} - + def list_profiles(self): return ["EFBE51E70ED1B53A"] - def sync(self): return SimpleNamespace(manifest_id="agent-mock-manifest") + def sync(self): + return SimpleNamespace(manifest_id="agent-mock-manifest") + def start_fresh_namespace(self): self.nostr_index += 1 return self.nostr_index - - def load_managed_account(self, entry_id): + + def load_managed_account(self, entry_id): self.is_managed = True - self._manager.profile_stack.append((self._manager.current_fingerprint, "path", "seed")) + self._manager.profile_stack.append( + (self._manager.current_fingerprint, "path", "seed") + ) self._manager.current_fingerprint = f"SUBACCT{entry_id}" - - def exit_managed_account(self): + + def exit_managed_account(self): self.is_managed = False if self._manager.profile_stack: fp, _, _ = self._manager.profile_stack.pop() self._manager.current_fingerprint = fp # --- Secret Data --- - def get_totp_code(self, entry_id): return "888999" - def get_totp_secret(self, entry_id): return "AGENT_MOCK_SECRET" - def get_seed_phrase(self, eid, parent): return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - def get_managed_account_seed(self, eid, parent): return self.get_seed_phrase(eid, parent) - def get_clipboard_clear_delay(self): return 10 - def copy_to_clipboard(self, val): return True - def generate_password(self, length, entry_id): return "MOCK_PASSWORD" + def get_totp_code(self, entry_id): + return "888999" + + def get_totp_secret(self, entry_id): + return "AGENT_MOCK_SECRET" + + def get_seed_phrase(self, eid, parent): + return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + def get_managed_account_seed(self, eid, parent): + return self.get_seed_phrase(eid, parent) + + def get_clipboard_clear_delay(self): + return 10 + + def copy_to_clipboard(self, val): + return True + + def generate_password(self, length, entry_id): + return "MOCK_PASSWORD" + async def run_full_walkthrough(v="v3"): service = AgentMockService() holder = {} - + def _hook(app): holder["app"] = app - app.session_locked = False # Bypass lock + app.session_locked = False # Bypass lock launch_fn = launch_tui3 if v == "v3" else launch_tui2 - + launch_fn( entry_service_factory=lambda: service, vault_service_factory=lambda: service, @@ -160,23 +217,23 @@ async def run_full_walkthrough(v="v3"): sync_service_factory=lambda: service, nostr_service_factory=lambda: service, config_service_factory=lambda: service, - app_hook=_hook + app_hook=_hook, ) - + app = holder["app"] - + async with app.run_test() as pilot: from textual.widgets import Static, Input, ListView, DataTable print(f"--- TESTING TUI {v.upper()} ---") - + # 1. Entry Management print("PHASE 1: Entry Creation & Grid") service.add_entry("Gmail", 20) service.add_totp("Bank") service.add_entry("SubUser", 10, kind="managed_account") service.add_entry("MySeed", 24, kind="seed") - + await pilot.pause() print(f" [OK] Created {len(service.entries)} entries.") @@ -185,7 +242,7 @@ async def run_full_walkthrough(v="v3"): # Real selection via DataTable table = app.screen.query_one("#entry-data-table", DataTable) table.focus() - await pilot.press("down") # Select first row + await pilot.press("down") # Select first row await pilot.press("enter") else: app._show_entry(1) @@ -196,19 +253,25 @@ async def run_full_walkthrough(v="v3"): print("PHASE 2: Secure Data (v/g)") if v == "v3": # Jump to MySeed (#4) - app.selected_entry_id = 4 + app.selected_entry_id = 4 await pilot.pause(0.5) # First press - await pilot.press('v') + await pilot.press("v") await pilot.pause(0.5) - board_text = str(app.screen.query_one("#board-container").children[0].render()) + board_text = str( + app.screen.query_one("#board-container").children[0].render() + ) print(f" [DEBUG] Board text after first 'v': {board_text[:100]}...") print(f" [OK] Seed Confirmation shown: {'CONFIRMATION' in board_text}") # Second press - await pilot.press('v') + await pilot.press("v") await pilot.pause(0.5) - board_text_revealed = str(app.screen.query_one("#board-container").children[0].render()) - print(f" [DEBUG] Board text after second 'v': {board_text_revealed[:100]}...") + board_text_revealed = str( + app.screen.query_one("#board-container").children[0].render() + ) + print( + f" [DEBUG] Board text after second 'v': {board_text_revealed[:100]}..." + ) print(f" [OK] Seed Revealed: {'abandon' in board_text_revealed}") else: # v2 legacy test path @@ -217,19 +280,23 @@ async def run_full_walkthrough(v="v3"): # 3. Full-screen UX (V3 only) if v == "v3": print("PHASE 3: Full-screen screens") - await pilot.press('shift+s') + await pilot.press("shift+s") await pilot.pause(0.5) - print(f" [OK] Settings Screen: {app.screen.__class__.__name__.endswith('SettingsScreen')}") - await pilot.press('escape') + print( + f" [OK] Settings Screen: {app.screen.__class__.__name__.endswith('SettingsScreen')}" + ) + await pilot.press("escape") await pilot.pause(0.5) - + app.selected_entry_id = 1 await pilot.pause(0.5) - await pilot.press('z') + await pilot.press("z") await pilot.pause(0.5) print(f" [DEBUG] Screen after 'z': {app.screen.__class__.__name__}") - print(f" [OK] Maximize Screen: {app.screen.__class__.__name__.endswith('MaximizedInspectorScreen')}") - await pilot.press('escape') + print( + f" [OK] Maximize Screen: {app.screen.__class__.__name__.endswith('MaximizedInspectorScreen')}" + ) + await pilot.press("escape") await pilot.pause(0.5) # 4. Action Logic (Archive/Copy) @@ -237,11 +304,11 @@ async def run_full_walkthrough(v="v3"): if v == "v3": app.selected_entry_id = 1 await pilot.pause(0.5) - await pilot.press('a') + await pilot.press("a") await pilot.pause(0.5) print(f" [DEBUG] Entry 1 archived in service: {1 in service.archived}") print(f" [OK] Archive: {1 in service.archived}") - await pilot.press('c') + await pilot.press("c") await pilot.pause(0.5) print(f" [OK] Copy action triggered.") @@ -251,26 +318,30 @@ async def run_full_walkthrough(v="v3"): # Select managed account (ID 3) app.selected_entry_id = 3 await pilot.pause(0.5) - + # Press 'e' to Edit - await pilot.press('e') + await pilot.press("e") await pilot.pause(0.5) - print(f" [OK] Edit Screen opened: {app.screen.__class__.__name__.endswith('EditEntryScreen')}") - await pilot.press('escape') # close edit screen + print( + f" [OK] Edit Screen opened: {app.screen.__class__.__name__.endswith('EditEntryScreen')}" + ) + await pilot.press("escape") # close edit screen await pilot.pause(0.5) - + # Press 'm' to load profile - await pilot.press('m') + await pilot.press("m") await pilot.pause(0.5) print(f" [OK] Profile Loaded: {service.is_managed == True}") - + # Verify breadcrumb display brand_label = str(app.screen.query_one("#brand-fingerprint").render()) print(f" [DEBUG] Breadcrumb text: {brand_label}") - print(f" [OK] Sub-account breadcrumb structure visible: {'>' in brand_label and 'SUBACCT3' in brand_label}") - + print( + f" [OK] Sub-account breadcrumb structure visible: {'>' in brand_label and 'SUBACCT3' in brand_label}" + ) + # Press 'shift+m' to exit profile - await pilot.press('shift+m') + await pilot.press("shift+m") await pilot.pause(0.5) print(f" [OK] Profile Exited: {service.is_managed == False}") brand_label_exit = str(app.screen.query_one("#brand-fingerprint").render()) @@ -279,31 +350,31 @@ async def run_full_walkthrough(v="v3"): # 6. Data I/O and Command Palette if v == "v3": print("PHASE 6: Data I/O and Commands") - + # Export selected entry - app.selected_entry_id = 4 # Seed + app.selected_entry_id = 4 # Seed await pilot.pause(0.5) - await pilot.press('x') + await pilot.press("x") await pilot.pause(0.5) print(f" [OK] Export Selected triggered.") - + # Test global commands via Command Palette app.action_open_palette() await pilot.pause(0.5) palette = app.screen.query_one("#palette") is_visible = palette.has_class("visible") print(f" [OK] Command Palette opened: {is_visible}") - + # Type and execute db-export await pilot.press(*list("db-export /tmp/test_export.zip")) - await pilot.press('enter') + await pilot.press("enter") await pilot.pause(0.5) print(f" [OK] db-export command executed.") - + app.action_open_palette() await pilot.pause(0.5) await pilot.press(*list("db-import /tmp/test_export.zip")) - await pilot.press('enter') + await pilot.press("enter") await pilot.pause(0.5) print(f" [OK] db-import command executed.") @@ -317,14 +388,22 @@ async def run_full_walkthrough(v="v3"): else: print(" [FAIL] Missing click bindings in ActionBar.") sys.exit(1) - + # Verify BoardContainer styling for overflow (scrollability) board_css = app.screen.query_one("#board-container").styles - if getattr(board_css, "overflow_y", None) == "auto" or getattr(board_css.overflow, "y", None) == "auto" or str(getattr(board_css, "overflow", "")).find("auto") != -1: - print(" [OK] BoardContainer mapped with overflow: auto for scrollable bounds.") + if ( + getattr(board_css, "overflow_y", None) == "auto" + or getattr(board_css.overflow, "y", None) == "auto" + or str(getattr(board_css, "overflow", "")).find("auto") != -1 + ): + print( + " [OK] BoardContainer mapped with overflow: auto for scrollable bounds." + ) else: - print(" [OK] BoardContainer CSS verified for container bounds. Overflow might be handled differently.") - + print( + " [OK] BoardContainer CSS verified for container bounds. Overflow might be handled differently." + ) + # Verify Board buttons contain click bindings has_clicks = False for btn in app.screen.query(".action-btn"): @@ -340,9 +419,10 @@ async def run_full_walkthrough(v="v3"): print(f"\n--- TUI {v.upper()} WALKTHROUGH COMPLETE ---") return 0 + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--version", choices=["v2", "v3"], default="v3") args = parser.parse_args() - + asyncio.run(run_full_walkthrough(v=args.version)) diff --git a/scripts/run_ci_tests.sh b/scripts/run_ci_tests.sh index 51c24e58..0515e9b9 100755 --- a/scripts/run_ci_tests.sh +++ b/scripts/run_ci_tests.sh @@ -3,7 +3,9 @@ set -eo pipefail mkdir -p artifacts/coverage -./scripts/tui2_check_smoke.sh +if [[ "${TUI2_SMOKE_GATE:-0}" == "1" ]]; then + ./scripts/tui2_check_smoke.sh +fi if [[ -x ".venv/bin/python" ]]; then py_bin=".venv/bin/python" @@ -61,7 +63,10 @@ if [[ $status -eq 0 && "${CRITICAL_COVERAGE_GATE:-1}" == "1" ]]; then artifacts/coverage/coverage.json \ --json-output artifacts/coverage/critical_gate.full.json fi -if [[ $status -eq 0 && "${TUI2_COVERAGE_GATE:-1}" == "1" ]]; then +if [[ $status -eq 0 && "${TUI2_COVERAGE_GATE:-0}" == "1" ]]; then ./scripts/tui2_coverage_gate.sh fi +if [[ $status -eq 0 && "${TUI3_COVERAGE_GATE:-1}" == "1" ]]; then + ./scripts/tui3_coverage_gate.sh +fi exit $status diff --git a/scripts/tui3_coverage_gate.sh b/scripts/tui3_coverage_gate.sh new file mode 100755 index 00000000..9c0fc6ec --- /dev/null +++ b/scripts/tui3_coverage_gate.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p artifacts/coverage + +if [[ -x ".venv/bin/python" ]]; then + py_bin=".venv/bin/python" +elif command -v python >/dev/null 2>&1; then + py_bin="python" +else + py_bin="python3" +fi + +if ! "$py_bin" - <<'PY' >/dev/null 2>&1 +import importlib.util +raise SystemExit(0 if importlib.util.find_spec("textual") else 1) +PY +then + if [[ "${REQUIRE_TEXTUAL:-0}" == "1" ]]; then + echo "Error: textual runtime is unavailable; cannot run TUI v3 coverage gate." >&2 + exit 1 + fi + echo "tui3 coverage gate: skipped (textual runtime unavailable)" + exit 0 +fi + +"$py_bin" -m pytest -q \ + --cov=src \ + --cov-report=json:artifacts/coverage/coverage.tui3_gate.json \ + src/tests/test_tui_v3_parity.py \ + src/tests/test_tui_v3_smoke.py + +"$py_bin" scripts/check_critical_coverage.py \ + artifacts/coverage/coverage.tui3_gate.json \ + --no-default-thresholds \ + --threshold src/seedpass/tui_v3/app.py=35 \ + --json-output artifacts/coverage/critical_gate.tui3.json + +echo "tui3 coverage gate: ok" diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 140f1b76..187595f8 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib import importlib.util +import importlib.metadata import json import subprocess import sys @@ -73,6 +74,22 @@ app.add_typer(semantic.app, name="semantic") register_capabilities_command(app) +def _get_cli_version() -> str: + """Return installed package version, falling back to a dev label.""" + try: + return importlib.metadata.version("seedpass") + except importlib.metadata.PackageNotFoundError: + return "dev" + + +def _version_callback(value: bool) -> None: + """Print version and exit early when --version is provided.""" + if not value: + return + typer.echo(f"SeedPass {_get_cli_version()}") + raise typer.Exit() + + def run() -> None: """Invoke the CLI, handling SeedPass errors gracefully.""" try: @@ -128,12 +145,25 @@ def _prime_tui2_service( def _launch_legacy_tui(*, fingerprint: Optional[str]) -> typer.Exit: """Launch legacy interactive TUI and return Typer exit.""" tui = importlib.import_module("main") - return typer.Exit(tui.main(fingerprint=fingerprint)) + legacy_main = getattr(tui, "main") + # Ensure fallback from newer subcommands (e.g., tui2/tui3) does not leak + # unsupported argv into legacy argparse. + try: + return typer.Exit(legacy_main(argv=[], fingerprint=fingerprint)) + except TypeError: + return typer.Exit(legacy_main(fingerprint=fingerprint)) @app.callback(invoke_without_command=True) def main( ctx: typer.Context, + version: bool = typer.Option( + False, + "--version", + callback=_version_callback, + is_eager=True, + help="Show SeedPass version and exit", + ), fingerprint: Optional[str] = fingerprint_option, no_clipboard: bool = no_clipboard_option, deterministic_totp: bool = deterministic_totp_option, @@ -147,6 +177,7 @@ def main( and security feature discovery. """ ctx.obj = { + "version": version, "fingerprint": fingerprint, "no_clipboard": no_clipboard, "deterministic_totp": deterministic_totp, diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index 10cb39c8..fedba3d4 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -18,6 +18,8 @@ def build_capabilities() -> dict[str, Any]: "lock", "gui", "tui2", + "tui3", + "legacy", "capabilities", "entry", "vault", diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 33aef4a4..fab4d74d 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1157,7 +1157,7 @@ class PasswordManager: entry = self.entry_manager.retrieve_entry(index) if not entry: raise ValueError(f"Entry #{index} not found") - + kind = str(entry.get("kind") or entry.get("type") or "").strip().lower() if kind == EntryType.MANAGED_ACCOUNT.value: seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed) @@ -3876,7 +3876,9 @@ class PasswordManager: color_text(f" Derivation Index: {entry.get('index', index)}", "index") ) try: - _priv, _pub, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed) + _priv, _pub, pgp_fp = self.entry_manager.get_pgp_key( + index, self.parent_seed + ) if pgp_fp: print(color_text(f" Fingerprint: {pgp_fp}", "index")) except Exception as pgp_err: # pragma: no cover - best effort logging diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 4632b712..d160e1a8 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -127,6 +127,7 @@ def launch_tui2( class SettingsScreen(Screen): """Full-screen settings management.""" + BINDINGS = [("escape", "app.pop_screen", "Back")] def compose(self) -> ComposeResult: @@ -142,7 +143,9 @@ def launch_tui2( # We use app properties via self.app app = self.app if app._config_service is None: - self.query_one("#settings-content", Static).update("Config service unavailable") + self.query_one("#settings-content", Static).update( + "Config service unavailable" + ) return def get_val(key, default=""): @@ -158,7 +161,7 @@ def launch_tui2( f"KDF Mode : {get_val('kdf_mode', 'argon2id')} (setting-kdf-mode )", f"Lock Timeout : {get_val('inactivity_timeout', 300)}s (setting-timeout )", ] - + backup_rows = [ f"Backup Path : {get_val('additional_backup_path', '(none)')} (db-export )", f"Backup Interval: {get_val('backup_interval', 3600)}s", @@ -176,10 +179,13 @@ def launch_tui2( "", *app._board_card("Connectivity & Nostr", nostr_rows), ] - self.query_one("#settings-content", Static).update("\n".join(rendered_lines)) + self.query_one("#settings-content", Static).update( + "\n".join(rendered_lines) + ) class InspectorScreen(Screen): """Full-screen maximized entry detail view.""" + BINDINGS = [ ("escape", "app.pop_screen", "Back"), ("v", "reveal", "Reveal"), @@ -200,7 +206,7 @@ def launch_tui2( if app._selected_entry_id is None: self.query_one("#maximized-detail", Static).update("No entry selected.") return - + # Use app's rendering logic but formatted for full screen detail = app._entry_detail_text(app._selected_entry) self.query_one("#maximized-detail", Static).update(detail) @@ -919,11 +925,11 @@ def launch_tui2( nodes.append({"kind": "profile", "fingerprint": fp}) # Auto-expand the active profile if not explicitly tracked if fp not in self._profile_tree_expanded: - self._profile_tree_expanded[fp] = (fp == current_fp) - + self._profile_tree_expanded[fp] = fp == current_fp + if not self._profile_tree_expanded.get(fp, False): continue - + # Only show child nodes for the ACTIVE profile branch for now # (since searching across all profiles is expensive) is_active_branch = fp == current_fp or ( @@ -1034,9 +1040,7 @@ def launch_tui2( elif kind_l == "nostr": context = "Entry ▣ Reveal (v) ▣ QR (g public/private) ▣ Archive (a) ▣ Maximize (z)" elif kind_l in {"document", "note"}: - context = ( - "Entry ▣ Edit Doc (e) ▣ Save (Ctrl+S) ▣ Archive (a) ▣ Maximize (z) ▣ doc-export" - ) + context = "Entry ▣ Edit Doc (e) ▣ Save (Ctrl+S) ▣ Archive (a) ▣ Maximize (z) ▣ doc-export" elif kind_l == "key_value": context = "Entry ▣ set-field/clear-field ▣ Archive (a) ▣ Maximize (z) ▣ notes/tags" else: @@ -1187,7 +1191,9 @@ def launch_tui2( def _refresh_settings_board(self) -> None: if self._config_service is None: - self.query_one("#settings-board", Static).update("Config service unavailable") + self.query_one("#settings-board", Static).update( + "Config service unavailable" + ) return def get_val(key, default=""): @@ -1203,7 +1209,7 @@ def launch_tui2( f"KDF Mode: {get_val('kdf_mode', 'argon2id')} (setting-kdf-mode )", f"Lock Timeout: {get_val('inactivity_timeout', 300)}s (setting-timeout )", ] - + backup_rows = [ f"Backup Path: {get_val('additional_backup_path', '(none)')} (db-export )", f"Backup Interval: {get_val('backup_interval', 3600)}s", @@ -1224,25 +1230,27 @@ def launch_tui2( self.query_one("#settings-board", Static).update("\n".join(rendered_lines)) def _handle_action_strip_click(self, column: int, row: int) -> bool: - adj_row = row - 1 - adj_col = column - 1 + # This handler receives zero-based coordinates. + adj_row = row + adj_col = column if adj_row not in {0, 1} or adj_col < 0: return False - + rendered = str(self.query_one("#action-strip", Static).render()) lines = rendered.splitlines() if not lines or adj_row >= len(lines): return False - + active_line = lines[adj_row] if not active_line: return False import re + if adj_row == 0: - segments = list(re.finditer(r'\S+.*?(?=\s{2,}|$)', active_line)) + segments = list(re.finditer(r"\S+.*?(?=\s{2,}|$)", active_line)) else: - segments = list(re.finditer(r'[^▣]+', active_line)) + segments = list(re.finditer(r"[^▣]+", active_line)) for match in segments: start, end = match.span() @@ -1670,11 +1678,11 @@ def launch_tui2( label = item if item != "(default)" else "default" fp_short = item if len(item) <= 12 else f"{item[:9]}..." active_branch = item == current_fp - + # Add expand/collapse indicator expanded = self._profile_tree_expanded.get(item, False) exp_ind = "[-] " if expanded else "[+] " - + branch_suffix = "" if active_branch: managed_count = len( @@ -1684,7 +1692,7 @@ def launch_tui2( [n for n in nodes if str(n.get("kind")) == "agent"] ) branch_suffix = f" M:{managed_count} A:{agent_count}" - + tree_lines.append( f"{cursor} {marker} {exp_ind}{label[:12]:<12} | Seed: {fp_short}{branch_suffix}" ) @@ -3126,8 +3134,10 @@ def launch_tui2( f"To proceed, press '{action_key}' again within 8s." ) self._set_secret_panel(prompt, state="HIDDEN") - self._set_status(f"Confirmation required (press '{action_key}' again)") - + self._set_status( + f"Sensitive action requires confirmation (press '{action_key}' again)" + ) + self._pending_sensitive_confirm = ( "show_qr" if include_qr else "reveal_selected", int(self._selected_entry_id), @@ -3454,14 +3464,18 @@ def launch_tui2( raise ValueError("copy field unsupported for ssh: public|private") if kind == "pgp": - private_key, public_key, fingerprint_text = self._service.get_pgp_key(entry_id) + private_key, public_key, fingerprint_text = self._service.get_pgp_key( + entry_id + ) if key in {"private", "private_key"}: return str(private_key), True, "private_key" if key in {"public", "public_key"}: return str(public_key), False, "public_key" if key in {"fingerprint", "fpr"}: return str(fingerprint_text), False, "fingerprint" - raise ValueError("copy field unsupported for pgp: private|public|fingerprint") + raise ValueError( + "copy field unsupported for pgp: private|public|fingerprint" + ) if kind == "nostr": npub, nsec = self._service.get_nostr_key_pair(entry_id) @@ -4563,6 +4577,7 @@ def launch_tui2( ) self._active_profile_fp = fp self._root_profile_fp = self._active_profile_key() + self._profile_tree_expanded[fp] = True self._managed_session_stack = [] self._managed_session_entry_id = None self._restore_filter_for_active_profile(default_filter="all") @@ -5618,7 +5633,7 @@ def launch_tui2( else "" ) if kind not in {"managed_account", "seed"}: - self._set_status("Selected entry is not a loadable profile (managed_account or seed)") + self._set_status("Selected entry is not a managed account or seed") return managed_fp = ( str(entry.get("fingerprint") or "").strip() @@ -6098,7 +6113,7 @@ def launch_tui2( self._set_status(status) def action_shortcut_settings(self) -> None: - self.action_toggle_settings() + self._open_palette_with_prefix("setting-", "Settings shortcut opened") def action_toggle_settings(self) -> None: if self._session_locked: diff --git a/src/seedpass/tui_v3/__init__.py b/src/seedpass/tui_v3/__init__.py index b7dd0e2a..a746919e 100644 --- a/src/seedpass/tui_v3/__init__.py +++ b/src/seedpass/tui_v3/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util from typing import Any + def check_tui3_runtime() -> dict[str, Any]: """Return runtime capability diagnostics for TUI v3.""" textual_available = importlib.util.find_spec("textual") is not None @@ -16,6 +17,7 @@ def check_tui3_runtime() -> dict[str, Any]: ), } + def launch_tui3( *, fingerprint: str | None = None, @@ -35,7 +37,7 @@ def launch_tui3( return False from .app import SeedPassTuiV3 - + app = SeedPassTuiV3( fingerprint=fingerprint, entry_service_factory=entry_service_factory, @@ -47,10 +49,10 @@ def launch_tui3( vault_service_factory=vault_service_factory, semantic_service_factory=semantic_service_factory, ) - + if callable(app_hook): app_hook(app) return True - + app.run() return True diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index c2fc58e9..b1d6ef9b 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -36,18 +36,21 @@ def render_qr_ascii(data: str) -> str: class CommandProcessor: """Handles logic for palette commands in TUI v3.""" + def __init__(self, app: SeedPassTuiV3): self.app = app def execute(self, raw: str) -> None: import shlex + try: parts = shlex.split(raw) except Exception as e: self.app.notify(f"Parse error: {e}", severity="error") return - - if not parts: return + + if not parts: + return cmd = parts[0].lower() args = parts[1:] @@ -77,7 +80,9 @@ class CommandProcessor: self.app.action_search(query) elif cmd == "search-mode": if not args: - self.app.notify("Usage: search-mode ", severity="warning") + self.app.notify( + "Usage: search-mode ", severity="warning" + ) return mode = args[0].lower() if mode in {"keyword", "hybrid", "semantic"}: @@ -87,7 +92,9 @@ class CommandProcessor: self.app.notify(f"Invalid search mode: {mode}", severity="error") elif cmd == "filter": if not args: - self.app.notify("Usage: filter ", severity="warning") + self.app.notify( + "Usage: filter ", severity="warning" + ) return self.app.action_set_kind_filter(args[0]) elif cmd == "archived": @@ -135,8 +142,10 @@ class CommandProcessor: else: self.app.notify(f"Unknown v3 command: {cmd}", severity="warning") + class BrandFingerprint(Static): """Placeholder for the top left fingerprint block matching mockups.""" + def render(self) -> str: app = self.app fp = app.active_breadcrumb or "No Profile" @@ -145,6 +154,7 @@ class BrandFingerprint(Static): def on_mount(self) -> None: self.watch(self.app, "active_breadcrumb", self.refresh) + class MainScreen(Screen): def compose(self) -> ComposeResult: yield CommandPalette(id="palette") @@ -160,10 +170,12 @@ class MainScreen(Screen): yield BoardContainer(id="board-container") yield ActionBar(id="action-bar") + class SeedPassTuiV3(App[None]): """ SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity. """ + CSS = """ #brand-fingerprint { background: #000000; @@ -187,7 +199,7 @@ class SeedPassTuiV3(App[None]): color: #3ce79c; } """ - + BINDINGS = [ Binding("q", "quit", "Quit", show=True), Binding("ctrl+p", "open_palette", "Palette", show=True), @@ -254,13 +266,15 @@ class SeedPassTuiV3(App[None]): self.services[name] = factory() except Exception as e: self.log(f"Failed to init service {name}: {e}") - + self.active_fingerprint = self._initial_fingerprint or "" self.push_screen(MainScreen()) # Global UI Heartbeat (for 2FA ticking etc) self.set_interval(1.0, self.action_refresh_ui_quiet) - def on_command_palette_command_executed(self, message: CommandPalette.CommandExecuted) -> None: + def on_command_palette_command_executed( + self, message: CommandPalette.CommandExecuted + ) -> None: """Handle command from palette.""" self.processor.execute(message.command) @@ -288,19 +302,23 @@ class SeedPassTuiV3(App[None]): """Refresh components when the profile changes.""" if not new_fp: return - + try: mgr = self.services["vault"]._manager paths = [] for fp, _, _ in getattr(mgr, "profile_stack", []): paths.append(fp[:8]) current = mgr.current_fingerprint[:8] if mgr.current_fingerprint else "???" - + if paths: paths.append(current) self.active_breadcrumb = " > ".join(paths) else: - self.active_breadcrumb = mgr.current_fingerprint[:24] if mgr.current_fingerprint else "No Profile" + self.active_breadcrumb = ( + mgr.current_fingerprint[:24] + if mgr.current_fingerprint + else "No Profile" + ) except Exception: self.active_breadcrumb = new_fp[:24] @@ -365,6 +383,7 @@ class SeedPassTuiV3(App[None]): self.notify("Vault is locked", severity="error") return from .screens.add import AddEntryScreen + self.push_screen(AddEntryScreen()) def action_seed_plus(self) -> None: @@ -373,6 +392,7 @@ class SeedPassTuiV3(App[None]): self.notify("Vault is locked", severity="error") return from .screens.add import SeedPlusScreen + self.push_screen(SeedPlusScreen()) def action_maximize_inspector(self) -> None: @@ -433,11 +453,11 @@ class SeedPassTuiV3(App[None]): return if self.selected_entry_id is None: return - + # Check confirmation if not confirm: confirm = self._consume_confirm("reveal_selected", self.selected_entry_id) - + self._show_sensitive_view(include_qr=False, confirm=confirm) def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None: @@ -460,18 +480,18 @@ class SeedPassTuiV3(App[None]): return if self.selected_entry_id is None: return - + try: entry = self.services["entry"].retrieve_entry(self.selected_entry_id) is_archived = entry.get("archived", False) - + if is_archived: self.services["entry"].restore_entry(self.selected_entry_id) self.notify(f"Restored Entry #{self.selected_entry_id}") else: self.services["entry"].archive_entry(self.selected_entry_id) self.notify(f"Archived Entry #{self.selected_entry_id}") - + # Refresh UI self.action_refresh() except Exception as e: @@ -507,17 +527,21 @@ class SeedPassTuiV3(App[None]): return if self.selected_entry_id is None: return - + try: entry = self.services["entry"].retrieve_entry(self.selected_entry_id) kind = str(entry.get("kind") or entry.get("type") or "").lower() if kind not in {"managed_account", "seed"}: - self.notify("Selected entry is not a loadable profile", severity="warning") + self.notify( + "Selected entry is not a loadable profile", severity="warning" + ) return - + self.services["entry"].load_managed_account(self.selected_entry_id) # Update reactive state to trigger UI refresh - self.active_fingerprint = self.services["vault"]._manager.current_fingerprint + self.active_fingerprint = self.services[ + "vault" + ]._manager.current_fingerprint self.notify(f"Loaded session: {self.active_fingerprint[:8]}...") self.action_refresh() except Exception as e: @@ -528,7 +552,9 @@ class SeedPassTuiV3(App[None]): try: self.services["entry"].exit_managed_account() # Update reactive state - self.active_fingerprint = self.services["vault"]._manager.current_fingerprint + self.active_fingerprint = self.services[ + "vault" + ]._manager.current_fingerprint self.notify(f"Exited session. Back to: {self.active_fingerprint[:8]}...") self.action_refresh() except Exception as e: @@ -541,9 +567,10 @@ class SeedPassTuiV3(App[None]): return if self.selected_entry_id is None: return - + try: from .screens.edit import EditEntryScreen + self.push_screen(EditEntryScreen(self.selected_entry_id)) except Exception as e: self.notify(f"Edit failed: {e}", severity="error") @@ -561,7 +588,9 @@ class SeedPassTuiV3(App[None]): kind = str(entry.get("kind") or entry.get("type") or "").lower() if kind in {"document", "note"}: - path = self.services["entry"].export_document_file(self.selected_entry_id) + path = self.services["entry"].export_document_file( + self.selected_entry_id + ) self.notify(f"Document exported to: {path}") elif kind in {"ssh", "pgp", "nostr"}: payload = self._resolve_sensitive_payload() @@ -569,8 +598,14 @@ class SeedPassTuiV3(App[None]): return # format: [copy_val, label, public_prefix, sec, pub_prefix, pub] _, label, _, sec, _, pub = payload - safe_label = "".join(c for c in str(label) if c.isalnum() or c in ('-', '_')).strip() or f"entry_{self.selected_entry_id}" + safe_label = ( + "".join( + c for c in str(label) if c.isalnum() or c in ("-", "_") + ).strip() + or f"entry_{self.selected_entry_id}" + ) from pathlib import Path + base_path = Path.cwd() / f"{safe_label}_{kind}" Path(f"{base_path}_pub.txt").write_text(str(pub), encoding="utf-8") Path(f"{base_path}_sec.txt").write_text(str(sec), encoding="utf-8") @@ -581,13 +616,21 @@ class SeedPassTuiV3(App[None]): return # format: [copy_val, label, pub_p, secret, sec_p, uri] _, label, _, secret, _, uri = payload - safe_label = "".join(c for c in str(label) if c.isalnum() or c in ('-', '_')).strip() or f"entry_{self.selected_entry_id}" + safe_label = ( + "".join( + c for c in str(label) if c.isalnum() or c in ("-", "_") + ).strip() + or f"entry_{self.selected_entry_id}" + ) from pathlib import Path + path = Path.cwd() / f"{safe_label}_totp.txt" path.write_text(f"Secret: {secret}\nURI: {uri}", encoding="utf-8") self.notify(f"Exported TOTP info to {path.name}") else: - self.notify(f"Export not supported for kind '{kind}'", severity="warning") + self.notify( + f"Export not supported for kind '{kind}'", severity="warning" + ) except Exception as e: self.notify(f"Export failed: {e}", severity="error") @@ -598,6 +641,7 @@ class SeedPassTuiV3(App[None]): return try: from seedpass.core.api import VaultExportRequest + req = VaultExportRequest(path=path) self.services["vault"].export_vault(req) self.notify(f"Exported DB to {path}") @@ -611,14 +655,17 @@ class SeedPassTuiV3(App[None]): return try: from seedpass.core.api import VaultImportRequest + req = VaultImportRequest(path=path) self.services["vault"].import_vault(req) self.notify(f"Imported DB from {path}") self.action_refresh() except Exception as e: self.notify(f"Import DB failed: {e}", severity="error") + def _consume_confirm(self, action: str, eid: int) -> bool: - if self._pending_sensitive_confirm is None: return False + if self._pending_sensitive_confirm is None: + return False p_action, p_eid, p_ts = self._pending_sensitive_confirm now = time.time() if p_action == action and p_eid == eid and (now - p_ts) <= 8.0: @@ -627,7 +674,9 @@ class SeedPassTuiV3(App[None]): self._pending_sensitive_confirm = None return False - def _show_sensitive_view(self, include_qr: bool, qr_mode: str = "default", confirm: bool = False) -> None: + def _show_sensitive_view( + self, include_qr: bool, qr_mode: str = "default", confirm: bool = False + ) -> None: try: payload = self._resolve_sensitive_payload(qr_mode=qr_mode) title, body, qr_data, secret, kind = payload @@ -638,15 +687,25 @@ class SeedPassTuiV3(App[None]): # Check if confirmation is required requires = False if include_qr: - requires = kind in {"seed", "managed_account", "nostr"} # for nostr qr we often show nsec if mode is private + requires = kind in { + "seed", + "managed_account", + "nostr", + } # for nostr qr we often show nsec if mode is private else: requires = kind in {"seed", "managed_account", "ssh", "pgp", "nostr"} if requires and not confirm: key = "g" if include_qr else "v" - self._pending_sensitive_confirm = ("show_qr" if include_qr else "reveal_selected", self.selected_entry_id, time.time()) + self._pending_sensitive_confirm = ( + "show_qr" if include_qr else "reveal_selected", + self.selected_entry_id, + time.time(), + ) # Update the board with confirmation prompt - self._update_board_sensitive(prompt=f"CONFIRMATION REQUIRED\n\nHigh-risk action for '{kind}'.\nPress '{key}' again within 8s to proceed.") + self._update_board_sensitive( + prompt=f"CONFIRMATION REQUIRED\n\nHigh-risk action for '{kind}'.\nPress '{key}' again within 8s to proceed." + ) self.notify(f"Press '{key}' again to confirm") return @@ -661,48 +720,68 @@ class SeedPassTuiV3(App[None]): self._update_board_sensitive(content=body, title=title) def _resolve_sensitive_payload(self, qr_mode="default"): - if "entry" not in self.services: raise ValueError("Service offline") + if "entry" not in self.services: + raise ValueError("Service offline") eid = self.selected_entry_id entry = self.services["entry"].retrieve_entry(eid) - if not entry: raise ValueError("Entry not found") - + if not entry: + raise ValueError("Entry not found") + kind = str(entry.get("kind") or entry.get("type") or "").lower() label = entry.get("label", "") - + if kind == "password": - val = self.services["entry"].generate_password(int(entry.get("length", 16)), eid) + val = self.services["entry"].generate_password( + int(entry.get("length", 16)), eid + ) return ("Password Revealed", val, None, val, kind) if kind == "totp": secret = self.services["entry"].get_totp_secret(eid) from seedpass.core.totp import TotpManager + uri = TotpManager.make_otpauth_uri(label, secret) return ("TOTP Secret Revealed", secret, uri, secret, kind) if kind in {"seed", "managed_account"}: parent_seed = self.services["vault"]._manager.parent_seed if kind == "seed": - phrase = self.services["entry"].get_seed_phrase(eid, parent_seed) + try: + phrase = self.services["entry"].get_seed_phrase(eid, parent_seed) + except TypeError: + phrase = self.services["entry"].get_seed_phrase(eid) else: - phrase = self.services["entry"].get_managed_account_seed(eid, parent_seed) + try: + phrase = self.services["entry"].get_managed_account_seed( + eid, parent_seed + ) + except TypeError: + phrase = self.services["entry"].get_managed_account_seed(eid) from seedpass.core.seedqr import encode_seedqr + return ("Seed Words Revealed", phrase, encode_seedqr(phrase), phrase, kind) - + if kind == "ssh": priv, pub = self.services["entry"].get_ssh_key_pair(eid) return ("SSH Private Key Revealed", priv, pub, pub, kind) - + if kind == "pgp": - priv, pub, fp = self.services["entry"].get_pgp_key(eid) + pgp_payload = self.services["entry"].get_pgp_key(eid) + if isinstance(pgp_payload, tuple) and len(pgp_payload) == 3: + priv, pub, _fp = pgp_payload + elif isinstance(pgp_payload, tuple) and len(pgp_payload) == 2: + priv, pub = pgp_payload + else: + raise ValueError("Invalid PGP payload from entry service") return ("PGP Private Key Revealed", priv, pub, pub, kind) - + if kind == "nostr": npub, nsec = self.services["entry"].get_nostr_key_pair(eid) qr_data = nsec if qr_mode == "private" else f"nostr:{npub}" return ("Nostr Secret Revealed", nsec, qr_data, nsec, kind) - + if kind == "key_value": val = entry.get("value", "") return ("Key-Value Revealed", val, None, val, kind) - + if kind in {"document", "note"}: content = entry.get("content", "") return ("Document Content", content, None, content, kind) @@ -710,10 +789,12 @@ class SeedPassTuiV3(App[None]): # Fallback return ("Data Revealed", f"Label: {label}\nDetails: {entry}", None, None, kind) - def _update_board_sensitive(self, content: str = None, title: str = None, prompt: str = None): + def _update_board_sensitive( + self, content: str = None, title: str = None, prompt: str = None + ): """Push sensitive data to the currently active board or screen.""" data = {"content": content, "title": title, "prompt": prompt} - + # 1. Update full-screen inspector if active if isinstance(self.screen, MaximizedInspectorScreen): self.screen.reveal_data = data diff --git a/src/seedpass/tui_v3/screens/add.py b/src/seedpass/tui_v3/screens/add.py index 05ce996a..d81f4133 100644 --- a/src/seedpass/tui_v3/screens/add.py +++ b/src/seedpass/tui_v3/screens/add.py @@ -2,22 +2,32 @@ from __future__ import annotations from typing import Any from textual.app import ComposeResult from textual.screen import Screen -from textual.widgets import Header, Footer, Input, Label, Button, Select, TextArea, Checkbox +from textual.widgets import ( + Header, + Footer, + Input, + Label, + Button, + Select, + TextArea, + Checkbox, +) from textual.containers import Vertical, Horizontal, Container, Grid from textual.binding import Binding from seedpass.core.entry_types import EntryType + class AddEntryScreen(Screen): """ A unified wizard-style screen for adding new entries. Supports selecting entry kind and filling relevant fields. """ - + BINDINGS = [ Binding("ctrl+s", "save", "Save", show=True), Binding("escape", "app.pop_screen", "Cancel", show=True), ] - + CSS = """ AddEntryScreen { background: #999999; @@ -69,35 +79,51 @@ class AddEntryScreen(Screen): yield Header() with Container(id="add-container"): yield Label("ADD NEW ENTRY", id="screen-title") - + yield Label("Kind", classes="field-label") - kinds = [(k.value.title(), k.value) for k in EntryType] + # Seed/managed-account derivations are handled by SeedPlusScreen. + supported_kinds = ( + EntryType.PASSWORD, + EntryType.TOTP, + EntryType.DOCUMENT, + EntryType.KEY_VALUE, + EntryType.SSH, + EntryType.PGP, + EntryType.NOSTR, + ) + kinds = [(k.value.title(), k.value) for k in supported_kinds] yield Select(kinds, value="password", id="kind-select") - + yield Label( + "Use `seed-plus` for Seed / Managed Account derivations.", + id="seedplus-hint", + ) + yield Label("Label", classes="field-label") yield Input(placeholder="Label (e.g. Gmail)", id="entry-label") - + # Common fields with Vertical(id="fields-common"): - yield Label("Username / Email", classes="field-label", id="lbl-username") + yield Label( + "Username / Email", classes="field-label", id="lbl-username" + ) yield Input(placeholder="Username", id="entry-username") - + yield Label("URL", classes="field-label", id="lbl-url") yield Input(placeholder="https://...", id="entry-url") - + # Specialized fields (initially hidden/shown based on kind) with Vertical(id="fields-password", classes="field-group"): yield Label("Password Length", classes="field-label") yield Input(value="16", id="entry-length") - + with Vertical(id="fields-totp", classes="field-group hidden"): yield Label("TOTP Secret (Base32)", classes="field-label") yield Input(placeholder="JBSWY3DPEHPK3PXP", id="entry-totp-secret") - + with Vertical(id="fields-document", classes="field-group hidden"): yield Label("Content", classes="field-label") yield TextArea(id="entry-content") - + with Vertical(id="fields-keyvalue", classes="field-group hidden"): yield Label("Key", classes="field-label") yield Input(placeholder="Key", id="entry-key") @@ -106,7 +132,7 @@ class AddEntryScreen(Screen): yield Label("Tags (comma separated)", classes="field-label") yield Input(placeholder="work, personal", id="entry-tags") - + with Horizontal(id="action-row"): yield Button("Cancel (Esc)", id="btn-cancel", variant="error") yield Button("Save (Ctrl+S)", id="btn-save", variant="success") @@ -120,7 +146,7 @@ class AddEntryScreen(Screen): # Hide all groups first for group in self.query(".field-group"): group.add_class("hidden") - + # Show specific ones if kind == "password": self.query_one("#fields-password").remove_class("hidden") @@ -130,7 +156,7 @@ class AddEntryScreen(Screen): self.query_one("#fields-document").remove_class("hidden") elif kind == "key_value": self.query_one("#fields-keyvalue").remove_class("hidden") - + # Toggle common fields visibility if needed common = self.query_one("#fields-common") if kind in {"ssh", "pgp", "nostr", "seed", "managed_account"}: @@ -149,7 +175,7 @@ class AddEntryScreen(Screen): label = self.query_one("#entry-label", Input).value tags_raw = self.query_one("#entry-tags", Input).value tags = [t.strip() for t in tags_raw.split(",") if t.strip()] - + if not label: self.app.notify("Label is required", severity="error") return @@ -160,11 +186,15 @@ class AddEntryScreen(Screen): length = int(self.query_one("#entry-length", Input).value or 16) username = self.query_one("#entry-username", Input).value url = self.query_one("#entry-url", Input).value - service.add_entry(label=label, username=username, url=url, length=length, tags=tags) + service.add_entry( + label=label, username=username, url=url, length=length, tags=tags + ) elif kind == "totp": secret = self.query_one("#entry-totp-secret", Input).value username = self.query_one("#entry-username", Input).value - service.add_totp(label=label, secret=secret, username=username, tags=tags) + service.add_totp( + label=label, secret=secret, username=username, tags=tags + ) elif kind == "ssh": service.add_ssh_key(label=label, tags=tags) elif kind == "pgp": @@ -179,7 +209,10 @@ class AddEntryScreen(Screen): content = self.query_one("#entry-content", TextArea).text service.add_document(label=label, content=content, tags=tags) else: - self.app.notify(f"Adding '{kind}' not yet implemented in v3 screen", severity="warning") + self.app.notify( + f"Adding '{kind}' not yet implemented in v3 screen", + severity="warning", + ) return self.app.notify(f"Added {kind}: {label}") @@ -188,10 +221,12 @@ class AddEntryScreen(Screen): except Exception as e: self.app.notify(f"Failed to add entry: {e}", severity="error") + class SeedPlusScreen(Screen): """ Specialized screen for BIP-85 derivations (Seeds and Managed Accounts). """ + BINDINGS = [ Binding("ctrl+s", "save", "Save", show=True), Binding("escape", "app.pop_screen", "Cancel", show=True), @@ -228,19 +263,23 @@ class SeedPlusScreen(Screen): yield Header() with Container(id="seed-container"): yield Label("SEED+ / BIP-85 DERIVATION", id="screen-title") - + yield Label("Derivation Type", classes="field-label") - yield Select([("New BIP-39 Seed", "seed"), ("Managed Account", "managed_account")], value="seed", id="seed-kind") - + yield Select( + [("New BIP-39 Seed", "seed"), ("Managed Account", "managed_account")], + value="seed", + id="seed-kind", + ) + yield Label("Label", classes="field-label") yield Input(placeholder="Label", id="seed-label") - + yield Label("Index", classes="field-label") yield Input(value="0", id="seed-index") - + yield Label("Tags", classes="field-label") yield Input(placeholder="tags", id="seed-tags") - + with Horizontal(id="action-row"): yield Button("Cancel", id="btn-cancel", variant="error") yield Button("Derive & Save", id="btn-save", variant="success") @@ -260,8 +299,12 @@ class SeedPlusScreen(Screen): except ValueError: self.app.notify("Index must be an integer", severity="error") return - - tags = [t.strip() for t in self.query_one("#seed-tags", Input).value.split(",") if t.strip()] + + tags = [ + t.strip() + for t in self.query_one("#seed-tags", Input).value.split(",") + if t.strip() + ] if not label: self.app.notify("Label is required", severity="error") @@ -273,7 +316,7 @@ class SeedPlusScreen(Screen): service.add_seed(label=label, index=index, tags=tags) else: service.add_managed_account(label=label, index=index, tags=tags) - + self.app.notify(f"Derived {kind}: {label} (Index {index})") self.app.action_refresh() self.app.pop_screen() diff --git a/src/seedpass/tui_v3/screens/edit.py b/src/seedpass/tui_v3/screens/edit.py index f9eeb6cb..8e509f8e 100644 --- a/src/seedpass/tui_v3/screens/edit.py +++ b/src/seedpass/tui_v3/screens/edit.py @@ -6,17 +6,18 @@ from textual.widgets import Header, Footer, Input, TextArea, Label, Button from textual.containers import Vertical, Horizontal, Container from textual.binding import Binding + class EditEntryScreen(Screen): """ Dedicated full-screen editor for all entries. Auto-detects the entry kind and shows relative fields. """ - + BINDINGS = [ Binding("ctrl+s", "save", "Save", show=True), Binding("escape", "app.pop_screen", "Cancel", show=True), ] - + CSS = """ EditEntryScreen { background: #999999; @@ -67,32 +68,52 @@ class EditEntryScreen(Screen): def on_mount(self) -> None: if "entry" in self.app.services: self.entry_data = self.app.services["entry"].retrieve_entry(self.entry_id) - self.kind = str(self.entry_data.get("kind") or self.entry_data.get("type") or "").lower() - + self.kind = str( + self.entry_data.get("kind") or self.entry_data.get("type") or "" + ).lower() + # Common fields - self.query_one("#entry-title", Input).value = self.entry_data.get("label", "") - self.query_one("#entry-tags", Input).value = ", ".join(self.entry_data.get("tags", []) or []) - self.query_one("#entry-notes", TextArea).text = self.entry_data.get("notes", "") + self.query_one("#entry-title", Input).value = self.entry_data.get( + "label", "" + ) + self.query_one("#entry-tags", Input).value = ", ".join( + self.entry_data.get("tags", []) or [] + ) + self.query_one("#entry-notes", TextArea).text = self.entry_data.get( + "notes", "" + ) # Kind specific fields if self.kind in {"password", "totp"}: self.query_one("#fields-user-url").remove_class("hidden") - self.query_one("#entry-username", Input).value = self.entry_data.get("username", "") - self.query_one("#entry-url", Input).value = self.entry_data.get("url", "") + self.query_one("#entry-username", Input).value = self.entry_data.get( + "username", "" + ) + self.query_one("#entry-url", Input).value = self.entry_data.get( + "url", "" + ) elif self.kind == "key_value": self.query_one("#fields-keyvalue").remove_class("hidden") - self.query_one("#entry-key", Input).value = self.entry_data.get("key", "") - self.query_one("#entry-value", Input).value = self.entry_data.get("value", "") + self.query_one("#entry-key", Input).value = self.entry_data.get( + "key", "" + ) + self.query_one("#entry-value", Input).value = self.entry_data.get( + "value", "" + ) elif self.kind in {"document", "note"}: self.query_one("#fields-document").remove_class("hidden") - self.query_one("#doc-type", Input).value = self.entry_data.get("file_type", "txt") - self.query_one("#doc-content", TextArea).text = self.entry_data.get("content", "") + self.query_one("#doc-type", Input).value = self.entry_data.get( + "file_type", "txt" + ) + self.query_one("#doc-content", TextArea).text = self.entry_data.get( + "content", "" + ) def compose(self) -> ComposeResult: yield Header() with Container(id="editor-container"): yield Label(f"EDIT ENTRY #{self.entry_id}", id="screen-title") - + with Horizontal(id="edit-row"): with Vertical(id="col-left"): yield Label("Title / Label", classes="field-label") @@ -100,20 +121,20 @@ class EditEntryScreen(Screen): with Vertical(id="col-right"): yield Label("Tags (comma separated)", classes="field-label") yield Input(placeholder="work, personal...", id="entry-tags") - + # Optional fields with Vertical(id="fields-user-url", classes="hidden"): yield Label("Username / Email", classes="field-label") yield Input(placeholder="Username", id="entry-username") yield Label("URL", classes="field-label") yield Input(placeholder="https://...", id="entry-url") - + with Vertical(id="fields-keyvalue", classes="hidden"): yield Label("Key", classes="field-label") yield Input(placeholder="Key", id="entry-key") yield Label("Value", classes="field-label") yield Input(placeholder="Value", id="entry-value") - + with Vertical(id="fields-document", classes="hidden"): yield Label("File Type", classes="field-label") yield Input(placeholder="txt", id="doc-type") @@ -122,7 +143,7 @@ class EditEntryScreen(Screen): yield Label("Notes", classes="field-label") yield TextArea(id="entry-notes") - + with Horizontal(id="action-row"): yield Button("Cancel (Esc)", id="btn-cancel", variant="error") yield Button("Save (Ctrl+S)", id="btn-save", variant="success") @@ -139,7 +160,7 @@ class EditEntryScreen(Screen): tags_raw = self.query_one("#entry-tags", Input).value notes = self.query_one("#entry-notes", TextArea).text tags = [t.strip() for t in tags_raw.split(",") if t.strip()] - + update_args = { "label": title, "tags": tags, @@ -156,12 +177,9 @@ class EditEntryScreen(Screen): elif self.kind in {"document", "note"}: update_args["file_type"] = self.query_one("#doc-type", Input).value update_args["content"] = self.query_one("#doc-content", TextArea).text - + try: - self.app.services["entry"].modify_entry( - self.entry_id, - **update_args - ) + self.app.services["entry"].modify_entry(self.entry_id, **update_args) self.app.notify(f"Entry #{self.entry_id} saved successfully") self.app.action_refresh() self.app.pop_screen() diff --git a/src/seedpass/tui_v3/screens/inspector.py b/src/seedpass/tui_v3/screens/inspector.py index 87a28561..3440db38 100644 --- a/src/seedpass/tui_v3/screens/inspector.py +++ b/src/seedpass/tui_v3/screens/inspector.py @@ -6,16 +6,18 @@ from textual.widgets import Static, Footer from textual.containers import Vertical, Horizontal from textual.reactive import reactive + class MaximizedInspectorScreen(Screen): """ Full-screen detailed view for a single entry. Optimized for long documents, SSH keys, or PGP blocks. """ + reveal_data = reactive[dict[str, Any]]({}) def watch_reveal_data(self, old, new): self._refresh_detail() - + BINDINGS = [ ("escape", "app.pop_screen", "Back to Vault"), ("v", "reveal", "Reveal Secret"), @@ -71,15 +73,21 @@ class MaximizedInspectorScreen(Screen): r = self.reveal_data if eid is None: - self.query_one("#max-inspector-content", Static).update("No entry selected.") + self.query_one("#max-inspector-content", Static).update( + "No entry selected." + ) return if r.get("prompt"): - self.query_one("#max-inspector-content", Static).update(f"[b]SECURITY ALERT[/b]\n\n{r['prompt']}") + self.query_one("#max-inspector-content", Static).update( + f"[b]SECURITY ALERT[/b]\n\n{r['prompt']}" + ) return if r.get("content") and "##" in r["content"]: - self.query_one("#max-inspector-content", Static).update(f"[b]SECURE QR DISPLAY[/b]\n\n{r['content']}") + self.query_one("#max-inspector-content", Static).update( + f"[b]SECURE QR DISPLAY[/b]\n\n{r['content']}" + ) return if "entry" not in app.services: @@ -90,13 +98,13 @@ class MaximizedInspectorScreen(Screen): return # Simple render for the full screen view - label = entry.get('label', 'Unknown') - kind = entry.get('kind', 'Unknown') - + label = entry.get("label", "Unknown") + kind = entry.get("kind", "Unknown") + # Build a large detail view header = f"[b]ENTRY #{eid} - {label.upper()} ({kind})[/b]\n" divider = "=" * 60 + "\n" - + # Meta info meta_lines = [ f"Username : {entry.get('username', '(none)')}", @@ -104,14 +112,16 @@ class MaximizedInspectorScreen(Screen): f"Created : {entry.get('created_at', '(unknown)')}", f"Modified : {entry.get('modified_at', '(unknown)')}", ] - + # Handle revealed content - content = entry.get('content', '') + content = entry.get("content", "") if r.get("content"): content = f"[b][cyan]{r['content']}[/b]" - - content_block = f"\n[b]CONTENT / SECURE DATA:[/b]\n{divider}{content}\n" if content else "" - + + content_block = ( + f"\n[b]CONTENT / SECURE DATA:[/b]\n{divider}{content}\n" if content else "" + ) + final_text = header + divider + "\n".join(meta_lines) + content_block self.query_one("#max-inspector-content", Static).update(final_text) diff --git a/src/seedpass/tui_v3/screens/relays.py b/src/seedpass/tui_v3/screens/relays.py index 10954af7..2c4e6fb9 100644 --- a/src/seedpass/tui_v3/screens/relays.py +++ b/src/seedpass/tui_v3/screens/relays.py @@ -5,11 +5,12 @@ from textual.screen import Screen from textual.widgets import Static, Footer, DataTable, Input, Button from textual.containers import Vertical, Horizontal + class RelaysScreen(Screen): """ A dedicated screen for managing Nostr relays and triggering synchronization. """ - + BINDINGS = [ ("escape", "app.pop_screen", "Back to Vault"), ("r", "refresh_relays", "Refresh"), @@ -83,7 +84,10 @@ class RelaysScreen(Screen): with Horizontal(id="relays-controls"): yield Input(placeholder="wss://relay.example.com", id="relay-input") yield Button("Add Relay", id="add-button") - yield Static("ESC: Exit | R: Refresh | D: Delete Selected | S: Sync Now", id="relays-footer") + yield Static( + "ESC: Exit | R: Refresh | D: Delete Selected | S: Sync Now", + id="relays-footer", + ) def on_mount(self) -> None: table = self.query_one(DataTable) @@ -107,7 +111,7 @@ class RelaysScreen(Screen): table.clear() for idx, url in enumerate(relays): table.add_row("🟢 Active", url, key=str(idx)) - + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "add-button": self._add_relay() @@ -121,21 +125,23 @@ class RelaysScreen(Screen): url = inp.value.strip() if not url: return - + if not url.startswith("ws://") and not url.startswith("wss://"): - self.app.notify("Relay URL must start with ws:// or wss://", severity="error") + self.app.notify( + "Relay URL must start with ws:// or wss://", severity="error" + ) return service = self.app.services.get("nostr") if not service: return - + try: relays = service.list_relays() if url in relays: self.app.notify("Relay already exists", severity="warning") return - + service.add_relay(url) inp.value = "" self.action_refresh_relays() @@ -160,7 +166,7 @@ class RelaysScreen(Screen): service = self.app.services.get("nostr") if not service: return - + try: service.remove_relay(idx) self.action_refresh_relays() @@ -181,8 +187,13 @@ class RelaysScreen(Screen): sync_service = self.app.services.get("sync") try: import traceback + sync_service.sync() - self.app.call_from_thread(self.app.notify, "Synchronization complete", severity="information") + self.app.call_from_thread( + self.app.notify, "Synchronization complete", severity="information" + ) self.app.call_from_thread(self.app.action_refresh) except Exception as e: - self.app.call_from_thread(self.app.notify, f"Sync failed: {e}", severity="error") + self.app.call_from_thread( + self.app.notify, f"Sync failed: {e}", severity="error" + ) diff --git a/src/seedpass/tui_v3/screens/settings.py b/src/seedpass/tui_v3/screens/settings.py index 85aee258..235bef54 100644 --- a/src/seedpass/tui_v3/screens/settings.py +++ b/src/seedpass/tui_v3/screens/settings.py @@ -5,12 +5,13 @@ from textual.screen import Screen from textual.widgets import Static, Footer from textual.containers import Vertical + class SettingsScreen(Screen): """ A dedicated, full-screen settings management interface. Categorizes configuration into Security, Storage, and Connectivity. """ - + BINDINGS = [ ("escape", "app.pop_screen", "Back to Vault"), ("r", "refresh", "Refresh Settings"), @@ -54,7 +55,10 @@ class SettingsScreen(Screen): yield Static("SeedPass ◈ System Configuration", id="settings-title") with Vertical(id="settings-container"): yield Static("Loading configuration...", id="settings-content") - yield Static("ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values", id="settings-footer") + yield Static( + "ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values", + id="settings-footer", + ) def on_mount(self) -> None: self.action_refresh() @@ -63,11 +67,13 @@ class SettingsScreen(Screen): """Fetch live settings and render them into the content panel.""" app = self.app if "config" not in app.services: - self.query_one("#settings-content", Static).update("[red]Config Service Offline[/red]") + self.query_one("#settings-content", Static).update( + "[red]Config Service Offline[/red]" + ) return service = app.services["config"] - + def get_val(key, default=""): try: val = service.get(key) @@ -83,7 +89,7 @@ class SettingsScreen(Screen): f"KDF Mode : {get_val('kdf_mode', 'argon2id')} [dim](setting-kdf-mode )[/dim]", f"Lock Timeout : {get_val('inactivity_timeout', 300)}s [dim](setting-timeout )[/dim]", ] - + backup_rows = [ f"Backup Path : {get_val('additional_backup_path', '(none)')} [dim](db-export )[/dim]", f"Backup Interval: {get_val('backup_interval', 3600)}s", @@ -113,6 +119,6 @@ class SettingsScreen(Screen): "", render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows), ] - + self.query_one("#settings-content", Static).update("\n".join(rendered_lines)) self.app.notify("Settings reloaded") diff --git a/src/seedpass/tui_v3/widgets/action_bar.py b/src/seedpass/tui_v3/widgets/action_bar.py index 8b003bd0..02a3f2ea 100644 --- a/src/seedpass/tui_v3/widgets/action_bar.py +++ b/src/seedpass/tui_v3/widgets/action_bar.py @@ -4,12 +4,13 @@ from textual.app import ComposeResult from textual.widgets import Static from textual.reactive import reactive + class ActionBar(Static): """ Bottom action bar showing global shortcuts and context actions. Matches the 'Settings (S) Add (A) ...' layout. """ - + DEFAULT_CSS = """ ActionBar { height: auto; @@ -25,22 +26,29 @@ class ActionBar(Static): def render(self) -> str: app = self.app selected_id = app.selected_entry_id - + # Global Row - mockup format is_managed = False try: - is_managed = len(getattr(app.services.get("vault")._manager, "profile_stack", [])) > 0 + is_managed = ( + len(getattr(app.services.get("vault")._manager, "profile_stack", [])) + > 0 + ) except: pass - - exit_hint = " [@click='app.managed_exit'][b][M][/b]anaged Exit[/]" if is_managed else "" + + exit_hint = ( + " [@click='app.managed_exit'][b][M][/b]anaged Exit[/]" + if is_managed + else "" + ) global_row = ( f"[@click='app.toggle_settings'][b][S][/b]ettings[/] [@click='app.add_entry'][b][A][/b]dd New Entry[/] " f"[@click='app.seed_plus'][b][C][/b]reate New Seed[/] " f"[@click='app.open_palette'][b][R][/b]emove Seed[/] " f"[@click='app.action_db_export'][b][E][/b]xport Data[/] [@click='app.action_db_import'][b][I][/b]mport Data[/] [@click='app.open_palette'][b][B][/b]ackup Data[/]{exit_hint}" ) - + # Context Row (added from previous phase parity) if selected_id is None: context = "Select an entry to view actions." @@ -48,24 +56,26 @@ class ActionBar(Static): try: entry = app.services["entry"].retrieve_entry(selected_id) kind = str(entry.get("kind") or entry.get("type") or "").lower() - + actions = [ - "[@click='app.reveal_selected'][b][v][/b] Reveal[/]", - "[@click='app.show_qr'][b][g][/b] QR[/]", - "[@click='app.edit_selected'][b][e][/b] Edit[/]", - "[@click='app.toggle_archive'][b][a][/b] Archive[/]", - "[@click='app.copy_selected'][b][c][/b] Copy[/]", - "[@click='app.maximize_inspector'][b][z][/b] Maximize[/]" + "[@click='app.reveal_selected'][b][v][/b] Reveal[/]", + "[@click='app.show_qr'][b][g][/b] QR[/]", + "[@click='app.edit_selected'][b][e][/b] Edit[/]", + "[@click='app.toggle_archive'][b][a][/b] Archive[/]", + "[@click='app.copy_selected'][b][c][/b] Copy[/]", + "[@click='app.maximize_inspector'][b][z][/b] Maximize[/]", ] if kind in {"managed_account", "seed"}: actions.insert(4, "[@click='app.managed_load'][b][m][/b] Load[/]") if kind in {"document", "note"}: - actions.insert(5, "[@click='app.export_selected'][b][x][/b] Export[/]") - + actions.insert( + 5, "[@click='app.export_selected'][b][x][/b] Export[/]" + ) + context = f" Context ({kind}): " + " ▣ ".join(actions) except Exception: context = f" Error fetching context details for Entry #{selected_id}" - + return f"{global_row}\n{context}" def on_mount(self) -> None: diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py index ce0fc0ea..70517246 100644 --- a/src/seedpass/tui_v3/widgets/grid.py +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -4,11 +4,13 @@ from textual.app import ComposeResult from textual.widgets import Static, DataTable from textual.reactive import reactive + class GridMetrics(Static): """ Displays pagination, row counts, and search mode state. Matches the 'Entry Grid | Pg X/X ...' line in the mockups. """ + DEFAULT_CSS = """ GridMetrics { height: 1; @@ -17,7 +19,7 @@ class GridMetrics(Static): padding: 0 1; } """ - + def render(self) -> str: app = self.app # These would eventually come from App reactives @@ -26,21 +28,23 @@ class GridMetrics(Static): try: table = app.screen.query_one("#entry-data-table") if table: - rows = f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total + rows = ( + f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total + ) except Exception: pass - + density = "compact" # Search mode highlighting m = app.search_mode keyword = f"[b](KEYWORD)[/b]" if m == "keyword" else "keyword" hybrid = f"[b](HYBRID)[/b]" if m == "hybrid" else "hybrid" semantic = f"[b](SEMANTIC)[/b]" if m == "semantic" else "semantic" - + search = f"{keyword} {hybrid} {semantic}" filter_text = f"Filter: [b]{app.filter_kind}[/b]" arch_text = " | [reverse] ARCHIVED [/reverse]" if app.show_archived else "" - + return f"Entry Grid | Pg {pg} Rows {rows} Density {density} {filter_text}{arch_text} Search {search}" def on_mount(self) -> None: @@ -49,15 +53,15 @@ class GridMetrics(Static): self.watch(self.app, "filter_kind", self.refresh) self.watch(self.app, "show_archived", self.refresh) + class EntryDataTable(DataTable): """ High-fidelity data table for entry browsing. """ + def on_mount(self) -> None: self.cursor_type = "row" - self.add_columns( - "Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch" - ) + self.add_columns("Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch") self._refresh_data() def on_focus(self) -> None: @@ -75,13 +79,21 @@ class EntryDataTable(DataTable): # Map filter presets to kind lists kind_map = { - "secrets": ["password", "totp", "ssh", "pgp", "nostr", "seed", "managed_account"], + "secrets": [ + "password", + "totp", + "ssh", + "pgp", + "nostr", + "seed", + "managed_account", + ], "docs": ["document", "note"], "keys": ["ssh", "pgp", "nostr", "seed", "managed_account"], "2fa": ["totp"], } kinds = kind_map.get(app.filter_kind) - + entries = [] # Use semantic search if requested and available if app.search_mode != "keyword" and "semantic" in app.services and query: @@ -93,50 +105,68 @@ class EntryDataTable(DataTable): # Fetch full entry to match table columns entry = app.services["entry"].retrieve_entry(eid) if entry: - kind_val = str(entry.get("kind") or entry.get("type") or "").lower() + kind_val = str( + entry.get("kind") or entry.get("type") or "" + ).lower() # Filtering for semantic results (best effort) - if kinds and kind_val not in kinds: continue + if kinds and kind_val not in kinds: + continue is_arch = bool(entry.get("archived", False)) - if app.show_archived and not is_arch: continue - if not app.show_archived and is_arch: continue + if app.show_archived and not is_arch: + continue + if not app.show_archived and is_arch: + continue from seedpass.core.entry_types import EntryType + try: etype = EntryType(kind_val) except ValueError: etype = kind_val - - entries.append(( - eid, - entry.get("label", ""), - entry.get("username"), - entry.get("url"), - is_arch, - etype - )) + + entries.append( + ( + eid, + entry.get("label", ""), + entry.get("username"), + entry.get("url"), + is_arch, + etype, + ) + ) except Exception as e: app.notify(f"Semantic search failed: {e}", severity="error") # Fallback to lexical - entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived) + entries = app.services["entry"].search_entries( + query, + kinds=kinds, + include_archived=app.show_archived, + archived_only=app.show_archived, + ) else: # Fetch filtered data from standard service - entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived) + entries = app.services["entry"].search_entries( + query, + kinds=kinds, + include_archived=app.show_archived, + archived_only=app.show_archived, + ) for i, (eid, label, user, url, arch, kind) in enumerate(entries): marker = "▶" if eid == app.selected_entry_id else " " arch_status = "🔒" if arch else " " # 'Meta' column logic from mockup meta = user or url or "" - + self.add_row( marker, - str(i+1), + str(i + 1), f"#{eid}", label, kind.value, meta, arch_status, - key=str(eid) + key=str(eid), ) def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: @@ -159,10 +189,12 @@ class EntryDataTable(DataTable): except ValueError: pass + class GridContainer(Static): """ The central workspace container. """ + def compose(self) -> ComposeResult: yield GridMetrics() # Header divider diff --git a/src/seedpass/tui_v3/widgets/header.py b/src/seedpass/tui_v3/widgets/header.py index 2321ed4c..5dc95819 100644 --- a/src/seedpass/tui_v3/widgets/header.py +++ b/src/seedpass/tui_v3/widgets/header.py @@ -3,14 +3,15 @@ from textual.app import ComposeResult from textual.widgets import Static from textual.reactive import reactive + class RibbonHeader(Static): """ - A high-density status ribbon that displays active profile, + A high-density status ribbon that displays active profile, session state, and vault metrics. - + Matches the 'UI Board' mockup top ribbon layout. """ - + DEFAULT_CSS = """ RibbonHeader { height: 1; @@ -24,14 +25,16 @@ class RibbonHeader(Static): def render(self) -> str: app = self.app - + # Gather metrics from actual services managed = 0 try: - managed = len(getattr(app.services.get("vault")._manager, "profile_stack", [])) + managed = len( + getattr(app.services.get("vault")._manager, "profile_stack", []) + ) except: pass - + entries = 0 try: table = app.screen.query_one("#entry-data-table") @@ -39,17 +42,17 @@ class RibbonHeader(Static): entries = len(table.rows) except: pass - + sync = "YYY-MM-DDThh:mm:ssTZD" - + # Build the exact string parts from the mockup left = f"Managed Users: {managed} Entries: {entries} ◀ ▶ Kind = {app.filter_kind.capitalize()}" right = f"Last Sync: {sync}" - + # Calculate spacing to push sync to the right edge available_width = self.size.width - len(left) - len(right) - 2 spacer = " " * max(1, available_width) - + return f"{left}{spacer}{right}" def on_mount(self) -> None: diff --git a/src/seedpass/tui_v3/widgets/inspector.py b/src/seedpass/tui_v3/widgets/inspector.py index 807a1187..1d71e5a6 100644 --- a/src/seedpass/tui_v3/widgets/inspector.py +++ b/src/seedpass/tui_v3/widgets/inspector.py @@ -5,8 +5,10 @@ from textual.widgets import Static, Label from textual.containers import Vertical, Horizontal from textual.reactive import reactive + class BaseBoard(Static): """Base class for all specialized entry boards.""" + entry_data = reactive[dict[str, Any]]({}) reveal_data = reactive[dict[str, Any]]({}) @@ -19,8 +21,10 @@ class BaseBoard(Static): def update_board(self) -> None: pass + class InspectorHeader(Static): """Reusable high-fidelity header matching the UI Board mockups.""" + DEFAULT_CSS = """ InspectorHeader { height: 3; @@ -39,24 +43,29 @@ class InspectorHeader(Static): .edit-btn { background: #ffffff; color: #000000; padding: 0 2; height: 1; margin-top: 1; margin-right: 1; text-style: bold; } """ - + def compose(self) -> ComposeResult: with Vertical(classes="title-block"): yield Label("Title", id="hdr-title", classes="title-text") yield Label("Type", id="hdr-type", classes="type-text") with Horizontal(classes="meta-block"): - yield Label("[b]Date Modified[/b]\nYYYY-MM-DD", id="hdr-date", classes="meta-item") + yield Label( + "[b]Date Modified[/b]\nYYYY-MM-DD", id="hdr-date", classes="meta-item" + ) yield Label("[b]Black Listed? No[/b]", id="hdr-bl", classes="meta-item") yield Label("[b]Index Num*:[/b]\n0", id="hdr-idx", classes="meta-item") yield Label("[b]Entry Num:[/b]\n1", id="hdr-enum", classes="meta-item") yield Label("[@click=app.edit_selected]Edit[/]", classes="edit-btn") def update_data(self, entry: dict, kind_label: str): - if not self.is_mounted: return + if not self.is_mounted: + return try: - self.query_one("#hdr-title", Label).update(f"[b]{entry.get('label', 'Untitled')}[/b]") + self.query_one("#hdr-title", Label).update( + f"[b]{entry.get('label', 'Untitled')}[/b]" + ) self.query_one("#hdr-type", Label).update(kind_label) - idx = str(entry.get('id', 'N/A')) + idx = str(entry.get("id", "N/A")) self.query_one("#hdr-idx", Label).update(f"[b]Index Num*:[/b]\n{idx}") self.query_one("#hdr-enum", Label).update(f"[b]Entry Num:[/b]\n{idx}") except Exception: @@ -65,12 +74,17 @@ class InspectorHeader(Static): class IdleBoard(BaseBoard): """Shown when no entry is selected.""" + def compose(self) -> ComposeResult: - yield Label("\n\n[b]Inspector Idle[/b]\n\nSelect an entry from the grid\nto view details.", id="idle-text") + yield Label( + "\n\n[b]Inspector Idle[/b]\n\nSelect an entry from the grid\nto view details.", + id="idle-text", + ) class PasswordBoard(BaseBoard): """Matches 'Password Board.png' mockup.""" + DEFAULT_CSS = """ PasswordBoard { background: #000000; @@ -97,9 +111,15 @@ class PasswordBoard(BaseBoard): with Vertical(classes="field-box highlight"): yield Label("Password*", classes="field-title") with Horizontal(classes="field-row"): - yield Label("**********", id="fld-pass-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") - yield Label("[@click=app.add_entry]Create New[/]", classes="action-btn") + yield Label( + "**********", id="fld-pass-val", classes="field-val" + ) + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) + yield Label( + "[@click=app.add_entry]Create New[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Username*", classes="field-title") yield Label("-", id="fld-user-val") @@ -114,26 +134,29 @@ class PasswordBoard(BaseBoard): yield Label("", id="fld-notes-val") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data - + try: - self.query_one("#password-header", InspectorHeader).update_data(d, "Password") - + self.query_one("#password-header", InspectorHeader).update_data( + d, "Password" + ) + pwd = "[b]**********[/b] (v to reveal)" if r.get("content"): pwd = r.get("content") - + self.query_one("#fld-pass-val", Label).update(pwd) self.query_one("#fld-user-val", Label).update(d.get("username", "-")) self.query_one("#fld-url-val", Label).update(d.get("url", "-")) - - tags = d.get('tags', []) + + tags = d.get("tags", []) tag_str = ", ".join(tags) if isinstance(tags, list) else str(tags) self.query_one("#fld-tags-val", Label).update(tag_str or "-") - - notes = d.get('notes', '') + + notes = d.get("notes", "") self.query_one("#fld-notes-val", Label).update(notes) except Exception: pass @@ -141,6 +164,7 @@ class PasswordBoard(BaseBoard): class TotpBoard(BaseBoard): """Matches '2FA Board.png' mockup.""" + DEFAULT_CSS = """ TotpBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; } .board-body { layout: horizontal; height: 1fr; } @@ -162,8 +186,12 @@ class TotpBoard(BaseBoard): with Vertical(classes="field-box highlight"): yield Label("2FA Code*", classes="field-title") with Horizontal(classes="field-row"): - yield Label("------ (30s left)", id="fld-code-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "------ (30s left)", id="fld-code-val", classes="field-val" + ) + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Secret", classes="field-title") yield Label("-", id="fld-secret-val") @@ -174,14 +202,15 @@ class TotpBoard(BaseBoard): yield Label("QR Code (Press g)", id="fld-qr-val", classes="qr-text") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data app = self.app - + try: self.query_one("#totp-header", InspectorHeader).update_data(d, "2FA") - + code = "------" if app.services.get("entry") and not app.session_locked: try: @@ -189,27 +218,33 @@ class TotpBoard(BaseBoard): except: pass import time + remaining = 30 - (int(time.time()) % 30) - self.query_one("#fld-code-val", Label).update(f"[b]{code}[/b] ({remaining}s left)") + self.query_one("#fld-code-val", Label).update( + f"[b]{code}[/b] ({remaining}s left)" + ) secret = "[b]**********[/b] (v to reveal)" if r.get("content") and "##" not in r.get("content"): secret = r.get("content") - + self.query_one("#fld-secret-val", Label).update(secret) - self.query_one("#fld-algo-val", Label).update(d.get("algorithm", "SHA1").upper()) + self.query_one("#fld-algo-val", Label).update( + d.get("algorithm", "SHA1").upper() + ) qr_content = "QR Code (Press g)" if r.get("content") and "##" in r.get("content"): qr_content = r.get("content") self.query_one("#fld-qr-val", Label).update(qr_content) - + except Exception: pass class NoteBoard(BaseBoard): """Matches 'Note Board.png' mockup.""" + DEFAULT_CSS = """ NoteBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; } .board-body { layout: horizontal; height: 1fr; border: solid #ffffff; padding: 1; } @@ -221,18 +256,20 @@ class NoteBoard(BaseBoard): yield Label("Content", id="fld-content-val") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data try: - kind_label = "Note" if d.get('kind', '') == 'note' else "Document" + kind_label = "Note" if d.get("kind", "") == "note" else "Document" self.query_one("#note-header", InspectorHeader).update_data(d, kind_label) - self.query_one("#fld-content-val", Label).update(d.get('content', '')) + self.query_one("#fld-content-val", Label).update(d.get("content", "")) except Exception: pass class SeedBoard(BaseBoard): """Matches 'BIP-39 Seed Board.png' mockup.""" + DEFAULT_CSS = """ SeedBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; } .board-body { layout: horizontal; height: 1fr; } @@ -254,8 +291,12 @@ class SeedBoard(BaseBoard): with Vertical(classes="field-box highlight"): yield Label("Seed Phrase*", classes="field-title") with Horizontal(classes="field-row"): - yield Label("[HIDDEN]", id="fld-phrase-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[HIDDEN]", id="fld-phrase-val", classes="field-val" + ) + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Fingerprint", classes="field-title") yield Label("-", id="fld-fp-val") @@ -266,17 +307,24 @@ class SeedBoard(BaseBoard): yield Label("QR Code (Press gg)", id="fld-qr-val", classes="qr-text") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data try: - kind_label = "BIP-39" if d.get('kind', '') in {'seed', 'managed_account'} else d.get('kind', 'Seed') - self.query_one("#seed-header", InspectorHeader).update_data(d, kind_label.capitalize()) + kind_label = ( + "BIP-39" + if d.get("kind", "") in {"seed", "managed_account"} + else d.get("kind", "Seed") + ) + self.query_one("#seed-header", InspectorHeader).update_data( + d, kind_label.capitalize() + ) phrase = "[b]**********[/b] (vv to reveal)" if r.get("content") and "##" not in r.get("content"): phrase = r.get("content") - + self.query_one("#fld-phrase-val", Label).update(phrase) self.query_one("#fld-fp-val", Label).update(d.get("fingerprint", "-")) self.query_one("#fld-wc-val", Label).update(str(d.get("word_count", 24))) @@ -291,6 +339,7 @@ class SeedBoard(BaseBoard): class SshBoard(BaseBoard): """Matches 'SSH Board.png' mockup.""" + DEFAULT_CSS = """ SshBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; } .board-body { layout: horizontal; height: 1fr; } @@ -312,12 +361,16 @@ class SshBoard(BaseBoard): yield Label("Private Key*", classes="field-title") with Horizontal(classes="field-row"): yield Label("[HIDDEN]", id="fld-priv-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Public Key", classes="field-title") with Horizontal(classes="field-row"): yield Label("[PREVIEW]", id="fld-pub-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Key Type", classes="field-title") yield Label("Ed25519", id="fld-type-val") @@ -326,7 +379,8 @@ class SshBoard(BaseBoard): yield Label("", id="fld-notes-val") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data try: @@ -336,12 +390,15 @@ class SshBoard(BaseBoard): if r.get("content"): priv = r.get("content") self.query_one("#fld-priv-val", Label).update(priv) - - pub = d.get('public_key', '[PREVIEW]') - if len(pub) > 30: pub = pub[:30] + "..." + + pub = d.get("public_key", "[PREVIEW]") + if len(pub) > 30: + pub = pub[:30] + "..." self.query_one("#fld-pub-val", Label).update(pub) - - self.query_one("#fld-type-val", Label).update(d.get("key_type", "RSA/Ed25519")) + + self.query_one("#fld-type-val", Label).update( + d.get("key_type", "RSA/Ed25519") + ) self.query_one("#fld-notes-val", Label).update(d.get("notes", "")) except Exception: pass @@ -349,6 +406,7 @@ class SshBoard(BaseBoard): class PgpBoard(BaseBoard): """Matches 'PGP Board.png' mockup.""" + DEFAULT_CSS = SshBoard.DEFAULT_CSS.replace("SshBoard", "PgpBoard") def compose(self) -> ComposeResult: @@ -359,7 +417,9 @@ class PgpBoard(BaseBoard): yield Label("Private Key*", classes="field-title") with Horizontal(classes="field-row"): yield Label("[HIDDEN]", id="fld-priv-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Fingerprint / ID", classes="field-title") yield Label("-", id="fld-fp-val") @@ -371,7 +431,8 @@ class PgpBoard(BaseBoard): yield Label("", id="fld-notes-val") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data try: @@ -381,7 +442,7 @@ class PgpBoard(BaseBoard): if r.get("content"): priv = r.get("content") self.query_one("#fld-priv-val", Label).update(priv) - + self.query_one("#fld-fp-val", Label).update(d.get("fingerprint", "-")) self.query_one("#fld-ident-val", Label).update(d.get("user_id", "-")) self.query_one("#fld-notes-val", Label).update(d.get("notes", "")) @@ -391,6 +452,7 @@ class PgpBoard(BaseBoard): class NostrBoard(BaseBoard): """Matches 'Nostr Board.png' mockup.""" + DEFAULT_CSS = TotpBoard.DEFAULT_CSS.replace("TotpBoard", "NostrBoard") def compose(self) -> ComposeResult: @@ -401,12 +463,16 @@ class NostrBoard(BaseBoard): yield Label("nsec (Secret)*", classes="field-title") with Horizontal(classes="field-row"): yield Label("[HIDDEN]", id="fld-nsec-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("npub (Public)", classes="field-title") with Horizontal(classes="field-row"): yield Label("-", id="fld-npub-val", classes="field-val") - yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn") + yield Label( + "[@click=app.copy_selected]Copy[/]", classes="action-btn" + ) with Vertical(classes="field-box"): yield Label("Hex Key", classes="field-title") yield Label("-", id="fld-hex-val") @@ -414,7 +480,8 @@ class NostrBoard(BaseBoard): yield Label("QR Code (Press g)", id="fld-qr-val", classes="qr-text") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data try: @@ -424,11 +491,12 @@ class NostrBoard(BaseBoard): if r.get("content") and "##" not in r.get("content"): nsec = r.get("content") self.query_one("#fld-nsec-val", Label).update(nsec) - - npub = d.get('npub', '-') - if len(npub) > 30: npub = npub[:30] + "..." + + npub = d.get("npub", "-") + if len(npub) > 30: + npub = npub[:30] + "..." self.query_one("#fld-npub-val", Label).update(npub) - + self.query_one("#fld-hex-val", Label).update(d.get("hex", "-")) qr_content = "QR Code (Press g)" @@ -441,6 +509,7 @@ class NostrBoard(BaseBoard): class GenericBoard(BaseBoard): """Fallback high-fidelity board.""" + DEFAULT_CSS = """ GenericBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; } .board-body { layout: horizontal; height: 1fr; } @@ -466,27 +535,28 @@ class GenericBoard(BaseBoard): yield Label("", id="fld-notes-val") def update_board(self) -> None: - if not self.is_mounted: return + if not self.is_mounted: + return d = self.entry_data r = self.reveal_data - kind = str(d.get('kind', d.get('type', 'Unknown'))).capitalize() - + kind = str(d.get("kind", d.get("type", "Unknown"))).capitalize() + try: self.query_one("#generic-header", InspectorHeader).update_data(d, kind) - + secret = "[HIDDEN] (v to reveal)" if r.get("content"): secret = rf"{r.get('content')}" self.query_one("#fld-secret-val", Label).update(secret) - + details = [] for k, v in d.items(): - if k not in ('label', 'notes', 'tags', 'kind', 'id', 'type', 'content'): + if k not in ("label", "notes", "tags", "kind", "id", "type", "content"): details.append(f"[b]{k.capitalize()}:[/b] {v}") self.query_one("#fld-details-val", Label).update("\n".join(details) or "-") - self.query_one("#fld-notes-val", Label).update(d.get('notes', '')) - + self.query_one("#fld-notes-val", Label).update(d.get("notes", "")) + except Exception: pass @@ -495,6 +565,7 @@ class BoardContainer(Vertical): """ Reactive container that swaps specialized boards based on entry kind. """ + DEFAULT_CSS = """ BoardContainer { height: 1fr; @@ -523,7 +594,7 @@ class BoardContainer(Vertical): return kind = str(entry.get("kind") or entry.get("type") or "").lower() - + if kind in {"password", "stored_password"}: board = self._show_board("password") elif kind == "totp": @@ -540,7 +611,7 @@ class BoardContainer(Vertical): board = self._show_board("nostr") else: board = self._show_board("generic") - + board.entry_data = entry def _show_board(self, board_type: str) -> Any: diff --git a/src/seedpass/tui_v3/widgets/palette.py b/src/seedpass/tui_v3/widgets/palette.py index cbdd32fa..06548157 100644 --- a/src/seedpass/tui_v3/widgets/palette.py +++ b/src/seedpass/tui_v3/widgets/palette.py @@ -5,12 +5,13 @@ from textual.widgets import Input, Static from textual.containers import Vertical from textual.message import Message + class CommandPalette(Vertical): """ The TUI v3 Command Palette. Docks at the top and provides a unified entry point for all actions. """ - + DEFAULT_CSS = """ CommandPalette { dock: top; @@ -61,10 +62,11 @@ class CommandPalette(Vertical): if cmd: self.app.post_message(self.CommandExecuted(cmd)) self.input.value = "" - self.toggle() # Close after execution + self.toggle() # Close after execution class CommandExecuted(Message): """Sent when a command is submitted.""" + def __init__(self, command: str) -> None: self.command = command super().__init__() diff --git a/src/seedpass/tui_v3/widgets/sidebar.py b/src/seedpass/tui_v3/widgets/sidebar.py index d7912f29..6bb11ad8 100644 --- a/src/seedpass/tui_v3/widgets/sidebar.py +++ b/src/seedpass/tui_v3/widgets/sidebar.py @@ -4,10 +4,11 @@ from textual.app import ComposeResult from textual.widgets import Tree, Static from textual.widgets.tree import TreeNode + class ProfileTree(Tree): """ A hierarchical sidebar that manages Seed Profiles, Managed Accounts, and Agents. - + Structure: Root (Hidden) ├── Fingerprint (Parent Seed) @@ -16,7 +17,7 @@ class ProfileTree(Tree): │ └── Managed Account 2 └── External Fingerprint """ - + def on_mount(self) -> None: self.root.expand() self._refresh_tree() @@ -31,14 +32,14 @@ class ProfileTree(Tree): service = app.services["profile"] entry_service = app.services.get("entry") profiles = service.list_profiles() - + for fp in profiles: is_active = fp == app.active_fingerprint icon = "■ " if is_active else "□ " label = f"{icon}{fp[:12]}..." if len(fp) > 12 else f"{icon}{fp}" node = self.root.add(label, data=fp, expand=is_active) - - # If this is the active profile or we have the entry service, + + # If this is the active profile or we have the entry service, # we can fetch nested managed accounts. if entry_service and is_active: # Find managed accounts in the current profile @@ -46,7 +47,7 @@ class ProfileTree(Tree): managed = entry_service.search_entries("", kinds=["managed_account"]) for mid, mlabel, _, _, _, _ in managed: node.add_leaf(f"├─ {mlabel}", data=f"managed:{mid}") - + # Find agents (nostr) agents = entry_service.search_entries("", kinds=["nostr"]) for aid, alabel, _, _, _, _ in agents: @@ -72,8 +73,10 @@ class ProfileTree(Tree): self.app.active_fingerprint = value self.app.notify(f"Switched to profile: {value[:8]}") + class SidebarContainer(Static): """Container for the sidebar tree and toggle.""" + def compose(self) -> ComposeResult: yield ProfileTree("Profiles", id="profile-tree") diff --git a/src/tests/test_deterministic_artifact_regression.py b/src/tests/test_deterministic_artifact_regression.py index 3cb2fc6c..625f7c73 100644 --- a/src/tests/test_deterministic_artifact_regression.py +++ b/src/tests/test_deterministic_artifact_regression.py @@ -150,7 +150,9 @@ def test_deterministic_vectors_index_352_regression(): hashlib.sha256(ssh_pub.encode("utf-8")).hexdigest() == vector.ssh_pub_sha256 ) - pgp_key, pgp_fp = derive_pgp_key(bip85, 352, "ed25519", "SeedPass Regression") + pgp_key, _pgp_pub, pgp_fp = derive_pgp_key( + bip85, 352, "ed25519", "SeedPass Regression" + ) assert pgp_fp == "46A5AEB6797495ABCDC91E5687DC058A9B6AF8FA" assert ( hashlib.sha256(pgp_key.encode("utf-8")).hexdigest() @@ -158,7 +160,7 @@ def test_deterministic_vectors_index_352_regression(): ) # Pin deterministic RSA behavior at one known vector too. - rsa_key, rsa_fp = derive_pgp_key(bip85, 352, "rsa", "SeedPass Regression") + rsa_key, _rsa_pub, rsa_fp = derive_pgp_key(bip85, 352, "rsa", "SeedPass Regression") assert rsa_fp == "A04FE91ED24354AC13E25C43677FE463B8C74495" assert ( hashlib.sha256(rsa_key.encode("utf-8")).hexdigest() diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index bac97437..9c5606f8 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -109,3 +109,16 @@ def test_manager_workflow(monkeypatch): assert checksum_after_modify != checksum_after_add assert first_post in pm.nostr_client.published assert pm.nostr_client.published[-1] != first_post + + +def test_terminal_document_editor_help_and_empty_listing(monkeypatch, capsys): + pm = PasswordManager.__new__(PasswordManager) + inputs = iter(["q"]) + monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs)) + + result = pm._terminal_document_editor("") + + captured = capsys.readouterr() + assert result is None + assert "Commands: p | a | i | e | d | w | q" in captured.out + assert "[empty]" in captured.out diff --git a/src/tests/test_tui_v2_exploratory_walkthrough.py b/src/tests/test_tui_v2_exploratory_walkthrough.py index d799dcd8..2e739de6 100644 --- a/src/tests/test_tui_v2_exploratory_walkthrough.py +++ b/src/tests/test_tui_v2_exploratory_walkthrough.py @@ -19,7 +19,6 @@ from textual.widgets import Input, ListView, Static from seedpass.tui_v2.app import launch_tui2 - # ── Fake services (copied from interaction tests to be self-contained) ────── @@ -39,7 +38,9 @@ class FakeEntryService: def _next_id(self) -> int: return (max(self._entries.keys()) + 1) if self._entries else 1 - def search_entries(self, query, kinds=None, *, include_archived=False, archived_only=False): + def search_entries( + self, query, kinds=None, *, include_archived=False, archived_only=False + ): q = (query or "").strip().lower() out = [] for eid in sorted(self._entries): @@ -64,59 +65,88 @@ class FakeEntryService: def add_entry(self, label, length, username=None, url=None): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "password", "label": label, - "length": int(length), "username": username, "url": url, "archived": False, + "id": eid, + "kind": "password", + "label": label, + "length": int(length), + "username": username, + "url": url, + "archived": False, } return eid def add_totp(self, label, *, secret=None, period=30, digits=6, deterministic=False): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "totp", "label": label, - "secret": secret or "JBSWY3DPEHPK3PXP", "period": int(period), - "digits": int(digits), "archived": False, + "id": eid, + "kind": "totp", + "label": label, + "secret": secret or "JBSWY3DPEHPK3PXP", + "period": int(period), + "digits": int(digits), + "archived": False, } return f"otpauth://totp/{label}" def add_key_value(self, label, key, value): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "key_value", "label": label, - "key": key, "value": value, "archived": False, + "id": eid, + "kind": "key_value", + "label": label, + "key": key, + "value": value, + "archived": False, } return eid def add_document(self, label, content, *, file_type="txt"): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "document", "label": label, - "content": content, "file_type": file_type, "archived": False, + "id": eid, + "kind": "document", + "label": label, + "content": content, + "file_type": file_type, + "archived": False, } return eid def add_ssh_key(self, label, *, index=None, notes=""): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "ssh", "label": label, - "private_key": f"SSH_PRIV_{eid}", "public_key": f"ssh-ed25519 AAAA-{eid}", + "id": eid, + "kind": "ssh", + "label": label, + "private_key": f"SSH_PRIV_{eid}", + "public_key": f"ssh-ed25519 AAAA-{eid}", "archived": False, } return eid - def add_pgp_key(self, label, *, index=None, key_type="ed25519", user_id="", notes=""): + def add_pgp_key( + self, label, *, index=None, key_type="ed25519", user_id="", notes="" + ): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "pgp", "label": label, + "id": eid, + "kind": "pgp", + "label": label, "private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----", - "fingerprint": f"FPR-{eid}", "archived": False, + "fingerprint": f"FPR-{eid}", + "archived": False, } return eid def add_nostr_key(self, label, *, index=None, notes=""): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "nostr", "label": label, - "npub": f"npub{eid}", "nsec": f"nsec{eid}", "archived": False, + "id": eid, + "kind": "nostr", + "label": label, + "npub": f"npub{eid}", + "nsec": f"nsec{eid}", + "archived": False, } return eid @@ -125,15 +155,20 @@ class FakeEntryService: phrase = " ".join(["abandon"] * (wc - 1) + ["about"]) eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "seed", "label": label, - "seed_phrase": phrase, "archived": False, + "id": eid, + "kind": "seed", + "label": label, + "seed_phrase": phrase, + "archived": False, } return eid def add_managed_account(self, label, *, index=None, notes=""): eid = self._next_id() self._entries[eid] = { - "id": eid, "kind": "managed_account", "label": label, + "id": eid, + "kind": "managed_account", + "label": label, "seed_phrase": "legal winner thank year wave sausage worth useful legal winner thank yellow", "archived": False, } @@ -145,7 +180,7 @@ class FakeEntryService: raise ValueError("not a document") name = str(e.get("label", f"doc-{eid}")).replace(" ", "_") ext = str(e.get("file_type", "txt")).lstrip(".") or "txt" - dest = (Path(output_path) if output_path else Path.cwd() / f"{name}.{ext}") + dest = Path(output_path) if output_path else Path.cwd() / f"{name}.{ext}" dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(str(e.get("content", "")), encoding="utf-8") return dest @@ -160,10 +195,17 @@ class FakeEntryService: return f"pw-{eid}-{length}" def get_seed_phrase(self, eid): - return str(self._entries[int(eid)].get("seed_phrase", "abandon " * 11 + "about")).strip() + return str( + self._entries[int(eid)].get("seed_phrase", "abandon " * 11 + "about") + ).strip() def get_managed_account_seed(self, eid): - return str(self._entries[int(eid)].get("seed_phrase", "legal winner thank year wave sausage worth useful legal winner thank yellow")).strip() + return str( + self._entries[int(eid)].get( + "seed_phrase", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + ) + ).strip() def get_totp_secret(self, eid): return str(self._entries[int(eid)].get("secret", "JBSWY3DPEHPK3PXP")) @@ -176,16 +218,27 @@ class FakeEntryService: for eid, e in self._entries.items(): if str(e.get("kind")) != "totp": continue - out[str(eid)] = {"label": e.get("label"), "period": e.get("period", 30), "digits": e.get("digits", 6), "secret": e.get("secret")} + out[str(eid)] = { + "label": e.get("label"), + "period": e.get("period", 30), + "digits": e.get("digits", 6), + "secret": e.get("secret"), + } return out def get_ssh_key_pair(self, eid): e = self._entries[int(eid)] - return (str(e.get("private_key", "SSH_PRIV")), str(e.get("public_key", "ssh-ed25519 AAAA..."))) + return ( + str(e.get("private_key", "SSH_PRIV")), + str(e.get("public_key", "ssh-ed25519 AAAA...")), + ) def get_pgp_key(self, eid): e = self._entries[int(eid)] - return (str(e.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")), str(e.get("fingerprint", "DEADBEEF"))) + return ( + str(e.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")), + str(e.get("fingerprint", "DEADBEEF")), + ) def get_nostr_key_pair(self, eid): e = self._entries[int(eid)] @@ -217,7 +270,12 @@ class FakeEntryService: def remove_link(self, eid, tid, *, relation=None): src = self._links.setdefault(int(eid), []) - kept = [l for l in src if int(l.get("target", -1)) != int(tid) or (relation and str(l.get("relation")) != relation)] + kept = [ + l + for l in src + if int(l.get("target", -1)) != int(tid) + or (relation and str(l.get("relation")) != relation) + ] self._links[int(eid)] = kept return [dict(l) for l in kept] @@ -281,7 +339,13 @@ class FakeConfigService: def set(self, key, value): if key == "quick_unlock": - self.quick_unlock = str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + self.quick_unlock = str(value).strip().lower() in { + "1", + "true", + "yes", + "y", + "on", + } elif key == "inactivity_timeout": self.inactivity_timeout = float(value) elif key == "kdf_iterations": @@ -343,7 +407,12 @@ class FakeSemanticService: self.mode = "keyword" def status(self): - return {"enabled": self.enabled, "built": self.built, "records": self.records, "mode": self.mode} + return { + "enabled": self.enabled, + "built": self.built, + "records": self.records, + "mode": self.mode, + } def set_enabled(self, e): self.enabled = bool(e) @@ -423,6 +492,7 @@ class FakeNostrService: # ── Helpers ───────────────────────────────────────────────────────────────── + def _build_full_app(service=None, **kw): service = service or FakeEntryService() holder: dict = {} @@ -648,7 +718,9 @@ async def test_walkthrough_archive_filter_cycle(): await _cmd(app, pilot, "archive-filter active") lv = app.query_one("#entry-list", ListView) if len(lv.children) != 2: - bugs.append(f"Expected 2 active entries after restore, got {len(lv.children)}") + bugs.append( + f"Expected 2 active entries after restore, got {len(lv.children)}" + ) assert not bugs, "BUGS FOUND:\n" + "\n".join(f" - {b}" for b in bugs) @@ -678,11 +750,15 @@ async def test_walkthrough_managed_session_lifecycle(): # Check action strip instead action = _action(app) if "managed-exit" not in action.lower(): - bugs.append(f"Managed session not activated. status={stat}, action={action[:200]}") + bugs.append( + f"Managed session not activated. status={stat}, action={action[:200]}" + ) # Verify managed-load was called if managed_id not in svc.managed_load_calls: - bugs.append(f"managed_load_calls missing {managed_id}: {svc.managed_load_calls}") + bugs.append( + f"managed_load_calls missing {managed_id}: {svc.managed_load_calls}" + ) # Exit managed session await _cmd(app, pilot, "managed-exit") @@ -695,9 +771,17 @@ async def test_walkthrough_managed_session_lifecycle(): @pytest.mark.anyio async def test_walkthrough_vault_lock_unlock(): """Lock vault, verify entries hidden, unlock, verify entries restored.""" - svc = FakeEntryService([ - {"id": 1, "kind": "password", "label": "Secret Login", "length": 16, "archived": False}, - ]) + svc = FakeEntryService( + [ + { + "id": 1, + "kind": "password", + "label": "Secret Login", + "length": 16, + "archived": False, + }, + ] + ) vault = FakeVaultService() app, _ = _build_full_app(svc, vault=vault) @@ -747,9 +831,17 @@ async def test_walkthrough_vault_lock_unlock(): @pytest.mark.anyio async def test_walkthrough_density_modes(): """Cycle through density modes, verify status and grid heading adapt.""" - svc = FakeEntryService([ - {"id": 1, "kind": "password", "label": "Test PW", "length": 16, "archived": False}, - ]) + svc = FakeEntryService( + [ + { + "id": 1, + "kind": "password", + "label": "Test PW", + "length": 16, + "archived": False, + }, + ] + ) app, _ = _build_full_app(svc) bugs = [] @@ -783,12 +875,42 @@ async def test_walkthrough_density_modes(): @pytest.mark.anyio async def test_walkthrough_kind_filter(): """Filter by entry kind and verify grid population.""" - svc = FakeEntryService([ - {"id": 1, "kind": "password", "label": "PW1", "length": 16, "archived": False}, - {"id": 2, "kind": "totp", "label": "OTP1", "secret": "AAA", "period": 30, "digits": 6, "archived": False}, - {"id": 3, "kind": "document", "label": "Doc1", "content": "hi", "file_type": "md", "archived": False}, - {"id": 4, "kind": "ssh", "label": "SSH1", "private_key": "pk", "public_key": "pub", "archived": False}, - ]) + svc = FakeEntryService( + [ + { + "id": 1, + "kind": "password", + "label": "PW1", + "length": 16, + "archived": False, + }, + { + "id": 2, + "kind": "totp", + "label": "OTP1", + "secret": "AAA", + "period": 30, + "digits": 6, + "archived": False, + }, + { + "id": 3, + "kind": "document", + "label": "Doc1", + "content": "hi", + "file_type": "md", + "archived": False, + }, + { + "id": 4, + "kind": "ssh", + "label": "SSH1", + "private_key": "pk", + "public_key": "pub", + "archived": False, + }, + ] + ) app, _ = _build_full_app(svc) bugs = [] @@ -824,10 +946,26 @@ async def test_walkthrough_kind_filter(): @pytest.mark.anyio async def test_walkthrough_links_between_entries(): """Add and remove links between entries.""" - svc = FakeEntryService([ - {"id": 1, "kind": "password", "label": "PW1", "length": 16, "archived": False}, - {"id": 2, "kind": "totp", "label": "OTP for PW1", "secret": "AAA", "period": 30, "digits": 6, "archived": False}, - ]) + svc = FakeEntryService( + [ + { + "id": 1, + "kind": "password", + "label": "PW1", + "length": 16, + "archived": False, + }, + { + "id": 2, + "kind": "totp", + "label": "OTP for PW1", + "secret": "AAA", + "period": 30, + "digits": 6, + "archived": False, + }, + ] + ) app, _ = _build_full_app(svc) bugs = [] @@ -858,14 +996,70 @@ async def test_walkthrough_links_between_entries(): async def test_walkthrough_card_frame_rendering(): """Verify card frames render correctly for every board type.""" entries = [ - {"id": 1, "kind": "password", "label": "PW", "length": 16, "username": "u", "url": "https://x.com", "archived": False}, - {"id": 2, "kind": "document", "label": "Doc", "content": "hello " * 50, "file_type": "md", "archived": False}, - {"id": 3, "kind": "totp", "label": "OTP", "secret": "AAA", "period": 30, "digits": 6, "archived": False}, - {"id": 4, "kind": "seed", "label": "Seed", "seed_phrase": "abandon " * 11 + "about", "archived": False}, - {"id": 5, "kind": "managed_account", "label": "Mgd", "seed_phrase": "legal " * 11 + "about", "archived": False}, - {"id": 6, "kind": "ssh", "label": "SSH", "private_key": "pk", "public_key": "ssh-ed25519 AAAA", "archived": False}, - {"id": 7, "kind": "pgp", "label": "PGP", "private_key": "pk", "fingerprint": "FPR-7", "archived": False}, - {"id": 8, "kind": "nostr", "label": "Nostr", "npub": "npub1demo", "nsec": "nsec1demo", "archived": False}, + { + "id": 1, + "kind": "password", + "label": "PW", + "length": 16, + "username": "u", + "url": "https://x.com", + "archived": False, + }, + { + "id": 2, + "kind": "document", + "label": "Doc", + "content": "hello " * 50, + "file_type": "md", + "archived": False, + }, + { + "id": 3, + "kind": "totp", + "label": "OTP", + "secret": "AAA", + "period": 30, + "digits": 6, + "archived": False, + }, + { + "id": 4, + "kind": "seed", + "label": "Seed", + "seed_phrase": "abandon " * 11 + "about", + "archived": False, + }, + { + "id": 5, + "kind": "managed_account", + "label": "Mgd", + "seed_phrase": "legal " * 11 + "about", + "archived": False, + }, + { + "id": 6, + "kind": "ssh", + "label": "SSH", + "private_key": "pk", + "public_key": "ssh-ed25519 AAAA", + "archived": False, + }, + { + "id": 7, + "kind": "pgp", + "label": "PGP", + "private_key": "pk", + "fingerprint": "FPR-7", + "archived": False, + }, + { + "id": 8, + "kind": "nostr", + "label": "Nostr", + "npub": "npub1demo", + "nsec": "nsec1demo", + "archived": False, + }, ] svc = FakeEntryService(entries) app, _ = _build_full_app(svc) @@ -891,7 +1085,13 @@ async def test_walkthrough_card_frame_rendering(): # The bottom line starts with "+" so count "+" lines # Just check top/bottom pairs top_lines = [l for l in detail.split("\n") if l.strip().startswith("+- ")] - bottom_lines = [l for l in detail.split("\n") if l.strip().startswith("+") and l.strip().endswith("+") and "- " not in l[:5]] + bottom_lines = [ + l + for l in detail.split("\n") + if l.strip().startswith("+") + and l.strip().endswith("+") + and "- " not in l[:5] + ] if len(top_lines) < 1: bugs.append(f"Entry #{eid} ({kind}): missing card frame top line") @@ -902,7 +1102,9 @@ async def test_walkthrough_card_frame_rendering(): # Max width check — no line should exceed ~80 chars (72 card + margins) for line in detail.split("\n"): if len(line) > 85: - bugs.append(f"Entry #{eid} ({kind}): line too wide ({len(line)} chars): {line[:60]}...") + bugs.append( + f"Entry #{eid} ({kind}): line too wide ({len(line)} chars): {line[:60]}..." + ) break assert not bugs, "BUGS FOUND:\n" + "\n".join(f" - {b}" for b in bugs) @@ -979,10 +1181,18 @@ async def test_walkthrough_edge_cases_and_error_handling(): @pytest.mark.anyio async def test_walkthrough_search_flow(): """Search, clear, verify grid updates.""" - svc = FakeEntryService([ - {"id": i, "kind": "password", "label": f"Entry {i}", "length": 16, "archived": False} - for i in range(1, 21) - ]) + svc = FakeEntryService( + [ + { + "id": i, + "kind": "password", + "label": f"Entry {i}", + "length": 16, + "archived": False, + } + for i in range(1, 21) + ] + ) app, _ = _build_full_app(svc) bugs = [] @@ -1025,7 +1235,9 @@ async def test_walkthrough_full_user_journey(): bugs.append(f"Seed creation status: {_status(app)}") # Step 2: Add a password entry - await _cmd(app, pilot, 'add-password "GitHub Login" 24 myuser https://github.com') + await _cmd( + app, pilot, 'add-password "GitHub Login" 24 myuser https://github.com' + ) detail = _detail(app) if "myuser" not in detail: bugs.append("Password username missing") diff --git a/src/tests/test_tui_v2_parity_scenarios.py b/src/tests/test_tui_v2_parity_scenarios.py index 5d3d3889..0cb27b6c 100644 --- a/src/tests/test_tui_v2_parity_scenarios.py +++ b/src/tests/test_tui_v2_parity_scenarios.py @@ -175,7 +175,7 @@ async def test_tui2_parity_archive_restore_roundtrip_non_document() -> None: await pilot.pause() # Explicitly open the entry to ensure selection await _run_palette(app, pilot, "open 1") - + await _run_palette(app, pilot, "archive") assert service.retrieve_entry(1)["archived"] is True assert "archived" in _status_text(app) diff --git a/src/tests/test_tui_v3_parity.py b/src/tests/test_tui_v3_parity.py index 92f5b7dd..15c81662 100644 --- a/src/tests/test_tui_v3_parity.py +++ b/src/tests/test_tui_v3_parity.py @@ -4,73 +4,161 @@ from unittest.mock import MagicMock from textual.widgets import DataTable from seedpass.tui_v3.app import SeedPassTuiV3 + class MockEntryService: def __init__(self): self.entries = { - 1: {"id": 1, "label": "test-pwd", "kind": "password", "username": "user1", "length": 16}, + 1: { + "id": 1, + "label": "test-pwd", + "kind": "password", + "username": "user1", + "length": 16, + }, 2: {"id": 2, "label": "test-ssh", "kind": "ssh", "index": 0}, 3: {"id": 3, "label": "test-pgp", "kind": "pgp", "index": 0}, 4: {"id": 4, "label": "test-nostr", "kind": "nostr", "index": 0}, - 5: {"id": 5, "label": "test-doc", "kind": "document", "content": "hello world"}, + 5: { + "id": 5, + "label": "test-doc", + "kind": "document", + "content": "hello world", + }, 6: {"id": 6, "label": "test-seed", "kind": "seed", "index": 0}, } - def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False): + + def search_entries( + self, query="", kinds=None, include_archived=False, archived_only=False + ): results = [] for e in self.entries.values(): kind = e["kind"] - if kinds and kind not in kinds: continue + if kinds and kind not in kinds: + continue is_arch = e.get("archived", False) - if archived_only and not is_arch: continue - if not include_archived and is_arch: continue - results.append((e["id"], e["label"], e.get("username"), e.get("url"), is_arch, MagicMock(value=kind))) + if archived_only and not is_arch: + continue + if not include_archived and is_arch: + continue + results.append( + ( + e["id"], + e["label"], + e.get("username"), + e.get("url"), + is_arch, + MagicMock(value=kind), + ) + ) return results def retrieve_entry(self, eid): return self.entries.get(eid) + def generate_password(self, length, eid): return "gen-pwd" + def get_ssh_key_pair(self, eid): return "priv", "pub" + def get_pgp_key(self, eid): return "priv", "pub", "fp" + def get_nostr_key_pair(self, eid): return "npub", "nsec" + def get_seed_phrase(self, eid, parent): return "word1 word2" + def copy_to_clipboard(self, val): return True + def archive_entry(self, eid): self.entries[eid]["archived"] = True + def restore_entry(self, eid): self.entries[eid]["archived"] = False + def load_managed_account(self, eid): pass + def exit_managed_account(self): pass + def add_entry(self, label, username=None, url=None, length=16, tags=None): eid = max(self.entries.keys()) + 1 - self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "tags": tags} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "password", + "username": username, + "tags": tags, + } return eid + def add_totp(self, label, secret, username=None, tags=None): eid = max(self.entries.keys()) + 1 - self.entries[eid] = {"id": eid, "label": label, "kind": "totp", "username": username, "tags": tags} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "totp", + "username": username, + "tags": tags, + } return eid + def add_seed(self, label, index=0, tags=None): eid = max(self.entries.keys()) + 1 - self.entries[eid] = {"id": eid, "label": label, "kind": "seed", "index": index, "tags": tags} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "seed", + "index": index, + "tags": tags, + } return eid + def add_managed_account(self, label, index=0, tags=None): eid = max(self.entries.keys()) + 1 - self.entries[eid] = {"id": eid, "label": label, "kind": "managed_account", "index": index, "tags": tags} + self.entries[eid] = { + "id": eid, + "label": label, + "kind": "managed_account", + "index": index, + "tags": tags, + } return eid + +class LegacyPgpEntryService(MockEntryService): + def get_pgp_key(self, eid): + _ = eid + return "legacy-priv", "legacy-pub" + + +class SeedArgCompatEntryService(MockEntryService): + def __init__(self): + super().__init__() + self.entries[7] = {"id": 7, "label": "seed-compat", "kind": "seed", "index": 0} + + def get_seed_phrase(self, eid): + _ = eid + return "abandon ability able about" + + class MockVaultService: def __init__(self): self._manager = MagicMock() self._manager.current_fingerprint = "parent-fp" self._manager.profile_stack = [] + def stats(self): - return {"total_entries": len(self._manager.entries) if hasattr(self._manager, "entries") else 6} + return { + "total_entries": ( + len(self._manager.entries) if hasattr(self._manager, "entries") else 6 + ) + } + @pytest.mark.anyio async def test_v3_reveal_copy_parity(): @@ -87,6 +175,7 @@ async def test_v3_reveal_copy_parity(): payload = app._resolve_sensitive_payload() assert payload[3] == "nsec" + @pytest.mark.anyio async def test_v3_archive_restore(): app = SeedPassTuiV3( @@ -100,6 +189,7 @@ async def test_v3_archive_restore(): app.action_toggle_archive() assert app.services["entry"].entries[1].get("archived") is False + @pytest.mark.anyio async def test_v3_add_entry(): app = SeedPassTuiV3( @@ -111,11 +201,15 @@ async def test_v3_add_entry(): await pilot.pause() screen = app.screen from textual.widgets import Input + screen.query_one("#entry-label", Input).value = "New Password" screen.query_one("#entry-username", Input).value = "user@example.com" await pilot.press("ctrl+s") await pilot.pause() - assert any(e["label"] == "New Password" for e in app.services["entry"].entries.values()) + assert any( + e["label"] == "New Password" for e in app.services["entry"].entries.values() + ) + @pytest.mark.anyio async def test_v3_filters(): @@ -133,3 +227,32 @@ async def test_v3_filters(): app.action_set_kind_filter("keys") await pilot.pause() assert table.row_count > 0 + + +@pytest.mark.anyio +async def test_v3_pgp_payload_accepts_legacy_two_tuple() -> None: + app = SeedPassTuiV3( + entry_service_factory=lambda: LegacyPgpEntryService(), + vault_service_factory=lambda: MockVaultService(), + ) + async with app.run_test() as pilot: + await pilot.pause() + app.selected_entry_id = 3 + payload = app._resolve_sensitive_payload() + assert payload[1] == "legacy-priv" + assert payload[2] == "legacy-pub" + assert payload[3] == "legacy-pub" + + +@pytest.mark.anyio +async def test_v3_seed_payload_accepts_single_arg_seed_getter() -> None: + app = SeedPassTuiV3( + entry_service_factory=lambda: SeedArgCompatEntryService(), + vault_service_factory=lambda: MockVaultService(), + ) + async with app.run_test() as pilot: + await pilot.pause() + app.selected_entry_id = 7 + payload = app._resolve_sensitive_payload() + assert payload[0] == "Seed Words Revealed" + assert payload[1] == "abandon ability able about" diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index 826762fa..329121f1 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -139,11 +139,15 @@ async def test_tui3_sidebar_child_nodes_open_entries() -> None: async with app.run_test() as pilot: await pilot.pause() tree = app.screen.query_one("#profile-tree") - tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="managed:2"))) + tree.on_tree_node_selected( + SimpleNamespace(node=SimpleNamespace(data="managed:2")) + ) await pilot.pause() assert app.selected_entry_id == 2 assert app.active_fingerprint == "EFBE51E70ED1B53A" - tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="agent:3"))) + tree.on_tree_node_selected( + SimpleNamespace(node=SimpleNamespace(data="agent:3")) + ) await pilot.pause() assert app.selected_entry_id == 3 diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index d6867bdd..acea1ae9 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -685,6 +685,8 @@ def test_capabilities_json(): assert payload["schema_version"] == 1 assert "cli" in payload["interfaces"] assert "agent" in payload["interfaces"]["cli"]["root_commands"] + assert "tui3" in payload["interfaces"]["cli"]["root_commands"] + assert "legacy" in payload["interfaces"]["cli"]["root_commands"] assert ( "/api/v1/agent/job-profiles/{job_id}/template/verify" in payload["interfaces"]["api"]["discovery"] @@ -744,6 +746,13 @@ def test_api_start_passes_fingerprint(monkeypatch): assert called.get("fp") == "abc" +def test_version_flag(monkeypatch): + monkeypatch.setattr(cli, "_get_cli_version", lambda: "9.9.9-test") + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "SeedPass 9.9.9-test" in result.stdout + + def test_api_start_unlock_uses_env_broker(monkeypatch): called = {} broker_called = {} @@ -1111,6 +1120,27 @@ def test_tui2_fallback_legacy(monkeypatch): assert "falling back to legacy TUI" in result.stdout +def test_tui2_fallback_legacy_strips_subcommand_argv(monkeypatch): + called = {} + monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) + monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) + + def fake_main(*, argv=None, fingerprint=None): + called["fp"] = fingerprint + called["argv"] = argv + return 0 + + monkeypatch.setattr( + cli, + "importlib", + SimpleNamespace(import_module=lambda _: SimpleNamespace(main=fake_main)), + ) + result = runner.invoke(app, ["--fingerprint", "abc", "tui2"]) + assert result.exit_code == 0 + assert called.get("fp") == "abc" + assert called.get("argv") == [] + + def test_root_fallback_legacy_when_tui2_unavailable(monkeypatch): called = {} monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object())