From ec0d77d3e615fdcddf937d9cad53605aee8ec1f4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sun, 1 Mar 2026 16:17:42 -0500
Subject: [PATCH] Add TUI v2 parity checklist and large-vault pagination
---
docs/README.md | 1 +
docs/tui_v2_parity_checklist.md | 47 +++++++++
docs/tui_v2_plan.md | 4 +-
landing/docs.html | 1 +
memory-update.md | 2 +
src/seedpass/tui_v2/app.py | 181 +++++++++++++++++++++++++-------
6 files changed, 198 insertions(+), 38 deletions(-)
create mode 100644 docs/tui_v2_parity_checklist.md
diff --git a/docs/README.md b/docs/README.md
index c22c0ec2..ded6db51 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -24,6 +24,7 @@ This directory contains SeedPass project documentation.
- `entry_types.md`
- `entry_graph.md`
- `tui_v2_plan.md`
+- `tui_v2_parity_checklist.md`
- `security.md`
- `nostr_setup.md`
- `nostr_namespace_reset.md`
diff --git a/docs/tui_v2_parity_checklist.md b/docs/tui_v2_parity_checklist.md
new file mode 100644
index 00000000..c85c26e3
--- /dev/null
+++ b/docs/tui_v2_parity_checklist.md
@@ -0,0 +1,47 @@
+# TUI v2 Parity Checklist
+
+Status: In progress on `beta` (updated 2026-03-01).
+
+This checklist tracks readiness to switch default interactive mode from legacy TUI to `seedpass tui2`.
+
+## Scope
+
+- Legacy baseline: current default `seedpass` interactive path.
+- Candidate: `seedpass tui2` (Textual).
+- Non-goal for cutover: replacing CLI/API behavior.
+
+## Current Parity
+
+| Area | Legacy TUI | TUI v2 | Notes |
+|---|---|---|---|
+| Entry list/search | Yes | Yes | Includes kind filter and search. |
+| Entry detail view | Yes | Yes | Large document content preview is truncated for performance. |
+| Archive/restore | Yes | Yes | Keyboard and command palette paths. |
+| Document editing | Partial | Yes | Save/cancel flow in editor panel. |
+| Graph links list | Limited | Yes | Dedicated links panel. |
+| Link add/remove | Limited | Yes | Via command palette commands. |
+| Neighbor traversal | No | Yes | Link cursor + open target entry. |
+| Command discoverability | Menu-driven | Partial | Keybind hints and palette `help`. |
+| Full legacy workflow coverage | Yes | Pending | Additional edge-case parity still needed. |
+
+## Phase 4 Deliverables
+
+- [x] Feature parity checklist document.
+- [x] First UX/perf pass for large vaults.
+ - paginated entry list rendering (`p`/`n`, `page-*` palette commands)
+ - document content preview truncation in detail panel
+- [ ] Error messaging and recovery flow review.
+- [ ] Cutover decision memo (`seedpass` default switch timing).
+
+## Risks Remaining
+
+- Incomplete parity for legacy-only interactive operations not yet mapped into TUI v2.
+- Textual runtime/version behavior can vary by environment.
+- No dedicated Textual interaction test suite yet for pagination and graph traversal.
+
+## Validation Checklist Before Cutover
+
+- [ ] Define and run deterministic parity scenarios across entry kinds.
+- [ ] Add Textual interaction tests for list/search/edit/link/pagination flows.
+- [ ] Verify large vault behavior with realistic data volume in CI-like environment.
+- [ ] Confirm help text and docs match shipped keybindings and palette commands.
diff --git a/docs/tui_v2_plan.md b/docs/tui_v2_plan.md
index 9ebb5a05..8299e3f1 100644
--- a/docs/tui_v2_plan.md
+++ b/docs/tui_v2_plan.md
@@ -60,8 +60,8 @@ Goal: Introduce a modern, modular TUI (`seedpass tui2`) without rewriting core l
- [x] Neighbor navigation and quick traversal.
### Phase 4: Cutover readiness
-- [ ] Feature parity checklist against legacy TUI.
-- [ ] UX/perf pass for large vaults.
+- [x] Feature parity checklist against legacy TUI.
+- [x] UX/perf pass for large vaults.
- [ ] Error messaging and recovery flows.
- [ ] Decide default switch timing (`seedpass` -> `tui2`).
diff --git a/landing/docs.html b/landing/docs.html
index d7b1e6e4..f961449f 100644
--- a/landing/docs.html
+++ b/landing/docs.html
@@ -57,6 +57,7 @@
> Entry Types
> Entry Graph
> TUI v2 Plan
+ > TUI v2 Parity
> Security
> Security Readiness
> Agent Security Plan
diff --git a/memory-update.md b/memory-update.md
index 13a07902..46813100 100644
--- a/memory-update.md
+++ b/memory-update.md
@@ -184,3 +184,5 @@
- Added a graph links panel in the right view that renders current entry links via `EntryService.get_links` and updates after palette link mutations.
- 2026-03-01: TUI v2 graph navigation expanded with relation-aware link filtering (`l` or `link-filter`) and quick neighbor traversal (`[`/`]` to change selected link, `o` or `link-open` to jump to target entry).
- Link panel now tracks selection state and displays active relation filter + selected link position to support KB-style navigation in large graphs.
+- 2026-03-01: Phase 4 kickoff added `docs/tui_v2_parity_checklist.md` and linked it in docs indexes (`docs/README.md`, `landing/docs.html`) to track legacy-vs-v2 cutover readiness.
+- TUI v2 large-vault UX/perf pass: entry list pagination (200/page, `p`/`n`, palette `page-next/page-prev/page `) and document detail preview truncation (`content_truncated`) to avoid rendering extremely large payloads in the detail pane.
diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py
index cf21a89c..31a3c6c0 100644
--- a/src/seedpass/tui_v2/app.py
+++ b/src/seedpass/tui_v2/app.py
@@ -50,6 +50,9 @@ def launch_tui2(
self.entry_index = int(entry_index)
class SeedPassTuiV2(App[None]):
+ RESULT_PAGE_SIZE = 200
+ DETAIL_CONTENT_PREVIEW_LIMIT = 4000
+
CSS = """
#command-palette {
height: 3;
@@ -91,6 +94,8 @@ def launch_tui2(
("r", "refresh", "Refresh"),
("slash", "focus_search", "Search"),
("f", "cycle_filter", "Filter"),
+ ("p", "prev_page", "Prev Page"),
+ ("n", "next_page", "Next Page"),
("l", "cycle_link_filter", "Link Filter"),
("[", "prev_link", "Prev Link"),
("]", "next_link", "Next Link"),
@@ -115,7 +120,8 @@ def launch_tui2(
"filter | archive | restore | "
"link-add [relation] [note] | "
"link-rm [relation] | "
- "link-filter | link-next | link-prev | link-open"
+ "link-filter | link-next | link-prev | link-open | "
+ "page-next | page-prev | page "
),
id="command-palette",
classes="hidden",
@@ -161,6 +167,8 @@ def launch_tui2(
self._selected_entry: dict[str, Any] | None = None
self._last_query = ""
self._entry_ids_in_view: list[int] = []
+ self._all_results: list[tuple[Any, ...]] = []
+ self._result_page = 0
self._current_links: list[dict[str, Any]] = []
self._current_link_cursor = 0
try:
@@ -196,10 +204,15 @@ def launch_tui2(
fp_line,
"",
f"Active filter: {self.filter_kind}",
+ (
+ f"Results: {len(self._all_results)} "
+ f"Page: {self._result_page + 1}/{self._total_pages()}"
+ ),
"",
"Navigation:",
"- / search",
"- f cycle kind filter",
+ "- p/n prev/next page",
"- r refresh",
"",
"Actions:",
@@ -221,18 +234,83 @@ def launch_tui2(
arch = " [archived]" if archived else ""
return f"{idx:>4} {etype:<15} {label}{arch}"
- def _load_entries(self, query: str = "") -> None:
- self._last_query = query
+ def _total_pages(self) -> int:
+ total = len(self._all_results)
+ if total <= 0:
+ return 1
+ return (total + self.RESULT_PAGE_SIZE - 1) // self.RESULT_PAGE_SIZE
+
+ def _render_current_page(self, *, preserve_selected: bool = True) -> None:
self._entry_ids_in_view = []
list_view = self.query_one("#entry-list", ListView)
list_view.clear()
+ total = len(self._all_results)
+ if total == 0:
+ 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._current_links = []
+ self._current_link_cursor = 0
+ self._set_status("No entries match current filter/search")
+ self._update_filters_panel()
+ return
+
+ max_page = max(0, self._total_pages() - 1)
+ if self._result_page > max_page:
+ self._result_page = max_page
+ if self._result_page < 0:
+ self._result_page = 0
+
+ start = self._result_page * self.RESULT_PAGE_SIZE
+ end = min(total, start + self.RESULT_PAGE_SIZE)
+ page_rows = self._all_results[start:end]
+ for idx, label, _username, _url, archived, etype in page_rows:
+ kind = getattr(etype, "value", str(etype))
+ item = EntryListItem(
+ idx,
+ self._render_entry_label(idx, label, kind, bool(archived)),
+ )
+ self._entry_ids_in_view.append(int(idx))
+ list_view.append(item)
+
+ self._update_filters_panel()
+ chosen_id = None
+ if preserve_selected and self._selected_entry_id in self._entry_ids_in_view:
+ chosen_id = self._selected_entry_id
+ elif self._entry_ids_in_view:
+ chosen_id = self._entry_ids_in_view[0]
+ if chosen_id is not None:
+ self._show_entry(chosen_id)
+
+ def _entry_detail_text(self, entry: dict[str, Any]) -> str:
+ payload = dict(entry)
+ content = payload.get("content")
+ if (
+ isinstance(content, str)
+ and len(content) > self.DETAIL_CONTENT_PREVIEW_LIMIT
+ ):
+ head = content[: self.DETAIL_CONTENT_PREVIEW_LIMIT]
+ payload["content"] = (
+ f"{head}\n\n...[truncated {len(content) - len(head)} chars]"
+ )
+ payload["content_truncated"] = True
+ return json.dumps(payload, indent=2, sort_keys=True)
+
+ def _load_entries(self, query: str = "", *, reset_page: bool = False) -> None:
+ self._last_query = query
if self._service is None:
self.query_one("#entry-detail", Static).update(
"Entry service unavailable in this runtime."
)
self.query_one("#link-detail", Static).update("Links unavailable.")
+ self._all_results = []
+ self._result_page = 0
self._current_links = []
self._current_link_cursor = 0
+ self._update_filters_panel()
return
try:
@@ -244,35 +322,18 @@ def launch_tui2(
f"Failed to load entries: {exc}"
)
self.query_one("#link-detail", Static).update("Links unavailable.")
+ self._all_results = []
+ self._result_page = 0
self._current_links = []
self._current_link_cursor = 0
+ self._update_filters_panel()
self._set_status("Failed to load entries")
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)),
- )
- self._entry_ids_in_view.append(int(idx))
- list_view.append(item)
-
- if len(results) == 0:
- 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._current_links = []
- self._current_link_cursor = 0
- self._set_status("No entries match current filter/search")
- else:
- if list_view.children:
- first = list_view.children[0]
- if isinstance(first, EntryListItem):
- self._show_entry(first.entry_index)
+ self._all_results = list(results)
+ if reset_page:
+ self._result_page = 0
+ self._render_current_page(preserve_selected=not reset_page)
def _show_entry(self, entry_index: int) -> None:
if self._service is None:
@@ -290,7 +351,7 @@ def launch_tui2(
return
self._selected_entry_id = int(entry_index)
self._selected_entry = dict(entry)
- body = json.dumps(entry, indent=2, sort_keys=True)
+ body = self._entry_detail_text(entry)
self.query_one("#entry-detail", Static).update(body)
self._update_links_panel()
self._set_status(f"Selected entry {entry_index}")
@@ -439,7 +500,8 @@ def launch_tui2(
self._set_status(
"Palette commands: help, open, search, filter, archive, "
"restore, edit-doc, save-doc, cancel-edit, link-add, link-rm, "
- "link-filter, link-next, link-prev, link-open"
+ "link-filter, link-next, link-prev, link-open, page-next, "
+ "page-prev, page "
)
return
@@ -458,7 +520,7 @@ def launch_tui2(
if cmd == "search":
query = " ".join(args)
self.query_one("#search", Input).value = query
- self._load_entries(query=query)
+ self._load_entries(query=query, reset_page=True)
self._set_status(f"Applied search: {query}")
return
@@ -468,7 +530,7 @@ def launch_tui2(
return
self.filter_kind = args[0].strip().lower()
self._update_filters_panel()
- self._load_entries(query=self._last_query)
+ self._load_entries(query=self._last_query, reset_page=True)
self._set_status(f"Applied filter: {self.filter_kind}")
return
@@ -484,7 +546,7 @@ def launch_tui2(
self._service.restore_entry(self._selected_entry_id)
action = "restored"
current_id = self._selected_entry_id
- self._load_entries(self._last_query)
+ self._load_entries(self._last_query, reset_page=False)
if current_id in self._entry_ids_in_view:
self._show_entry(current_id)
self._set_status(f"Entry {current_id} {action}")
@@ -565,6 +627,30 @@ def launch_tui2(
if cmd == "refresh":
self.action_refresh()
return
+ if cmd == "page-next":
+ self.action_next_page()
+ return
+ if cmd == "page-prev":
+ self.action_prev_page()
+ return
+ if cmd == "page":
+ if len(args) != 1:
+ self._set_status("Usage: page ")
+ return
+ try:
+ page = int(args[0])
+ except ValueError:
+ self._set_status("page requires integer page number")
+ return
+ if page < 1:
+ self._set_status("page must be >= 1")
+ return
+ self._result_page = min(page - 1, self._total_pages() - 1)
+ self._render_current_page(preserve_selected=False)
+ self._set_status(
+ f"Moved to page {self._result_page + 1}/{self._total_pages()}"
+ )
+ return
if cmd == "link-filter":
if len(args) != 1:
self._set_status("Usage: link-filter ")
@@ -590,7 +676,7 @@ def launch_tui2(
def action_refresh(self) -> None:
search = self.query_one("#search", Input).value
self._update_filters_panel()
- self._load_entries(query=search)
+ self._load_entries(query=search, reset_page=False)
def action_focus_search(self) -> None:
if self.editing_document:
@@ -615,7 +701,30 @@ def launch_tui2(
]
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()
+ self._load_entries(query=self._last_query, reset_page=True)
+ self._set_status(f"Applied filter: {self.filter_kind}")
+
+ def action_next_page(self) -> None:
+ if not self._all_results:
+ self._set_status("No entries loaded")
+ return
+ if self._result_page >= self._total_pages() - 1:
+ self._set_status("Already on last page")
+ return
+ self._result_page += 1
+ self._render_current_page(preserve_selected=False)
+ self._set_status(f"Page {self._result_page + 1}/{self._total_pages()}")
+
+ def action_prev_page(self) -> None:
+ if not self._all_results:
+ self._set_status("No entries loaded")
+ return
+ if self._result_page <= 0:
+ self._set_status("Already on first page")
+ return
+ self._result_page -= 1
+ self._render_current_page(preserve_selected=False)
+ self._set_status(f"Page {self._result_page + 1}/{self._total_pages()}")
def action_cycle_link_filter(self) -> None:
if self._service is None or self._selected_entry_id is None:
@@ -772,7 +881,7 @@ def launch_tui2(
)
current_id = self._selected_entry_id
self._set_document_editor_visible(False)
- self._load_entries(self._last_query)
+ self._load_entries(self._last_query, reset_page=False)
if current_id in self._entry_ids_in_view:
self._show_entry(current_id)
self._set_status(f"Saved document {current_id}")
@@ -794,7 +903,7 @@ def launch_tui2(
if self.editing_document:
self._set_status("Finish document edit before searching")
return
- self._load_entries(query=event.value.strip())
+ self._load_entries(query=event.value.strip(), reset_page=True)
return
if event.input.id == "command-palette":
command = event.value