mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-08 05:42:27 +00:00
tui2: harden pane-state flow and dense layout parity
This commit is contained in:
1 parent
9fc8abccc8
commit
64a8b9f8b5
5 files changed
+1035
-99
No files matched your search
@@ -213,3 +213,34 @@ Rationale:
|
||||
- compact filter menu
|
||||
- reliable reveal/QR
|
||||
- dense readable layout at target resolution.
|
||||
|
||||
## 8) Progress Log (2026-03-02 Session)
|
||||
|
||||
Completed slices in this session:
|
||||
1. Phase A baseline: startup no longer forces first-row selection; neutral inspector empty state is shown.
|
||||
2. Phase B baseline: left tree now renders hierarchical managed-user/agent sections and supports sidebar collapse (`Ctrl+B` + button).
|
||||
3. Phase C baseline: top ribbon and action strip were simplified toward mockup rhythm; top ribbon now includes entry-type stats.
|
||||
4. Phase D baseline: filter controls moved behind menu flow (`f`) with presets (`all|secrets|docs|keys|2fa`), plus multi-kind comma filters.
|
||||
5. Phase D persistence: filter state now persists per active profile and restores on profile switch.
|
||||
6. Phase G baseline: sensitive panel now reports explicit states (`HIDDEN`, `REVEALED`, `QR`, `COPIED`) and clearer no-selection feedback.
|
||||
7. Phase H baseline: density tightened (reduced vertical chrome and panel padding) to increase effective content fit.
|
||||
8. Phase F partial: inspector side panels are context-aware (hidden when no selection; restored on selection).
|
||||
9. Phase B persistence: sidebar collapse/expand state now persists per active profile and restores on profile switch.
|
||||
10. Phase E slice: grid heading now exposes active search mode chips and hotkey mode-cycling (`m`) for keyword/hybrid/semantic workflows.
|
||||
11. Phase G slice: confirm-required reveal UX now supports direct keyboard double-confirm (`v` then `v`) with clear expiration guidance, reducing perceived no-op behavior.
|
||||
12. Phase B polish: profile tree labels now render cleanly with explicit `| Seed:` separators (fixing merged text artifacts in the left sidebar).
|
||||
13. Phase B slice: left tree navigation now traverses child nodes (managed/agent) and `profile-tree-open` can open selected child entries directly, not just switch profiles.
|
||||
14. Phase F slice: inspector side is now kind-aware; non-sensitive kinds (for example document/note) keep the secret panel hidden and return clear status messages for unsupported reveal/QR actions.
|
||||
15. Phase B polish: active profile branch now renders explicit child group headers (`Managed Users`, `Agents`) for clearer hierarchy scanning.
|
||||
16. CLI compatibility guard: added explicit regression tests for `seedpass legacy` and `seedpass --legacy-tui` launch paths to prevent fallback command regressions.
|
||||
17. Phase H slice: added high-resolution density mode (reduced top/bottom chrome heights and heading heights at large viewports) to improve functional content fit on 2256x1504-class screens.
|
||||
18. Phase H polish: removed extra default vertical whitespace by tightening status and 2FA board top margins.
|
||||
19. Phase F/H reliability slice: unified right-pane mode transitions so entry board, 2FA board, and document editor stay mutually exclusive with synchronized state flags.
|
||||
20. Phase F context guard: opening an entry now auto-closes 2FA board and entry switches are blocked while document edit mode is active to prevent unsaved-context drift.
|
||||
21. Phase H readability slice: dense high-res action strip now uses abbreviated labels to keep bottom command hints visible without clipping at large viewport widths.
|
||||
22. Phase F clarity slice: inspector heading is now context-aware and updates by mode (entry view, document editor, 2FA board) for clearer right-pane state awareness.
|
||||
|
||||
Validation status:
|
||||
1. Current focused TUI gate: `50 passed`
|
||||
2. Broader gate (CLI + TUI focused suites): `102 passed`
|
||||
3. Verification artifacts captured under `artifacts/ui_eval/audit_20260302_phase*/` (latest: `audit_20260302_phaseF5`).
|
||||
@@ -24,3 +24,49 @@
|
||||
- Created `docs/tui_v2_integration_execution_plan_2026-03-02.md` as the consolidated implementation roadmap.
|
||||
- Captured user-priority requirements: context-aware inspector lifecycle, true fingerprint/managed/agent tree, collapsible sidebar, filter menu replacing always-visible kind toggles, and unified lexical+tag+semantic search.
|
||||
- Reordered near-term execution toward structure and reliability first (Phases A-C, D, G, H), then semantic integration and final board-fidelity polish.
|
||||
|
||||
## Session Update (2026-03-02, evening)
|
||||
|
||||
### TUI v2 parity slices completed
|
||||
- Added per-profile sidebar collapse persistence in `src/seedpass/tui_v2/app.py` (state now restores on `profile-switch` and `profile-tree-open`).
|
||||
- Added search mode chips to grid heading and keyboard cycling (`m`) for `keyword -> hybrid -> semantic`.
|
||||
- Improved reveal UX for confirm-required secrets (`seed`, `managed_account`, `ssh`, `pgp`): pressing `v` now arms confirm and pressing `v` again (within 8s) confirms reveal.
|
||||
- Fixed profile tree label rendering glitch (`fp-aSeed:...`) by normalizing to `| Seed: ...` format.
|
||||
|
||||
### Regression coverage added
|
||||
- Sidebar state restore per profile.
|
||||
- Search-mode chip rendering + `m` hotkey cycle behavior.
|
||||
- Managed-account reveal double-confirm keyboard flow.
|
||||
- Profile-tree seed label formatting check.
|
||||
|
||||
### Verification status
|
||||
- Focused TUI v2 gate is green at `45 passed` for:
|
||||
- `src/tests/test_tui_v2_textual_interactions.py`
|
||||
- `src/tests/test_tui_v2_action_matrix.py`
|
||||
- New UI artifacts captured:
|
||||
- `artifacts/ui_eval/audit_20260302_phaseB2/`
|
||||
- `artifacts/ui_eval/audit_20260302_phaseE3/`
|
||||
- `artifacts/ui_eval/audit_20260302_phaseG2/`
|
||||
- `artifacts/ui_eval/audit_20260302_phaseB3/`
|
||||
|
||||
### Incremental parity progress (2026-03-02 late)
|
||||
- Left profile tree now supports child-node traversal and open actions for managed/agent rows via `↑/↓/Ctrl+O`.
|
||||
- Inspector behavior is more context-aware: non-sensitive kinds keep secret panel hidden by default and report clean unsupported-action status for `v`/`g`.
|
||||
- Tree rendering now includes explicit group headers (`Managed Users`, `Agents`) to match mockup hierarchy clarity.
|
||||
- Focused TUI gate remains stable at `46 passed` after these slices.
|
||||
- Added CLI regression tests for legacy launch compatibility (`seedpass legacy`, `seedpass --legacy-tui`) in `src/tests/test_typer_cli.py` to guard installer/runtime fallback behavior.
|
||||
- Added responsive high-resolution density mode for large viewports (notably 2256x1504 class), reducing chrome heights for brand/ribbon/status/action bars and heading rows to fit more working content.
|
||||
- Tightened default vertical whitespace by removing extra top margin on status and TOTP board panels.
|
||||
- Focused TUI gate after density updates: 47 passing tests.
|
||||
- Added regression check for unsupported QR behavior on non-sensitive kinds (`document`) so `g` returns an explicit status message instead of ambiguous UI behavior.
|
||||
- Added a unified right-pane mode switch path so document editor and 2FA board transitions remain mutually exclusive and state flags stay synchronized.
|
||||
- Added regression test covering 2FA board -> document editor -> view transitions.
|
||||
- Focused TUI gate now at 48 passing tests.
|
||||
- Added context guard so opening an entry auto-closes 2FA board and blocks entry switching while document editor is active (prevents unsaved context drift).
|
||||
- Added transition regression test for: 2FA board -> open entry (board closes) -> edit doc -> open other entry (blocked).
|
||||
- Focused TUI gate now at 49 passing tests.
|
||||
- Added dense high-res action strip mode with abbreviated global commands to reduce clipping and keep bottom controls readable on large viewports.
|
||||
- Added regression assertions for dense action-strip activation/deactivation tied to responsive layout thresholds.
|
||||
- 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.
|
||||
+516
-98
@@ -5,6 +5,7 @@ import json
|
||||
import shlex
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -204,7 +205,7 @@ def launch_tui2(
|
||||
border: heavy #58f29d;
|
||||
background: #11191f;
|
||||
color: #e4fff2;
|
||||
margin: 1 1 0 1;
|
||||
margin: 0 1 0 1;
|
||||
}
|
||||
#action-strip {
|
||||
height: 3;
|
||||
@@ -265,21 +266,21 @@ def launch_tui2(
|
||||
height: 1fr;
|
||||
overflow: auto;
|
||||
border: solid #58f29d;
|
||||
padding: 0 1;
|
||||
padding: 0;
|
||||
}
|
||||
#link-detail {
|
||||
min-height: 3;
|
||||
height: 2fr;
|
||||
overflow: auto;
|
||||
border: solid #2abf75;
|
||||
padding: 0 1;
|
||||
padding: 0;
|
||||
}
|
||||
#secret-detail {
|
||||
min-height: 3;
|
||||
height: 1fr;
|
||||
overflow: auto;
|
||||
border: solid #58f29d;
|
||||
padding: 0 1;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
@@ -288,7 +289,7 @@ def launch_tui2(
|
||||
overflow: auto;
|
||||
border: solid #2abf75;
|
||||
padding: 1;
|
||||
margin-top: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
#right-view { height: 1fr; }
|
||||
#right-editor { height: 1fr; }
|
||||
@@ -329,6 +330,7 @@ def launch_tui2(
|
||||
("3", "focus_right", "Right"),
|
||||
("f", "toggle_filter_menu", "Filter Menu"),
|
||||
("shift+f", "cycle_filter", "Filter"),
|
||||
("m", "cycle_search_mode", "Search Mode"),
|
||||
("d", "toggle_density", "Density"),
|
||||
("h", "cycle_archive_scope", "Archive View"),
|
||||
("up", "profile_tree_prev", "Profile Prev"),
|
||||
@@ -477,6 +479,7 @@ def launch_tui2(
|
||||
self._result_page = 0
|
||||
self._current_links: list[dict[str, Any]] = []
|
||||
self._current_link_cursor = 0
|
||||
self._search_reason_by_id: dict[int, str] = {}
|
||||
self._last_error: str | None = None
|
||||
self._retry_action: Any | None = None
|
||||
self._activity_log: list[str] = []
|
||||
@@ -493,11 +496,14 @@ def launch_tui2(
|
||||
self._profile_tree_items: list[str] = []
|
||||
self._profile_tree_cursor = 0
|
||||
self._compact_layout = False
|
||||
self._dense_hires_layout = False
|
||||
self._viewport_width = 0
|
||||
self._viewport_height = 0
|
||||
self._pending_sensitive_confirm: tuple[str, int, float] | None = None
|
||||
self._sidebar_collapsed = False
|
||||
self._active_profile_fp = (fingerprint or "").strip()
|
||||
self._profile_filter_by_fp: dict[str, str] = {}
|
||||
self._profile_sidebar_by_fp: dict[str, bool] = {}
|
||||
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
|
||||
try:
|
||||
self._service = (
|
||||
@@ -583,6 +589,7 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
|
||||
)
|
||||
self._update_inspector_heading()
|
||||
self._update_responsive_layout()
|
||||
self._load_entries()
|
||||
# Keep primary navigation on the entry list so global action keys
|
||||
@@ -718,15 +725,7 @@ def launch_tui2(
|
||||
if not profiles:
|
||||
profiles = [self._active_profile_key()]
|
||||
self._profile_tree_items = profiles
|
||||
active = self._active_profile_key()
|
||||
if active == "(default)" and "(default)" not in profiles:
|
||||
active = ""
|
||||
if active and active in profiles:
|
||||
self._profile_tree_cursor = profiles.index(active)
|
||||
else:
|
||||
self._profile_tree_cursor = min(
|
||||
max(0, self._profile_tree_cursor), max(0, len(profiles) - 1)
|
||||
)
|
||||
self._profile_tree_cursor = max(0, self._profile_tree_cursor)
|
||||
|
||||
def _profile_tree_child_nodes(self) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
||||
managed: list[tuple[int, str]] = []
|
||||
@@ -744,8 +743,58 @@ def launch_tui2(
|
||||
agent_like.append((entry_id, label))
|
||||
return managed, agent_like
|
||||
|
||||
def _profile_tree_visible_nodes(self) -> list[dict[str, Any]]:
|
||||
nodes: list[dict[str, Any]] = []
|
||||
profiles = list(self._profile_tree_items[:6])
|
||||
current_fp = self._active_profile_key()
|
||||
if current_fp == "(default)" and "(default)" not in profiles:
|
||||
current_fp = ""
|
||||
managed_nodes, agent_nodes = self._profile_tree_child_nodes()
|
||||
if not profiles:
|
||||
profiles = [current_fp or "(default)"]
|
||||
for fp in profiles:
|
||||
nodes.append({"kind": "profile", "fingerprint": fp})
|
||||
is_active_branch = fp == current_fp or (not current_fp and fp == "(default)")
|
||||
if not is_active_branch:
|
||||
continue
|
||||
for entry_id, label in managed_nodes[:3]:
|
||||
nodes.append(
|
||||
{
|
||||
"kind": "managed",
|
||||
"entry_id": int(entry_id),
|
||||
"label": str(label),
|
||||
}
|
||||
)
|
||||
for entry_id, label in agent_nodes[:3]:
|
||||
nodes.append(
|
||||
{
|
||||
"kind": "agent",
|
||||
"entry_id": int(entry_id),
|
||||
"label": str(label),
|
||||
}
|
||||
)
|
||||
return nodes
|
||||
|
||||
@staticmethod
|
||||
def _profile_tree_selection_text(node: dict[str, Any]) -> str:
|
||||
kind = str(node.get("kind", ""))
|
||||
if kind == "profile":
|
||||
return f"Profile selection: {str(node.get('fingerprint', ''))}"
|
||||
if kind == "managed":
|
||||
return (
|
||||
f"Managed selection: #{int(node.get('entry_id', 0))} "
|
||||
f"{str(node.get('label', ''))}"
|
||||
)
|
||||
if kind == "agent":
|
||||
return (
|
||||
f"Agent selection: #{int(node.get('entry_id', 0))} "
|
||||
f"{str(node.get('label', ''))}"
|
||||
)
|
||||
return "Tree selection updated"
|
||||
|
||||
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
|
||||
self._sidebar_collapsed = bool(collapsed)
|
||||
self._remember_sidebar_for_active_profile()
|
||||
left = self.query_one("#left", Vertical)
|
||||
toggle = self.query_one("#sidebar-toggle", Button)
|
||||
filters = self.query_one("#filters", Static)
|
||||
@@ -779,6 +828,19 @@ def launch_tui2(
|
||||
if self._focus_pane == "center":
|
||||
self.query_one("#entry-list", ListView).focus()
|
||||
|
||||
def _set_inspector_side_visible(self, visible: bool) -> None:
|
||||
link_detail = self.query_one("#link-detail", Static)
|
||||
secret_detail = self.query_one("#secret-detail", Static)
|
||||
if not visible:
|
||||
link_detail.add_class("hidden")
|
||||
secret_detail.add_class("hidden")
|
||||
return
|
||||
secret_detail.remove_class("hidden")
|
||||
if self._compact_layout:
|
||||
link_detail.add_class("hidden")
|
||||
else:
|
||||
link_detail.remove_class("hidden")
|
||||
|
||||
def _update_action_strip(self) -> None:
|
||||
kind = ""
|
||||
if isinstance(self._selected_entry, dict):
|
||||
@@ -817,12 +879,30 @@ def launch_tui2(
|
||||
context = context.replace("Reveal", "Rev")
|
||||
context = context.replace("Archive", "Arch")
|
||||
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 "
|
||||
"B Backup Ctrl+P Cmd Dense"
|
||||
)
|
||||
context = context.replace("Entry ▣ ", "")
|
||||
context = context.replace("Reveal", "Rev")
|
||||
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"
|
||||
text = f"{global_row}\n{context}"
|
||||
self.query_one("#action-strip", Static).update(text)
|
||||
|
||||
def _update_grid_heading(self) -> None:
|
||||
modes = ["keyword", "hybrid", "semantic"]
|
||||
chips = " ".join(
|
||||
[
|
||||
f"({item.upper()})"
|
||||
if str(self._semantic_mode).strip().lower() == item
|
||||
else item
|
||||
for item in modes
|
||||
]
|
||||
)
|
||||
table_cols = (
|
||||
"Sel Id Entry# Label Kind "
|
||||
"Meta Arch"
|
||||
@@ -830,7 +910,7 @@ def launch_tui2(
|
||||
metrics = (
|
||||
f"Pg {self._result_page + 1}/{self._total_pages()} "
|
||||
f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)} "
|
||||
f"Density {self._density_mode}"
|
||||
f"Density {self._density_mode} Search {chips}"
|
||||
)
|
||||
self.query_one("#grid-heading", Static).update(
|
||||
"\n".join(
|
||||
@@ -841,6 +921,19 @@ def launch_tui2(
|
||||
)
|
||||
)
|
||||
|
||||
def _update_inspector_heading(self) -> None:
|
||||
heading = "Inspector Board"
|
||||
if self.editing_document and self._selected_entry_id is not None:
|
||||
heading = f"Document Editor | Entry #{self._selected_entry_id}"
|
||||
elif self.totp_board_open:
|
||||
heading = "2FA Board | Live Codes"
|
||||
elif isinstance(self._selected_entry, dict) and self._selected_entry_id is not None:
|
||||
kind = self._entry_kind(self._selected_entry) or "unknown"
|
||||
label = str(self._selected_entry.get("label", "")).strip()
|
||||
suffix = f" {label}" if label else ""
|
||||
heading = f"Inspector | #{self._selected_entry_id} ({kind}){suffix}"
|
||||
self.query_one("#inspector-heading", Static).update(heading)
|
||||
|
||||
def _quickstart_text(self) -> str:
|
||||
return self._onboarding_text()
|
||||
|
||||
@@ -929,6 +1022,7 @@ def launch_tui2(
|
||||
"TUI v2 Quick Help (Esc to close)",
|
||||
"",
|
||||
"Core : / search j jump-id p/n page f filter menu Shift+F kind cycle h archive scope r refresh",
|
||||
"Search : m cycle search mode (keyword/hybrid/semantic)",
|
||||
"Modes : Ctrl+P palette e edit-doc Ctrl+S save Esc cancel/close",
|
||||
"Graph ⚯ : l relation filter brackets link select o open link target",
|
||||
"Secrets 🔑: v reveal selected secret g QR for selected entry",
|
||||
@@ -962,13 +1056,18 @@ def launch_tui2(
|
||||
self._viewport_width = width
|
||||
self._viewport_height = height
|
||||
compact = width > 0 and width < 150
|
||||
dense_hires = width >= 200 and height >= 50
|
||||
previous = bool(getattr(self, "_compact_layout", False))
|
||||
previous_dense = bool(getattr(self, "_dense_hires_layout", False))
|
||||
try:
|
||||
link_detail = self.query_one("#link-detail", Static)
|
||||
except Exception:
|
||||
self._compact_layout = compact
|
||||
self._dense_hires_layout = dense_hires
|
||||
return
|
||||
if compact:
|
||||
if getattr(self, "_selected_entry_id", None) is None:
|
||||
link_detail.add_class("hidden")
|
||||
elif compact:
|
||||
link_detail.add_class("hidden")
|
||||
else:
|
||||
link_detail.remove_class("hidden")
|
||||
@@ -976,6 +1075,12 @@ def launch_tui2(
|
||||
top_work = self.query_one("#top-work", Horizontal)
|
||||
right = self.query_one("#right", Vertical)
|
||||
activity = self.query_one("#activity", Static)
|
||||
brand = self.query_one("#brand-strip", Static)
|
||||
ribbon = self.query_one("#top-ribbon", Static)
|
||||
status = self.query_one("#status", Static)
|
||||
action_strip = self.query_one("#action-strip", Static)
|
||||
grid_heading = self.query_one("#grid-heading", Static)
|
||||
inspector_heading = self.query_one("#inspector-heading", Static)
|
||||
if height > 0 and height < 32:
|
||||
top_work.styles.height = "6fr"
|
||||
right.styles.height = "5fr"
|
||||
@@ -990,11 +1095,30 @@ def launch_tui2(
|
||||
right.styles.height = "4fr"
|
||||
activity.remove_class("hidden")
|
||||
activity.styles.height = 3
|
||||
|
||||
# Dense high-resolution mode optimized for larger laptop/desktop
|
||||
# viewports (e.g. 2256x1504) to increase content fit.
|
||||
if dense_hires:
|
||||
brand.styles.height = 1
|
||||
ribbon.styles.height = 2
|
||||
status.styles.height = 2
|
||||
action_strip.styles.height = 2
|
||||
grid_heading.styles.height = 2
|
||||
inspector_heading.styles.height = 2
|
||||
else:
|
||||
brand.styles.height = 3
|
||||
ribbon.styles.height = 3
|
||||
status.styles.height = 3
|
||||
action_strip.styles.height = 3
|
||||
grid_heading.styles.height = 4
|
||||
inspector_heading.styles.height = 3
|
||||
except Exception:
|
||||
pass
|
||||
if compact != previous:
|
||||
self._dense_hires_layout = dense_hires
|
||||
if compact != previous or dense_hires != previous_dense:
|
||||
self._compact_layout = compact
|
||||
try:
|
||||
self._update_filters_panel()
|
||||
self._update_action_strip()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1062,6 +1186,17 @@ def launch_tui2(
|
||||
raw = self._profile_filter_by_fp.get(key, default_filter)
|
||||
self.filter_kind = self._normalize_filter_kind(raw)
|
||||
|
||||
def _remember_sidebar_for_active_profile(self) -> None:
|
||||
key = self._active_profile_key()
|
||||
self._profile_sidebar_by_fp[key] = bool(self._sidebar_collapsed)
|
||||
|
||||
def _restore_sidebar_for_active_profile(
|
||||
self, *, default_collapsed: bool = False
|
||||
) -> None:
|
||||
key = self._active_profile_key()
|
||||
collapsed = bool(self._profile_sidebar_by_fp.get(key, default_collapsed))
|
||||
self._set_sidebar_collapsed(collapsed)
|
||||
|
||||
def _normalize_filter_kind(self, value: str) -> str:
|
||||
token = (value or "all").strip().lower()
|
||||
if token in self.FILTER_PRESETS:
|
||||
@@ -1118,41 +1253,43 @@ def launch_tui2(
|
||||
def _update_filters_panel(self) -> None:
|
||||
self._refresh_profile_tree()
|
||||
tree_lines = ["Profile Tree", "------------"]
|
||||
profiles = list(self._profile_tree_items)
|
||||
current_fp = self._active_profile_key()
|
||||
if current_fp == "(default)" and "(default)" not in profiles:
|
||||
current_fp = ""
|
||||
managed_nodes, agent_nodes = self._profile_tree_child_nodes()
|
||||
if profiles:
|
||||
for idx, item in enumerate(profiles[:6]):
|
||||
marker = "■" if item == current_fp else "□"
|
||||
nodes = self._profile_tree_visible_nodes()
|
||||
if nodes:
|
||||
self._profile_tree_cursor = min(
|
||||
max(0, self._profile_tree_cursor), len(nodes) - 1
|
||||
)
|
||||
rendered_managed_header = False
|
||||
rendered_agent_header = False
|
||||
for idx, node in enumerate(nodes):
|
||||
cursor = "▶" if idx == self._profile_tree_cursor else " "
|
||||
item_name = f"{item[:12]}Seed"
|
||||
tree_lines.append(f"{cursor} {marker} {item_name}:{item[:10]}")
|
||||
is_active_branch = (
|
||||
item == current_fp
|
||||
or (not current_fp and idx == self._profile_tree_cursor)
|
||||
)
|
||||
if not is_active_branch:
|
||||
continue
|
||||
tree_lines.append(" ├─ 👤 Managed Users")
|
||||
if managed_nodes:
|
||||
for entry_id, label in managed_nodes[:3]:
|
||||
tree_lines.append(f" │ └─ #{entry_id} {label[:20]}")
|
||||
else:
|
||||
tree_lines.append(" │ └─ (none)")
|
||||
tree_lines.append(" └─ 🤖 Agents")
|
||||
if agent_nodes:
|
||||
for entry_id, label in agent_nodes[:3]:
|
||||
tree_lines.append(f" └─ #{entry_id} {label[:20]}")
|
||||
else:
|
||||
tree_lines.append(" └─ (none)")
|
||||
kind = str(node.get("kind", ""))
|
||||
if kind == "profile":
|
||||
rendered_managed_header = False
|
||||
rendered_agent_header = False
|
||||
item = str(node.get("fingerprint", ""))
|
||||
marker = "■" if item == current_fp else "□"
|
||||
label = item if item != "(default)" else "default"
|
||||
fp_short = item if len(item) <= 12 else f"{item[:9]}..."
|
||||
tree_lines.append(
|
||||
f"{cursor} {marker} {label[:16]:<16} | Seed: {fp_short}"
|
||||
)
|
||||
elif kind == "managed":
|
||||
if not rendered_managed_header:
|
||||
tree_lines.append(" 👤 Managed Users")
|
||||
rendered_managed_header = True
|
||||
entry_id = int(node.get("entry_id", 0))
|
||||
label = str(node.get("label", ""))[:20]
|
||||
tree_lines.append(f"{cursor} #{entry_id:<4} {label}")
|
||||
elif kind == "agent":
|
||||
if not rendered_agent_header:
|
||||
tree_lines.append(" 🤖 Agents")
|
||||
rendered_agent_header = True
|
||||
entry_id = int(node.get("entry_id", 0))
|
||||
label = str(node.get("label", ""))[:20]
|
||||
tree_lines.append(f"{cursor} #{entry_id:<4} {label}")
|
||||
else:
|
||||
tree_lines.append(f"▶ ■ default:{(current_fp or '(default)')[:10]}")
|
||||
tree_lines.append(" ├─ 👤 Managed Users")
|
||||
tree_lines.append(" │ └─ (none)")
|
||||
tree_lines.append(" └─ 🤖 Agents")
|
||||
tree_lines.append(" └─ (none)")
|
||||
tree_lines.append("▶ ■ default | Seed: (default)")
|
||||
|
||||
fp_line = (
|
||||
f"Fingerprint: {self._active_profile_key()}"
|
||||
@@ -1165,10 +1302,18 @@ def launch_tui2(
|
||||
if self._managed_session_entry_id is not None
|
||||
else "(none)"
|
||||
)
|
||||
profiles_loaded = len(self._profile_tree_items) if self._profile_tree_items else 1
|
||||
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"
|
||||
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(
|
||||
[
|
||||
"SeedPass ◈ TUI v2",
|
||||
fp_line,
|
||||
f"Profiles: {profiles_loaded}",
|
||||
"",
|
||||
self._selected_summary(),
|
||||
f"Filter : {self.filter_kind}",
|
||||
@@ -1184,8 +1329,8 @@ def launch_tui2(
|
||||
"",
|
||||
*tree_lines,
|
||||
"",
|
||||
"Nav: / j f(menu) Shift+F(cycle) h p/n 1/2/3 ↑/↓ Ctrl+O Ctrl+B ? r x",
|
||||
"Act: Ctrl+P a e Ctrl+S Esc l [ ] o v g 6 (v confirm for seed/ssh/pgp)",
|
||||
nav_line,
|
||||
act_line,
|
||||
]
|
||||
)
|
||||
self.query_one("#filters", Static).update(text)
|
||||
@@ -1215,6 +1360,11 @@ def launch_tui2(
|
||||
meta = str(username).replace("\n", " ").strip()
|
||||
else:
|
||||
meta = "-"
|
||||
reason = self._search_reason_by_id.get(int(idx), "")
|
||||
if reason == "semantic":
|
||||
meta = f"sem:{meta}"
|
||||
elif reason == "hybrid":
|
||||
meta = f"mix:{meta}"
|
||||
meta = meta[:meta_limit]
|
||||
arch = "YES" if archived else "NO"
|
||||
selected = "▌" if self._selected_entry_id == idx else " "
|
||||
@@ -1341,6 +1491,7 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Select an entry, then use 'v' (reveal) or 'g' (QR)."
|
||||
)
|
||||
self._set_inspector_side_visible(False)
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
if is_empty_vault:
|
||||
@@ -1388,6 +1539,7 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Select an entry, then use 'v' (reveal) or 'g' (QR)."
|
||||
)
|
||||
self._set_inspector_side_visible(False)
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
try:
|
||||
@@ -1409,6 +1561,31 @@ def launch_tui2(
|
||||
str(entry.get("kind") or entry.get("type") or "unknown").strip().lower()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _entry_uses_sensitive_panel(kind: str) -> bool:
|
||||
return kind in {
|
||||
"password",
|
||||
"stored_password",
|
||||
"seed",
|
||||
"managed_account",
|
||||
"totp",
|
||||
"ssh",
|
||||
"pgp",
|
||||
"nostr",
|
||||
}
|
||||
|
||||
def _apply_entry_inspector_visibility(self, entry: dict[str, Any] | None) -> None:
|
||||
if not isinstance(entry, dict):
|
||||
self._set_inspector_side_visible(False)
|
||||
return
|
||||
self._set_inspector_side_visible(True)
|
||||
secret_detail = self.query_one("#secret-detail", Static)
|
||||
kind = self._entry_kind(entry)
|
||||
if self._entry_uses_sensitive_panel(kind):
|
||||
secret_detail.remove_class("hidden")
|
||||
else:
|
||||
secret_detail.add_class("hidden")
|
||||
|
||||
@staticmethod
|
||||
def _entry_tags_text(entry: dict[str, Any]) -> str:
|
||||
tags = entry.get("tags")
|
||||
@@ -1742,6 +1919,104 @@ def launch_tui2(
|
||||
f"{json.dumps(entry, indent=2, sort_keys=True)}"
|
||||
)
|
||||
|
||||
def _entry_kind_from_row(self, row: tuple[Any, ...]) -> str:
|
||||
if len(row) < 6:
|
||||
return ""
|
||||
kind_obj = row[5]
|
||||
return str(getattr(kind_obj, "value", kind_obj)).strip().lower()
|
||||
|
||||
def _search_rows_with_semantic(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
include_archived: bool,
|
||||
archived_only: bool,
|
||||
) -> tuple[list[tuple[Any, ...]], dict[int, str]]:
|
||||
rows = list(
|
||||
self._service.search_entries(
|
||||
query,
|
||||
kinds=self._current_filter_kinds(),
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
)
|
||||
reasons: dict[int, str] = {}
|
||||
for row in rows:
|
||||
if len(row) >= 1:
|
||||
reasons[int(row[0])] = "keyword"
|
||||
|
||||
mode = str(self._semantic_mode or "keyword").strip().lower()
|
||||
if (
|
||||
not query.strip()
|
||||
or mode == "keyword"
|
||||
or self._semantic_service is None
|
||||
):
|
||||
return rows, reasons
|
||||
|
||||
searcher = getattr(self._semantic_service, "search", None)
|
||||
if not callable(searcher):
|
||||
return rows, reasons
|
||||
|
||||
kind_filter = self._current_filter_kinds()
|
||||
semantic_rows: list[tuple[Any, ...]] = []
|
||||
try:
|
||||
matches = list(searcher(query, k=50, kind=None, mode=mode) or [])
|
||||
except Exception:
|
||||
return rows, reasons
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
entry_id = int(match.get("entry_id", 0))
|
||||
except Exception:
|
||||
continue
|
||||
if entry_id <= 0:
|
||||
continue
|
||||
try:
|
||||
entry = self._service.retrieve_entry(entry_id)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(entry, dict) or not entry:
|
||||
continue
|
||||
kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
|
||||
if kind_filter and kind not in kind_filter:
|
||||
continue
|
||||
archived = bool(entry.get("archived", False))
|
||||
if archived_only and not archived:
|
||||
continue
|
||||
if not include_archived and archived:
|
||||
continue
|
||||
label = str(entry.get("label") or match.get("label") or f"entry-{entry_id}")
|
||||
username = entry.get("username")
|
||||
url = entry.get("url")
|
||||
semantic_rows.append(
|
||||
(
|
||||
entry_id,
|
||||
label,
|
||||
username,
|
||||
url,
|
||||
archived,
|
||||
SimpleNamespace(value=kind or "unknown"),
|
||||
)
|
||||
)
|
||||
current = reasons.get(entry_id)
|
||||
reasons[entry_id] = "hybrid" if current == "keyword" else "semantic"
|
||||
|
||||
if mode == "semantic":
|
||||
dedup: dict[int, tuple[Any, ...]] = {}
|
||||
for row in semantic_rows:
|
||||
dedup[int(row[0])] = row
|
||||
return list(dedup.values()), reasons
|
||||
|
||||
# Hybrid mode: keyword rows first, then semantic-only rows.
|
||||
known_ids = {int(row[0]) for row in rows if len(row) >= 1}
|
||||
for row in semantic_rows:
|
||||
entry_id = int(row[0])
|
||||
if entry_id in known_ids:
|
||||
continue
|
||||
rows.append(row)
|
||||
known_ids.add(entry_id)
|
||||
return rows, reasons
|
||||
|
||||
def _load_entries(self, query: str = "", *, reset_page: bool = False) -> None:
|
||||
self._last_query = query
|
||||
if self._service is None:
|
||||
@@ -1752,7 +2027,9 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data unavailable: entry service missing."
|
||||
)
|
||||
self._set_inspector_side_visible(False)
|
||||
self._all_results = []
|
||||
self._search_reason_by_id = {}
|
||||
self._result_page = 0
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
@@ -1761,9 +2038,8 @@ def launch_tui2(
|
||||
|
||||
try:
|
||||
include_archived, archived_only = self._current_archive_scope_flags()
|
||||
results = self._service.search_entries(
|
||||
query,
|
||||
kinds=self._current_filter_kinds(),
|
||||
results, reasons = self._search_rows_with_semantic(
|
||||
query=query,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
@@ -1775,7 +2051,9 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data unavailable: failed to load entries."
|
||||
)
|
||||
self._set_inspector_side_visible(False)
|
||||
self._all_results = []
|
||||
self._search_reason_by_id = {}
|
||||
self._result_page = 0
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
@@ -1791,6 +2069,7 @@ def launch_tui2(
|
||||
return
|
||||
|
||||
self._all_results = list(results)
|
||||
self._search_reason_by_id = dict(reasons)
|
||||
self._clear_failure()
|
||||
if reset_page:
|
||||
self._result_page = 0
|
||||
@@ -1800,8 +2079,13 @@ def launch_tui2(
|
||||
if self._session_locked:
|
||||
self._set_status("Vault is locked. Run: unlock <password>")
|
||||
return
|
||||
if self.editing_document:
|
||||
self._set_status("Finish document edit before opening another entry")
|
||||
return
|
||||
if self._service is None:
|
||||
return
|
||||
if self.totp_board_open:
|
||||
self._set_totp_board_visible(False)
|
||||
try:
|
||||
entry = self._service.retrieve_entry(entry_index)
|
||||
if not isinstance(entry, dict):
|
||||
@@ -1815,11 +2099,18 @@ def launch_tui2(
|
||||
return
|
||||
self._selected_entry_id = int(entry_index)
|
||||
self._selected_entry = dict(entry)
|
||||
self._pending_sensitive_confirm = None
|
||||
body = self._entry_detail_text(entry)
|
||||
self.query_one("#entry-detail", Static).update(body)
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
|
||||
)
|
||||
kind = self._entry_kind(entry)
|
||||
if self._entry_uses_sensitive_panel(kind):
|
||||
self._set_secret_panel(
|
||||
"Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦."
|
||||
)
|
||||
else:
|
||||
self._set_secret_panel("No sensitive reveal for this entry kind.", state=None)
|
||||
self._apply_entry_inspector_visibility(entry)
|
||||
self._update_inspector_heading()
|
||||
self._update_links_panel()
|
||||
self._update_filters_panel()
|
||||
self._set_status(f"Selected entry {entry_index}")
|
||||
@@ -1831,6 +2122,8 @@ def launch_tui2(
|
||||
self._set_secret_panel(
|
||||
"Sensitive data unavailable: failed to load selected entry."
|
||||
)
|
||||
self._apply_entry_inspector_visibility(None)
|
||||
self._update_inspector_heading()
|
||||
self._current_links = []
|
||||
self._current_link_cursor = 0
|
||||
self._record_failure(
|
||||
@@ -2223,6 +2516,24 @@ def launch_tui2(
|
||||
return kind == "nostr" and qr_mode == "private"
|
||||
return kind in {"seed", "managed_account", "ssh", "pgp"}
|
||||
|
||||
def _consume_pending_sensitive_confirm(
|
||||
self, *, action: str, entry_id: int, ttl_seconds: float = 8.0
|
||||
) -> bool:
|
||||
pending = self._pending_sensitive_confirm
|
||||
if pending is None:
|
||||
return False
|
||||
pending_action, pending_entry_id, ts = pending
|
||||
now = float(self._time_now())
|
||||
if (
|
||||
pending_action == action
|
||||
and int(pending_entry_id) == int(entry_id)
|
||||
and (now - float(ts)) <= ttl_seconds
|
||||
):
|
||||
self._pending_sensitive_confirm = None
|
||||
return True
|
||||
self._pending_sensitive_confirm = None
|
||||
return False
|
||||
|
||||
def _show_sensitive_panel(
|
||||
self, *, include_qr: bool, qr_mode: str = "default", confirm: bool = False
|
||||
) -> None:
|
||||
@@ -2313,17 +2624,49 @@ def launch_tui2(
|
||||
self._set_status("Revealed selected sensitive data")
|
||||
|
||||
def _set_document_editor_visible(self, visible: bool) -> None:
|
||||
view = self.query_one("#right-view", Vertical)
|
||||
editor = self.query_one("#right-editor", Vertical)
|
||||
if visible:
|
||||
view.add_class("hidden")
|
||||
editor.remove_class("hidden")
|
||||
else:
|
||||
editor.add_class("hidden")
|
||||
view.remove_class("hidden")
|
||||
self.editing_document = visible
|
||||
self._set_right_pane_mode("edit" if visible else "view")
|
||||
self._focus_pane = "right"
|
||||
self._apply_focus_style()
|
||||
self._update_inspector_heading()
|
||||
|
||||
def _set_right_pane_mode(self, mode: str) -> None:
|
||||
view = self.query_one("#right-view", Vertical)
|
||||
editor = self.query_one("#right-editor", Vertical)
|
||||
board = self.query_one("#totp-board", Static)
|
||||
entry_detail = self.query_one("#entry-detail", Static)
|
||||
mode_token = str(mode or "view").strip().lower()
|
||||
if mode_token == "edit":
|
||||
view.add_class("hidden")
|
||||
editor.remove_class("hidden")
|
||||
board.add_class("hidden")
|
||||
entry_detail.remove_class("hidden")
|
||||
self.totp_board_open = False
|
||||
self.editing_document = True
|
||||
self._update_inspector_heading()
|
||||
return
|
||||
if mode_token == "totp":
|
||||
editor.add_class("hidden")
|
||||
view.remove_class("hidden")
|
||||
entry_detail.add_class("hidden")
|
||||
self._set_inspector_side_visible(False)
|
||||
board.remove_class("hidden")
|
||||
self.totp_board_open = True
|
||||
self.editing_document = False
|
||||
self._refresh_totp_board(force_reload=True)
|
||||
self._update_inspector_heading()
|
||||
return
|
||||
# Default "view": entry board + side panels, no editor/2FA board.
|
||||
if mode_token != "view":
|
||||
mode_token = "view"
|
||||
if mode_token == "view":
|
||||
editor.add_class("hidden")
|
||||
view.remove_class("hidden")
|
||||
board.add_class("hidden")
|
||||
entry_detail.remove_class("hidden")
|
||||
self._apply_entry_inspector_visibility(self._selected_entry)
|
||||
self.totp_board_open = False
|
||||
self.editing_document = False
|
||||
self._update_inspector_heading()
|
||||
|
||||
def _set_palette_visible(self, visible: bool) -> None:
|
||||
palette = self.query_one("#command-palette", Input)
|
||||
@@ -2337,23 +2680,10 @@ def launch_tui2(
|
||||
self._update_help_overlay()
|
||||
|
||||
def _set_totp_board_visible(self, visible: bool) -> None:
|
||||
board = self.query_one("#totp-board", Static)
|
||||
entry_detail = self.query_one("#entry-detail", Static)
|
||||
link_detail = self.query_one("#link-detail", Static)
|
||||
secret_detail = self.query_one("#secret-detail", Static)
|
||||
if visible:
|
||||
entry_detail.add_class("hidden")
|
||||
link_detail.add_class("hidden")
|
||||
secret_detail.add_class("hidden")
|
||||
board.remove_class("hidden")
|
||||
self.totp_board_open = True
|
||||
self._refresh_totp_board(force_reload=True)
|
||||
self._set_right_pane_mode("totp")
|
||||
else:
|
||||
board.add_class("hidden")
|
||||
entry_detail.remove_class("hidden")
|
||||
link_detail.remove_class("hidden")
|
||||
secret_detail.remove_class("hidden")
|
||||
self.totp_board_open = False
|
||||
self._set_right_pane_mode("view")
|
||||
|
||||
@staticmethod
|
||||
def _totp_source(entry: dict[str, Any]) -> str:
|
||||
@@ -3634,11 +3964,13 @@ def launch_tui2(
|
||||
try:
|
||||
from seedpass.core.api import ProfileSwitchRequest
|
||||
|
||||
self._remember_sidebar_for_active_profile()
|
||||
self._profile_service.switch_profile(
|
||||
ProfileSwitchRequest(fingerprint=fp, password=password)
|
||||
)
|
||||
self._active_profile_fp = fp
|
||||
self._restore_filter_for_active_profile(default_filter="all")
|
||||
self._restore_sidebar_for_active_profile(default_collapsed=False)
|
||||
self._clear_failure()
|
||||
self._load_entries(self._last_query, reset_page=True)
|
||||
self._refresh_profile_tree()
|
||||
@@ -3989,6 +4321,7 @@ def launch_tui2(
|
||||
setter(mode)
|
||||
self._refresh_semantic_state()
|
||||
self._update_top_ribbon()
|
||||
self._update_grid_heading()
|
||||
self._clear_failure()
|
||||
self._set_status(f"Search mode set to {mode}")
|
||||
except Exception as exc:
|
||||
@@ -4917,15 +5250,14 @@ def launch_tui2(
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
if not self._profile_tree_items:
|
||||
nodes = self._profile_tree_visible_nodes()
|
||||
if not nodes:
|
||||
self._set_status("No profiles available")
|
||||
return
|
||||
self._profile_tree_cursor = (self._profile_tree_cursor + 1) % len(
|
||||
self._profile_tree_items
|
||||
)
|
||||
selected = self._profile_tree_items[self._profile_tree_cursor]
|
||||
self._profile_tree_cursor = (self._profile_tree_cursor + 1) % len(nodes)
|
||||
selected = nodes[self._profile_tree_cursor]
|
||||
self._update_filters_panel()
|
||||
self._set_status(f"Profile selection: {selected}")
|
||||
self._set_status(self._profile_tree_selection_text(selected))
|
||||
|
||||
def action_profile_tree_prev(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
@@ -4933,29 +5265,45 @@ def launch_tui2(
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
if not self._profile_tree_items:
|
||||
nodes = self._profile_tree_visible_nodes()
|
||||
if not nodes:
|
||||
self._set_status("No profiles available")
|
||||
return
|
||||
self._profile_tree_cursor = (self._profile_tree_cursor - 1) % len(
|
||||
self._profile_tree_items
|
||||
)
|
||||
selected = self._profile_tree_items[self._profile_tree_cursor]
|
||||
self._profile_tree_cursor = (self._profile_tree_cursor - 1) % len(nodes)
|
||||
selected = nodes[self._profile_tree_cursor]
|
||||
self._update_filters_panel()
|
||||
self._set_status(f"Profile selection: {selected}")
|
||||
self._set_status(self._profile_tree_selection_text(selected))
|
||||
|
||||
def action_profile_tree_open(self) -> None:
|
||||
if self._sidebar_collapsed:
|
||||
return
|
||||
if self._focus_pane != "left":
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
nodes = self._profile_tree_visible_nodes()
|
||||
if not nodes:
|
||||
self._set_status("No profiles available")
|
||||
return
|
||||
self._profile_tree_cursor = min(
|
||||
max(0, self._profile_tree_cursor), len(nodes) - 1
|
||||
)
|
||||
selected = nodes[self._profile_tree_cursor]
|
||||
selected_kind = str(selected.get("kind", ""))
|
||||
if selected_kind in {"managed", "agent"}:
|
||||
target_entry = int(selected.get("entry_id", 0))
|
||||
if target_entry <= 0:
|
||||
self._set_status("Selected tree entry is invalid")
|
||||
return
|
||||
self._show_entry(target_entry)
|
||||
self._focus_pane = "center"
|
||||
self._apply_focus_style()
|
||||
self.query_one("#entry-list", ListView).focus()
|
||||
self._set_status(f"Opened tree entry {target_entry}")
|
||||
return
|
||||
if self._profile_service is None:
|
||||
self._set_status("Profile service unavailable")
|
||||
return
|
||||
self._refresh_profile_tree()
|
||||
if not self._profile_tree_items:
|
||||
self._set_status("No profiles available")
|
||||
return
|
||||
target = self._profile_tree_items[self._profile_tree_cursor]
|
||||
target = str(selected.get("fingerprint", ""))
|
||||
if target == "(default)":
|
||||
self._set_status("No switch needed for default profile")
|
||||
return
|
||||
@@ -4965,11 +5313,13 @@ def launch_tui2(
|
||||
try:
|
||||
from seedpass.core.api import ProfileSwitchRequest
|
||||
|
||||
self._remember_sidebar_for_active_profile()
|
||||
self._profile_service.switch_profile(
|
||||
ProfileSwitchRequest(fingerprint=target, password=None)
|
||||
)
|
||||
self._active_profile_fp = target
|
||||
self._restore_filter_for_active_profile(default_filter="all")
|
||||
self._restore_sidebar_for_active_profile(default_collapsed=False)
|
||||
self._clear_failure()
|
||||
self._load_entries(self._last_query, reset_page=True)
|
||||
self._refresh_profile_tree()
|
||||
@@ -5061,6 +5411,34 @@ def launch_tui2(
|
||||
self._load_entries(query=self._last_query, reset_page=True)
|
||||
self._set_status(f"Applied filter: {self.filter_kind}")
|
||||
|
||||
def action_cycle_search_mode(self) -> None:
|
||||
if self._semantic_service is None:
|
||||
self._set_status("Semantic service unavailable")
|
||||
return
|
||||
modes = ["keyword", "hybrid", "semantic"]
|
||||
current = str(self._semantic_mode or "keyword").strip().lower()
|
||||
idx = modes.index(current) if current in modes else 0
|
||||
target = modes[(idx + 1) % len(modes)]
|
||||
setter = getattr(self._semantic_service, "set_mode", None)
|
||||
if not callable(setter):
|
||||
self._set_status("Semantic service does not support search mode")
|
||||
return
|
||||
try:
|
||||
setter(target)
|
||||
except Exception as exc:
|
||||
self._record_failure(
|
||||
"search-mode failed",
|
||||
exc,
|
||||
retry=self.action_cycle_search_mode,
|
||||
hint="Press 'x' to retry.",
|
||||
)
|
||||
return
|
||||
self._refresh_semantic_state()
|
||||
self._update_top_ribbon()
|
||||
self._update_grid_heading()
|
||||
self._clear_failure()
|
||||
self._set_status(f"Search mode set to {self._semantic_mode}")
|
||||
|
||||
def action_cycle_archive_scope(self) -> None:
|
||||
if self.editing_document:
|
||||
self._set_status("Finish document edit before changing archive scope")
|
||||
@@ -5187,12 +5565,46 @@ def launch_tui2(
|
||||
self._set_status("Vault is locked. Run: unlock <password>")
|
||||
return
|
||||
if not self._select_highlighted_entry_for_sensitive_action():
|
||||
self._set_inspector_side_visible(True)
|
||||
self._set_secret_panel(
|
||||
"No entry selected.\n\nSelect an entry, then press 'v' to reveal.",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status("No entry selected")
|
||||
return
|
||||
if isinstance(self._selected_entry, dict):
|
||||
kind = self._entry_kind(self._selected_entry)
|
||||
if not self._entry_uses_sensitive_panel(kind):
|
||||
self._set_status(f"Reveal not supported for kind: {kind}")
|
||||
return
|
||||
if not confirm and isinstance(self._selected_entry, dict):
|
||||
kind = self._entry_kind(self._selected_entry)
|
||||
entry_id = int(self._selected_entry_id or 0)
|
||||
requires_confirm = self._requires_confirm(
|
||||
kind=kind, include_qr=False, qr_mode="default"
|
||||
)
|
||||
if requires_confirm:
|
||||
shortcut_confirmed = self._consume_pending_sensitive_confirm(
|
||||
action="reveal", entry_id=entry_id
|
||||
)
|
||||
if not shortcut_confirmed:
|
||||
self._pending_sensitive_confirm = (
|
||||
"reveal",
|
||||
entry_id,
|
||||
float(self._time_now()),
|
||||
)
|
||||
self._set_secret_panel(
|
||||
"Sensitive reveal hidden.\n"
|
||||
"Confirmation required for this action.\n"
|
||||
"Press 'v' again to confirm (expires in 8s), "
|
||||
"or run: reveal confirm",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status(
|
||||
"Sensitive reveal requires confirmation. Press 'v' again."
|
||||
)
|
||||
return
|
||||
confirm = True
|
||||
self._show_sensitive_panel(include_qr=False, confirm=confirm)
|
||||
|
||||
def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None:
|
||||
@@ -5200,12 +5612,18 @@ def launch_tui2(
|
||||
self._set_status("Vault is locked. Run: unlock <password>")
|
||||
return
|
||||
if not self._select_highlighted_entry_for_sensitive_action():
|
||||
self._set_inspector_side_visible(True)
|
||||
self._set_secret_panel(
|
||||
"No entry selected.\n\nSelect an entry, then press 'g' to show QR.",
|
||||
state="HIDDEN",
|
||||
)
|
||||
self._set_status("No entry selected")
|
||||
return
|
||||
if isinstance(self._selected_entry, dict):
|
||||
kind = self._entry_kind(self._selected_entry)
|
||||
if not self._entry_uses_sensitive_panel(kind):
|
||||
self._set_status(f"QR not supported for kind: {kind}")
|
||||
return
|
||||
self._show_sensitive_panel(include_qr=True, qr_mode=mode, confirm=confirm)
|
||||
|
||||
def action_toggle_totp_board(self) -> None:
|
||||
|
||||
@@ -722,6 +722,100 @@ async def test_tui2_textual_pagination_and_search_flow() -> None:
|
||||
assert "Page: 1/1" in _filters_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_search_bar_hybrid_mode_includes_semantic_matches() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "document", "label": "Alpha Notes", "content": "alpha"},
|
||||
{"id": 2, "kind": "document", "label": "Roadmap Draft", "content": "beta"},
|
||||
]
|
||||
)
|
||||
semantic = FakeSemanticService()
|
||||
|
||||
def _semantic_search(query: str, *, k: int = 10, kind=None, mode=None):
|
||||
_ = (k, kind)
|
||||
semantic.last_query = str(query)
|
||||
if mode:
|
||||
semantic.mode = str(mode)
|
||||
return [
|
||||
{
|
||||
"entry_id": 2,
|
||||
"kind": "document",
|
||||
"label": "Roadmap Draft",
|
||||
"score": 0.91,
|
||||
"excerpt": "semantic-match",
|
||||
}
|
||||
]
|
||||
|
||||
semantic.search = _semantic_search
|
||||
app = _build_app(service, semantic_service=semantic)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("search-mode hybrid")
|
||||
await pilot.pause()
|
||||
assert "Search mode set to hybrid" in _status_text(app)
|
||||
|
||||
app.action_focus_search()
|
||||
await pilot.pause()
|
||||
search = app.query_one("#search", Input)
|
||||
search.value = "nonkeyword query"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
list_view = app.query_one("#entry-list", ListView)
|
||||
assert len(list_view.children) == 1
|
||||
row_text = str(list_view.children[0].children[0].render())
|
||||
assert "Roadmap Draft" in row_text
|
||||
assert "sem:" in row_text
|
||||
assert semantic.last_query == "nonkeyword query"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_search_bar_hybrid_mode_marks_overlap_as_mix() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "document", "label": "Alpha Notes", "content": "alpha"},
|
||||
{"id": 2, "kind": "document", "label": "Roadmap Draft", "content": "beta"},
|
||||
]
|
||||
)
|
||||
semantic = FakeSemanticService()
|
||||
|
||||
def _semantic_search(query: str, *, k: int = 10, kind=None, mode=None):
|
||||
_ = (k, kind, mode)
|
||||
semantic.last_query = str(query)
|
||||
return [
|
||||
{
|
||||
"entry_id": 2,
|
||||
"kind": "document",
|
||||
"label": "Roadmap Draft",
|
||||
"score": 0.95,
|
||||
"excerpt": "semantic+keyword",
|
||||
}
|
||||
]
|
||||
|
||||
semantic.search = _semantic_search
|
||||
app = _build_app(service, semantic_service=semantic)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("search-mode hybrid")
|
||||
await pilot.pause()
|
||||
app.action_focus_search()
|
||||
await pilot.pause()
|
||||
search = app.query_one("#search", Input)
|
||||
search.value = "Roadmap"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
list_view = app.query_one("#entry-list", ListView)
|
||||
assert len(list_view.children) == 1
|
||||
row_text = str(list_view.children[0].children[0].render())
|
||||
assert "Roadmap Draft" in row_text
|
||||
assert "mix:" in row_text
|
||||
assert semantic.last_query == "Roadmap"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_archive_scope_filters() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -805,6 +899,125 @@ async def test_tui2_textual_document_edit_save_flow() -> None:
|
||||
assert "Saved document 1" in _status_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_right_pane_transitions_totp_board_and_doc_editor() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "document", "label": "Doc A", "content": "hello"},
|
||||
{
|
||||
"id": 2,
|
||||
"kind": "totp",
|
||||
"label": "Auth",
|
||||
"period": 30,
|
||||
"digits": 6,
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("2fa-board")
|
||||
await pilot.pause()
|
||||
assert app.totp_board_open is True
|
||||
assert app.editing_document is False
|
||||
assert not app.query_one("#totp-board", Static).has_class("hidden")
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
app.action_edit_document()
|
||||
await pilot.pause()
|
||||
assert app.editing_document is True
|
||||
assert app.totp_board_open is False
|
||||
assert app.query_one("#totp-board", Static).has_class("hidden")
|
||||
assert app.query_one("#right-view").has_class("hidden")
|
||||
assert not app.query_one("#right-editor").has_class("hidden")
|
||||
|
||||
app.action_cancel_document_edit()
|
||||
await pilot.pause()
|
||||
assert app.editing_document is False
|
||||
assert app.totp_board_open is False
|
||||
assert app.query_one("#right-editor").has_class("hidden")
|
||||
assert not app.query_one("#right-view").has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_open_entry_closes_totp_and_blocks_during_edit() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "document", "label": "Doc A", "content": "hello"},
|
||||
{
|
||||
"id": 2,
|
||||
"kind": "totp",
|
||||
"label": "Auth",
|
||||
"period": 30,
|
||||
"digits": 6,
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._run_palette_command("2fa-board")
|
||||
await pilot.pause()
|
||||
assert app.totp_board_open is True
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
assert app.totp_board_open is False
|
||||
assert "Selected entry 1" in _status_text(app)
|
||||
|
||||
app.action_edit_document()
|
||||
await pilot.pause()
|
||||
assert app.editing_document is True
|
||||
|
||||
app._run_palette_command("open 2")
|
||||
await pilot.pause()
|
||||
assert app.editing_document is True
|
||||
assert "Finish document edit before opening another entry" in _status_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_inspector_heading_tracks_right_pane_mode() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "document", "label": "Doc A", "content": "hello"},
|
||||
{
|
||||
"id": 2,
|
||||
"kind": "totp",
|
||||
"label": "Auth",
|
||||
"period": 30,
|
||||
"digits": 6,
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert "Inspector Board" in _widget_text(app, "#inspector-heading")
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
heading = _widget_text(app, "#inspector-heading")
|
||||
assert "Inspector | #1 (document)" in heading
|
||||
assert "Doc A" in heading
|
||||
|
||||
app.action_edit_document()
|
||||
await pilot.pause()
|
||||
assert "Document Editor | Entry #1" in _widget_text(app, "#inspector-heading")
|
||||
|
||||
app.action_cancel_document_edit()
|
||||
await pilot.pause()
|
||||
app._run_palette_command("2fa-board")
|
||||
await pilot.pause()
|
||||
assert "2FA Board | Live Codes" in _widget_text(app, "#inspector-heading")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_link_commands_and_neighbor_open() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -974,7 +1187,14 @@ async def test_tui2_textual_managed_account_keyboard_reveal_and_qr() -> None:
|
||||
await pilot.press("v")
|
||||
await pilot.pause()
|
||||
assert "requires confirmation" in _status_text(app)
|
||||
assert "Run: reveal confirm" in _widget_text(app, "#secret-detail")
|
||||
secret_text = _widget_text(app, "#secret-detail")
|
||||
assert "Press 'v' again to confirm" in secret_text
|
||||
assert "run: reveal confirm" in secret_text.lower()
|
||||
|
||||
await pilot.press("v")
|
||||
await pilot.pause()
|
||||
assert "Managed Account Seed #1" in _widget_text(app, "#secret-detail")
|
||||
assert "legal winner thank year" in _widget_text(app, "#secret-detail")
|
||||
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
@@ -1078,6 +1298,41 @@ async def test_tui2_textual_viewport_balance_hides_activity_on_short_height() ->
|
||||
assert not activity.has_class("hidden")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_hires_density_compacts_vertical_chrome() -> None:
|
||||
service = FakeEntryService([{"id": 1, "kind": "document", "label": "Doc 1"}])
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
brand = app.query_one("#brand-strip", Static)
|
||||
ribbon = app.query_one("#top-ribbon", Static)
|
||||
status = app.query_one("#status", Static)
|
||||
action_strip = app.query_one("#action-strip", Static)
|
||||
grid_heading = app.query_one("#grid-heading", Static)
|
||||
|
||||
app._update_responsive_layout(width=220, height=55)
|
||||
await pilot.pause()
|
||||
assert str(brand.styles.height) == "1"
|
||||
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 "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")
|
||||
|
||||
app._update_responsive_layout(width=170, height=46)
|
||||
await pilot.pause()
|
||||
assert str(brand.styles.height) == "3"
|
||||
assert str(ribbon.styles.height) == "3"
|
||||
assert str(status.styles.height) == "3"
|
||||
assert str(action_strip.styles.height) == "3"
|
||||
assert str(grid_heading.styles.height) == "4"
|
||||
assert "Dense" not in _widget_text(app, "#action-strip")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_ssh_pgp_nostr_boards_show_action_fidelity() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -1319,6 +1574,40 @@ 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_inspector_side_is_context_aware() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "password", "label": "Login 1", "length": 16},
|
||||
{"id": 2, "kind": "document", "label": "Doc 2", "content": "hello"},
|
||||
]
|
||||
)
|
||||
app = _build_app(service)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
link_detail = app.query_one("#link-detail", Static)
|
||||
secret_detail = app.query_one("#secret-detail", Static)
|
||||
assert link_detail.has_class("hidden")
|
||||
assert secret_detail.has_class("hidden")
|
||||
|
||||
app._run_palette_command("open 1")
|
||||
await pilot.pause()
|
||||
assert not secret_detail.has_class("hidden")
|
||||
|
||||
app._run_palette_command("open 2")
|
||||
await pilot.pause()
|
||||
assert secret_detail.has_class("hidden")
|
||||
|
||||
app.action_reveal_selected()
|
||||
await pilot.pause()
|
||||
assert "Reveal not supported for kind: document" in _status_text(app)
|
||||
|
||||
app.action_show_qr()
|
||||
await pilot.pause()
|
||||
assert "QR not supported for kind: document" in _status_text(app)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> None:
|
||||
service = FakeEntryService(
|
||||
@@ -2023,6 +2312,7 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None:
|
||||
await pilot.pause()
|
||||
assert semantic.mode == "hybrid"
|
||||
assert "Search mode set to hybrid" in _status_text(app)
|
||||
assert "(HYBRID)" in _widget_text(app, "#grid-heading")
|
||||
|
||||
app._run_palette_command("semantic-build")
|
||||
await pilot.pause()
|
||||
@@ -2172,6 +2462,127 @@ async def test_tui2_textual_profile_switch_restores_filter_state() -> None:
|
||||
assert "Filter : keys" in _widget_text(app, "#filters")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_profile_tree_seed_label_formatting() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "managed_account", "label": "Managed User 1"},
|
||||
{"id": 2, "kind": "nostr", "label": "Agent Alpha"},
|
||||
]
|
||||
)
|
||||
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()
|
||||
filters = _widget_text(app, "#filters")
|
||||
assert "| Seed: fp-a" in filters
|
||||
assert "👤 Managed Users" in filters
|
||||
assert "🤖 Agents" in filters
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_profile_tree_can_open_managed_and_agent_children() -> None:
|
||||
service = FakeEntryService(
|
||||
[
|
||||
{"id": 1, "kind": "managed_account", "label": "Managed User 1"},
|
||||
{"id": 2, "kind": "nostr", "label": "Agent Alpha"},
|
||||
]
|
||||
)
|
||||
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.action_focus_left()
|
||||
await pilot.pause()
|
||||
|
||||
app.action_profile_tree_next()
|
||||
await pilot.pause()
|
||||
assert "Managed selection: #1 Managed User 1" in _status_text(app)
|
||||
|
||||
app.action_profile_tree_open()
|
||||
await pilot.pause()
|
||||
assert "Opened tree entry 1" in _status_text(app)
|
||||
assert "Selected: #1" in _filters_text(app)
|
||||
assert "Managed User 1" in _filters_text(app)
|
||||
|
||||
app.action_focus_left()
|
||||
await pilot.pause()
|
||||
app.action_profile_tree_next()
|
||||
await pilot.pause()
|
||||
assert "Agent selection: #2 Agent Alpha" in _status_text(app)
|
||||
|
||||
app.action_profile_tree_open()
|
||||
await pilot.pause()
|
||||
assert "Opened tree entry 2" in _status_text(app)
|
||||
assert "Selected: #2" in _filters_text(app)
|
||||
assert "Agent Alpha" in _filters_text(app)
|
||||
|
||||
|
||||
@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"}])
|
||||
semantic = FakeSemanticService()
|
||||
app = _build_app(service, semantic_service=semantic)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
heading = _widget_text(app, "#grid-heading")
|
||||
assert "(KEYWORD)" in heading
|
||||
|
||||
await pilot.press("m")
|
||||
await pilot.pause()
|
||||
assert semantic.mode == "hybrid"
|
||||
assert "(HYBRID)" in _widget_text(app, "#grid-heading")
|
||||
|
||||
await pilot.press("m")
|
||||
await pilot.pause()
|
||||
assert semantic.mode == "semantic"
|
||||
assert "(SEMANTIC)" in _widget_text(app, "#grid-heading")
|
||||
|
||||
await pilot.press("m")
|
||||
await pilot.pause()
|
||||
assert semantic.mode == "keyword"
|
||||
assert "(KEYWORD)" in _widget_text(app, "#grid-heading")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_profile_switch_restores_sidebar_state() -> None:
|
||||
service = FakeEntryService([{"id": 1, "kind": "password", "label": "Login 1"}])
|
||||
profiles = FakeProfileService(["fp-a", "fp-b"])
|
||||
app = _build_app(service, profile_service=profiles)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
left = app.query_one("#left")
|
||||
assert not left.has_class("sidebar-collapsed")
|
||||
|
||||
app.action_toggle_sidebar()
|
||||
await pilot.pause()
|
||||
assert left.has_class("sidebar-collapsed")
|
||||
|
||||
app._run_palette_command("profile-switch fp-a")
|
||||
await pilot.pause()
|
||||
assert not left.has_class("sidebar-collapsed")
|
||||
|
||||
app.action_toggle_sidebar()
|
||||
await pilot.pause()
|
||||
assert left.has_class("sidebar-collapsed")
|
||||
|
||||
app._run_palette_command("profile-switch fp-b")
|
||||
await pilot.pause()
|
||||
assert not left.has_class("sidebar-collapsed")
|
||||
|
||||
app._run_palette_command("profile-switch fp-a")
|
||||
await pilot.pause()
|
||||
assert left.has_class("sidebar-collapsed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tui2_textual_managed_account_session_palette_commands() -> None:
|
||||
service = FakeEntryService(
|
||||
|
||||
@@ -19,6 +19,36 @@ import seedpass.core.agent_secret_isolation as isolation_core
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_legacy_command_invokes_legacy_tui(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def fake_launch_legacy_tui(*, fingerprint=None):
|
||||
called["fingerprint"] = fingerprint
|
||||
raise typer.Exit(0)
|
||||
|
||||
import typer
|
||||
|
||||
monkeypatch.setattr(cli, "_launch_legacy_tui", fake_launch_legacy_tui)
|
||||
result = runner.invoke(app, ["legacy"])
|
||||
assert result.exit_code == 0
|
||||
assert called["fingerprint"] is None
|
||||
|
||||
|
||||
def test_legacy_flag_invokes_legacy_tui(monkeypatch):
|
||||
called = {}
|
||||
|
||||
def fake_launch_legacy_tui(*, fingerprint=None):
|
||||
called["fingerprint"] = fingerprint
|
||||
raise typer.Exit(0)
|
||||
|
||||
import typer
|
||||
|
||||
monkeypatch.setattr(cli, "_launch_legacy_tui", fake_launch_legacy_tui)
|
||||
result = runner.invoke(app, ["--legacy-tui"])
|
||||
assert result.exit_code == 0
|
||||
assert called["fingerprint"] is None
|
||||
|
||||
|
||||
def test_entry_list(monkeypatch):
|
||||
called = {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user