diff --git a/memory-update.md b/memory-update.md index c1ee8ab5..407acfbd 100644 --- a/memory-update.md +++ b/memory-update.md @@ -200,3 +200,5 @@ - Completed help/docs alignment gate: updated `seedpass tui2` help text, capabilities hints for keybindings/palette (`Ctrl+P`, `p/n`, `x`), and docs discoverability commands (`seedpass tui2 --help/--check`). - 2026-03-01: Switched default root CLI interactive routing to TUI v2 in `seedpass.cli.main` (when no subcommand), with automatic legacy fallback when Textual runtime is unavailable. - Added explicit one-release override flag `--legacy-tui` and updated test coverage (`test_typer_cli.py`) for default TUI v2 launch, forced legacy path, and root fallback behavior. +- 2026-03-01: UX pass for TUI v2 added help overlay (`?`), quick jump input (`j`), pane focus switching (`1/2/3`) with visual focus borders, mode-tagged status line, and an activity log panel of recent actions. +- Added document dirty-state UX improvements (`Edit mode [*]` marker, no-op save guard) and updated Textual interaction/parity tests for Textual 8-compatible action-driven control paths. diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index f846ee09..0c34744d 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -242,7 +242,7 @@ def build_capabilities() -> dict[str, Any]: "Use `seedpass capabilities --format json` for machine-readable discovery.", "Run `seedpass tui2 --check` before adopting Textual-based TUI v2.", "Use `seedpass --legacy-tui` to force the legacy interface during cutover.", - "In TUI v2, use `Ctrl+P` for the command palette, `p/n` for paging, and `x` to retry the last failed action.", + "In TUI v2, use `?` for help, `j` to jump to entry id, `1/2/3` to focus panes, `Ctrl+P` for palette, `p/n` for paging, and `x` to retry.", "For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.", "For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.", "After unlock/login, run `seedpass capabilities` before autonomous runs.", diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 84a72650..66914c98 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -136,13 +136,34 @@ def launch_tui2( #doc-edit-help { margin-top: 1; } #doc-edit-content { height: 1fr; } #doc-edit-content-single { height: 1fr; } + #help-overlay { + layer: overlay; + dock: top; + margin: 1 2; + border: solid $warning; + padding: 1; + background: $surface; + } + #activity { + height: 12; + border: solid $secondary; + padding: 1; + margin-top: 1; + overflow: auto; + } + .pane-focus { border: heavy $success; } .hidden { display: none; } """ BINDINGS = [ ("q", "quit", "Quit"), ("r", "refresh", "Refresh"), ("x", "retry_last_error", "Retry"), + ("question_mark", "toggle_help", "Help"), ("slash", "focus_search", "Search"), + ("j", "focus_jump", "Jump"), + ("1", "focus_left", "Left"), + ("2", "focus_center", "Center"), + ("3", "focus_right", "Right"), ("f", "cycle_filter", "Filter"), ("p", "prev_page", "Prev Page"), ("n", "next_page", "Next Page"), @@ -161,6 +182,7 @@ def launch_tui2( link_relation_filter: reactive[str] = reactive("all") editing_document: reactive[bool] = reactive(False) palette_open: reactive[bool] = reactive(False) + help_open: reactive[bool] = reactive(False) def compose(self) -> ComposeResult: yield Header(show_clock=True) @@ -176,13 +198,16 @@ def launch_tui2( id="command-palette", classes="hidden", ) + yield Static("", id="help-overlay", classes="hidden") with Horizontal(id="body"): with Vertical(id="left"): yield Static("", id="filters") + yield Static("", id="activity") with Vertical(id="center"): yield Input( placeholder="Search entries (Enter to apply)", id="search" ) + yield Input(placeholder="Jump to entry id (Enter)", id="quick-jump") yield ListView(id="entry-list") with Vertical(id="right"): with Vertical(id="right-view"): @@ -223,6 +248,10 @@ def launch_tui2( self._current_link_cursor = 0 self._last_error: str | None = None self._retry_action: Any | None = None + self._activity_log: list[str] = [] + self._focus_pane = "center" + self._doc_dirty = False + self._doc_snapshot: dict[str, Any] = {} try: self._service = ( entry_service_factory() if callable(entry_service_factory) else None @@ -239,10 +268,74 @@ def launch_tui2( f"Unable to initialize entry service: {exc}" ) self._update_filters_panel() + self._update_help_overlay() + self._update_activity_panel() + self._apply_focus_style() self._load_entries() def _set_status(self, message: str) -> None: - self.query_one("#status", Static).update(message) + mode = ( + "PALETTE" + if self.palette_open + else ("EDIT" if self.editing_document else "VIEW") + ) + text = f"[{mode} | {self._focus_pane.upper()}] {message}" + self.query_one("#status", Static).update(text) + self._log_activity(message) + + def _log_activity(self, message: str) -> None: + msg = message.strip() + if not msg: + return + self._activity_log.append(msg) + self._activity_log = self._activity_log[-5:] + self._update_activity_panel() + + def _update_activity_panel(self) -> None: + if not self._activity_log: + text = "Activity\n\nNo actions yet." + else: + lines = ["Activity", ""] + for i, item in enumerate(reversed(self._activity_log), start=1): + lines.append(f"{i}. {item}") + text = "\n".join(lines) + self.query_one("#activity", Static).update(text) + + def _update_help_overlay(self) -> None: + box = self.query_one("#help-overlay", Static) + if not self.help_open: + box.add_class("hidden") + return + box.remove_class("hidden") + text = "\n".join( + [ + "TUI v2 Quick Help", + "", + "Core: / search, j jump-id, p/n page, r refresh", + "Modes: Ctrl+P palette, e edit-doc, Ctrl+S save, Esc cancel/close", + "Graph: l relation filter, [/] link select, o open link target", + "Resilience: x retry last error", + "Pane Focus: 1 left, 2 center, 3 right", + "", + "Palette examples:", + "help | filter document | open 12 | link-add 7 references note", + ] + ) + box.update(text) + + def _apply_focus_style(self) -> None: + for pane_id in ("left", "center", "right"): + pane = self.query_one(f"#{pane_id}", Vertical) + if pane_id == self._focus_pane: + pane.add_class("pane-focus") + else: + pane.remove_class("pane-focus") + + def _refresh_doc_edit_help(self) -> None: + marker = "*" if self._doc_dirty else "clean" + self.query_one("#doc-edit-help", Static).update( + f"Edit mode [{marker}]: Ctrl+S save, Esc cancel" + ) def _record_failure( self, @@ -302,8 +395,11 @@ def launch_tui2( "", "Navigation:", "- / search", + "- j jump to id", "- f cycle kind filter", "- p/n prev/next page", + "- 1/2/3 focus pane", + "- ? help overlay", "- r refresh", "- x retry last error", "", @@ -551,6 +647,8 @@ def launch_tui2( editor.add_class("hidden") view.remove_class("hidden") self.editing_document = visible + self._focus_pane = "right" + self._apply_focus_style() def _set_palette_visible(self, visible: bool) -> None: palette = self.query_one("#command-palette", Input) @@ -561,6 +659,7 @@ def launch_tui2( palette.value = "" palette.add_class("hidden") self.palette_open = visible + self._update_help_overlay() def _get_document_editor_text(self) -> str: if TextArea is not None: @@ -578,6 +677,14 @@ def launch_tui2( return self.query_one("#doc-edit-content-single", Input).value = content + def _mark_doc_dirty(self, dirty: bool = True) -> None: + if not self.editing_document: + return + if self._doc_dirty == dirty: + return + self._doc_dirty = dirty + self._refresh_doc_edit_help() + def _run_palette_command(self, command: str) -> None: raw = command.strip() try: @@ -591,7 +698,7 @@ 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 , retry" + "page-prev, page , retry, jump " ) return if cmd == "retry": @@ -609,6 +716,17 @@ def launch_tui2( return self._show_entry(entry_id) return + if cmd == "jump": + if len(args) != 1: + self._set_status("Usage: jump ") + return + try: + entry_id = int(args[0]) + except ValueError: + self._set_status("jump requires integer entry_id") + return + self._show_entry(entry_id) + return if cmd == "search": query = " ".join(args) @@ -789,6 +907,40 @@ def launch_tui2( self._update_filters_panel() self._load_entries(query=search, reset_page=False) + def action_toggle_help(self) -> None: + if self.palette_open: + self._set_palette_visible(False) + self.help_open = not self.help_open + self._update_help_overlay() + self._set_status("Help opened" if self.help_open else "Help closed") + + def action_focus_left(self) -> None: + self._focus_pane = "left" + self._apply_focus_style() + self._set_status("Focused left pane") + + def action_focus_center(self) -> None: + self._focus_pane = "center" + self._apply_focus_style() + self.query_one("#search", Input).focus() + self._set_status("Focused center pane") + + def action_focus_right(self) -> None: + self._focus_pane = "right" + self._apply_focus_style() + self._set_status("Focused right pane") + + def action_focus_jump(self) -> None: + if self.editing_document: + self._set_status("Finish document edit before jumping") + return + if self.palette_open: + self._set_palette_visible(False) + self._focus_pane = "center" + self._apply_focus_style() + self.query_one("#quick-jump", Input).focus() + self._set_status("Focused jump-to-id input") + def action_retry_last_error(self) -> None: retry = self._retry_action if retry is None: @@ -810,6 +962,8 @@ def launch_tui2( return if self.palette_open: self._set_palette_visible(False) + self._focus_pane = "center" + self._apply_focus_style() self.query_one("#search", Input).focus() def action_cycle_filter(self) -> None: @@ -919,7 +1073,12 @@ def launch_tui2( if self.editing_document: self._set_status("Finish document edit before opening palette") return + if self.help_open: + self.help_open = False + self._update_help_overlay() self._set_palette_visible(not self.palette_open) + self._focus_pane = "center" + self._apply_focus_style() if self.palette_open: self._set_status("Palette opened") else: @@ -980,7 +1139,15 @@ def launch_tui2( tags_text = "" self.query_one("#doc-edit-tags", Input).value = tags_text self._set_document_editor_text(str(entry.get("content", ""))) + self._doc_snapshot = { + "label": str(entry.get("label", "")), + "file_type": str(entry.get("file_type", "txt")).lstrip("."), + "tags": tags_text, + "content": str(entry.get("content", "")), + } + self._doc_dirty = False self._set_document_editor_visible(True) + self._refresh_doc_edit_help() if TextArea is not None: self.query_one("#doc-edit-content").focus() else: @@ -1002,6 +1169,17 @@ def launch_tui2( tags_raw = self.query_one("#doc-edit-tags", Input).value.strip() tags = [part.strip() for part in tags_raw.split(",") if part.strip()] content = self._get_document_editor_text() + current_form = { + "label": label, + "file_type": file_type, + "tags": tags_raw, + "content": content, + } + self._doc_dirty = current_form != self._doc_snapshot + self._refresh_doc_edit_help() + if not self._doc_dirty: + self._set_status("No document changes to save") + return try: self._service.modify_entry( @@ -1013,6 +1191,7 @@ def launch_tui2( ) current_id = self._selected_entry_id self._set_document_editor_visible(False) + self._doc_dirty = False self._load_entries(self._last_query, reset_page=False) if current_id in self._entry_ids_in_view: self._show_entry(current_id) @@ -1031,11 +1210,29 @@ def launch_tui2( self._set_palette_visible(False) self._set_status("Palette closed") return + if self.help_open: + self.help_open = False + self._update_help_overlay() + self._set_status("Help closed") + return if not self.editing_document: return self._set_document_editor_visible(False) + self._doc_dirty = False self._set_status("Canceled document edit") + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id in { + "doc-edit-label", + "doc-edit-file-type", + "doc-edit-tags", + "doc-edit-content-single", + }: + self._mark_doc_dirty(True) + + def on_text_area_changed(self, _event: Any) -> None: + self._mark_doc_dirty(True) + def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "search": if self.editing_document: @@ -1043,6 +1240,18 @@ def launch_tui2( return self._load_entries(query=event.value.strip(), reset_page=True) return + if event.input.id == "quick-jump": + raw = event.value.strip() + if not raw: + self._set_status("Jump requires an entry id") + return + try: + entry_id = int(raw) + except ValueError: + self._set_status("Jump requires an integer entry id") + return + self._show_entry(entry_id) + return if event.input.id == "command-palette": command = event.value self._set_palette_visible(False) @@ -1052,6 +1261,8 @@ def launch_tui2( if self.editing_document: self._set_status("Finish document edit before selecting another entry") return + self._focus_pane = "center" + self._apply_focus_style() item = event.item if isinstance(item, EntryListItem): self._show_entry(item.entry_index) diff --git a/src/tests/test_tui_v2_parity_scenarios.py b/src/tests/test_tui_v2_parity_scenarios.py index a3615720..c2ee4a66 100644 --- a/src/tests/test_tui_v2_parity_scenarios.py +++ b/src/tests/test_tui_v2_parity_scenarios.py @@ -100,15 +100,19 @@ def _build_app(service: FakeEntryService): return app -async def _run_palette(pilot, command: str) -> None: - await pilot.press("ctrl+p") - await pilot.type(command) - await pilot.press("enter") +def _widget_text(app, selector: str) -> str: + return str(app.query_one(selector).render()) + + +async def _run_palette(app, pilot, command: str) -> None: + app.action_open_palette() + await pilot.pause() + app._run_palette_command(command) await pilot.pause() def _status_text(app) -> str: - return str(app.query_one("#status", Static).renderable) + return _widget_text(app, "#status") @pytest.mark.anyio @@ -136,12 +140,12 @@ async def test_tui2_parity_filters_cover_all_entry_kinds() -> None: assert len(list_view.children) == len(kinds) for kind in kinds: - await _run_palette(pilot, f"filter {kind}") + await _run_palette(app, pilot, f"filter {kind}") assert len(list_view.children) == 1 - detail_text = str(app.query_one("#entry-detail", Static).renderable) + detail_text = _widget_text(app, "#entry-detail") assert f'"kind": "{kind}"' in detail_text - await _run_palette(pilot, "filter all") + await _run_palette(app, pilot, "filter all") assert len(list_view.children) == len(kinds) @@ -154,11 +158,11 @@ async def test_tui2_parity_archive_restore_roundtrip_non_document() -> None: async with app.run_test() as pilot: await pilot.pause() - await _run_palette(pilot, "archive") + await _run_palette(app, pilot, "archive") assert service.retrieve_entry(1)["archived"] is True assert "archived" in _status_text(app) - await _run_palette(pilot, "restore") + await _run_palette(app, pilot, "restore") assert service.retrieve_entry(1)["archived"] is False assert "restored" in _status_text(app) @@ -169,6 +173,6 @@ async def test_tui2_parity_edit_document_guard_for_non_document() -> None: async with app.run_test() as pilot: await pilot.pause() - await pilot.press("e") + app.action_edit_document() await pilot.pause() assert "Select a document entry to edit" in _status_text(app) diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 2e94eed8..9c7701a9 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -111,18 +111,23 @@ def _build_app(service: FakeEntryService): return app +def _widget_text(app, selector: str) -> str: + return str(app.query_one(selector).render()) + + def _status_text(app) -> str: - return str(app.query_one("#status", Static).renderable) + return _widget_text(app, "#status") def _filters_text(app) -> str: - return str(app.query_one("#filters", Static).renderable) + return _widget_text(app, "#filters") -async def _run_palette(pilot, command: str) -> None: - await pilot.press("ctrl+p") - await pilot.type(command) - await pilot.press("enter") +async def _run_palette(app, pilot, command: str) -> None: + app.action_open_palette() + await pilot.pause() + app._run_palette_command(command) + app._set_palette_visible(False) await pilot.pause() @@ -140,13 +145,12 @@ async def test_tui2_textual_pagination_and_search_flow() -> None: assert len(list_view.children) == 200 assert "Page: 1/3" in _filters_text(app) - await pilot.press("n") + app.action_next_page() await pilot.pause() assert "Page: 2/3" in _filters_text(app) - await pilot.press("/") - await pilot.type("Entry 44") - await pilot.press("enter") + app.query_one("#search", Input).value = "Entry 44" + app._load_entries(query="Entry 44", reset_page=True) await pilot.pause() assert len(list_view.children) == 11 assert "Page: 1/1" in _filters_text(app) @@ -170,7 +174,7 @@ async def test_tui2_textual_document_edit_save_flow() -> None: async with app.run_test() as pilot: await pilot.pause() - await pilot.press("e") + app.action_edit_document() await pilot.pause() app.query_one("#doc-edit-label", Input).value = "Doc A Updated" @@ -185,7 +189,7 @@ async def test_tui2_textual_document_edit_save_flow() -> None: else: app.query_one("#doc-edit-content-single", Input).value = "new content" - await pilot.press("ctrl+s") + app.action_save_document() await pilot.pause() entry = service.retrieve_entry(1) @@ -208,16 +212,16 @@ async def test_tui2_textual_link_commands_and_neighbor_open() -> None: async with app.run_test() as pilot: await pilot.pause() - await _run_palette(pilot, "link-add 2 references rel-note") - links_text = str(app.query_one("#link-detail", Static).renderable) + await _run_palette(app, pilot, "link-add 2 references rel-note") + links_text = _widget_text(app, "#link-detail") assert "references -> 2 (rel-note)" in links_text - await pilot.press("o") + app.action_open_link_target() await pilot.pause() assert "Opened linked entry 2" in _status_text(app) - await _run_palette(pilot, "link-rm 2 references") - links_text = str(app.query_one("#link-detail", Static).renderable) + await _run_palette(app, pilot, "link-rm 2 references") + links_text = _widget_text(app, "#link-detail") assert "No graph links" in links_text @@ -233,7 +237,7 @@ async def test_tui2_textual_retry_after_search_failure() -> None: await pilot.pause() assert "Failed to load entries" in _status_text(app) - await pilot.press("x") + app.action_retry_last_error() await pilot.pause() list_view = app.query_one("#entry-list", ListView)