From 437254b5bb15cf9e775338c4adfdd4227e4f4ded Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:13:09 -0500 Subject: [PATCH] Add TUI v2 command palette and graph links panel --- docs/tui_v2_plan.md | 4 +- memory-update.md | 2 + src/seedpass/tui_v2/app.py | 270 ++++++++++++++++++++++++++++++++++++- 3 files changed, 271 insertions(+), 5 deletions(-) diff --git a/docs/tui_v2_plan.md b/docs/tui_v2_plan.md index e0eb711c..3a3d20a8 100644 --- a/docs/tui_v2_plan.md +++ b/docs/tui_v2_plan.md @@ -52,10 +52,10 @@ Goal: Introduce a modern, modular TUI (`seedpass tui2`) without rewriting core l ### Phase 2: Core editing workflows - [x] Archive/restore action + document modify flow. - [x] Document editor panel with dirty-state and save/cancel. -- [ ] Keybindings + command palette. +- [x] Keybindings + command palette. ### Phase 3: Graph workflows -- [ ] Entry links panel (list, add, remove). +- [x] Entry links panel (list, add, remove). - [ ] Relation-aware search controls. - [ ] Neighbor navigation and quick traversal. diff --git a/memory-update.md b/memory-update.md index aee5d71a..80765e45 100644 --- a/memory-update.md +++ b/memory-update.md @@ -180,3 +180,5 @@ - Added focused CLI tests in `src/tests/test_typer_cli.py` for `tui2 --check`, unavailable + no fallback exit path, and legacy fallback fingerprint forwarding. - 2026-03-01: TUI v2 phase 2 slice added in `seedpass/tui_v2/app.py`: archive/restore action (`a`), document editor mode (`e`) with save/cancel (`Ctrl+S`/`Esc`), and a persistent status line for operation feedback. - Document editor uses `TextArea` when available and falls back to single-line input if unavailable, keeping runtime compatibility across Textual versions. +- 2026-03-01: TUI v2 gained `Ctrl+P` command palette with command parsing (`help`, `open`, `search`, `filter`, `archive/restore`, `edit-doc/save-doc/cancel-edit`, `link-add`, `link-rm`, `refresh`). +- Added a graph links panel in the right view that renders current entry links via `EntryService.get_links` and updates after palette link mutations. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index b17d54d1..75971c12 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util import json +import shlex from typing import Any @@ -50,6 +51,11 @@ def launch_tui2( class SeedPassTuiV2(App[None]): CSS = """ + #command-palette { + height: 3; + margin: 0 1; + border: solid $secondary; + } #body { height: 1fr; } #status { height: 1; padding: 0 1; } #left { width: 30; border: solid $primary; padding: 1; } @@ -57,7 +63,19 @@ def launch_tui2( #right { width: 1fr; border: solid $primary; padding: 1; } #search { margin-bottom: 1; } #entry-list { height: 1fr; } - #entry-detail { height: 1fr; overflow: auto; } + #entry-detail { + height: 1fr; + overflow: auto; + border: solid $boost; + padding: 1; + } + #link-detail { + height: 12; + overflow: auto; + border: solid $accent; + padding: 1; + margin-top: 1; + } #right-view { height: 1fr; } #right-editor { height: 1fr; } #doc-edit-label { margin-bottom: 1; } @@ -76,14 +94,26 @@ def launch_tui2( ("a", "toggle_archive", "Archive/Restore"), ("e", "edit_document", "Edit Document"), ("ctrl+s", "save_document", "Save"), + ("ctrl+p", "open_palette", "Palette"), ("escape", "cancel_document_edit", "Cancel"), ] filter_kind: reactive[str] = reactive("all") editing_document: reactive[bool] = reactive(False) + palette_open: reactive[bool] = reactive(False) def compose(self) -> ComposeResult: yield Header(show_clock=True) + yield Input( + placeholder=( + "Command palette: help | open | search | " + "filter | archive | restore | " + "link-add [relation] [note] | " + "link-rm [relation]" + ), + id="command-palette", + classes="hidden", + ) with Horizontal(id="body"): with Vertical(id="left"): yield Static("", id="filters") @@ -95,6 +125,7 @@ def launch_tui2( with Vertical(id="right"): with Vertical(id="right-view"): yield Static("", id="entry-detail") + yield Static("", id="link-detail") with Vertical(id="right-editor", classes="hidden"): yield Input(placeholder="Document title", id="doc-edit-label") yield Input( @@ -153,7 +184,7 @@ def launch_tui2( ) text = "\n".join( [ - "TUI v2 (Phase 2)", + "TUI v2 (Phase 2/3)", fp_line, "", f"Active filter: {self.filter_kind}", @@ -167,7 +198,8 @@ def launch_tui2( "- a archive/restore", "- e edit document", "- Ctrl+S save doc", - "- Esc cancel edit", + "- Ctrl+P command palette", + "- Esc cancel/close", ] ) self.query_one("#filters", Static).update(text) @@ -187,6 +219,7 @@ def launch_tui2( self.query_one("#entry-detail", Static).update( "Entry service unavailable in this runtime." ) + self.query_one("#link-detail", Static).update("Links unavailable.") return try: @@ -197,6 +230,7 @@ def launch_tui2( self.query_one("#entry-detail", Static).update( f"Failed to load entries: {exc}" ) + self.query_one("#link-detail", Static).update("Links unavailable.") self._set_status("Failed to load entries") return @@ -213,6 +247,9 @@ def launch_tui2( self._selected_entry_id = None self._selected_entry = None self.query_one("#entry-detail", Static).update("No entries match.") + self.query_one("#link-detail", Static).update( + "Links: select an entry first." + ) self._set_status("No entries match current filter/search") else: if list_view.children: @@ -227,19 +264,63 @@ def launch_tui2( entry = self._service.retrieve_entry(entry_index) if not isinstance(entry, dict): self.query_one("#entry-detail", Static).update("Entry not found.") + self.query_one("#link-detail", Static).update( + "Links: entry not found." + ) self._set_status(f"Entry {entry_index} not found") return self._selected_entry_id = int(entry_index) self._selected_entry = dict(entry) body = json.dumps(entry, indent=2, sort_keys=True) self.query_one("#entry-detail", Static).update(body) + self._update_links_panel() self._set_status(f"Selected entry {entry_index}") except Exception as exc: self.query_one("#entry-detail", Static).update( f"Failed to load entry {entry_index}: {exc}" ) + self.query_one("#link-detail", Static).update("Links unavailable.") self._set_status(f"Failed to load entry {entry_index}") + def _update_links_panel(self) -> None: + if self._service is None or self._selected_entry_id is None: + self.query_one("#link-detail", Static).update( + "Links: select an entry first." + ) + return + try: + links = self._service.get_links(self._selected_entry_id) + except Exception as exc: + self.query_one("#link-detail", Static).update( + f"Links unavailable: {exc}" + ) + return + + if not links: + self.query_one("#link-detail", Static).update( + "Links\n\nNo graph links for this entry.\n" + "Use Ctrl+P and run: link-add [relation] [note]" + ) + return + + lines = [ + "Links", + "", + "Format: relation -> target_id (note)", + "", + ] + for link in links: + if not isinstance(link, dict): + continue + target = link.get("target") + relation = link.get("relation", "related_to") + note = str(link.get("note", "")).strip() + if note: + lines.append(f"- {relation} -> {target} ({note})") + else: + lines.append(f"- {relation} -> {target}") + self.query_one("#link-detail", Static).update("\n".join(lines)) + def _is_selected_document(self) -> bool: if not isinstance(self._selected_entry, dict): return False @@ -259,6 +340,16 @@ def launch_tui2( view.remove_class("hidden") self.editing_document = visible + def _set_palette_visible(self, visible: bool) -> None: + palette = self.query_one("#command-palette", Input) + if visible: + palette.remove_class("hidden") + palette.focus() + else: + palette.value = "" + palette.add_class("hidden") + self.palette_open = visible + def _get_document_editor_text(self) -> str: if TextArea is not None: area = self.query_one("#doc-edit-content") @@ -275,12 +366,166 @@ def launch_tui2( return self.query_one("#doc-edit-content-single", Input).value = content + def _run_palette_command(self, command: str) -> None: + raw = command.strip() + if not raw: + self._set_status("Palette: command required") + return + try: + parts = shlex.split(raw) + except ValueError as exc: + self._set_status(f"Palette parse error: {exc}") + return + if not parts: + self._set_status("Palette: command required") + return + + cmd = parts[0].lower() + args = parts[1:] + + if cmd == "help": + self._set_status( + "Palette commands: help, open, search, filter, archive, " + "restore, edit-doc, save-doc, cancel-edit, link-add, link-rm" + ) + return + + if cmd == "open": + if len(args) != 1: + self._set_status("Usage: open ") + return + try: + entry_id = int(args[0]) + except ValueError: + self._set_status("open requires integer entry_id") + return + self._show_entry(entry_id) + return + + if cmd == "search": + query = " ".join(args) + self.query_one("#search", Input).value = query + self._load_entries(query=query) + self._set_status(f"Applied search: {query}") + return + + if cmd == "filter": + if len(args) != 1: + self._set_status("Usage: filter ") + return + self.filter_kind = args[0].strip().lower() + self._update_filters_panel() + self._load_entries(query=self._last_query) + self._set_status(f"Applied filter: {self.filter_kind}") + return + + if cmd in ("archive", "restore"): + if self._service is None or self._selected_entry_id is None: + self._set_status("No entry selected") + return + try: + if cmd == "archive": + self._service.archive_entry(self._selected_entry_id) + action = "archived" + else: + self._service.restore_entry(self._selected_entry_id) + action = "restored" + current_id = self._selected_entry_id + self._load_entries(self._last_query) + if current_id in self._entry_ids_in_view: + self._show_entry(current_id) + self._set_status(f"Entry {current_id} {action}") + except Exception as exc: + self._set_status(f"{cmd} failed: {exc}") + return + + if cmd == "edit-doc": + self.action_edit_document() + return + + if cmd == "save-doc": + self.action_save_document() + return + + if cmd == "cancel-edit": + self.action_cancel_document_edit() + return + + if cmd == "link-add": + if self._service is None or self._selected_entry_id is None: + self._set_status("No source entry selected") + return + if len(args) < 1: + self._set_status( + "Usage: link-add [relation] [note text]" + ) + return + try: + target = int(args[0]) + except ValueError: + self._set_status("link-add target_id must be an integer") + return + relation = args[1] if len(args) >= 2 else "related_to" + note = " ".join(args[2:]) if len(args) >= 3 else "" + try: + self._service.add_link( + self._selected_entry_id, + target, + relation=relation, + note=note, + ) + self._update_links_panel() + self._set_status( + f"Link added: {self._selected_entry_id} {relation} {target}" + ) + except Exception as exc: + self._set_status(f"link-add failed: {exc}") + return + + if cmd == "link-rm": + if self._service is None or self._selected_entry_id is None: + self._set_status("No source entry selected") + return + if len(args) < 1: + self._set_status("Usage: link-rm [relation]") + return + try: + target = int(args[0]) + except ValueError: + self._set_status("link-rm target_id must be an integer") + return + relation = args[1] if len(args) >= 2 else None + try: + self._service.remove_link( + self._selected_entry_id, + target, + relation=relation, + ) + self._update_links_panel() + self._set_status( + f"Link removed: {self._selected_entry_id} -> {target}" + ) + except Exception as exc: + self._set_status(f"link-rm failed: {exc}") + return + + if cmd == "refresh": + self.action_refresh() + return + + self._set_status(f"Unknown command: {cmd}") + 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: + if self.editing_document: + self._set_status("Finish document edit before searching") + return + if self.palette_open: + self._set_palette_visible(False) self.query_one("#search", Input).focus() def action_cycle_filter(self) -> None: @@ -300,6 +545,16 @@ def launch_tui2( self.filter_kind = order[(idx + 1) % len(order)] self.action_refresh() + def action_open_palette(self) -> None: + if self.editing_document: + self._set_status("Finish document edit before opening palette") + return + self._set_palette_visible(not self.palette_open) + if self.palette_open: + self._set_status("Palette opened") + else: + self._set_status("Palette closed") + def action_toggle_archive(self) -> None: if self._service is None or self._selected_entry_id is None: self._set_status("No entry selected") @@ -390,6 +645,10 @@ def launch_tui2( self._set_status(f"Failed to save document: {exc}") def action_cancel_document_edit(self) -> None: + if self.palette_open: + self._set_palette_visible(False) + self._set_status("Palette closed") + return if not self.editing_document: return self._set_document_editor_visible(False) @@ -401,6 +660,11 @@ def launch_tui2( self._set_status("Finish document edit before searching") return self._load_entries(query=event.value.strip()) + return + if event.input.id == "command-palette": + command = event.value + self._set_palette_visible(False) + self._run_palette_command(command) def on_list_view_selected(self, event: ListView.Selected) -> None: if self.editing_document: