From 4113bd12822e4b40b80551485f720950180b6581 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:06:39 -0500 Subject: [PATCH] Implement TUI v2 phase 1 read-only shell --- src/seedpass/cli/__init__.py | 7 +- src/seedpass/tui_v2/app.py | 191 +++++++++++++++++++++++++++++++---- src/tests/test_typer_cli.py | 39 +++++++ 3 files changed, 214 insertions(+), 23 deletions(-) diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 35ae8558..e7ecfae8 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -9,7 +9,7 @@ from typing import Optional import typer -from .common import _get_services +from .common import _get_entry_service, _get_services from seedpass.core.errors import SeedPassError from constants import GUI_BACKEND_CONFIG from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2 @@ -127,7 +127,10 @@ def tui2( return fingerprint = (ctx.obj or {}).get("fingerprint") - launched = launch_tui2(fingerprint=fingerprint) + launched = launch_tui2( + fingerprint=fingerprint, + entry_service_factory=lambda: _get_entry_service(ctx), + ) if launched: return diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 4349586f..dd63d4e7 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json from typing import Any @@ -19,7 +20,11 @@ def check_tui2_runtime() -> dict[str, Any]: } -def launch_tui2(*, fingerprint: str | None = None) -> bool: +def launch_tui2( + *, + fingerprint: str | None = None, + entry_service_factory: Any | None = None, +) -> bool: """Launch TUI v2 when runtime dependencies are available. Returns ``True`` when launch succeeds, ``False`` when runtime is unavailable. @@ -28,35 +33,179 @@ def launch_tui2(*, fingerprint: str | None = None) -> bool: if not runtime["textual_available"]: return False - # Placeholder shell for incremental migration. + # Textual shell (phase 1: read-only list/search/details). from textual.app import App, ComposeResult - from textual.containers import Container - from textual.widgets import Footer, Header, Static + from textual.containers import Horizontal, Vertical + from textual.reactive import reactive + from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, Static + + class EntryListItem(ListItem): + def __init__(self, entry_index: int, text: str) -> None: + super().__init__(Label(text)) + self.entry_index = int(entry_index) class SeedPassTuiV2(App[None]): + CSS = """ + #body { height: 1fr; } + #left { width: 28; border: solid $primary; padding: 1; } + #center { width: 1fr; border: solid $primary; padding: 1; } + #right { width: 1fr; border: solid $primary; padding: 1; } + #search { margin-bottom: 1; } + #entry-list { height: 1fr; } + #entry-detail { height: 1fr; overflow: auto; } + """ BINDINGS = [("q", "quit", "Quit")] + BINDINGS += [ + ("r", "refresh", "Refresh"), + ("slash", "focus_search", "Search"), + ("f", "cycle_filter", "Filter"), + ] + filter_kind: reactive[str] = reactive("all") def compose(self) -> ComposeResult: yield Header(show_clock=True) - with Container(): - fp_line = f"Fingerprint: {fingerprint}" if fingerprint else "No fingerprint selected" - yield Static( - "\n".join( - [ - "SeedPass TUI v2 scaffold", - fp_line, - "", - "Next steps:", - "- Implement list/search/details panes", - "- Add document editor panel", - "- Add graph links panel", - "", - "Press 'q' to quit.", - ] + with Horizontal(id="body"): + with Vertical(id="left"): + yield Static("", id="filters") + with Vertical(id="center"): + yield Input( + placeholder="Search entries (Enter to apply)", id="search" ) - ) + yield ListView(id="entry-list") + with Vertical(id="right"): + yield Static("", id="entry-detail") yield Footer() + def on_mount(self) -> None: + try: + self._service = ( + entry_service_factory() if callable(entry_service_factory) else None + ) + except Exception as exc: + self._service = None + self.query_one("#entry-detail", Static).update( + f"Unable to initialize entry service: {exc}" + ) + self._update_filters_panel() + self._load_entries() + + def _current_filter_kinds(self) -> list[str] | None: + if self.filter_kind == "all": + return None + return [self.filter_kind] + + def _update_filters_panel(self) -> None: + fp_line = ( + f"Fingerprint: {fingerprint}" + if fingerprint + else "Fingerprint: (default)" + ) + text = "\n".join( + [ + "TUI v2 (Phase 1)", + fp_line, + "", + f"Active filter: {self.filter_kind}", + "Press 'f' to cycle filter", + "Press '/' to focus search", + "Press 'r' to refresh", + "", + "Planned next:", + "- document editor pane", + "- graph links pane", + ] + ) + self.query_one("#filters", Static).update(text) + + def _render_entry_label( + self, idx: int, label: str, etype: str, archived: bool + ) -> str: + arch = " [archived]" if archived else "" + return f"{idx:>4} {etype:<15} {label}{arch}" + + def _load_entries(self, query: str = "") -> None: + list_view = self.query_one("#entry-list", ListView) + list_view.clear() + if self._service is None: + self.query_one("#entry-detail", Static).update( + "Entry service unavailable in this runtime." + ) + return + + try: + results = self._service.search_entries( + query, kinds=self._current_filter_kinds() + ) + except Exception as exc: + self.query_one("#entry-detail", Static).update( + f"Failed to load entries: {exc}" + ) + return + + for idx, label, _username, _url, archived, etype in results: + kind = getattr(etype, "value", str(etype)) + item = EntryListItem( + idx, + self._render_entry_label(idx, label, kind, bool(archived)), + ) + list_view.append(item) + + if len(results) == 0: + self.query_one("#entry-detail", Static).update("No entries match.") + else: + if list_view.children: + first = list_view.children[0] + if isinstance(first, EntryListItem): + self._show_entry(first.entry_index) + + def _show_entry(self, entry_index: int) -> None: + if self._service is None: + return + try: + entry = self._service.retrieve_entry(entry_index) + if not isinstance(entry, dict): + self.query_one("#entry-detail", Static).update("Entry not found.") + return + body = json.dumps(entry, indent=2, sort_keys=True) + self.query_one("#entry-detail", Static).update(body) + except Exception as exc: + self.query_one("#entry-detail", Static).update( + f"Failed to load entry {entry_index}: {exc}" + ) + + def action_refresh(self) -> None: + search = self.query_one("#search", Input).value + self._update_filters_panel() + self._load_entries(query=search) + + def action_focus_search(self) -> None: + self.query_one("#search", Input).focus() + + def action_cycle_filter(self) -> None: + order = [ + "all", + "password", + "totp", + "document", + "key_value", + "ssh", + "pgp", + "nostr", + "seed", + "managed_account", + ] + idx = order.index(self.filter_kind) if self.filter_kind in order else 0 + self.filter_kind = order[(idx + 1) % len(order)] + self.action_refresh() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "search": + self._load_entries(query=event.value.strip()) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if isinstance(item, EntryListItem): + self._show_entry(item.entry_index) + SeedPassTuiV2().run() return True - diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 2529df9a..31eff852 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -990,6 +990,45 @@ def test_tui_forward_fingerprint(monkeypatch): assert called.get("fp") == "abc" +def test_tui2_check(monkeypatch): + monkeypatch.setattr( + cli, + "check_tui2_runtime", + lambda: {"status": "ok", "backend": "textual", "textual_available": True}, + ) + result = runner.invoke(app, ["tui2", "--check"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["backend"] == "textual" + assert payload["textual_available"] is True + + +def test_tui2_unavailable_without_fallback(monkeypatch): + monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) + result = runner.invoke(app, ["tui2", "--no-fallback-legacy"]) + assert result.exit_code == 1 + assert "Install `textual`" in result.stderr + + +def test_tui2_fallback_legacy(monkeypatch): + called = {} + monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) + + def fake_main(*, fingerprint=None): + called["fp"] = fingerprint + 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 "falling back to legacy TUI" in result.stdout + + def test_gui_command(monkeypatch): called = {}