diff --git a/src/seedpass/tui_v3/screens/settings.py b/src/seedpass/tui_v3/screens/settings.py index 6e68f879..4ea55844 100644 --- a/src/seedpass/tui_v3/screens/settings.py +++ b/src/seedpass/tui_v3/screens/settings.py @@ -66,20 +66,20 @@ class SettingsScreen(Screen): def action_refresh(self) -> None: """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]" - ) - return + config_online = "config" in app.services - service = app.services["config"] + if config_online: + service = app.services["config"] - def get_val(key, default=""): - try: - val = service.get(key) - return val if val is not None else default - except Exception: - return "[red](error)[/red]" + def get_val(key, default=""): + try: + val = service.get(key) + return val if val is not None else default + except Exception: + return "[red](error)[/red]" + else: + def get_val(key, default=""): # type: ignore[misc] + return "[dim](offline)[/dim]" # Categorized Rows security_rows = [ @@ -106,6 +106,15 @@ class SettingsScreen(Screen): "Fresh Namespace: [dim](nostr-fresh-namespace)[/dim]", ] + utility_rows = [ + "Statistics : [dim](stats)[/dim]", + "DB Export : [dim](db-export )[/dim]", + "DB Import : [dim](db-import )[/dim]", + "Checksum : [dim](checksum-verify)[/dim] | [dim](checksum-update)[/dim]", + "TOTP Export : [dim](totp-export)[/dim]", + "Offline Mode : [dim](setting-offline on|off)[/dim]", + ] + # Use the app's card rendering utility if available, or fallback # In v3, we keep card logic in a central place or widget. # For now, we'll implement a clean local version. @@ -124,6 +133,8 @@ class SettingsScreen(Screen): render_card("STORAGE & BACKUP POLICY", backup_rows), "", render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows), + "", + render_card("UTILITY & MAINTENANCE", utility_rows), ] self.query_one("#settings-content", Static).update("\n".join(rendered_lines)) diff --git a/src/seedpass/tui_v3/widgets/inspector.py b/src/seedpass/tui_v3/widgets/inspector.py index 3cf6c9a6..657ad0b1 100644 --- a/src/seedpass/tui_v3/widgets/inspector.py +++ b/src/seedpass/tui_v3/widgets/inspector.py @@ -561,6 +561,43 @@ class GenericBoard(BaseBoard): pass +class UtilityHintsBar(Static): + """Shows kind-specific palette command hints at the bottom of the inspector.""" + + DEFAULT_CSS = """ + UtilityHintsBar { + height: auto; + min-height: 2; + background: #111111; + color: #888888; + border-top: solid #333333; + padding: 0 1; + } + """ + + _HINTS: dict[str, list[str]] = { + "idle": [], + "password": ["copy", "v: reveal", "edit", "export", "archive"], + "totp": ["copy (2FA code)", "v: reveal secret", "g: QR", "archive"], + "note": ["copy (content)", "edit", "archive"], + "document": ["copy (content)", "edit", "archive"], + "seed": ["vv: reveal phrase", "gg: QR", "archive"], + "managed_account": ["ml: managed-load", "vv: reveal phrase", "archive"], + "ssh": ["copy (private key)", "vv: reveal", "archive"], + "pgp": ["copy (private key)", "vv: reveal", "archive"], + "nostr": ["copy (nsec/npub)", "v: reveal nsec", "g: QR", "npub", "archive"], + "generic": ["copy", "v: reveal", "archive"], + } + + def update_kind(self, board_type: str) -> None: + hints = self._HINTS.get(board_type, self._HINTS["generic"]) + if not hints: + self.update("") + return + hint_str = " | ".join(hints) + self.update(f"[b]Actions:[/b] {hint_str} [dim](Ctrl+P: palette)[/dim]") + + class BoardContainer(Vertical): """ Reactive container that swaps specialized boards based on entry kind. @@ -574,14 +611,23 @@ class BoardContainer(Vertical): background: #000000; overflow: auto; } + #board-slot { + height: 1fr; + overflow: auto; + } """ + def compose(self) -> ComposeResult: + yield Vertical(id="board-slot") + yield UtilityHintsBar(id="utility-hints-bar") + def on_mount(self) -> None: self._show_board("idle") def update_entry(self, entry_id: int | None) -> None: if entry_id is None: self._show_board("idle") + self._update_hints("idle") return app = self.app @@ -591,37 +637,53 @@ class BoardContainer(Vertical): entry = app.services["entry"].retrieve_entry(entry_id) if not entry: self._show_board("idle") + self._update_hints("idle") return kind = str(entry.get("kind") or entry.get("type") or "").lower() if kind in {"password", "stored_password"}: - board = self._show_board("password") + board_type = "password" elif kind == "totp": - board = self._show_board("totp") + board_type = "totp" elif kind in {"document", "note"}: - board = self._show_board("note") - elif kind in {"seed", "managed_account"}: - board = self._show_board("seed") + board_type = "note" + elif kind == "managed_account": + board_type = "managed_account" + elif kind == "seed": + board_type = "seed" elif kind == "ssh": - board = self._show_board("ssh") + board_type = "ssh" elif kind == "pgp": - board = self._show_board("pgp") + board_type = "pgp" elif kind == "nostr": - board = self._show_board("nostr") + board_type = "nostr" else: - board = self._show_board("generic") + board_type = "generic" + board = self._show_board(board_type) board.entry_data = entry + self._update_hints(board_type) + + def _update_hints(self, board_type: str) -> None: + try: + self.query_one("#utility-hints-bar", UtilityHintsBar).update_kind(board_type) + except Exception: + pass def _show_board(self, board_type: str) -> Any: - self.remove_children() + try: + slot = self.query_one("#board-slot", Vertical) + except Exception: + slot = self + slot.remove_children() mapping = { "idle": IdleBoard, "password": PasswordBoard, "totp": TotpBoard, "note": NoteBoard, "seed": SeedBoard, + "managed_account": SeedBoard, "ssh": SshBoard, "pgp": PgpBoard, "nostr": NostrBoard, @@ -629,7 +691,7 @@ class BoardContainer(Vertical): } board_cls = mapping.get(board_type, GenericBoard) new_board = board_cls() - self.mount(new_board) + slot.mount(new_board) return new_board diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index e984d887..bc912a12 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -22,6 +22,8 @@ from seedpass.tui_v3.screens.atlas import AtlasWayfinderScreen from seedpass.tui_v3.screens.pubkey import NostrPubkeyScreen from seedpass.tui_v3.screens.relays import RelaysScreen from seedpass.tui_v3.screens.security import ChangePasswordScreen +from seedpass.tui_v3.screens.settings import SettingsScreen +from seedpass.tui_v3.widgets.inspector import UtilityHintsBar class V3EntryService: @@ -983,3 +985,46 @@ async def test_tui3_word_entry_flags_invalid_bip39_word() -> None: progress = str(app.screen.query_one("#seed-words-progress").render()) assert "not in the BIP-39 wordlist" in status assert "invalid: 1" in progress + + +@pytest.mark.anyio +async def test_tui3_settings_screen_shows_utility_maintenance_section() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_toggle_settings() + await pilot.pause() + assert isinstance(app.screen, SettingsScreen) + content = str(app.screen.query_one("#settings-content", Static).render()) + assert "UTILITY & MAINTENANCE" in content + assert "db-export" in content + assert "db-import" in content + assert "checksum-verify" in content + assert "stats" in content + + +@pytest.mark.anyio +async def test_tui3_inspector_utility_hints_update_by_entry_kind() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + + # Select password entry (id=1) + app.selected_entry_id = 1 + await pilot.pause() + hints_bar = app.screen.query_one("#utility-hints-bar", UtilityHintsBar) + hints_text = str(hints_bar.render()) + assert "reveal" in hints_text.lower() + assert "copy" in hints_text.lower() + + # Select nostr entry (id=3) — should show npub hint + app.selected_entry_id = 3 + await pilot.pause() + hints_text = str(hints_bar.render()) + assert "npub" in hints_text.lower() + + # Deselect — hints bar should be empty + app.selected_entry_id = None + await pilot.pause() + hints_text = str(hints_bar.render()) + assert "Actions" not in hints_text