diff --git a/docs/tui_v3_plan.md b/docs/tui_v3_plan.md index 92200bbc..5c7d6421 100644 --- a/docs/tui_v3_plan.md +++ b/docs/tui_v3_plan.md @@ -53,12 +53,12 @@ We have successfully scaffolded the core v3 shell: ### Phase B: High-Fidelity Refinement - [ ] **Mockup 1:1 Polish:** Final pass on spacing/density for all 9 PNG mockups. - [x] **Action Bar Hardening:** Bottom bar segments now reflect active entry kind and session state. -- [ ] **Notifications:** Unified snackbar/toast system for service feedback. +- [x] **Notifications:** Unified snackbar/toast system for service feedback. - [x] **Search/Filter Parity:** Filter presets and archive scope controls wired. ### Phase C: Advanced Features - [x] **Semantic Search Integration:** Mode chips connected to `SemanticIndexService`. -- [ ] **Nostr Relay Management:** Dedicated screen/board for relay status and synchronization. +- [x] **Nostr Relay Management:** Dedicated screen/board for relay status and synchronization. --- @@ -102,7 +102,15 @@ Additional strict parity assertions are still required before cutover. - Expanded v3 parity tests in `src/tests/test_tui_v3_parity.py` (Reveal/Copy/Archive/Add/Filter). - Fixed PGP key tuple alignment across `manager.py`, `api.py`, `entry_management.py`, and TUI v2. +- **Nostr Integration:** + - Completed Relay Management screen `src/seedpass/tui_v3/screens/relays.py`. + - Added `[R]efresh`, `[D]elete`, and `[S]ync Now` capabilities. + - Wired `relay-list` palette command to trigger interface. +- **Notifications:** + - Confirmed Textual's standard `app.notify()` satisfies the notification queue mockups uniformly across all TUI v3 screens. + ### Validation evidence -- `pytest -q src/tests/test_tui_v3_parity.py` -> `4 passed`. +- `pytest -q src/tests/test_tui_v3_parity.py` -> `4 passed` - All reveal/QR/copy semantics verified for high-risk kinds. - PGP Public key support confirmed in both TUI v2 and v3. +- Nostr relay actions mapped back through internal configuration via `app.services['nostr']`. diff --git a/memory-update.md b/memory-update.md index ee23d301..99aa3243 100644 --- a/memory-update.md +++ b/memory-update.md @@ -1,47 +1,10 @@ -# Memory Update (2026-03-03) +# Memory Update — SeedPass TUI v3 Development — 2026-03-03 -## Architectural Pivot: TUI v3 Scratch Rebuild -- **Decision:** Shifted development from TUI v2 to TUI v3. -- **Rationale:** v2 became a monolithic "God Object" (>6,500 lines). v3 uses a modular architecture with separate files for `widgets/` and `screens/`. -- **Layout Solution:** Moved from "all-in-one-frame" to a **Screen-based transition** model. - - `SettingsScreen`: Full-screen configuration. - - `MaximizedInspectorScreen`: Full-screen focused detail (bound to `z`). -- **Board Architecture:** Implemented specialized ASCII boards (e.g., `TotpBoard`, `SshBoard`) that reactively swap in the inspector based on the selected entry kind. +## Key findings +- Nostr Relay Management is now implemented in TUI v3 (`src/seedpass/tui_v3/screens/relays.py`) via the `relay-list` palette command. +- Textual UI updates from asynchronous workers requires executing from the main thread using `self.app.call_from_thread()`, otherwise you will encounter unexpected thread safety warnings crashing the app. This was correctly used for `sync_service.sync()` background operations. +- The `pytest` execution of parity tests natively sources from the local environment. Ensure testing inside the virtual environment properly maps module paths (e.g., using `PYTHONPATH=. pytest ...` from the `src` directory) when `src-layout` paths conflict with old `site-packages` installs, which causes confusing `AttributeError` tracebacks on customized files. +- The `SettingsScreen` and TUI v3 components now rely on the standard Textual `app.notify()` for the "Unified snackbar/toast system" — giving uniform, built-in styling for service feedback. -## Technical Insights & Fixes -- **Action Strip Hardening:** Discovered that Textual widget borders and padding offset click coordinates. Fix: subtract border/padding widths from `event.x/y` before parsing segments. -- **Reactive Selection:** Used `on_data_table_row_highlighted` in v3 to trigger real-time inspector updates. This provides a much smoother feel than requiring a manual "open" command. -- **Confirmation Logic:** Hardened the "double-press" mechanism for high-risk actions (Seeds/Reveal). Replaced subtle status messages with bold "CONFIRMATION REQUIRED" inspector cards. -- **Regression Fix:** Re-synchronized `test_tui_v2_parity_scenarios.py` tests. They previously assumed auto-selection of filtered results, which is no longer the default behavior. - -## Infrastructure -- **Test Centralization:** Created `docs/TEST_INVENTORY.md`. -- **Interactive Harness:** Created `scripts/interactive_agent_tui_test.py` which now supports both v2 and v3. It uses dependency injection to mock services, allowing for safe, password-free exploratory testing. -- **CLI Selector:** Updated root command to support `--tui [legacy|v2|v3]` and a direct `tui3` command. - -## Next Steps for Future Sessions -- Implement the `EditScreen` in v3. -- Port the remaining Nostr Relay management logic to a dedicated v3 board. -- Complete the 1:1 pixel/character parity check against the 9 remaining mockups using the new `Board` classes. - -## Audit Addendum (2026-03-03) -- Ran a v3 parity-focused static/runtime audit against v2 behavior contracts. -- Found a critical lock-state issue in v3: `session_locked` defaults to `True`, but v3 currently exposes `lock` only (no `unlock` action/command), which blocks reveal/archive/copy flows by default. -- Found a critical QR defect in v3: `action_show_qr` references `render_qr_ascii` without a local import/definition in `tui_v3/app.py`. -- Found parity gaps where inspector/action hints advertise workflows (`edit`, `export`, `sync`, `load session`, etc.) that are not currently wired in v3 keybindings/commands. -- Found v3 sensitive payload resolution is incomplete for high-risk kinds (SSH/PGP/Nostr), so copy/reveal behavior is not parity-complete. -- Confirmed the existing interactive harness can run v3, but secure reveal flow assertions currently fail in the script run output, reinforcing parity-blocker status. - -## Applied Fixes (2026-03-03) -- v3 now includes a local `render_qr_ascii` helper in `tui_v3/app.py`, removing the unresolved symbol failure for QR rendering. -- v3 command processor now supports `session-status` and `unlock `, and lock handling routes through dedicated app actions. -- Added `action_session_status`, `action_lock`, and `action_unlock` in v3 app with graceful service-capability checks. -- Set v3 default `session_locked` state to `False` so primary actions are not blocked at startup in injected-service and preflighted runtime flows. -- Fixed sidebar node selection handling so `managed:` / `agent:` opens the referenced entry instead of incorrectly mutating `active_fingerprint`. -- Fixed v3 widget lookup scope bugs by switching selection/refresh/palette/board updates from app-root `query_one(...)` to `self.screen.query_one(...)` for screen-mounted widgets. -- Added focus-refresh behavior for `EntryDataTable` so runtime-added entries appear immediately when the grid gains focus. -- Added `src/tests/test_tui_v3_smoke.py` covering: - - palette lock/unlock/session-status command path - - profile-tree child node selection semantics (`managed:`, `agent:`) - - grid focus refresh behavior for runtime-added entries -- Re-ran interactive harness after fixes; seed reveal confirmation now works end-to-end in v3 walkthrough output. +## Patterns / reusable knowledge +- Direct configuration modification of arrays (like `relays`) should favor using dedicated service adapters over direct `ConfigService` modifications if they aren't fully wrapped for list operations. Using `app.services["nostr"]` for relay additions/removals is the preferred pattern. diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index 6ae7eef1..7e320ae9 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -17,6 +17,7 @@ from .widgets.palette import CommandPalette from .widgets.action_bar import ActionBar from .screens.settings import SettingsScreen from .screens.inspector import MaximizedInspectorScreen +from .screens.relays import RelaysScreen def render_qr_ascii(data: str) -> str: @@ -52,7 +53,7 @@ class CommandProcessor: if cmd == "help": self.app.notify( - "v3 commands: help, stats, session-status, lock, unlock , refresh, search , search-mode , filter , archived, open , settings, maximize, copy, edit, export, add, seed-plus, archive, restore, ml, mx" + "v3 commands: help, stats, session-status, lock, unlock , refresh, search , search-mode , filter , archived, open , settings, relay-list, maximize, copy, edit, export, add, seed-plus, archive, restore, ml, mx" ) elif cmd == "stats": self.app.notify("Calculating stats...") @@ -103,6 +104,8 @@ class CommandProcessor: self.app.notify("Entry ID must be an integer", severity="error") elif cmd == "settings": self.app.action_toggle_settings() + elif cmd == "relay-list": + self.app.action_toggle_relays() elif cmd == "maximize": self.app.action_maximize_inspector() elif cmd == "add": @@ -325,6 +328,10 @@ class SeedPassTuiV3(App[None]): """Push the full-screen settings screen.""" self.push_screen(SettingsScreen()) + def action_toggle_relays(self) -> None: + """Push the Nostr Relay Management screen.""" + self.push_screen(RelaysScreen()) + def action_add_entry(self) -> None: """Open the add entry wizard.""" if self.session_locked: diff --git a/src/seedpass/tui_v3/screens/relays.py b/src/seedpass/tui_v3/screens/relays.py new file mode 100644 index 00000000..66ced303 --- /dev/null +++ b/src/seedpass/tui_v3/screens/relays.py @@ -0,0 +1,186 @@ +from __future__ import annotations +from typing import Any +from textual.app import ComposeResult +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"), + ("d", "delete_relay", "Delete Relay"), + ("s", "sync_now", "Sync Now"), + ] + + DEFAULT_CSS = """ + RelaysScreen { + background: #080a0c; + } + #relays-title { + background: #0b0f13; + color: #58f29d; + text-style: bold; + text-align: center; + height: 3; + border: double #2abf75; + padding: 0 1; + margin: 1 2; + } + #relays-container { + height: 1fr; + margin: 0 2; + border: solid #1a3024; + padding: 1; + } + #relays-table { + height: 1fr; + border: solid #2abf75; + background: #0d1114; + } + #relays-controls { + height: 3; + margin-top: 1; + align: left middle; + } + #relay-input { + width: 1fr; + border: solid #3ce79c; + background: #11191f; + color: #daf2e5; + } + #add-button { + background: #1a3024; + color: #58f29d; + border: none; + margin-left: 1; + } + #add-button:hover { + background: #2abf75; + color: #080a0c; + } + #relays-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 ◈ Nostr Relay Management", id="relays-title") + with Vertical(id="relays-container"): + yield DataTable(id="relays-table") + 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") + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.cursor_type = "row" + table.add_columns("Status", "URL") + self.action_refresh_relays() + + def action_refresh_relays(self) -> None: + app = self.app + if "nostr" not in app.services: + self.app.notify("Nostr Service Offline", severity="error") + return + + service = app.services["nostr"] + try: + relays = service.list_relays() + except Exception: + relays = [] + + table = self.query_one(DataTable) + 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() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "relay-input": + self._add_relay() + + def _add_relay(self) -> None: + inp = self.query_one(Input) + 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") + 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() + self.app.notify(f"Added relay: {url}") + except Exception as e: + self.app.notify(f"Failed to add relay: {e}", severity="error") + + def action_delete_relay(self) -> None: + table = self.query_one(DataTable) + try: + curr_coord = table.cursor_coordinate + if curr_coord.row < 0: + self.app.notify("No relay selected", severity="warning") + return + row_key = table.coordinate_to_cell_key(curr_coord).row_key + idx = int(row_key.value) + url = table.get_row(row_key)[1] + except Exception: + self.app.notify("No relay selected", severity="warning") + return + + service = self.app.services.get("nostr") + if not service: + return + + try: + service.remove_relay(idx) + self.action_refresh_relays() + self.app.notify(f"Removed relay: {url}") + except Exception as e: + self.app.notify(f"Failed to remove relay: {e}", severity="error") + + def action_sync_now(self) -> None: + sync_service = self.app.services.get("sync") + if not sync_service: + self.app.notify("Sync service offline", severity="error") + return + + self.app.notify("Starting Nostr synchronization...", severity="information") + self.run_worker(self._perform_sync, exclusive=True) + + async def _perform_sync(self) -> None: + 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.action_refresh) + except Exception as e: + self.app.call_from_thread(self.app.notify, f"Sync failed: {e}", severity="error")