mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 18:02:05 +00:00
Add TUI v2 parity checklist and large-vault pagination
This commit is contained in:
1 parent
6478987254
commit
ec0d77d3e6
6 files changed
+198
-38
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
+2
-2
@@ -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`).
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<li><a href="#" data-file="entry_types.md" class="doc-link">> Entry Types</a></li>
|
||||
<li><a href="#" data-file="entry_graph.md" class="doc-link">> Entry Graph</a></li>
|
||||
<li><a href="#" data-file="tui_v2_plan.md" class="doc-link">> TUI v2 Plan</a></li>
|
||||
<li><a href="#" data-file="tui_v2_parity_checklist.md" class="doc-link">> TUI v2 Parity</a></li>
|
||||
<li><a href="#" data-file="security.md" class="doc-link">> Security</a></li>
|
||||
<li><a href="#" data-file="security_readiness_checklist.md" class="doc-link">> Security Readiness</a></li>
|
||||
<li><a href="#" data-file="agent_autonomy_security_plan.md" class="doc-link">> Agent Security Plan</a></li>
|
||||
|
||||
@@ -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 <n>`) and document detail preview truncation (`content_truncated`) to avoid rendering extremely large payloads in the detail pane.
|
||||
+145
-36
@@ -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 <kind> | archive | restore | "
|
||||
"link-add <target> [relation] [note] | "
|
||||
"link-rm <target> [relation] | "
|
||||
"link-filter <relation|all> | link-next | link-prev | link-open"
|
||||
"link-filter <relation|all> | link-next | link-prev | link-open | "
|
||||
"page-next | page-prev | page <n>"
|
||||
),
|
||||
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 <n>"
|
||||
)
|
||||
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 <page_number>")
|
||||
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 <relation|all>")
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user