From 46ffff577bd91ca7dae8f682a7e1c1ac6f7963a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:10:36 -0500 Subject: [PATCH] Improve v3 grid navigation and graph browsing --- ...earch_graph_integration_plan_2026-03-05.md | 6 + docs/dev_control_center.md | 19 +- docs/tui_v3_plan.md | 21 ++- memory-update.md | 16 ++ src/seedpass/core/api.py | 129 +++++++++++++ src/seedpass/tui_v3/app.py | 62 ++++++- src/seedpass/tui_v3/widgets/grid.py | 79 ++++++-- src/seedpass/tui_v3/widgets/inspector.py | 114 +++++++++++- src/seedpass/tui_v3/widgets/palette.py | 5 +- src/tests/test_core_api_services.py | 67 +++++++ src/tests/test_tui_v3_smoke.py | 170 +++++++++++++++++- 11 files changed, 656 insertions(+), 32 deletions(-) diff --git a/docs/atlas_search_graph_integration_plan_2026-03-05.md b/docs/atlas_search_graph_integration_plan_2026-03-05.md index d22d2ac1..16a48e9a 100644 --- a/docs/atlas_search_graph_integration_plan_2026-03-05.md +++ b/docs/atlas_search_graph_integration_plan_2026-03-05.md @@ -258,6 +258,12 @@ Required linked item card data: 4. `relation` 5. `scope_path` 6. `archived` + +Baseline now implemented in code: +1. `SearchService.linked_neighbors(...)` returns deterministic incoming/outgoing neighbor cards +2. `SearchService.relation_summary(...)` returns grouped incoming/outgoing/combined relation counts +3. TUI v3 inspector now surfaces linked-item summaries and direct open-entry actions +4. TUI v3 grid now exposes explicit sort/filter/mode controls and preserves the active search query across refreshes 7. `modified_ts` Required actions: diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index 69adbdb0..43090221 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -1,6 +1,6 @@ # SeedPass Dev Control Center -Last updated: 2026-03-05 +Last updated: 2026-03-06 Branch baseline: `beta` This is the single source-of-truth document to decide what to work on next. @@ -97,9 +97,14 @@ Use these docs as inputs, but treat this file as the decision layer: - deterministic filter/sort pipeline added for keyword / hybrid / semantic search modes - v3 entry grid now routes through the unified search path instead of branching search logic in the widget layer - focused regression coverage added in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py` +- Phase B baseline landed: + - `SearchService.linked_neighbors(...)` and `relation_summary(...)` now provide explicit outgoing/incoming graph navigation data + - v3 grid now preserves active search query across refresh/filter/sort changes + - v3 grid now exposes explicit in-app filter/mode/sort controls instead of palette-only command flow + - v3 inspector now includes a linked-items panel with direct open-entry actions + - focused regression coverage expanded in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py` - Next planned implementation: - - richer sort/filter controls in v3 - - linked-item navigation service methods and inspector consumers + - deeper linked-navigation workflows and richer graph-oriented pivots - deeper atlas/search handoff and graph-aware result workflows ### Future Capability: Nostr Communications @@ -174,7 +179,7 @@ Use these docs as inputs, but treat this file as the decision layer: 3. Continue v3 startup/restore hardening: : clearer restore warnings, success summaries, and import/recovery guidance. 4. Continue Phase B/C from `docs/atlas_search_graph_integration_plan_2026-03-05.md`: -: linked-item navigation, richer filter/sort controls, and deeper v3 search/navigation handoff. +: deeper v3 search/navigation handoff, graph pivots, and next-level linked-item workflows. 5. Resume Index0 Atlas and graph follow-on work after current v3 UX slices stabilize: : agent-facing atlas workflows, linked-item navigation, deeper search/navigation handoff, and any later event-pruning compaction policy. 6. Continue security-readiness blockers in parallel where they do not conflict with the v3 path. @@ -184,9 +189,9 @@ Use these docs as inputs, but treat this file as the decision layer: ### Slice A: “Search Graph Navigation” Deliverables: -- Add linked-neighbor and relation-summary methods above the current atlas/search foundations. -- Expose explicit sort and filter controls in v3 on top of the existing unified `SearchService`. -- Add inspector linked-item navigation so users can browse outgoing/incoming relationships without raw JSON inspection. +- Extend the new linked-neighbor and relation-summary methods with deeper graph pivots above the current atlas/search foundations. +- Continue iterating on the new explicit v3 sort/filter controls on top of the unified `SearchService`. +- Expand the new inspector linked-item navigation so users can pivot through relationships more fluidly without raw JSON inspection. - Keep the atlas/search/graph plan and control center synchronized with shipped behavior as this slice lands. Exit criteria: diff --git a/docs/tui_v3_plan.md b/docs/tui_v3_plan.md index fb9f49bc..bd209b6a 100644 --- a/docs/tui_v3_plan.md +++ b/docs/tui_v3_plan.md @@ -1,6 +1,6 @@ # TUI v3 Modular Architecture & Parity Plan -Last updated: 2026-03-05 +Last updated: 2026-03-06 Branch target: `beta` Status: Active (Default UI / parity hardening in progress) Companion search/atlas plan: `docs/atlas_search_graph_integration_plan_2026-03-05.md` @@ -168,3 +168,22 @@ To finalize v3, we must verify 100% functional parity with the **Legacy TUI**. - 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']`. + +## 6. Progress Log (2026-03-06) + +### Completed in this session +- **Main grid reliability:** + - fixed v3 main-grid keyboard navigation by restoring focus ownership to `#entry-data-table` on workspace mount + - closing the command palette now restores focus to the main grid instead of leaving focus on a hidden input + - added focused regression coverage for default grid focus and post-palette keyboard navigation +- **Search graph navigation baseline:** + - added `SearchService.linked_neighbors(...)` for deterministic incoming/outgoing link traversal + - added `SearchService.relation_summary(...)` for grouped relation counts + - v3 inspector now includes a linked-items panel with direct open-entry actions +- **Grid control hardening:** + - v3 grid now persists the active search query across refresh/filter/sort changes + - added explicit in-app filter, search-mode, archived, and sort controls on the main grid surface + - palette command surface now includes `sort <...>` and routes search-mode changes through app actions for one consistent control path + +### Validation evidence +- `pytest -q src/tests/test_tui_v3_smoke.py src/tests/test_tui_v3_parity.py src/tests/test_core_api_services.py` -> `107 passed` diff --git a/memory-update.md b/memory-update.md index 71159126..ad4da54e 100644 --- a/memory-update.md +++ b/memory-update.md @@ -46,3 +46,19 @@ - Added regression tests in `src/tests/test_tui_v3_parity.py` to lock this compatibility behavior. - Current CI-equivalent state: - Full main suite passes (`1064 passed, 16 skipped`), but existing `tui2_coverage_gate.sh` still fails `src/seedpass/core/api.py >= 85` on its focused subset (`68.79%`) and needs separate gate policy/workload adjustment. + +## Session addendum — v3 grid focus + graph navigation baseline (2026-03-06) +- Root cause for the reported v3 "main index list / scrolling" bug was focus management, not `DataTable` scrolling itself: + - first render focus landed on the hidden command palette input + - arrow-key navigation did nothing until the entry table was manually focused +- Stable fix: + - `MainScreen.on_mount()` now focuses `#entry-data-table` + - closing the command palette restores focus to `#entry-data-table` instead of clearing focus +- Reusable v3 search/navigation pattern: + - persist the active search query in app state so refresh/filter/sort changes do not silently clear the current result set + - keep sort and mode changes as explicit app actions (`action_set_search_mode`, `action_set_search_sort`) so toolbar buttons and palette commands stay aligned +- Search/graph baseline shipped: + - `SearchService.linked_neighbors(...)` now returns deterministic incoming/outgoing link cards + - `SearchService.relation_summary(...)` now returns grouped relation counts + - TUI v3 inspector now exposes a linked-items panel with direct open-entry actions + - TUI v3 grid now has explicit filter/mode/sort buttons instead of palette-only control diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index fef6bef4..9b01a89e 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -1501,6 +1501,135 @@ class SearchService: results.sort(key=_result_key) return results[: max(1, int(limit))] + def linked_neighbors( + self, + entry_id: int, + *, + relation: str | None = None, + direction: str = "both", + include_archived: bool = True, + limit: int = 50, + ) -> list[dict[str, Any]]: + with self._lock: + target_entry_id = int(entry_id or 0) + if target_entry_id <= 0: + return [] + + direction_key = str(direction or "both").strip().lower() + if direction_key not in {"incoming", "outgoing", "both"}: + direction_key = "both" + relation_filter = str(relation or "").strip().lower() + scope_path = self._scope_path() + + entries = self._all_entries() + entry_map = { + int(item.get("id", 0) or 0): item + for item in entries + if int(item.get("id", 0) or 0) > 0 + } + current_entry = entry_map.get(target_entry_id) + if current_entry is None: + return [] + + neighbors: list[dict[str, Any]] = [] + + if direction_key in {"outgoing", "both"}: + for link in self._normalized_links(current_entry): + link_relation = str(link.get("relation", "")).strip().lower() + if relation_filter and link_relation != relation_filter: + continue + neighbor_id = int(link.get("target_id", 0) or 0) + neighbor = entry_map.get(neighbor_id) + if neighbor is None: + continue + archived = bool(neighbor.get("archived", False)) + if not include_archived and archived: + continue + kind = self._entry_kind(neighbor) + neighbors.append( + { + "entry_id": neighbor_id, + "label": str(neighbor.get("label", "")).strip(), + "kind": kind, + "scope_path": scope_path, + "archived": archived, + "direction": "outgoing", + "relation": link_relation, + "note": str(link.get("note", "")).strip(), + "tags": self._normalized_tags(neighbor), + "meta": self._meta(neighbor, kind), + } + ) + + if direction_key in {"incoming", "both"}: + for source in entries: + source_id = int(source.get("id", 0) or 0) + if source_id <= 0 or source_id == target_entry_id: + continue + archived = bool(source.get("archived", False)) + if not include_archived and archived: + continue + for link in self._normalized_links(source): + if int(link.get("target_id", 0) or 0) != target_entry_id: + continue + link_relation = str(link.get("relation", "")).strip().lower() + if relation_filter and link_relation != relation_filter: + continue + kind = self._entry_kind(source) + neighbors.append( + { + "entry_id": source_id, + "label": str(source.get("label", "")).strip(), + "kind": kind, + "scope_path": scope_path, + "archived": archived, + "direction": "incoming", + "relation": link_relation, + "note": str(link.get("note", "")).strip(), + "tags": self._normalized_tags(source), + "meta": self._meta(source, kind), + } + ) + + neighbors.sort( + key=lambda item: ( + str(item.get("direction", "")), + str(item.get("relation", "")), + str(item.get("label", "")).lower(), + int(item.get("entry_id", 0) or 0), + ) + ) + return neighbors[: max(1, int(limit))] + + def relation_summary( + self, + entry_id: int, + *, + include_archived: bool = True, + ) -> dict[str, dict[str, int]]: + neighbors = self.linked_neighbors( + entry_id, + include_archived=include_archived, + direction="both", + limit=1000, + ) + summary = { + "incoming": {}, + "outgoing": {}, + "combined": {}, + } + for item in neighbors: + relation = str(item.get("relation", "")).strip().lower() + direction = str(item.get("direction", "")).strip().lower() + if not relation or direction not in {"incoming", "outgoing"}: + continue + summary[direction][relation] = summary[direction].get(relation, 0) + 1 + summary["combined"][relation] = summary["combined"].get(relation, 0) + 1 + return { + key: dict(sorted(value.items())) + for key, value in summary.items() + } + class AtlasService: """Thread-safe wrapper around canonical atlas/index0 read operations.""" diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index b625cf23..e200071c 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -14,7 +14,7 @@ from utils.fingerprint_manager import FingerprintManager from .widgets.header import AtlasStrip, RibbonHeader from .widgets.sidebar import SidebarContainer from .widgets.grid import GridContainer -from .widgets.inspector import BoardContainer +from .widgets.inspector import BoardContainer, LinkedItemsPanel from .widgets.palette import CommandPalette from .widgets.action_bar import ActionBar from .screens.atlas import AtlasWayfinderScreen @@ -63,7 +63,7 @@ class CommandProcessor: if cmd == "help": self.app.notify( - "v3 commands: help, stats, atlas, wayfinder, session-status, lock, unlock , refresh, search , search-mode , filter , archived, open , settings, profiles, relay-list, npub, nostr-pubkey, nostr-reset-sync-state, nostr-fresh-namespace, change-password, backup-parent-seed (optional: password), maximize, copy, edit, export, add, seed-plus, archive, restore, delete, ml, mx, db-export , db-import " + "v3 commands: help, stats, atlas, wayfinder, session-status, lock, unlock , refresh, search , search-mode , sort , filter , archived, open , settings, profiles, relay-list, npub, nostr-pubkey, nostr-reset-sync-state, nostr-fresh-namespace, change-password, backup-parent-seed (optional: password), maximize, copy, edit, export, add, seed-plus, archive, restore, delete, ml, mx, db-export , db-import " ) elif cmd == "stats": self.app.notify("Calculating stats...") @@ -93,12 +93,15 @@ class CommandProcessor: "Usage: search-mode ", severity="warning" ) return - mode = args[0].lower() - if mode in {"keyword", "hybrid", "semantic"}: - self.app.search_mode = mode - self.app.notify(f"Search mode set to: {mode}") - else: - self.app.notify(f"Invalid search mode: {mode}", severity="error") + self.app.action_set_search_mode(args[0]) + elif cmd == "sort": + if not args: + self.app.notify( + "Usage: sort ", + severity="warning", + ) + return + self.app.action_set_search_sort(args[0]) elif cmd == "filter": if not args: self.app.notify( @@ -211,8 +214,16 @@ class MainScreen(Screen): yield Static("Inspector Board", id="inspector-heading") yield Button("Close", id="inspector-close", variant="default") yield BoardContainer(id="board-container") + yield LinkedItemsPanel(id="linked-items-panel") yield ActionBar(id="action-bar") + def on_mount(self) -> None: + """Keep keyboard navigation on the main entry grid by default.""" + try: + self.query_one("#entry-data-table").focus() + except Exception: + pass + class StartupScreen(Screen): """Profile-selection and unlock screen for TUI v3 startup.""" @@ -821,7 +832,9 @@ class SeedPassTuiV3(App[None]): active_breadcrumb = reactive("") selected_entry_id = reactive[int | None](None) session_locked = reactive(False) + search_query = reactive("") search_mode = reactive("keyword") + search_sort = reactive("relevance") filter_kind = reactive("all") show_archived = reactive(False) @@ -1104,6 +1117,7 @@ class SeedPassTuiV3(App[None]): else: inspector.remove_class("hidden") self.screen.query_one("#board-container").update_entry(new_id) + self.screen.query_one("#linked-items-panel").update_entry(new_id) except Exception: pass @@ -1131,11 +1145,41 @@ class SeedPassTuiV3(App[None]): def action_search(self, query: str) -> None: """Search entries and update grid.""" try: - self.screen.query_one("#entry-data-table")._refresh_data(query) + self.search_query = str(query or "") + self.screen.query_one("#entry-data-table")._refresh_data() self.notify(f"Search results for: {query}") except Exception: pass + def action_set_search_mode(self, mode: str) -> None: + """Set the active search mode and refresh the grid.""" + normalized = str(mode or "").strip().lower() + if normalized not in {"keyword", "hybrid", "semantic"}: + self.notify(f"Invalid search mode: {mode}", severity="error") + return + self.search_mode = normalized + self.notify(f"Search mode set to: {normalized}") + self.action_refresh() + + def action_set_search_sort(self, sort_key: str) -> None: + """Set the active grid sort mode and refresh the grid.""" + normalized = str(sort_key or "").strip().lower() + allowed = { + "relevance", + "modified_desc", + "modified_asc", + "label_asc", + "kind", + "created_desc", + "most_linked", + } + if normalized not in allowed: + self.notify(f"Invalid sort mode: {sort_key}", severity="error") + return + self.search_sort = normalized + self.notify(f"Grid sort set to: {normalized}") + self.action_refresh() + def action_toggle_archived_view(self) -> None: """Toggle between active entries and archived entries.""" self.show_archived = not self.show_archived diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py index 29b8c64e..f39dac34 100644 --- a/src/seedpass/tui_v3/widgets/grid.py +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -2,7 +2,8 @@ from __future__ import annotations from typing import Any from types import SimpleNamespace from textual.app import ComposeResult -from textual.widgets import Static, DataTable +from textual.widgets import Static, DataTable, Button +from textual.containers import Horizontal from textual.reactive import reactive @@ -23,7 +24,6 @@ class GridMetrics(Static): def render(self) -> str: app = self.app - # These would eventually come from App reactives pg = "1/1" rows = "0/0" try: @@ -36,7 +36,6 @@ class GridMetrics(Static): pass density = "compact" - # Search mode highlighting m = app.search_mode keyword = f"[b](KEYWORD)[/b]" if m == "keyword" else "keyword" hybrid = f"[b](HYBRID)[/b]" if m == "hybrid" else "hybrid" @@ -44,15 +43,72 @@ class GridMetrics(Static): search = f"{keyword} {hybrid} {semantic}" filter_text = f"Filter: [b]{app.filter_kind}[/b]" + sort_text = f"Sort: [b]{app.search_sort}[/b]" + query_text = ( + f' Query: [b]"{app.search_query}"[/b]' if str(app.search_query).strip() else "" + ) arch_text = " | [reverse] ARCHIVED [/reverse]" if app.show_archived else "" - return f"Entry Grid | Pg {pg} Rows {rows} Density {density} {filter_text}{arch_text} Search {search}" + return ( + f"Entry Grid | Pg {pg} Rows {rows} Density {density} " + f"{filter_text} {sort_text}{arch_text}{query_text} Search {search}" + ) def on_mount(self) -> None: self.watch(self.app, "search_mode", self.refresh) self.watch(self.app, "selected_entry_id", self.refresh) self.watch(self.app, "filter_kind", self.refresh) self.watch(self.app, "show_archived", self.refresh) + self.watch(self.app, "search_sort", self.refresh) + self.watch(self.app, "search_query", self.refresh) + + +class GridToolbar(Horizontal): + """Explicit filter/sort/search-mode controls for the main grid.""" + + DEFAULT_CSS = """ + GridToolbar { + height: 3; + background: #d9d9d9; + color: #000000; + padding: 0 1; + } + GridToolbar Button { + min-width: 8; + width: auto; + margin-right: 1; + height: 1; + } + """ + + def compose(self) -> ComposeResult: + yield Button("All", id="grid-filter-all") + yield Button("Secrets", id="grid-filter-secrets") + yield Button("Docs", id="grid-filter-docs") + yield Button("Keys", id="grid-filter-keys") + yield Button("2FA", id="grid-filter-2fa") + yield Button("Archived", id="grid-toggle-archived") + yield Button("Keyword", id="grid-mode-keyword") + yield Button("Hybrid", id="grid-mode-hybrid") + yield Button("Semantic", id="grid-mode-semantic") + yield Button("Relevance", id="grid-sort-relevance") + yield Button("Recent", id="grid-sort-modified_desc") + yield Button("Label", id="grid-sort-label_asc") + yield Button("Linked", id="grid-sort-most_linked") + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id or "" + if button_id.startswith("grid-filter-"): + self.app.action_set_kind_filter(button_id.removeprefix("grid-filter-")) + return + if button_id == "grid-toggle-archived": + self.app.action_toggle_archived_view() + return + if button_id.startswith("grid-mode-"): + self.app.action_set_search_mode(button_id.removeprefix("grid-mode-")) + return + if button_id.startswith("grid-sort-"): + self.app.action_set_search_sort(button_id.removeprefix("grid-sort-")) class EntryDataTable(DataTable): @@ -76,11 +132,12 @@ class EntryDataTable(DataTable): except Exception: pass - def _refresh_data(self, query: str = "") -> None: + def _refresh_data(self, query: str | None = None) -> None: self.clear() app = self.app if "entry" not in app.services: return + active_query = app.search_query if query is None else query if app.selected_entry_id is None: self._suppress_next_highlight = True @@ -105,11 +162,12 @@ class EntryDataTable(DataTable): if "search" in app.services: try: results = app.services["search"].search( - query, + active_query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived, mode=app.search_mode, + sort=app.search_sort, ) for result in results: try: @@ -131,9 +189,9 @@ class EntryDataTable(DataTable): app.notify(f"Search failed: {e}", severity="error") entries = [] # Fallback to direct semantic search if requested and available - elif app.search_mode != "keyword" and "semantic" in app.services and query: + elif app.search_mode != "keyword" and "semantic" in app.services and active_query: try: - results = app.services["semantic"].search(query, mode=app.search_mode) + results = app.services["semantic"].search(active_query, mode=app.search_mode) # results is list of {entry_id, kind, label, score, excerpt} for r in results: eid = r["entry_id"] @@ -173,7 +231,7 @@ class EntryDataTable(DataTable): app.notify(f"Semantic search failed: {e}", severity="error") # Fallback to lexical entries = app.services["entry"].search_entries( - query, + active_query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived, @@ -181,7 +239,7 @@ class EntryDataTable(DataTable): else: # Fetch filtered data from standard service entries = app.services["entry"].search_entries( - query, + active_query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived, @@ -236,6 +294,7 @@ class GridContainer(Static): def compose(self) -> ComposeResult: yield GridMetrics() + yield GridToolbar() # Header divider yield Static("-" * 120, id="grid-divider") yield EntryDataTable(id="entry-data-table") diff --git a/src/seedpass/tui_v3/widgets/inspector.py b/src/seedpass/tui_v3/widgets/inspector.py index 1d71e5a6..3cf6c9a6 100644 --- a/src/seedpass/tui_v3/widgets/inspector.py +++ b/src/seedpass/tui_v3/widgets/inspector.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Any from textual.app import ComposeResult -from textual.widgets import Static, Label +from textual.widgets import Static, Label, Button from textual.containers import Vertical, Horizontal from textual.reactive import reactive @@ -631,3 +631,115 @@ class BoardContainer(Vertical): new_board = board_cls() self.mount(new_board) return new_board + + +class LinkedItemsPanel(Vertical): + """Inspector panel for browsing explicit incoming and outgoing relationships.""" + + DEFAULT_CSS = """ + LinkedItemsPanel { + height: auto; + max-height: 12; + min-height: 6; + background: #111111; + color: #ffffff; + border-top: solid #ffffff; + padding: 1; + } + #linked-items-title { + text-style: bold; + margin-bottom: 1; + } + .linked-summary { + color: #cccccc; + margin-bottom: 1; + } + .linked-empty { + color: #aaaaaa; + } + .linked-open { + width: 100%; + height: auto; + margin-bottom: 1; + content-align: left middle; + } + """ + + def compose(self) -> ComposeResult: + yield Label("Linked Items", id="linked-items-title") + yield Label("No linked items.", id="linked-items-summary", classes="linked-summary") + with Vertical(id="linked-items-list"): + yield Label("Select an entry to inspect relationships.", classes="linked-empty") + + def update_entry(self, entry_id: int | None) -> None: + summary_label = self.query_one("#linked-items-summary", Label) + list_container = self.query_one("#linked-items-list", Vertical) + list_container.remove_children() + + if entry_id is None: + summary_label.update("No linked items.") + list_container.mount( + Label( + "Select an entry to inspect relationships.", + classes="linked-empty", + ) + ) + return + + search = self.app.services.get("search") + if search is None or not hasattr(search, "linked_neighbors"): + summary_label.update("Search graph service unavailable.") + list_container.mount( + Label( + "Linked navigation requires SearchService graph helpers.", + classes="linked-empty", + ) + ) + return + + try: + neighbors = search.linked_neighbors(entry_id, direction="both", limit=8) + summary = search.relation_summary(entry_id) + except Exception as exc: + summary_label.update(f"Linked navigation failed: {exc}") + list_container.mount(Label("Unable to load linked items.", classes="linked-empty")) + return + + outgoing = ", ".join( + f"{relation}:{count}" for relation, count in summary.get("outgoing", {}).items() + ) or "none" + incoming = ", ".join( + f"{relation}:{count}" for relation, count in summary.get("incoming", {}).items() + ) or "none" + summary_label.update(f"Outgoing {outgoing} | Incoming {incoming}") + + if not neighbors: + list_container.mount(Label("No linked items for this entry.", classes="linked-empty")) + return + + for item in neighbors: + direction = "->" if item.get("direction") == "outgoing" else "<-" + relation = str(item.get("relation", "")).strip() or "related_to" + label = str(item.get("label", "")).strip() or f"Entry #{item.get('entry_id', '?')}" + kind = str(item.get("kind", "")).strip() or "entry" + archived = " archived" if item.get("archived") else "" + line = f"{direction} {relation} #{item.get('entry_id')} {label} [{kind}{archived}]" + list_container.mount( + Button( + f"{line} | Open", + id=f"linked-open-{int(item.get('entry_id', 0) or 0)}", + classes="linked-open", + ) + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id or "" + if not button_id.startswith("linked-open-"): + return + try: + entry_id = int(button_id.removeprefix("linked-open-")) + except ValueError: + self.app.notify("Invalid linked item target", severity="error") + return + self.app.selected_entry_id = entry_id + self.app.notify(f"Opened linked entry #{entry_id}") diff --git a/src/seedpass/tui_v3/widgets/palette.py b/src/seedpass/tui_v3/widgets/palette.py index 06548157..071080d6 100644 --- a/src/seedpass/tui_v3/widgets/palette.py +++ b/src/seedpass/tui_v3/widgets/palette.py @@ -51,7 +51,10 @@ class CommandPalette(Vertical): def toggle(self) -> None: if self.has_class("visible"): self.remove_class("visible") - self.app.set_focus(None) + try: + self.app.screen.query_one("#entry-data-table").focus() + except Exception: + self.app.set_focus(None) else: self.add_class("visible") self.input.focus() diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py index 67e336e1..271a5499 100644 --- a/src/tests/test_core_api_services.py +++ b/src/tests/test_core_api_services.py @@ -391,6 +391,73 @@ class TestSearchService: assert results[0]["linked_hits"][0]["relation"] == "references" assert results[0]["excerpt"].startswith("SeedPass atlas") + def test_linked_neighbors_and_relation_summary( + self, service, mock_manager, tmp_path + ): + mock_manager.fingerprint_dir = tmp_path + mock_manager.entry_manager.search_entries.return_value = [ + (1, "Project Plan", None, None, False, EntryType.DOCUMENT), + (2, "Ops Vault", "ops", "https://ops", False, EntryType.PASSWORD), + (3, "Runbook", None, None, False, EntryType.DOCUMENT), + ] + entries = { + 1: { + "id": 1, + "kind": "document", + "label": "Project Plan", + "links": [{"target_id": 2, "relation": "references"}], + }, + 2: { + "id": 2, + "kind": "password", + "label": "Ops Vault", + "username": "ops", + "url": "https://ops", + }, + 3: { + "id": 3, + "kind": "document", + "label": "Runbook", + "links": [{"target_id": 1, "relation": "depends_on"}], + }, + } + mock_manager.entry_manager.retrieve_entry.side_effect = lambda eid: entries[eid] + + neighbors = service.linked_neighbors(1) + summary = service.relation_summary(1) + + assert neighbors == [ + { + "entry_id": 3, + "label": "Runbook", + "kind": "document", + "scope_path": f"seed/{tmp_path.name}", + "archived": False, + "direction": "incoming", + "relation": "depends_on", + "note": "", + "tags": [], + "meta": "", + }, + { + "entry_id": 2, + "label": "Ops Vault", + "kind": "password", + "scope_path": f"seed/{tmp_path.name}", + "archived": False, + "direction": "outgoing", + "relation": "references", + "note": "", + "tags": [], + "meta": "ops", + }, + ] + assert summary == { + "incoming": {"depends_on": 1}, + "outgoing": {"references": 1}, + "combined": {"depends_on": 1, "references": 1}, + } + @patch("seedpass.core.api.SemanticIndexService.search") def test_hybrid_search_combines_semantic_and_filters( self, semantic_search, service, mock_manager, tmp_path diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index 344a5700..e984d887 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -27,9 +27,24 @@ from seedpass.tui_v3.screens.security import ChangePasswordScreen class V3EntryService: def __init__(self) -> None: self._entries: dict[int, dict] = { - 1: {"id": 1, "kind": "password", "label": "Email", "length": 16}, - 2: {"id": 2, "kind": "managed_account", "label": "Managed Ops"}, - 3: {"id": 3, "kind": "nostr", "label": "Agent Nostr"}, + 1: { + "id": 1, + "kind": "password", + "label": "Email", + "length": 16, + "links": [{"target_id": 2, "relation": "references"}], + }, + 2: { + "id": 2, + "kind": "managed_account", + "label": "Managed Ops", + }, + 3: { + "id": 3, + "kind": "nostr", + "label": "Agent Nostr", + "links": [{"target_id": 1, "relation": "uses"}], + }, } self.copied: list[str] = [] @@ -199,6 +214,7 @@ class V3SearchService: def __init__(self, entry: V3EntryService) -> None: self.entry = entry self.calls: list[dict] = [] + self.link_calls: list[dict] = [] def search( self, @@ -259,6 +275,62 @@ class V3SearchService: ) return out + def linked_neighbors( + self, + entry_id: int, + *, + relation: str | None = None, + direction: str = "both", + include_archived: bool = True, + limit: int = 50, + ) -> list[dict]: + _ = (include_archived, limit) + self.link_calls.append( + { + "entry_id": entry_id, + "relation": relation, + "direction": direction, + } + ) + if int(entry_id) == 1: + rows = [ + { + "entry_id": 2, + "label": "Managed Ops", + "kind": "managed_account", + "archived": False, + "direction": "outgoing", + "relation": "references", + }, + { + "entry_id": 3, + "label": "Agent Nostr", + "kind": "nostr", + "archived": False, + "direction": "incoming", + "relation": "uses", + }, + ] + if relation: + return [row for row in rows if row["relation"] == relation] + return rows + return [] + + def relation_summary( + self, + entry_id: int, + *, + include_archived: bool = True, + ) -> dict[str, dict[str, int]]: + _ = include_archived + if int(entry_id) != 1: + return {"incoming": {}, "outgoing": {}, "combined": {}} + return { + "incoming": {"uses": 1}, + "outgoing": {"references": 1}, + "combined": {"references": 1, "uses": 1}, + } + def _build_app() -> tuple[object, V3EntryService, V3VaultService, V3SearchService]: holder: dict[str, object] = {} @@ -360,6 +432,98 @@ async def test_tui3_grid_uses_search_service_for_query_and_mode() -> None: assert table.row_count >= 1 +@pytest.mark.anyio +async def test_tui3_main_grid_has_default_focus_and_keyboard_navigation() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + table = app.screen.query_one("#entry-data-table", DataTable) + assert app.focused is table + assert app.selected_entry_id is None + + await pilot.press("down") + await pilot.pause() + + assert app.focused is table + assert app.selected_entry_id == 2 + assert table.cursor_coordinate.row == 1 + + +@pytest.mark.anyio +async def test_tui3_palette_close_restores_focus_to_main_grid() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + table = app.screen.query_one("#entry-data-table", DataTable) + palette_input = app.screen.query_one("#palette-input", Input) + + app.action_open_palette() + await pilot.pause() + assert app.focused is palette_input + + app.action_open_palette() + await pilot.pause() + assert app.focused is table + + await pilot.press("down") + await pilot.pause() + assert app.selected_entry_id == 2 + + +@pytest.mark.anyio +async def test_tui3_grid_toolbar_updates_filter_mode_and_sort() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.screen.query_one("#grid-filter-keys", Button).press() + await pilot.pause() + assert app.filter_kind == "keys" + + app.screen.query_one("#grid-mode-hybrid", Button).press() + await pilot.pause() + assert app.search_mode == "hybrid" + + app.screen.query_one("#grid-sort-most_linked", Button).press() + await pilot.pause() + assert app.search_sort == "most_linked" + assert search.calls + assert search.calls[-1]["sort"] == "most_linked" + + +@pytest.mark.anyio +async def test_tui3_search_query_persists_across_refresh() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_search("Email") + await pilot.pause() + assert app.search_query == "Email" + assert search.calls[-1]["query"] == "Email" + + app.action_set_kind_filter("all") + await pilot.pause() + assert search.calls[-1]["query"] == "Email" + + +@pytest.mark.anyio +async def test_tui3_linked_items_panel_shows_neighbors_and_opens_entry() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.selected_entry_id = 1 + await pilot.pause() + + summary = app.screen.query_one("#linked-items-summary", Static) + assert "Outgoing references:1" in str(summary.render()) + assert "Incoming uses:1" in str(summary.render()) + assert search.link_calls[-1]["entry_id"] == 1 + + open_button = app.screen.query_one("#linked-open-2", Button) + open_button.press() + await pilot.pause() + assert app.selected_entry_id == 2 + + @pytest.mark.anyio async def test_tui3_inspector_stays_collapsed_until_selection_and_can_close() -> None: app, _entry, _vault, _search = _build_app()