From c32d5e84a7e8404ed45efa2bab1583a08437cf58 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:26:11 -0500 Subject: [PATCH] tui2: fix dense grid labels and profile-tree visibility --- memory-update.md | 12 + src/seedpass/tui_v2/app.py | 241 ++++++++++++++++-- src/tests/test_tui_v2_textual_interactions.py | 134 +++++++++- 3 files changed, 362 insertions(+), 25 deletions(-) diff --git a/memory-update.md b/memory-update.md index b7ba4460..5794b9f9 100644 --- a/memory-update.md +++ b/memory-update.md @@ -70,3 +70,15 @@ - Inspector heading is now context-aware and tracks right-pane mode: selected entry view, document editor, and 2FA board states. - Added regression coverage for heading transitions across view/edit/2FA modes. - Focused TUI gate now at 50 passing tests. +- Refined inspector board typography for password/document/TOTP kinds by grouping fields and operations into clearer compact sections (`Login Fields`, `Document Fields`, `2FA Fields`) to reduce visual clutter. +- Captured visual/state evidence in `artifacts/ui_eval/audit_20260302_phaseF6/`. +- Bottom action strip shortcuts are now functional and test-covered via new shift-shortcut actions mapped to palette command prefixes plus reveal action. +- Added focused artifact: `artifacts/ui_eval/audit_20260302_phaseH6/` for bottom action-strip validation. +- Added clickable action-strip routing: clicking top-row shortcut segments now triggers the same actions as keyboard shortcuts. +- Added regression test for action-strip click routing and captured artifact `artifacts/ui_eval/audit_20260302_phaseH7/`. +- Focused TUI gate now at 53 passing tests. +- Refined dense-mode top-left sidebar hierarchy: compact FP/profile/session scope lines plus reduced label noise while preserving tree navigation and status cues. +- Added artifact `artifacts/ui_eval/audit_20260302_phaseH8/` documenting the updated hierarchy. +- Continued board-rhythm parity pass: aligned TOTP field labels (`Period :`, `Digits :`) to match password/document row style and improve scan consistency. +- Added artifacts `audit_20260302_phaseF7` and `audit_20260302_phaseF8` for board layout comparison against mockups. +- Extended dense-mode action verbosity reduction across additional boards (seed/managed, ssh, pgp, nostr) for consistent low-noise inspector language. diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 8716026f..193f69b8 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -332,6 +332,14 @@ def launch_tui2( ("shift+f", "cycle_filter", "Filter"), ("m", "cycle_search_mode", "Search Mode"), ("d", "toggle_density", "Density"), + ("shift+s", "shortcut_settings", "Settings"), + ("shift+a", "shortcut_add_entry", "Add Entry"), + ("shift+c", "shortcut_create_seed", "Create Seed"), + ("shift+r", "shortcut_remove_seed", "Remove Seed"), + ("shift+h", "shortcut_hide_reveal", "Hide/Reveal"), + ("shift+e", "shortcut_export_data", "Export Data"), + ("shift+i", "shortcut_import_data", "Import Data"), + ("shift+b", "shortcut_backup_data", "Backup Data"), ("h", "cycle_archive_scope", "Archive View"), ("up", "profile_tree_prev", "Profile Prev"), ("down", "profile_tree_next", "Profile Next"), @@ -730,7 +738,22 @@ def launch_tui2( def _profile_tree_child_nodes(self) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: managed: list[tuple[int, str]] = [] agent_like: list[tuple[int, str]] = [] - for row in self._all_results: + source_rows: list[tuple[Any, ...]] = [] + if self._service is not None and self._all_results: + try: + source_rows = list( + self._service.search_entries( + "", + kinds=["managed_account", "nostr"], + include_archived=False, + archived_only=False, + ) + ) + except Exception: + source_rows = [] + if not source_rows: + source_rows = list(self._all_results) + for row in source_rows: if len(row) < 6: continue entry_id = int(row[0]) @@ -881,7 +904,7 @@ def launch_tui2( context = context.replace("confirm", "cfm") elif self._dense_hires_layout: global_row = ( - "S Set A Add C Seed+ R Seed- H Reveal E Export I Import " + "Shift+S Set Shift+A Add Shift+C Seed+ Shift+R Seed- Shift+H Reveal Shift+E Export Shift+I Import " "B Backup Ctrl+P Cmd Dense" ) context = context.replace("Entry ▣ ", "") @@ -889,7 +912,7 @@ def launch_tui2( context = context.replace("Archive", "Arch") context = context.replace("confirm", "cfm") else: - global_row = "S Settings A Add New Entry C Create New Seed R Remove Seed H Hide/Reveal Sensitive E Export Data I Import Data B Backup Data" + global_row = "Shift+S Settings Shift+A Add Entry Shift+C Create Seed Shift+R Remove Seed Shift+H Hide/Reveal Shift+E Export Shift+I Import Shift+B Backup" text = f"{global_row}\n{context}" self.query_one("#action-strip", Static).update(text) @@ -907,6 +930,7 @@ def launch_tui2( "Sel Id Entry# Label Kind " "Meta Arch" ) + divider = "-" * len(table_cols) metrics = ( f"Pg {self._result_page + 1}/{self._total_pages()} " f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)} " @@ -917,10 +941,77 @@ def launch_tui2( [ f"Entry Grid | {metrics}", table_cols, + divider, ] ) ) + @staticmethod + def _action_strip_segment_action(segment: str) -> str | None: + token = segment.strip() + if token.startswith("Shift+S") or token.startswith("S "): + return "settings" + if token.startswith("Shift+A") or token.startswith("A "): + return "add" + if token.startswith("Shift+C") or token.startswith("C "): + return "create_seed" + if token.startswith("Shift+R") or token.startswith("R "): + return "remove_seed" + if token.startswith("Shift+H") or token.startswith("H "): + return "hide_reveal" + if token.startswith("Shift+E") or token.startswith("E "): + return "export" + if token.startswith("Shift+I") or token.startswith("I "): + return "import" + if token.startswith("Shift+B") or token.startswith("B "): + return "backup" + if token.startswith("Ctrl+P"): + return "palette" + return None + + def _trigger_action_strip_shortcut(self, action: str) -> None: + if action == "settings": + self.action_shortcut_settings() + elif action == "add": + self.action_shortcut_add_entry() + elif action == "create_seed": + self.action_shortcut_create_seed() + elif action == "remove_seed": + self.action_shortcut_remove_seed() + elif action == "hide_reveal": + self.action_shortcut_hide_reveal() + elif action == "export": + self.action_shortcut_export_data() + elif action == "import": + self.action_shortcut_import_data() + elif action == "backup": + self.action_shortcut_backup_data() + elif action == "palette": + self.action_open_palette() + + def _handle_action_strip_click(self, column: int, row: int) -> bool: + if row != 0 or column < 0: + return False + rendered = str(self.query_one("#action-strip", Static).render()) + first_line = rendered.splitlines()[0] if rendered else "" + if not first_line: + return False + parts = [part for part in first_line.split(" ") if part.strip()] + cursor = 0 + for part in parts: + start = first_line.find(part, cursor) + if start < 0: + continue + end = start + len(part) + cursor = end + if start <= column < end: + action = self._action_strip_segment_action(part) + if action is None: + return False + self._trigger_action_strip_shortcut(action) + return True + return False + def _update_inspector_heading(self) -> None: heading = "Inspector Board" if self.editing_document and self._selected_entry_id is not None: @@ -1103,7 +1194,7 @@ def launch_tui2( ribbon.styles.height = 2 status.styles.height = 2 action_strip.styles.height = 2 - grid_heading.styles.height = 2 + grid_heading.styles.height = 3 inspector_heading.styles.height = 2 else: brand.styles.height = 3 @@ -1252,7 +1343,9 @@ def launch_tui2( def _update_filters_panel(self) -> None: self._refresh_profile_tree() - tree_lines = ["Profile Tree", "------------"] + tree_lines = ( + ["Tree", "----"] if self._dense_hires_layout else ["Profile Tree", "------------"] + ) current_fp = self._active_profile_key() nodes = self._profile_tree_visible_nodes() if nodes: @@ -1306,11 +1399,21 @@ def launch_tui2( if self._dense_hires_layout: nav_line = "Keys: / j f Shift+F m h p/n ? r x Pane:1/2/3 Tree:↑/↓ Ctrl+O Ctrl+B" act_line = "Act: Ctrl+P a e Ctrl+S Esc l [ ] o v g 6" + header_lines = [ + "SeedPass ◈ TUI v2", + f"Fingerprint: {self._active_profile_key()}", + f"FP: {self._active_profile_key()} | Profiles: {profiles_loaded} | Session: {session_state}", + f"Scope: filter={self.filter_kind} arch={self.archive_scope} links={self.link_relation_filter} managed={managed_state}", + self._selected_summary(), + ( + f"Results: {len(self._all_results)} | " + f"Page: {self._result_page + 1}/{self._total_pages()}" + ), + ] else: nav_line = "Nav: / j f(menu) Shift+F(cycle) h p/n 1/2/3 ↑/↓ Ctrl+O Ctrl+B ? r x" act_line = "Act: Ctrl+P a e Ctrl+S Esc l [ ] o v g 6 (v confirm for seed/ssh/pgp)" - text = "\n".join( - [ + header_lines = [ "SeedPass ◈ TUI v2", fp_line, f"Profiles: {profiles_loaded}", @@ -1326,6 +1429,10 @@ def launch_tui2( f"Results: {len(self._all_results)} | " f"Page: {self._result_page + 1}/{self._total_pages()}" ), + ] + text = "\n".join( + [ + *header_lines, "", *tree_lines, "", @@ -1605,6 +1712,8 @@ def launch_tui2( def _notes_tags_panel_hint( self, *, tags_text: str, notes_text: str ) -> list[str]: + if self._dense_hires_layout: + return ["Tags/Notes: side panel."] if not self._compact_layout: return ["Tags/Notes appear in right panel."] tags_preview = tags_text if len(tags_text) <= 88 else f"{tags_text[:85]}..." @@ -1676,15 +1785,22 @@ def launch_tui2( "-" * max(24, len(label) + 10), f"{self._kind_icon(kind)} {board_name}", f"Kind: {kind} | Modified: {modified} | Archived: {archived}", - f"Index Num*: {index_num} | Entry Num: {entry_id}", + f"Index Num*: {index_num}", + "Login Fields", + "Password*: hidden (v reveal, g qr)", + f"Username*: {username}", + f"URL : {url}", + f"Length : {length} chars", + "Operations", action_row, - "Credentials", - f"Password*: hidden (v reveal, g qr) | Length: {length} chars", - f"Username*: {username} | URL: {url}", *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Edit (e) | Archive (a)", + ( + "Actions: e edit | a archive" + if self._dense_hires_layout + else "Actions: Edit (e) | Archive (a)" + ), ] return "\n".join(lines) @@ -1714,15 +1830,20 @@ def launch_tui2( "-" * 28, f"{self._kind_icon(kind)} Note Board", f"Kind: {kind} | Modified: {modified} | Archived: {archived}", - f"Index Num*: {index_num} | Entry Num: {entry_id} | File Type: {file_type}", + f"Index Num*: {index_num} | File Type: {file_type}", + "Document Fields", f"Content Length: {len(content)} chars", + f"Preview : {preview}", + "Operations", "▣ Edit ▣ Export", - "Content Preview", - f"{preview}", *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Edit (e) | Save (Ctrl+S) | Cancel (Esc) | Archive (a)", + ( + "Actions: e edit | Ctrl+S save | Esc cancel | a archive" + if self._dense_hires_layout + else "Actions: Edit (e) | Save (Ctrl+S) | Cancel (Esc) | Archive (a)" + ), ] return "\n".join(lines) @@ -1746,7 +1867,11 @@ def launch_tui2( *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Reveal (v confirm) | QR (g) | copy seed confirm | export-field seed confirm | Archive (a)", + ( + "Actions: v reveal(cfm) | g qr | copy seed cfm | export-field seed cfm | a archive" + if self._dense_hires_layout + else "Actions: Reveal (v confirm) | QR (g) | copy seed confirm | export-field seed confirm | Archive (a)" + ), ] return "\n".join(lines) @@ -1772,15 +1897,22 @@ def launch_tui2( "-" * 28, f"{self._kind_icon(kind)} 2FA Board", f"Kind: {kind} | Modified: {modified} | Archived: {archived}", - f"Index Num*: {index_num} | Entry Num: {entry_id}", - f"Period: {period}s | Digits: {digits}", - "▣ Copy Code ▣ Copy URL(confirm) ▣ Reveal Secret ▣ QR", + f"Index Num*: {index_num}", + "2FA Fields", + f"Period : {period}s", + f"Digits : {digits}", "Current Code: hidden (use '6' board or 'v' reveal)", "Secret: hidden | URI via QR", + "Operations", + "▣ Copy Code ▣ Copy URL(confirm) ▣ Reveal Secret ▣ QR", *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: 2FA Board (6) | 2fa-copy | 2fa-copy-url | Reveal (v) | QR (g) | Archive (a)", + ( + "Actions: 6 board | 2fa-copy | 2fa-copy-url | v reveal | g qr | a archive" + if self._dense_hires_layout + else "Actions: 2FA Board (6) | 2fa-copy | 2fa-copy-url | Reveal (v) | QR (g) | Archive (a)" + ), ] return "\n".join(lines) @@ -1819,7 +1951,11 @@ def launch_tui2( *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Reveal (v) | Edit (e) | Archive (a) | Ctrl+P copy/export-field", + ( + "Actions: v reveal | e edit | a archive | Ctrl+P copy/export-field" + if self._dense_hires_layout + else "Actions: Reveal (v) | Edit (e) | Archive (a) | Ctrl+P copy/export-field" + ), ] return "\n".join(lines) @@ -1853,7 +1989,11 @@ def launch_tui2( *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Reveal (v) | Edit (e) | Archive (a) | Ctrl+P copy/export-field", + ( + "Actions: v reveal | e edit | a archive | Ctrl+P copy/export-field" + if self._dense_hires_layout + else "Actions: Reveal (v) | Edit (e) | Archive (a) | Ctrl+P copy/export-field" + ), ] return "\n".join(lines) @@ -1886,7 +2026,11 @@ def launch_tui2( *self._notes_tags_panel_hint( tags_text=tags_text, notes_text=notes_text ), - "Actions: Reveal (v) | QR (g) | qr private confirm | Archive (a)", + ( + "Actions: v reveal | g qr | qr private cfm | a archive" + if self._dense_hires_layout + else "Actions: Reveal (v) | QR (g) | qr private confirm | Archive (a)" + ), ] return "\n".join(lines) @@ -5411,6 +5555,47 @@ def launch_tui2( self._load_entries(query=self._last_query, reset_page=True) self._set_status(f"Applied filter: {self.filter_kind}") + def _open_palette_with_prefix(self, prefix: str, status: str) -> None: + if self.editing_document: + self._set_status("Finish document edit before opening shortcuts") + return + if self.help_open: + self.help_open = False + self._update_help_overlay() + self._set_palette_visible(True) + self._focus_pane = "center" + self._apply_focus_style() + palette = self.query_one("#command-palette", Input) + palette.value = prefix + palette.cursor_position = len(prefix) + self._set_status(status) + + def action_shortcut_settings(self) -> None: + self._open_palette_with_prefix("setting-", "Settings shortcut opened") + + def action_shortcut_add_entry(self) -> None: + self._open_palette_with_prefix("add-", "Add-entry shortcut opened") + + def action_shortcut_create_seed(self) -> None: + self._open_palette_with_prefix("add-seed ", "Create-seed shortcut opened") + + def action_shortcut_remove_seed(self) -> None: + self._open_palette_with_prefix("profile-remove ", "Remove-seed shortcut opened") + + def action_shortcut_hide_reveal(self) -> None: + self.action_reveal_selected() + + def action_shortcut_export_data(self) -> None: + self._open_palette_with_prefix("db-export ", "Export shortcut opened") + + def action_shortcut_import_data(self) -> None: + self._open_palette_with_prefix("db-import ", "Import shortcut opened") + + def action_shortcut_backup_data(self) -> None: + self._open_palette_with_prefix( + "parent-seed-backup ", "Backup shortcut opened" + ) + def action_cycle_search_mode(self) -> None: if self._semantic_service is None: self._set_status("Semantic service unavailable") @@ -5805,6 +5990,14 @@ def launch_tui2( if event.button.id == "sidebar-toggle": self.action_toggle_sidebar() + def on_click(self, event: Any) -> None: + target = getattr(event, "widget", None) or getattr(event, "control", None) + if getattr(target, "id", "") != "action-strip": + return + x = int(getattr(event, "x", -1) or -1) + y = int(getattr(event, "y", -1) or -1) + self._handle_action_strip_click(x, y) + def on_text_area_changed(self, _event: Any) -> None: self._mark_doc_dirty(True) diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 1ddec5c3..c20416cd 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -1317,11 +1317,16 @@ async def test_tui2_textual_hires_density_compacts_vertical_chrome() -> None: assert str(ribbon.styles.height) == "2" assert str(status.styles.height) == "2" assert str(action_strip.styles.height) == "2" - assert str(grid_heading.styles.height) == "2" + assert str(grid_heading.styles.height) == "3" assert "Dense" in _widget_text(app, "#action-strip") assert "S Set" in _widget_text(app, "#action-strip") assert "Keys:" in _widget_text(app, "#filters") assert "Profiles:" in _widget_text(app, "#filters") + assert "Density:" not in _widget_text(app, "#filters") + assert "Scope: filter=" in _widget_text(app, "#filters") + assert "Fingerprint:" in _widget_text(app, "#filters") + assert "Sel Id" in _widget_text(app, "#grid-heading") + assert "---" in _widget_text(app, "#grid-heading") app._update_responsive_layout(width=170, height=46) await pilot.pause() @@ -1333,6 +1338,34 @@ async def test_tui2_textual_hires_density_compacts_vertical_chrome() -> None: assert "Dense" not in _widget_text(app, "#action-strip") +@pytest.mark.anyio +async def test_tui2_textual_dense_mode_reduces_inspector_helper_noise() -> None: + service = FakeEntryService( + [ + { + "id": 1, + "kind": "document", + "label": "Doc 1", + "content": "hello world", + "tags": ["alpha"], + "notes": "n1", + } + ] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + app._update_responsive_layout(width=220, height=55) + await pilot.pause() + app._run_palette_command("open 1") + await pilot.pause() + board = _widget_text(app, "#entry-detail") + assert "Tags/Notes: side panel." in board + assert "Tags/Notes appear in right panel." not in board + assert "Actions: e edit | Ctrl+S save | Esc cancel | a archive" in board + + @pytest.mark.anyio async def test_tui2_textual_ssh_pgp_nostr_boards_show_action_fidelity() -> None: service = FakeEntryService( @@ -1467,6 +1500,7 @@ async def test_tui2_textual_note_and_totp_boards_include_common_metadata() -> No assert "Note Board" in board assert "Kind: document | Modified:" in board assert "Index Num*:" in board + assert "Document Fields" in board app._run_palette_command("open 2") await pilot.pause() @@ -1474,6 +1508,7 @@ async def test_tui2_textual_note_and_totp_boards_include_common_metadata() -> No assert "2FA Board" in board assert "Kind: totp | Modified:" in board assert "Index Num*:" in board + assert "2FA Fields" in board @pytest.mark.anyio @@ -1574,6 +1609,80 @@ async def test_tui2_textual_filter_menu_closes_on_escape_and_focus_actions() -> assert filter_input.has_class("hidden") +@pytest.mark.anyio +async def test_tui2_textual_shift_shortcuts_open_palette_prefixes() -> None: + service = FakeEntryService( + [{"id": 1, "kind": "password", "label": "Login 1", "length": 16}] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + palette = app.query_one("#command-palette", Input) + + app.action_shortcut_settings() + await pilot.pause() + assert not palette.has_class("hidden") + assert palette.value == "setting-" + + app.action_shortcut_create_seed() + await pilot.pause() + assert palette.value == "add-seed " + + app.action_shortcut_export_data() + await pilot.pause() + assert palette.value == "db-export " + + app.action_shortcut_import_data() + await pilot.pause() + assert palette.value == "db-import " + + app.action_shortcut_backup_data() + await pilot.pause() + assert palette.value == "parent-seed-backup " + + +@pytest.mark.anyio +async def test_tui2_textual_shortcut_hide_reveal_invokes_reveal_flow() -> None: + service = FakeEntryService( + [{"id": 1, "kind": "password", "label": "Login 1", "length": 16}] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + app._run_palette_command("open 1") + await pilot.pause() + app.action_shortcut_hide_reveal() + await pilot.pause() + assert "Password : pw-1-16" in _widget_text(app, "#secret-detail") + + +@pytest.mark.anyio +async def test_tui2_textual_action_strip_click_routes_to_shortcuts() -> None: + service = FakeEntryService( + [{"id": 1, "kind": "password", "label": "Login 1", "length": 16}] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + app._update_responsive_layout(width=220, height=55) + await pilot.pause() + line = _widget_text(app, "#action-strip").splitlines()[0] + col = line.find("Shift+E") + assert col >= 0 + handled = app._handle_action_strip_click(col + 1, 0) + await pilot.pause() + assert handled is True + palette = app.query_one("#command-palette", Input) + assert not palette.has_class("hidden") + assert palette.value == "db-export " + + # Second row click is intentionally ignored. + assert app._handle_action_strip_click(1, 1) is False + + @pytest.mark.anyio async def test_tui2_textual_inspector_side_is_context_aware() -> None: service = FakeEntryService( @@ -2524,6 +2633,29 @@ async def test_tui2_textual_profile_tree_can_open_managed_and_agent_children() - assert "Agent Alpha" in _filters_text(app) +@pytest.mark.anyio +async def test_tui2_textual_profile_tree_children_not_removed_by_kind_filter() -> None: + service = FakeEntryService( + [ + {"id": 1, "kind": "managed_account", "label": "Managed User 1"}, + {"id": 2, "kind": "nostr", "label": "Agent Alpha"}, + {"id": 3, "kind": "document", "label": "Doc 3", "content": "x"}, + ] + ) + profiles = FakeProfileService(["fp-a"]) + app = _build_app(service, profile_service=profiles) + + async with app.run_test() as pilot: + await pilot.pause() + app._run_palette_command("profile-switch fp-a") + await pilot.pause() + app._run_palette_command("filter docs") + await pilot.pause() + filters = _widget_text(app, "#filters") + assert "Managed User 1" in filters + assert "Agent Alpha" in filters + + @pytest.mark.anyio async def test_tui2_textual_search_mode_hotkey_cycles_and_updates_heading() -> None: service = FakeEntryService([{"id": 1, "kind": "document", "label": "Doc 1"}])