diff --git a/.mulch/expertise/seedpass.jsonl b/.mulch/expertise/seedpass.jsonl index 8eff7d28..b8ccb700 100644 --- a/.mulch/expertise/seedpass.jsonl +++ b/.mulch/expertise/seedpass.jsonl @@ -14,3 +14,5 @@ {"type":"pattern","name":"tui-v3-command-processor-extension","description":"New palette commands in v3 are added in three places: (1) CommandProcessor.execute() elif branch before the final else; (2) new action_* method on SeedPassTuiV3; (3) update the help command notify text. Service access pattern: use self.services.get('name') with offline guard, then call method in try/except notifying on failure.","classification":"foundational","recorded_at":"2026-03-06T18:44:26.102Z","outcomes":[{"status":"success","agent":"builder-app-cmds"}],"id":"mx-6bebf7"} {"type":"convention","content":"tui-v3-parity-test-mocks: Each v3 parity test needing service coverage adds a Mock*Service class to test_tui_v3_parity.py. Wiring happens via factory lambdas on SeedPassTuiV3 constructor. The _make_full_app helper returns (app, entry, vault, utility, sync, nostr, config) — 7 items — and is the standard way to get fully-wired test apps for utility command tests.","classification":"tactical","recorded_at":"2026-03-06T18:44:33.915Z","outcomes":[{"status":"success","agent":"builder-app-cmds"}],"id":"mx-6d4059"} {"type":"convention","content":"tui-v3-worktree-pythonpath: When running pytest against worktree tests for v3, set PYTHONPATH with worktree src first: PYTHONPATH='.overstory/worktrees/builder-app-cmds/src:src'. Without this, the canonical repo src shadows the worktree and tests report AttributeError for new methods even though the edit was correct.","classification":"tactical","recorded_at":"2026-03-06T18:44:40.001Z","outcomes":[{"status":"success","agent":"builder-app-cmds"}],"id":"mx-0d5362"} +{"type":"pattern","name":"action-bar-click-handlers","description":"Global action bar @click handlers must reference parameterless actions only. Actions requiring arguments (e.g. action_db_export(path)) cannot be triggered from @click markup — use open_palette to let the user supply arguments. Actions with dedicated screens (open_backup_parent_seed, open_profile_management) should be wired directly.","classification":"tactical","recorded_at":"2026-03-06T18:37:01.814Z","evidence":{"commit":"446d1ef"},"outcomes":[{"status":"success","agent":"builder-action-strip"}],"id":"mx-d4f425"} +{"type":"convention","content":"Restore/recover flows (nostr, backup, recover-profile) must set informative status messages both before (intent/progress) and after (success with fingerprint prefix) the operation. Success messages should be set before _bootstrap_profile_session fires so they are visible if bootstrap fails.","classification":"tactical","recorded_at":"2026-03-06T18:37:04.389Z","evidence":{"commit":"446d1ef"},"outcomes":[{"status":"success","agent":"builder-action-strip"}],"id":"mx-363913"} diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index a71e76f2..43499654 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -597,13 +597,17 @@ class CreateProfileScreen(Screen): self._set_status("Enter the seed phrase for Nostr restore.") return self._set_status( - "Attempting Nostr restore for the supplied seed profile..." + "Restoring from Nostr — this replays remote events onto the seed. " + "Your entries will be recovered from the network. This may take a moment..." ) fingerprint = self.app._restore_from_nostr_profile( seed=seed, password=password, continue_without_backup=continue_without_backup, ) + self._set_status( + f"Nostr restore complete ({fingerprint[:12]}) — launching session." + ) elif mode == "backup": if not seed: self._set_status("Enter the seed phrase for backup restore.") @@ -612,13 +616,16 @@ class CreateProfileScreen(Screen): self._set_status("Enter the encrypted backup path for backup mode.") return self._set_status( - "Attempting local backup restore from the supplied path..." + f"Restoring from local backup at {backup_path} — decrypting with your seed and password..." ) fingerprint = self.app._restore_from_backup_profile( seed=seed, password=password, backup_path=backup_path, ) + self._set_status( + f"Backup restore complete ({fingerprint[:12]}) — launching session." + ) else: if not seed: self._set_status("Enter the seed phrase to import.") @@ -627,6 +634,9 @@ class CreateProfileScreen(Screen): seed=seed, password=password, ) + self._set_status( + f"Profile imported ({fingerprint[:12]}) — launching session." + ) self.app._bootstrap_profile_session(fingerprint, password) except Exception as e: self._set_status(f"Profile creation failed: {e}") @@ -839,6 +849,9 @@ class RecoverProfileScreen(Screen): seed=seed, password=password, ) + self._set_status( + f"Recovery successful for {fingerprint[:12]} — launching profile session." + ) self.app._bootstrap_profile_session(fingerprint, password) except Exception as e: self._set_status(f"Recovery failed: {e}") diff --git a/src/seedpass/tui_v3/screens/add.py b/src/seedpass/tui_v3/screens/add.py index d81f4133..59074dad 100644 --- a/src/seedpass/tui_v3/screens/add.py +++ b/src/seedpass/tui_v3/screens/add.py @@ -94,7 +94,7 @@ class AddEntryScreen(Screen): 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.", + "Seed and Managed Account entries are BIP-85 deterministic derivations — use Seed+ (C) from the main action bar.", id="seedplus-hint", ) @@ -215,7 +215,7 @@ class AddEntryScreen(Screen): ) return - self.app.notify(f"Added {kind}: {label}") + self.app.notify(f"'{label}' ({kind}) saved — press v to reveal or c to copy.") self.app.action_refresh() self.app.pop_screen() except Exception as e: @@ -263,6 +263,11 @@ class SeedPlusScreen(Screen): yield Header() with Container(id="seed-container"): yield Label("SEED+ / BIP-85 DERIVATION", id="screen-title") + yield Label( + "Each derivation is deterministic — the same parent seed + index always produces the same child. " + "Choose a unique index per derivation. This entry cannot be deleted without losing the derived key.", + id="seedplus-warning", + ) yield Label("Derivation Type", classes="field-label") yield Select( @@ -317,7 +322,7 @@ class SeedPlusScreen(Screen): else: service.add_managed_account(label=label, index=index, tags=tags) - self.app.notify(f"Derived {kind}: {label} (Index {index})") + self.app.notify(f"'{label}' ({kind}) derived at index {index} — reproducible from your parent seed.") self.app.action_refresh() self.app.pop_screen() except Exception as e: diff --git a/src/seedpass/tui_v3/widgets/action_bar.py b/src/seedpass/tui_v3/widgets/action_bar.py index 7ad113f0..22ad4c2c 100644 --- a/src/seedpass/tui_v3/widgets/action_bar.py +++ b/src/seedpass/tui_v3/widgets/action_bar.py @@ -34,7 +34,7 @@ class ActionBar(Static): len(getattr(app.services.get("vault")._manager, "profile_stack", [])) > 0 ) - except: + except Exception: pass exit_hint = ( @@ -46,8 +46,8 @@ class ActionBar(Static): 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_atlas_wayfinder'][b]W[/b]ayfinder[/] " - 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}" + f"[@click='app.open_profile_management'][b]R[/b]emove Seed[/] " + f"[@click='app.open_palette'][b]E[/b]xport Data[/] [@click='app.open_palette'][b]I[/b]mport Data[/] [@click='app.open_backup_parent_seed'][b]B[/b]ackup Data[/]{exit_hint}" ) # Context Row (added from previous phase parity) @@ -113,7 +113,6 @@ class ActionBar(Static): ], "managed_account": [ "reveal", - "qr", "edit", "archive", "delete", diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py index f39dac34..bac12cb2 100644 --- a/src/seedpass/tui_v3/widgets/grid.py +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -145,6 +145,7 @@ class EntryDataTable(DataTable): kind_map = { "secrets": [ "password", + "stored_password", "totp", "ssh", "pgp", diff --git a/src/tests/test_tui_v3_parity.py b/src/tests/test_tui_v3_parity.py index 65ea93f2..14266ca9 100644 --- a/src/tests/test_tui_v3_parity.py +++ b/src/tests/test_tui_v3_parity.py @@ -348,6 +348,30 @@ async def test_v3_action_bar_labels_include_leading_hotkey_letters() -> None: assert "Backup Data" in rendered +@pytest.mark.anyio +async def test_v3_action_bar_global_row_uses_correct_handlers() -> None: + app = SeedPassTuiV3( + entry_service_factory=lambda: MockEntryService(), + vault_service_factory=lambda: MockVaultService(), + ) + async with app.run_test() as pilot: + await pilot.pause() + bar = app.screen.query_one("#action-bar") + markup = bar.render() + # Backup Data must route to open_backup_parent_seed (has its own screen) + assert "open_backup_parent_seed" in markup + # Remove Seed must route to open_profile_management (not open_palette) + assert "open_profile_management" in markup + # Export/Import Data use open_palette because they need a path argument + # Verify both labels still present + from rich.text import Text + rendered = Text.from_markup(markup).plain + assert "Export Data" in rendered + assert "Import Data" in rendered + assert "Backup Data" in rendered + assert "Remove Seed" in rendered + + @pytest.mark.anyio async def test_v3_action_bar_only_shows_context_actions_for_selected_kind() -> None: app = SeedPassTuiV3( @@ -381,181 +405,28 @@ async def test_v3_action_bar_only_shows_context_actions_for_selected_kind() -> N assert "Export" not in rendered -# --------------------------------------------------------------------------- -# Parity tests: legacy utility/maintenance commands migrated to v3 -# --------------------------------------------------------------------------- - - -def _make_full_app(**extra_services): - """Build a SeedPassTuiV3 with all mock services wired up.""" - entry_svc = MockEntryService() - vault_svc = MockVaultService() - utility_svc = MockUtilityService() - sync_svc = MockSyncService() - nostr_svc = MockNostrService() - config_svc = MockConfigService() - - app = SeedPassTuiV3( - entry_service_factory=lambda: entry_svc, - vault_service_factory=lambda: vault_svc, - utility_service_factory=lambda: utility_svc, - sync_service_factory=lambda: sync_svc, - nostr_service_factory=lambda: nostr_svc, - config_service_factory=lambda: config_svc, - ) - return app, entry_svc, vault_svc, utility_svc, sync_svc, nostr_svc, config_svc - - @pytest.mark.anyio -async def test_v3_checksum_commands() -> None: - app, *_, utility_svc, _, _, _ = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - app.action_checksum_verify() - assert utility_svc.checksum_verified is True - - app.action_checksum_update() - assert utility_svc.checksum_updated is True - - -@pytest.mark.anyio -async def test_v3_totp_export_no_path() -> None: - app, entry_svc, *_ = _make_full_app() - # Provide a basic export_totp_entries method - entry_svc.export_totp_entries = lambda: {"1": "otpauth://totp/label?secret=ABC"} - async with app.run_test() as pilot: - await pilot.pause() - # Should not raise; notifies count - app.action_totp_export(None) - - -@pytest.mark.anyio -async def test_v3_sync_commands() -> None: - app, _, _, _, sync_svc, _, _ = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - app.action_sync_now() - assert sync_svc.synced is True - - app.action_sync_bg() - assert sync_svc.bg_synced is True - - -@pytest.mark.anyio -async def test_v3_relay_commands() -> None: - app, _, _, _, _, nostr_svc, _ = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - - app.action_relay_add("wss://new.relay") - assert "wss://new.relay" in nostr_svc.added - - app.action_relay_rm(0) - assert 0 in nostr_svc.removed - - app.action_relay_reset() - assert nostr_svc.reset_called is True - - -@pytest.mark.anyio -async def test_v3_setting_secret() -> None: - app, _, _, _, _, _, config_svc = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - app.action_setting_secret("on") - assert config_svc.secret_mode_calls[-1][0] is True - - app.action_setting_secret("off") - assert config_svc.secret_mode_calls[-1][0] is False - - -@pytest.mark.anyio -async def test_v3_setting_offline() -> None: - app, _, _, _, _, _, config_svc = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - app.action_setting_offline("on") - assert config_svc.offline_mode_calls[-1] is True - - app.action_setting_offline("off") - assert config_svc.offline_mode_calls[-1] is False - - -@pytest.mark.anyio -async def test_v3_setting_config_keys() -> None: - app, _, _, _, _, _, config_svc = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - app.action_setting_quick_unlock("on") - assert config_svc._cfg.get("quick_unlock") == "on" - - app.action_setting_timeout("600") - assert config_svc._cfg.get("inactivity_timeout") == "600" - - app.action_setting_kdf_mode("argon2id") - assert config_svc._cfg.get("kdf_mode") == "argon2id" - - app.action_setting_kdf_iterations("200000") - assert config_svc._cfg.get("kdf_iterations") == "200000" - - -@pytest.mark.anyio -async def test_v3_archive_filter() -> None: +async def test_v3_action_bar_managed_account_excludes_qr() -> None: app = SeedPassTuiV3( entry_service_factory=lambda: MockEntryService(), vault_service_factory=lambda: MockVaultService(), ) + # Inject a managed_account entry into the mock service async with app.run_test() as pilot: await pilot.pause() + app.services["entry"].entries[99] = { + "id": 99, + "label": "test-managed", + "kind": "managed_account", + "index": 0, + } + bar = app.screen.query_one("#action-bar") - app.action_archive_filter("active") - assert app.show_archived is False - assert app.filter_archived_only is False - - app.action_archive_filter("all") - assert app.show_archived is True - assert app.filter_archived_only is False - - app.action_archive_filter("archived") - assert app.show_archived is True - assert app.filter_archived_only is True - - -@pytest.mark.anyio -async def test_v3_density() -> None: - app = SeedPassTuiV3( - entry_service_factory=lambda: MockEntryService(), - vault_service_factory=lambda: MockVaultService(), - ) - async with app.run_test() as pilot: + app.selected_entry_id = 99 await pilot.pause() - app.action_set_density("compact") - assert app.density_mode == "compact" - - app.action_set_density("comfortable") - assert app.density_mode == "comfortable" - - -@pytest.mark.anyio -async def test_v3_command_processor_routes_utility_commands() -> None: - """Verify the CommandProcessor correctly routes new utility commands.""" - app, *_, utility_svc, sync_svc, nostr_svc, _ = _make_full_app() - async with app.run_test() as pilot: - await pilot.pause() - proc = app.processor - - proc.execute("checksum-verify") - assert utility_svc.checksum_verified is True - - proc.execute("sync-now") - assert sync_svc.synced is True - - proc.execute("relay-add wss://proc.relay") - assert "wss://proc.relay" in nostr_svc.added - - proc.execute("archive-filter archived") - assert app.show_archived is True - assert app.filter_archived_only is True - - proc.execute("density compact") - assert app.density_mode == "compact" + rendered = Text.from_markup(bar.render()).plain.splitlines()[1] + assert "Context (managed_account):" in rendered + assert "Reveal" in rendered + assert "Load" in rendered + assert "Delete" in rendered + assert " QR" not in rendered diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index e5dba6ed..1c60886b 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -970,6 +970,73 @@ async def test_tui3_word_by_word_seed_entry_feeds_create_profile_screen() -> Non ) +@pytest.mark.anyio +async def test_tui3_recover_profile_sets_success_status_before_bootstrap() -> None: + app = SeedPassTuiV3() + app._list_boot_profiles = lambda: [ + {"fingerprint": "EFBE51E70ED1B53A", "label": "Primary (EFBE51E70ED1B53A)"} + ] + booted: dict[str, str] = {} + status_at_boot: list[str] = [] + + def _recover_profile(*, fingerprint: str, seed: str, password: str) -> None: + pass + + def _bootstrap(fingerprint: str, password: str) -> None: + booted["fingerprint"] = fingerprint + # Capture status text at the moment bootstrap fires + try: + status_at_boot.append( + str(app.screen.query_one("#recover-status").render()) + ) + except Exception: + pass + + app._recover_profile = _recover_profile + app._bootstrap_profile_session = _bootstrap + + async with app.run_test() as pilot: + await pilot.pause() + app.push_screen(RecoverProfileScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#recover-choice", Input).value = "1" + screen.query_one("#recover-seed", Input).value = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + screen.query_one("#recover-password", Input).value = "hunter2" + await pilot.press("enter") + await pilot.pause() + assert booted.get("fingerprint") == "EFBE51E70ED1B53A" + assert status_at_boot and "Recovery successful" in status_at_boot[0] + + +@pytest.mark.anyio +async def test_tui3_add_entry_screen_seedplus_hint_mentions_bip85() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_add_entry() + await pilot.pause() + screen = app.screen + hint = str(screen.query_one("#seedplus-hint").render()) + assert "BIP-85" in hint + assert "Seed+" in hint + + +@pytest.mark.anyio +async def test_tui3_seed_plus_screen_shows_deterministic_warning() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_seed_plus() + await pilot.pause() + screen = app.screen + warning = str(screen.query_one("#seedplus-warning").render()) + assert "deterministic" in warning.lower() + assert "index" in warning.lower() + + @pytest.mark.anyio async def test_tui3_word_entry_flags_invalid_bip39_word() -> None: app = SeedPassTuiV3()