diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 9aaa81f6..140f1b76 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -13,6 +13,7 @@ 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 +from seedpass.tui_v3 import launch_tui3 app = typer.Typer( help=( @@ -50,6 +51,12 @@ legacy_tui_option = typer.Option( help="Use legacy interactive TUI (temporary fallback during TUI v2 cutover)", ) +tui_version_option = typer.Option( + "v2", + "--tui", + help="Select TUI version: legacy, v2, v3", +) + # Sub command groups from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic from .capabilities import register_capabilities_command @@ -131,6 +138,7 @@ def main( no_clipboard: bool = no_clipboard_option, deterministic_totp: bool = deterministic_totp_option, legacy_tui: bool = legacy_tui_option, + tui_version: str = tui_version_option, ) -> None: """SeedPass CLI entry point. @@ -143,19 +151,34 @@ def main( "no_clipboard": no_clipboard, "deterministic_totp": deterministic_totp, "legacy_tui": legacy_tui, + "tui_version": tui_version, } if ctx.invoked_subcommand is None: - if legacy_tui: + if legacy_tui or tui_version == "legacy": raise _launch_legacy_tui(fingerprint=fingerprint) services, preflight_error = _prime_tui2_service(ctx) if preflight_error is not None: typer.echo( - "TUI v2 preflight failed; falling back to legacy TUI. " + f"TUI {tui_version} preflight failed; falling back to legacy TUI. " f"Details: {preflight_error}" ) raise _launch_legacy_tui(fingerprint=fingerprint) + if tui_version == "v3": + launch_tui3( + fingerprint=fingerprint, + entry_service_factory=lambda: services["entry"], + profile_service_factory=lambda: services["profile"], + config_service_factory=lambda: services["config"], + nostr_service_factory=lambda: services["nostr"], + sync_service_factory=lambda: services["sync"], + utility_service_factory=lambda: services["utility"], + vault_service_factory=lambda: services["vault"], + semantic_service_factory=lambda: services["semantic"], + ) + return + launched = launch_tui2( fingerprint=fingerprint, entry_service_factory=lambda: services["entry"], @@ -171,7 +194,7 @@ def main( return typer.echo( - "TUI v2 runtime unavailable; falling back to legacy TUI. " + f"TUI {tui_version} runtime unavailable; falling back to legacy TUI. " "Run `seedpass tui2 --check` for diagnostics." ) raise _launch_legacy_tui(fingerprint=fingerprint) @@ -247,6 +270,28 @@ def tui2( raise typer.Exit(1) +@app.command("tui3") +def tui3(ctx: typer.Context) -> None: + """Launch the experimental TUI v3 scratch architecture.""" + fingerprint = (ctx.obj or {}).get("fingerprint") + services, preflight_error = _prime_tui2_service(ctx) + if preflight_error is not None: + typer.echo(f"TUI v3 preflight failed: {preflight_error}", err=True) + raise typer.Exit(1) + + launch_tui3( + fingerprint=fingerprint, + entry_service_factory=lambda: services["entry"], + profile_service_factory=lambda: services["profile"], + config_service_factory=lambda: services["config"], + nostr_service_factory=lambda: services["nostr"], + sync_service_factory=lambda: services["sync"], + utility_service_factory=lambda: services["utility"], + vault_service_factory=lambda: services["vault"], + semantic_service_factory=lambda: services["semantic"], + ) + + @app.command("legacy") def legacy_tui(ctx: typer.Context) -> None: """Launch the legacy interactive TUI explicitly.""" diff --git a/src/seedpass/tui_v3/__init__.py b/src/seedpass/tui_v3/__init__.py new file mode 100644 index 00000000..b7dd0e2a --- /dev/null +++ b/src/seedpass/tui_v3/__init__.py @@ -0,0 +1,56 @@ +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 + return { + "status": "ok" if textual_available else "unavailable", + "backend": "textual-v3", + "textual_available": textual_available, + "message": ( + "Textual runtime available." + if textual_available + else "Textual is not installed. Install `textual` to run tui3." + ), + } + +def launch_tui3( + *, + fingerprint: str | None = None, + entry_service_factory: Any | None = None, + profile_service_factory: Any | None = None, + config_service_factory: Any | None = None, + nostr_service_factory: Any | None = None, + sync_service_factory: Any | None = None, + utility_service_factory: Any | None = None, + vault_service_factory: Any | None = None, + semantic_service_factory: Any | None = None, + app_hook: Any | None = None, +) -> bool: + """Launch TUI v3 from scratch architecture.""" + runtime = check_tui3_runtime() + if not runtime["textual_available"]: + return False + + from .app import SeedPassTuiV3 + + app = SeedPassTuiV3( + fingerprint=fingerprint, + entry_service_factory=entry_service_factory, + profile_service_factory=profile_service_factory, + config_service_factory=config_service_factory, + nostr_service_factory=nostr_service_factory, + sync_service_factory=sync_service_factory, + utility_service_factory=utility_service_factory, + 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 new file mode 100644 index 00000000..bc051249 --- /dev/null +++ b/src/seedpass/tui_v3/app.py @@ -0,0 +1,158 @@ +from __future__ import annotations +import time +from typing import Any, Callable + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Header, Footer, Static +from textual.containers import Horizontal, Vertical + +from .widgets.header import RibbonHeader +from .widgets.sidebar import SidebarContainer +from .widgets.grid import GridContainer +from .widgets.inspector import BoardContainer +from .screens.settings import SettingsScreen +from .screens.inspector import MaximizedInspectorScreen + +class MainScreen(Screen): + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ UI v3", id="brand-strip") + yield RibbonHeader(id="top-ribbon") + with Vertical(id="body"): + with Horizontal(id="top-work"): + yield SidebarContainer(id="left") + with Vertical(id="center"): + yield GridContainer(id="grid-work") + with Vertical(id="right"): + yield Static("Inspector Board", id="inspector-heading") + yield BoardContainer(id="board-container") + yield Static("Ready", id="status") + yield Footer() + +class SeedPassTuiV3(App[None]): + """ + SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity. + """ + CSS = """ + #brand-strip { + background: #0b0f13; + color: #58f29d; + text-style: bold; + border: solid #2abf75; + margin: 0 1; + padding: 0 1; + height: 3; + content-align: center middle; + } + #body { height: 1fr; margin: 0 1; } + #top-work { height: 6fr; } + #left { width: 31; border: solid #1a3024; background: #0d1114; } + #center { width: 1fr; border: solid #1a3024; background: #0d1114; margin-left: 1; } + #right { height: 5fr; border: heavy #3ce79c; background: #0d1114; margin-top: 1; } + #status { + height: 3; + padding: 0 1; + border: heavy #58f29d; + background: #11191f; + color: #e4fff2; + margin: 0 1; + } + #sidebar-placeholder, #grid-placeholder, #inspector-placeholder { + height: 1fr; + content-align: center middle; + color: #3ce79c; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit", show=True), + Binding("ctrl+p", "open_palette", "Palette", show=True), + Binding("shift+s", "toggle_settings", "Settings", show=True), + Binding("z", "maximize_inspector", "Maximize", show=True), + ] + + # Shared Reactive State + active_fingerprint = reactive("") + selected_entry_id = reactive[int | None](None) + session_locked = reactive(True) + + def __init__( + self, + fingerprint: str | None = None, + entry_service_factory: Callable | None = None, + profile_service_factory: Callable | None = None, + config_service_factory: Callable | None = None, + nostr_service_factory: Callable | None = None, + sync_service_factory: Callable | None = None, + utility_service_factory: Callable | None = None, + vault_service_factory: Callable | None = None, + semantic_service_factory: Callable | None = None, + ) -> None: + super().__init__() + # Store factories + self.factories = { + "entry": entry_service_factory, + "profile": profile_service_factory, + "config": config_service_factory, + "nostr": nostr_service_factory, + "sync": sync_service_factory, + "utility": utility_service_factory, + "vault": vault_service_factory, + "semantic": semantic_service_factory, + } + # Initialized services + self.services: dict[str, Any] = {} + self._initial_fingerprint = fingerprint + + def on_mount(self) -> None: + """Initialize services and push the main screen.""" + for name, factory in self.factories.items(): + if factory: + try: + 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()) + + def watch_active_fingerprint(self, old_fp: str, new_fp: str) -> None: + """Refresh components when the profile changes.""" + if not new_fp: + return + # Notify sidebar and grid to refresh + try: + self.query_one("#profile-tree")._refresh_tree() + self.query_one("#entry-data-table")._refresh_data() + except Exception: + pass + + def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None: + """Update inspectors when an entry is selected.""" + try: + self.query_one("#board-container").update_entry(new_id) + except Exception: + pass + + def action_refresh(self) -> None: + """Force a global UI refresh.""" + self.query_one("#profile-tree")._refresh_tree() + self.query_one("#entry-data-table")._refresh_data() + self.notify("UI Refreshed") + + def action_open_palette(self) -> None: + # Placeholder for unified command palette + self.notify("Palette coming soon in v3") + + def action_toggle_settings(self) -> None: + """Push the full-screen settings screen.""" + self.push_screen(SettingsScreen()) + + def action_maximize_inspector(self) -> None: + """Push the full-screen maximized entry detail screen.""" + if self.selected_entry_id is None: + self.notify("Select an entry to maximize", severity="warning") + return + self.push_screen(MaximizedInspectorScreen()) diff --git a/src/seedpass/tui_v3/screens/inspector.py b/src/seedpass/tui_v3/screens/inspector.py new file mode 100644 index 00000000..01d21dc6 --- /dev/null +++ b/src/seedpass/tui_v3/screens/inspector.py @@ -0,0 +1,108 @@ +from __future__ import annotations +from typing import Any +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Footer +from textual.containers import Vertical, Horizontal + +class MaximizedInspectorScreen(Screen): + """ + Full-screen detailed view for a single entry. + Optimized for long documents, SSH keys, or PGP blocks. + """ + + BINDINGS = [ + ("escape", "app.pop_screen", "Back to Vault"), + ("v", "reveal", "Reveal Secret"), + ("g", "qr", "Show QR"), + ] + + DEFAULT_CSS = """ + MaximizedInspectorScreen { + background: #080a0c; + } + #max-inspector-title { + background: #0b0f13; + color: #58f29d; + text-style: bold; + text-align: center; + height: 3; + border: double #2abf75; + padding: 0 1; + margin: 1 2; + } + #max-inspector-container { + height: 1fr; + margin: 0 2; + border: solid #1a3024; + padding: 1; + overflow: auto; + } + #max-inspector-footer { + height: 3; + background: #11191f; + color: #daf2e5; + text-align: center; + border: double #2abf75; + padding: 0 1; + margin: 1 2; + } + """ + + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ Detailed Inspection", id="max-inspector-title") + with Vertical(id="max-inspector-container"): + yield Static("Loading details...", id="max-inspector-content") + yield Static("ESC: Back | v: Reveal | g: QR", id="max-inspector-footer") + + def on_mount(self) -> None: + self._refresh_detail() + + def _refresh_detail(self) -> None: + app = self.app + eid = app.selected_entry_id + if eid is None: + self.query_one("#max-inspector-content", Static).update("No entry selected.") + return + + if "entry" not in app.services: + return + + entry = app.services["entry"].retrieve_entry(eid) + if not entry: + return + + # Simple render for the full screen view + label = entry.get('label', 'Unknown') + kind = entry.get('kind', 'Unknown') + + # Build a large detail view + header = f"[b]ENTRY #{eid} - {label.upper()} ({kind})[/b] +" + divider = "=" * 60 + " +" + + # Meta info + meta_lines = [ + f"Username : {entry.get('username', '(none)')}", + f"URL : {entry.get('url', '(none)')}", + f"Created : {entry.get('created_at', '(unknown)')}", + f"Modified : {entry.get('modified_at', '(unknown)')}", + ] + + # Content (if note/doc) + content = entry.get('content', '') + content_block = f" +[b]CONTENT / NOTES:[/b] +{divider}{content} +" if content else "" + + final_text = header + divider + " +".join(meta_lines) + content_block + self.query_one("#max-inspector-content", Static).update(final_text) + + def action_reveal(self) -> None: + self.app.notify("Reveal triggered (Coming Soon in v3)") + + def action_qr(self) -> None: + self.app.notify("QR triggered (Coming Soon in v3)") diff --git a/src/seedpass/tui_v3/screens/settings.py b/src/seedpass/tui_v3/screens/settings.py new file mode 100644 index 00000000..3228409d --- /dev/null +++ b/src/seedpass/tui_v3/screens/settings.py @@ -0,0 +1,118 @@ +from __future__ import annotations +from typing import Any +from textual.app import ComposeResult +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"), + ] + + DEFAULT_CSS = """ + SettingsScreen { + background: #080a0c; + } + #settings-title { + background: #0b0f13; + color: #58f29d; + text-style: bold; + text-align: center; + height: 3; + border: double #2abf75; + padding: 0 1; + margin: 1 2; + } + #settings-container { + height: 1fr; + margin: 0 2; + border: solid #1a3024; + padding: 1; + overflow: auto; + } + #settings-footer { + height: 3; + background: #11191f; + color: #daf2e5; + text-align: center; + border: double #2abf75; + padding: 0 1; + margin: 1 2; + } + """ + + def compose(self) -> ComposeResult: + 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") + + def on_mount(self) -> None: + self.action_refresh() + + 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 + + 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]" + + # Categorized Rows + security_rows = [ + f"Secret Mode : {get_val('secret_mode_enabled', False)} [dim](setting-secret on|off)[/dim]", + f"Quick Unlock : {get_val('quick_unlock', False)} [dim](setting-quick-unlock on|off)[/dim]", + f"KDF Iterations : {get_val('kdf_iterations', 100000)} [dim](setting-kdf-iterations )[/dim]", + 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", + ] + + nostr_rows = [ + f"Sync Mode : {get_val('semantic_search_mode', 'keyword')} [dim](search-mode ...)[/dim]", + f"Relays : {len(get_val('relays', []))} connected [dim](relay-list)[/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. + def render_card(title: str, rows: list[str]) -> str: + width = 60 + top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐" + lines = [top] + for row in rows: + lines.append(f"│ {row:<{width-4}} │") + lines.append("└" + "─" * (width - 2) + "┘") + return " +".join(lines) + + rendered_lines = [ + render_card("SECURITY & AUTHENTICATION", security_rows), + "", + render_card("STORAGE & BACKUP POLICY", backup_rows), + "", + render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows), + ] + + self.query_one("#settings-content", Static).update(" +".join(rendered_lines)) + self.app.notify("Settings reloaded") diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py new file mode 100644 index 00000000..58afe37b --- /dev/null +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -0,0 +1,108 @@ +from __future__ import annotations +from typing import Any +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; + background: #0b0f13; + color: #daf2e5; + padding: 0 1; + } + """ + + def render(self) -> str: + # These would eventually come from App reactives + pg = "1/1" + rows = "0/0" + density = "compact" + # Search mode highlighting + search = "[b](KEYWORD)[/b] hybrid semantic" + + return f"Entry Grid | Pg {pg} Rows {rows} Density {density} Search {search}" + +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._refresh_data() + + def _refresh_data(self) -> None: + self.clear() + app = self.app + if "entry" not in app.services: + return + + # Fetch real data from service + entries = app.services["entry"].search_entries("") + 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), + f"#{eid}", + label, + kind.value, + meta, + arch_status, + key=str(eid) + ) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle entry selection.""" + if event.row_key.value: + self.app.selected_entry_id = int(event.row_key.value) + self.app.notify(f"Selected Entry #{event.row_key.value}") + +class GridContainer(Static): + """ + The central workspace container. + """ + def compose(self) -> ComposeResult: + yield GridMetrics() + # Header divider + yield Static("-" * 120, id="grid-divider") + yield EntryDataTable(id="entry-data-table") + + DEFAULT_CSS = """ + GridContainer { + height: 1fr; + background: #0d1114; + } + #grid-divider { + color: #1a3024; + height: 1; + padding: 0 1; + } + #entry-data-table { + background: transparent; + color: #97b8a6; + border: none; + height: 1fr; + } + #entry-data-table > .datatable--header { + background: #0b0f13; + color: #58f29d; + text-style: bold; + } + #entry-data-table > .datatable--cursor { + background: #122019; + color: #58f29d; + } + """ diff --git a/src/seedpass/tui_v3/widgets/header.py b/src/seedpass/tui_v3/widgets/header.py new file mode 100644 index 00000000..f5903913 --- /dev/null +++ b/src/seedpass/tui_v3/widgets/header.py @@ -0,0 +1,50 @@ +from __future__ import annotations +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, + session state, and vault metrics. + + Matches the 'UI Board' mockup top ribbon layout. + """ + + DEFAULT_CSS = """ + RibbonHeader { + height: 3; + background: #10181f; + color: #daf2e5; + border: heavy #2abf75; + padding: 0 1; + margin: 0 1; + text-style: bold; + } + """ + + def render(self) -> str: + app = self.app + fp = app.active_fingerprint or "No Profile" + lock_icon = "🔒" if app.session_locked else "🔓" + status = "LOCKED" if app.session_locked else "UNLOCKED" + + # In a real app, we'd fetch stats from the service. + # For the shell, we'll use placeholders that look like the mockup. + stats = "Entries: 0 | PWD: 0 | 2FA: 0 | Keys: 0" + sync = "Sync: Never" + + # Layout: [Icon] Status | Fingerprint | Stats | Sync + left = f"{lock_icon} {status} | Seed: {fp}" + right = f"{stats} | {sync}" + + # Calculate spacing to push sync to the right + available_width = self.size.width - len(left) - len(right) - 4 + spacer = " " * max(1, available_width) + + return f"{left}{spacer}{right}" + + def on_mount(self) -> None: + # Refresh the header whenever important app state changes + self.watch(self.app, "active_fingerprint", self.refresh) + self.watch(self.app, "session_locked", self.refresh) diff --git a/src/seedpass/tui_v3/widgets/inspector.py b/src/seedpass/tui_v3/widgets/inspector.py new file mode 100644 index 00000000..355078c0 --- /dev/null +++ b/src/seedpass/tui_v3/widgets/inspector.py @@ -0,0 +1,133 @@ +from __future__ import annotations +from typing import Any +from textual.app import ComposeResult +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]]({}) + + def _render_card(self, title: str, rows: list[str]) -> str: + """Utility to render ASCII cards matching mockup style.""" + width = 40 + top = f"┌─ {title} " + "─" * (width - len(title) - 4) + "┐" + bottom = "└" + "─" * (width - 2) + "┘" + lines = [top] + for row in rows: + lines.append(f"│ {row:<{width-4}} │") + lines.append(bottom) + return " +".join(lines) + +class IdleBoard(Static): + """Shown when no entry is selected.""" + def render(self) -> str: + return " + + +[b]Inspector Idle[/b] + +Select an entry from the grid +to view details." + +class PasswordBoard(BaseBoard): + """Matches 'Password Board.png' mockup.""" + def render(self) -> str: + d = self.entry_data + cred_rows = [ + f"Label : {d.get('label', '')}", + f"Username : {d.get('username', '')}", + f"URL : {d.get('url', '')}", + "Password : [HIDDEN]", + ] + action_rows = [ + "▣ Reveal (v) ▣ QR (g)", + "▣ Edit (e) ▣ Archive (a)", + ] + + return " +".join([ + self._render_card("Credentials", cred_rows), + "", + self._render_card("Quick Actions", action_rows) + ]) + +class NoteBoard(BaseBoard): + """Matches 'Note Board.png' mockup.""" + def render(self) -> str: + d = self.entry_data + content = d.get('content', '') + # Truncate for preview + preview = (content[:200] + '...') if len(content) > 200 else content + + content_rows = preview.splitlines()[:10] + action_rows = [ + "▣ Edit Doc (e) ▣ Export", + "▣ Archive (a)", + ] + + return " +".join([ + self._render_card("Content", content_rows), + "", + self._render_card("Quick Actions", action_rows) + ]) + +class BoardContainer(Vertical): + """ + Reactive container that swaps specialized boards based on entry kind. + """ + DEFAULT_CSS = """ + BoardContainer { + height: 1fr; + padding: 1; + content-align: center top; + } + """ + + 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") + return + + app = self.app + if "entry" not in app.services: + return + + entry = app.services["entry"].retrieve_entry(entry_id) + if not entry: + self._show_board("idle") + return + + kind = str(entry.get("kind") or entry.get("type") or "").lower() + + # Select appropriate board + if kind in {"password", "stored_password"}: + board = self._show_board("password") + board.entry_data = entry + elif kind in {"document", "note"}: + board = self._show_board("note") + board.entry_data = entry + else: + # Fallback for now + board = self._show_board("password") + board.entry_data = entry + + def _show_board(self, board_type: str) -> Any: + self.remove_children() + if board_type == "idle": + new_board = IdleBoard() + elif board_type == "password": + new_board = PasswordBoard() + elif board_type == "note": + new_board = NoteBoard() + else: + new_board = IdleBoard() + + self.mount(new_board) + return new_board diff --git a/src/seedpass/tui_v3/widgets/sidebar.py b/src/seedpass/tui_v3/widgets/sidebar.py new file mode 100644 index 00000000..40a5256e --- /dev/null +++ b/src/seedpass/tui_v3/widgets/sidebar.py @@ -0,0 +1,85 @@ +from __future__ import annotations +from typing import Any +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) + │ ├── Managed Account 1 + │ │ └── Agent 1 + │ └── Managed Account 2 + └── External Fingerprint + """ + + def on_mount(self) -> None: + self.root.expand() + self._refresh_tree() + + def _refresh_tree(self) -> None: + self.clear() + app = self.app + if "profile" not in app.services: + self.root.add_leaf("Profile Service Offline") + return + + service = app.services["profile"] + profiles = service.list_profiles() + + # In v3, we will properly fetch nested accounts. + # For this initial scaffold, we populate the top-level fingerprints. + for fp in profiles: + icon = "■ " if fp == app.active_fingerprint else "□ " + label = f"{icon}{fp[:12]}..." if len(fp) > 12 else f"{icon}{fp}" + node = self.root.add(label, data=fp, expand=True) + + # Placeholder for child accounts - in a real run, + # we would query the service for sub-entries of this FP. + # node.add_leaf(" ├─ Managed Account 1") + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + """Handle profile selection.""" + fp = event.node.data + if fp and isinstance(fp, str): + self.app.active_fingerprint = fp + self.app.notify(f"Switched to profile: {fp[:8]}") + +class SidebarContainer(Static): + """Container for the sidebar tree and toggle.""" + def compose(self) -> ComposeResult: + yield Static("PROFILES", id="sidebar-title") + yield ProfileTree("Profiles", id="profile-tree") + + DEFAULT_CSS = """ + SidebarContainer { + width: 31; + background: #0d1114; + border-right: solid #1a3024; + } + #sidebar-title { + background: #1a3024; + color: #58f29d; + text-style: bold; + padding: 0 1; + height: 1; + } + #profile-tree { + background: transparent; + color: #97b8a6; + border: none; + padding: 0; + } + #profile-tree > .tree--guides { + color: #1a3024; + } + #profile-tree > .tree--cursor { + background: #122019; + color: #58f29d; + text-style: bold; + } + """