diff --git a/docs/tui_v2_parity_checklist.md b/docs/tui_v2_parity_checklist.md index c85c26e3..451890a0 100644 --- a/docs/tui_v2_parity_checklist.md +++ b/docs/tui_v2_parity_checklist.md @@ -30,7 +30,9 @@ This checklist tracks readiness to switch default interactive mode from legacy T - [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. +- [x] Error messaging and recovery flow review. + - unified failure status messaging + - retry shortcut (`x`) and palette `retry` command - [ ] Cutover decision memo (`seedpass` default switch timing). ## Risks Remaining diff --git a/docs/tui_v2_plan.md b/docs/tui_v2_plan.md index 8299e3f1..71907219 100644 --- a/docs/tui_v2_plan.md +++ b/docs/tui_v2_plan.md @@ -62,7 +62,7 @@ Goal: Introduce a modern, modular TUI (`seedpass tui2`) without rewriting core l ### Phase 4: Cutover readiness - [x] Feature parity checklist against legacy TUI. - [x] UX/perf pass for large vaults. -- [ ] Error messaging and recovery flows. +- [x] Error messaging and recovery flows. - [ ] Decide default switch timing (`seedpass` -> `tui2`). ## Test Strategy diff --git a/memory-update.md b/memory-update.md index 46813100..90e09635 100644 --- a/memory-update.md +++ b/memory-update.md @@ -186,3 +186,5 @@ - 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. +- 2026-03-01: TUI v2 error/recovery pass added unified failure recording (`_record_failure`) and retry workflow (`x` key + palette `retry`) across init/search/select/link/save/archive paths. +- Recovery hints are now included in status messages (for example, "Press 'x' to retry") and Phase 4 docs mark error/recovery flow work complete. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 31a3c6c0..5cbb72d9 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -92,6 +92,7 @@ def launch_tui2( BINDINGS = [ ("q", "quit", "Quit"), ("r", "refresh", "Refresh"), + ("x", "retry_last_error", "Retry"), ("slash", "focus_search", "Search"), ("f", "cycle_filter", "Filter"), ("p", "prev_page", "Prev Page"), @@ -121,7 +122,7 @@ def launch_tui2( "link-add [relation] [note] | " "link-rm [relation] | " "link-filter | link-next | link-prev | link-open | " - "page-next | page-prev | page " + "page-next | page-prev | page | retry" ), id="command-palette", classes="hidden", @@ -171,13 +172,20 @@ def launch_tui2( self._result_page = 0 self._current_links: list[dict[str, Any]] = [] self._current_link_cursor = 0 + self._last_error: str | None = None + self._retry_action: Any | None = None try: self._service = ( entry_service_factory() if callable(entry_service_factory) else None ) except Exception as exc: self._service = None - self._set_status(f"Unable to initialize entry service: {exc}") + self._record_failure( + "Unable to initialize entry service", + exc, + retry=self._retry_initialize_service, + hint="Press 'x' to retry initialization.", + ) self.query_one("#entry-detail", Static).update( f"Unable to initialize entry service: {exc}" ) @@ -187,6 +195,40 @@ def launch_tui2( def _set_status(self, message: str) -> None: self.query_one("#status", Static).update(message) + def _record_failure( + self, + context: str, + exc: Exception, + *, + retry: Any | None = None, + hint: str = "", + ) -> None: + self._last_error = f"{context}: {exc}" + self._retry_action = retry + suffix = f" {hint}" if hint else "" + self._set_status(f"{self._last_error}.{suffix}") + + def _clear_failure(self) -> None: + self._last_error = None + self._retry_action = None + + def _retry_initialize_service(self) -> None: + try: + self._service = ( + entry_service_factory() if callable(entry_service_factory) else None + ) + except Exception as exc: + self._record_failure( + "Unable to initialize entry service", + exc, + retry=self._retry_initialize_service, + hint="Press 'x' to retry initialization.", + ) + return + self._clear_failure() + self._set_status("Entry service initialized") + self._load_entries(self._last_query, reset_page=False) + def _current_filter_kinds(self) -> list[str] | None: if self.filter_kind == "all": return None @@ -214,6 +256,7 @@ def launch_tui2( "- f cycle kind filter", "- p/n prev/next page", "- r refresh", + "- x retry last error", "", "Actions:", "- a archive/restore", @@ -327,10 +370,18 @@ def launch_tui2( self._current_links = [] self._current_link_cursor = 0 self._update_filters_panel() - self._set_status("Failed to load entries") + self._record_failure( + "Failed to load entries", + exc, + retry=lambda: self._load_entries( + self._last_query, reset_page=False + ), + hint="Press 'x' to retry.", + ) return self._all_results = list(results) + self._clear_failure() if reset_page: self._result_page = 0 self._render_current_page(preserve_selected=not reset_page) @@ -362,7 +413,12 @@ def launch_tui2( self.query_one("#link-detail", Static).update("Links unavailable.") self._current_links = [] self._current_link_cursor = 0 - self._set_status(f"Failed to load entry {entry_index}") + self._record_failure( + f"Failed to load entry {entry_index}", + exc, + retry=lambda: self._show_entry(entry_index), + hint="Press 'x' to retry.", + ) def _update_links_panel(self) -> None: if self._service is None or self._selected_entry_id is None: @@ -380,6 +436,12 @@ def launch_tui2( ) self._current_links = [] self._current_link_cursor = 0 + self._record_failure( + "Failed to load entry links", + exc, + retry=self._update_links_panel, + hint="Press 'x' to retry.", + ) return if self.link_relation_filter == "all": @@ -433,6 +495,7 @@ def launch_tui2( else: lines.append(f"{prefix} {relation} -> {target}") self.query_one("#link-detail", Static).update("\n".join(lines)) + self._clear_failure() def _is_selected_document(self) -> bool: if not isinstance(self._selected_entry, dict): @@ -501,9 +564,12 @@ def launch_tui2( "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, page-next, " - "page-prev, page " + "page-prev, page , retry" ) return + if cmd == "retry": + self.action_retry_last_error() + return if cmd == "open": if len(args) != 1: @@ -549,9 +615,15 @@ def launch_tui2( self._load_entries(self._last_query, reset_page=False) if current_id in self._entry_ids_in_view: self._show_entry(current_id) + self._clear_failure() self._set_status(f"Entry {current_id} {action}") except Exception as exc: - self._set_status(f"{cmd} failed: {exc}") + self._record_failure( + f"{cmd} failed", + exc, + retry=lambda: self._run_palette_command(cmd), + hint="Press 'x' to retry.", + ) return if cmd == "edit-doc": @@ -590,11 +662,17 @@ def launch_tui2( note=note, ) self._update_links_panel() + self._clear_failure() self._set_status( f"Link added: {self._selected_entry_id} {relation} {target}" ) except Exception as exc: - self._set_status(f"link-add failed: {exc}") + self._record_failure( + "link-add failed", + exc, + retry=lambda: self._run_palette_command(raw), + hint="Press 'x' to retry.", + ) return if cmd == "link-rm": @@ -617,11 +695,17 @@ def launch_tui2( relation=relation, ) self._update_links_panel() + self._clear_failure() self._set_status( f"Link removed: {self._selected_entry_id} -> {target}" ) except Exception as exc: - self._set_status(f"link-rm failed: {exc}") + self._record_failure( + "link-rm failed", + exc, + retry=lambda: self._run_palette_command(raw), + hint="Press 'x' to retry.", + ) return if cmd == "refresh": @@ -678,6 +762,21 @@ def launch_tui2( self._update_filters_panel() self._load_entries(query=search, reset_page=False) + def action_retry_last_error(self) -> None: + retry = self._retry_action + if retry is None: + self._set_status("No retry action available") + return + try: + retry() + except Exception as exc: + self._record_failure( + "Retry failed", + exc, + retry=retry, + hint="Press 'x' to retry again.", + ) + def action_focus_search(self) -> None: if self.editing_document: self._set_status("Finish document edit before searching") @@ -821,9 +920,15 @@ def launch_tui2( self._load_entries(self._last_query) if current_id in self._entry_ids_in_view: self._show_entry(current_id) + self._clear_failure() self._set_status(f"Entry {current_id} {action}") except Exception as exc: - self._set_status(f"Archive/restore failed: {exc}") + self._record_failure( + "Archive/restore failed", + exc, + retry=self.action_toggle_archive, + hint="Press 'x' to retry.", + ) def action_edit_document(self) -> None: if self._service is None: @@ -884,9 +989,15 @@ def launch_tui2( self._load_entries(self._last_query, reset_page=False) if current_id in self._entry_ids_in_view: self._show_entry(current_id) + self._clear_failure() self._set_status(f"Saved document {current_id}") except Exception as exc: - self._set_status(f"Failed to save document: {exc}") + self._record_failure( + "Failed to save document", + exc, + retry=self.action_save_document, + hint="Press 'x' to retry.", + ) def action_cancel_document_edit(self) -> None: if self.palette_open: