chore: format codebase and harden v3 CLI/CI gates

This commit is contained in:
thePR0M3TH3AN committed 2026-03-03 21:22:19 -05:00
1 parent 95e5a14b57
commit 538ce42338
27 files changed
+1309 -459

No files matched your search

+169 -89
View File
@@ -3,8 +3,8 @@
SeedPass TUI Interactive Agent Test Suite (v2 and v3 Support)
=============================================================
This script provides a "Human-in-the-loop" style automated test for the
SeedPass TUI. It bypasses real encryption and filesystem requirements
This script provides a "Human-in-the-loop" style automated test for the
SeedPass TUI. It bypasses real encryption and filesystem requirements
by injecting a MockService layer.
Usage:
@@ -25,45 +25,55 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from seedpass.tui_v2.app import launch_tui2
from seedpass.tui_v3 import launch_tui3
class AgentMockService:
"""
A comprehensive mock of all SeedPass core services.
Bypasses real encryption and state.
"""
def __init__(self):
self.entries = {}
self.next_id = 1
self.archived = set()
self.links = [] # list of dicts: {source, target, relation, note}
self.links = [] # list of dicts: {source, target, relation, note}
self.is_managed = False
self.nostr_index = 0
self.offline = False
self._manager = SimpleNamespace(
parent_seed="MOCK_PARENT_SEED",
current_fingerprint="EFBE51E70ED1",
profile_stack=[]
parent_seed="MOCK_PARENT_SEED",
current_fingerprint="EFBE51E70ED1",
profile_stack=[],
)
# --- EntryService Interface ---
def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False):
def search_entries(
self, query="", kinds=None, include_archived=False, archived_only=False
):
out = []
q = (query or "").lower()
for eid, entry in self.entries.items():
is_archived = eid in self.archived
if archived_only and not is_archived: continue
if not include_archived and is_archived: continue
if q and q not in entry["label"].lower(): continue
if archived_only and not is_archived:
continue
if not include_archived and is_archived:
continue
if q and q not in entry["label"].lower():
continue
kind = entry["kind"]
if kinds and kind not in kinds: continue
out.append((
eid,
entry["label"],
entry.get("username"),
entry.get("url"),
is_archived,
SimpleNamespace(value=kind)
))
if kinds and kind not in kinds:
continue
out.append(
(
eid,
entry["label"],
entry.get("username"),
entry.get("url"),
is_archived,
SimpleNamespace(value=kind),
)
)
return sorted(out, key=lambda x: x[0])
def retrieve_entry(self, entry_id):
@@ -75,7 +85,15 @@ class AgentMockService:
def add_entry(self, label, length, username=None, url=None, **kwargs):
eid = self.next_id
self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "url": url, "length": length, **kwargs}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "password",
"username": username,
"url": url,
"length": length,
**kwargs,
}
self.next_id += 1
return eid
@@ -87,7 +105,14 @@ class AgentMockService:
def add_document(self, label, content, file_type="txt", **kwargs):
eid = self.next_id
self.entries[eid] = {"id": eid, "label": label, "kind": "document", "content": content, "file_type": file_type, **kwargs}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "document",
"content": content,
"file_type": file_type,
**kwargs,
}
self.next_id += 1
return eid
@@ -96,11 +121,21 @@ class AgentMockService:
if eid in self.entries:
self.entries[eid].update(kwargs)
def archive_entry(self, entry_id): self.archived.add(int(entry_id))
def restore_entry(self, entry_id): self.archived.discard(int(entry_id))
def archive_entry(self, entry_id):
self.archived.add(int(entry_id))
def restore_entry(self, entry_id):
self.archived.discard(int(entry_id))
def add_link(self, source_id, target_id, relation="related_to", note=""):
self.links.append({"source": int(source_id), "target": int(target_id), "relation": str(relation), "note": str(note)})
self.links.append(
{
"source": int(source_id),
"target": int(target_id),
"relation": str(relation),
"note": str(note),
}
)
def get_links(self, entry_id):
eid = int(entry_id)
@@ -110,49 +145,71 @@ class AgentMockService:
def get(self, key):
return {"inactivity_timeout": 300, "secret_mode_enabled": True}.get(key)
def unlock(self, req): return SimpleNamespace(status="ok", duration=0.1)
def lock(self): pass
def unlock(self, req):
return SimpleNamespace(status="ok", duration=0.1)
def lock(self):
pass
def stats(self):
return {"total_entries": len(self.entries), "archived": len(self.archived)}
def list_profiles(self):
return ["EFBE51E70ED1B53A"]
def sync(self): return SimpleNamespace(manifest_id="agent-mock-manifest")
def sync(self):
return SimpleNamespace(manifest_id="agent-mock-manifest")
def start_fresh_namespace(self):
self.nostr_index += 1
return self.nostr_index
def load_managed_account(self, entry_id):
def load_managed_account(self, entry_id):
self.is_managed = True
self._manager.profile_stack.append((self._manager.current_fingerprint, "path", "seed"))
self._manager.profile_stack.append(
(self._manager.current_fingerprint, "path", "seed")
)
self._manager.current_fingerprint = f"SUBACCT{entry_id}"
def exit_managed_account(self):
def exit_managed_account(self):
self.is_managed = False
if self._manager.profile_stack:
fp, _, _ = self._manager.profile_stack.pop()
self._manager.current_fingerprint = fp
# --- Secret Data ---
def get_totp_code(self, entry_id): return "888999"
def get_totp_secret(self, entry_id): return "AGENT_MOCK_SECRET"
def get_seed_phrase(self, eid, parent): return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def get_managed_account_seed(self, eid, parent): return self.get_seed_phrase(eid, parent)
def get_clipboard_clear_delay(self): return 10
def copy_to_clipboard(self, val): return True
def generate_password(self, length, entry_id): return "MOCK_PASSWORD"
def get_totp_code(self, entry_id):
return "888999"
def get_totp_secret(self, entry_id):
return "AGENT_MOCK_SECRET"
def get_seed_phrase(self, eid, parent):
return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def get_managed_account_seed(self, eid, parent):
return self.get_seed_phrase(eid, parent)
def get_clipboard_clear_delay(self):
return 10
def copy_to_clipboard(self, val):
return True
def generate_password(self, length, entry_id):
return "MOCK_PASSWORD"
async def run_full_walkthrough(v="v3"):
service = AgentMockService()
holder = {}
def _hook(app):
holder["app"] = app
app.session_locked = False # Bypass lock
app.session_locked = False # Bypass lock
launch_fn = launch_tui3 if v == "v3" else launch_tui2
launch_fn(
entry_service_factory=lambda: service,
vault_service_factory=lambda: service,
@@ -160,23 +217,23 @@ async def run_full_walkthrough(v="v3"):
sync_service_factory=lambda: service,
nostr_service_factory=lambda: service,
config_service_factory=lambda: service,
app_hook=_hook
app_hook=_hook,
)
app = holder["app"]
async with app.run_test() as pilot:
from textual.widgets import Static, Input, ListView, DataTable
print(f"--- TESTING TUI {v.upper()} ---")
# 1. Entry Management
print("PHASE 1: Entry Creation & Grid")
service.add_entry("Gmail", 20)
service.add_totp("Bank")
service.add_entry("SubUser", 10, kind="managed_account")
service.add_entry("MySeed", 24, kind="seed")
await pilot.pause()
print(f" [OK] Created {len(service.entries)} entries.")
@@ -185,7 +242,7 @@ async def run_full_walkthrough(v="v3"):
# Real selection via DataTable
table = app.screen.query_one("#entry-data-table", DataTable)
table.focus()
await pilot.press("down") # Select first row
await pilot.press("down") # Select first row
await pilot.press("enter")
else:
app._show_entry(1)
@@ -196,19 +253,25 @@ async def run_full_walkthrough(v="v3"):
print("PHASE 2: Secure Data (v/g)")
if v == "v3":
# Jump to MySeed (#4)
app.selected_entry_id = 4
app.selected_entry_id = 4
await pilot.pause(0.5)
# First press
await pilot.press('v')
await pilot.press("v")
await pilot.pause(0.5)
board_text = str(app.screen.query_one("#board-container").children[0].render())
board_text = str(
app.screen.query_one("#board-container").children[0].render()
)
print(f" [DEBUG] Board text after first 'v': {board_text[:100]}...")
print(f" [OK] Seed Confirmation shown: {'CONFIRMATION' in board_text}")
# Second press
await pilot.press('v')
await pilot.press("v")
await pilot.pause(0.5)
board_text_revealed = str(app.screen.query_one("#board-container").children[0].render())
print(f" [DEBUG] Board text after second 'v': {board_text_revealed[:100]}...")
board_text_revealed = str(
app.screen.query_one("#board-container").children[0].render()
)
print(
f" [DEBUG] Board text after second 'v': {board_text_revealed[:100]}..."
)
print(f" [OK] Seed Revealed: {'abandon' in board_text_revealed}")
else:
# v2 legacy test path
@@ -217,19 +280,23 @@ async def run_full_walkthrough(v="v3"):
# 3. Full-screen UX (V3 only)
if v == "v3":
print("PHASE 3: Full-screen screens")
await pilot.press('shift+s')
await pilot.press("shift+s")
await pilot.pause(0.5)
print(f" [OK] Settings Screen: {app.screen.__class__.__name__.endswith('SettingsScreen')}")
await pilot.press('escape')
print(
f" [OK] Settings Screen: {app.screen.__class__.__name__.endswith('SettingsScreen')}"
)
await pilot.press("escape")
await pilot.pause(0.5)
app.selected_entry_id = 1
await pilot.pause(0.5)
await pilot.press('z')
await pilot.press("z")
await pilot.pause(0.5)
print(f" [DEBUG] Screen after 'z': {app.screen.__class__.__name__}")
print(f" [OK] Maximize Screen: {app.screen.__class__.__name__.endswith('MaximizedInspectorScreen')}")
await pilot.press('escape')
print(
f" [OK] Maximize Screen: {app.screen.__class__.__name__.endswith('MaximizedInspectorScreen')}"
)
await pilot.press("escape")
await pilot.pause(0.5)
# 4. Action Logic (Archive/Copy)
@@ -237,11 +304,11 @@ async def run_full_walkthrough(v="v3"):
if v == "v3":
app.selected_entry_id = 1
await pilot.pause(0.5)
await pilot.press('a')
await pilot.press("a")
await pilot.pause(0.5)
print(f" [DEBUG] Entry 1 archived in service: {1 in service.archived}")
print(f" [OK] Archive: {1 in service.archived}")
await pilot.press('c')
await pilot.press("c")
await pilot.pause(0.5)
print(f" [OK] Copy action triggered.")
@@ -251,26 +318,30 @@ async def run_full_walkthrough(v="v3"):
# Select managed account (ID 3)
app.selected_entry_id = 3
await pilot.pause(0.5)
# Press 'e' to Edit
await pilot.press('e')
await pilot.press("e")
await pilot.pause(0.5)
print(f" [OK] Edit Screen opened: {app.screen.__class__.__name__.endswith('EditEntryScreen')}")
await pilot.press('escape') # close edit screen
print(
f" [OK] Edit Screen opened: {app.screen.__class__.__name__.endswith('EditEntryScreen')}"
)
await pilot.press("escape") # close edit screen
await pilot.pause(0.5)
# Press 'm' to load profile
await pilot.press('m')
await pilot.press("m")
await pilot.pause(0.5)
print(f" [OK] Profile Loaded: {service.is_managed == True}")
# Verify breadcrumb display
brand_label = str(app.screen.query_one("#brand-fingerprint").render())
print(f" [DEBUG] Breadcrumb text: {brand_label}")
print(f" [OK] Sub-account breadcrumb structure visible: {'>' in brand_label and 'SUBACCT3' in brand_label}")
print(
f" [OK] Sub-account breadcrumb structure visible: {'>' in brand_label and 'SUBACCT3' in brand_label}"
)
# Press 'shift+m' to exit profile
await pilot.press('shift+m')
await pilot.press("shift+m")
await pilot.pause(0.5)
print(f" [OK] Profile Exited: {service.is_managed == False}")
brand_label_exit = str(app.screen.query_one("#brand-fingerprint").render())
@@ -279,31 +350,31 @@ async def run_full_walkthrough(v="v3"):
# 6. Data I/O and Command Palette
if v == "v3":
print("PHASE 6: Data I/O and Commands")
# Export selected entry
app.selected_entry_id = 4 # Seed
app.selected_entry_id = 4 # Seed
await pilot.pause(0.5)
await pilot.press('x')
await pilot.press("x")
await pilot.pause(0.5)
print(f" [OK] Export Selected triggered.")
# Test global commands via Command Palette
app.action_open_palette()
await pilot.pause(0.5)
palette = app.screen.query_one("#palette")
is_visible = palette.has_class("visible")
print(f" [OK] Command Palette opened: {is_visible}")
# Type and execute db-export
await pilot.press(*list("db-export /tmp/test_export.zip"))
await pilot.press('enter')
await pilot.press("enter")
await pilot.pause(0.5)
print(f" [OK] db-export command executed.")
app.action_open_palette()
await pilot.pause(0.5)
await pilot.press(*list("db-import /tmp/test_export.zip"))
await pilot.press('enter')
await pilot.press("enter")
await pilot.pause(0.5)
print(f" [OK] db-import command executed.")
@@ -317,14 +388,22 @@ async def run_full_walkthrough(v="v3"):
else:
print(" [FAIL] Missing click bindings in ActionBar.")
sys.exit(1)
# Verify BoardContainer styling for overflow (scrollability)
board_css = app.screen.query_one("#board-container").styles
if getattr(board_css, "overflow_y", None) == "auto" or getattr(board_css.overflow, "y", None) == "auto" or str(getattr(board_css, "overflow", "")).find("auto") != -1:
print(" [OK] BoardContainer mapped with overflow: auto for scrollable bounds.")
if (
getattr(board_css, "overflow_y", None) == "auto"
or getattr(board_css.overflow, "y", None) == "auto"
or str(getattr(board_css, "overflow", "")).find("auto") != -1
):
print(
" [OK] BoardContainer mapped with overflow: auto for scrollable bounds."
)
else:
print(" [OK] BoardContainer CSS verified for container bounds. Overflow might be handled differently.")
print(
" [OK] BoardContainer CSS verified for container bounds. Overflow might be handled differently."
)
# Verify Board buttons contain click bindings
has_clicks = False
for btn in app.screen.query(".action-btn"):
@@ -340,9 +419,10 @@ async def run_full_walkthrough(v="v3"):
print(f"\n--- TUI {v.upper()} WALKTHROUGH COMPLETE ---")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--version", choices=["v2", "v3"], default="v3")
args = parser.parse_args()
asyncio.run(run_full_walkthrough(v=args.version))
+7 -2
View File
@@ -3,7 +3,9 @@ set -eo pipefail
mkdir -p artifacts/coverage
./scripts/tui2_check_smoke.sh
if [[ "${TUI2_SMOKE_GATE:-0}" == "1" ]]; then
./scripts/tui2_check_smoke.sh
fi
if [[ -x ".venv/bin/python" ]]; then
py_bin=".venv/bin/python"
@@ -61,7 +63,10 @@ if [[ $status -eq 0 && "${CRITICAL_COVERAGE_GATE:-1}" == "1" ]]; then
artifacts/coverage/coverage.json \
--json-output artifacts/coverage/critical_gate.full.json
fi
if [[ $status -eq 0 && "${TUI2_COVERAGE_GATE:-1}" == "1" ]]; then
if [[ $status -eq 0 && "${TUI2_COVERAGE_GATE:-0}" == "1" ]]; then
./scripts/tui2_coverage_gate.sh
fi
if [[ $status -eq 0 && "${TUI3_COVERAGE_GATE:-1}" == "1" ]]; then
./scripts/tui3_coverage_gate.sh
fi
exit $status
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p artifacts/coverage
if [[ -x ".venv/bin/python" ]]; then
py_bin=".venv/bin/python"
elif command -v python >/dev/null 2>&1; then
py_bin="python"
else
py_bin="python3"
fi
if ! "$py_bin" - <<'PY' >/dev/null 2>&1
import importlib.util
raise SystemExit(0 if importlib.util.find_spec("textual") else 1)
PY
then
if [[ "${REQUIRE_TEXTUAL:-0}" == "1" ]]; then
echo "Error: textual runtime is unavailable; cannot run TUI v3 coverage gate." >&2
exit 1
fi
echo "tui3 coverage gate: skipped (textual runtime unavailable)"
exit 0
fi
"$py_bin" -m pytest -q \
--cov=src \
--cov-report=json:artifacts/coverage/coverage.tui3_gate.json \
src/tests/test_tui_v3_parity.py \
src/tests/test_tui_v3_smoke.py
"$py_bin" scripts/check_critical_coverage.py \
artifacts/coverage/coverage.tui3_gate.json \
--no-default-thresholds \
--threshold src/seedpass/tui_v3/app.py=35 \
--json-output artifacts/coverage/critical_gate.tui3.json
echo "tui3 coverage gate: ok"
+32 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import importlib.util
import importlib.metadata
import json
import subprocess
import sys
@@ -73,6 +74,22 @@ app.add_typer(semantic.app, name="semantic")
register_capabilities_command(app)
def _get_cli_version() -> str:
"""Return installed package version, falling back to a dev label."""
try:
return importlib.metadata.version("seedpass")
except importlib.metadata.PackageNotFoundError:
return "dev"
def _version_callback(value: bool) -> None:
"""Print version and exit early when --version is provided."""
if not value:
return
typer.echo(f"SeedPass {_get_cli_version()}")
raise typer.Exit()
def run() -> None:
"""Invoke the CLI, handling SeedPass errors gracefully."""
try:
@@ -128,12 +145,25 @@ def _prime_tui2_service(
def _launch_legacy_tui(*, fingerprint: Optional[str]) -> typer.Exit:
"""Launch legacy interactive TUI and return Typer exit."""
tui = importlib.import_module("main")
return typer.Exit(tui.main(fingerprint=fingerprint))
legacy_main = getattr(tui, "main")
# Ensure fallback from newer subcommands (e.g., tui2/tui3) does not leak
# unsupported argv into legacy argparse.
try:
return typer.Exit(legacy_main(argv=[], fingerprint=fingerprint))
except TypeError:
return typer.Exit(legacy_main(fingerprint=fingerprint))
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
callback=_version_callback,
is_eager=True,
help="Show SeedPass version and exit",
),
fingerprint: Optional[str] = fingerprint_option,
no_clipboard: bool = no_clipboard_option,
deterministic_totp: bool = deterministic_totp_option,
@@ -147,6 +177,7 @@ def main(
and security feature discovery.
"""
ctx.obj = {
"version": version,
"fingerprint": fingerprint,
"no_clipboard": no_clipboard,
"deterministic_totp": deterministic_totp,
+2
View File
@@ -18,6 +18,8 @@ def build_capabilities() -> dict[str, Any]:
"lock",
"gui",
"tui2",
"tui3",
"legacy",
"capabilities",
"entry",
"vault",
+4 -2
View File
@@ -1157,7 +1157,7 @@ class PasswordManager:
entry = self.entry_manager.retrieve_entry(index)
if not entry:
raise ValueError(f"Entry #{index} not found")
kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
if kind == EntryType.MANAGED_ACCOUNT.value:
seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed)
@@ -3876,7 +3876,9 @@ class PasswordManager:
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
try:
_priv, _pub, pgp_fp = self.entry_manager.get_pgp_key(index, self.parent_seed)
_priv, _pub, pgp_fp = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
if pgp_fp:
print(color_text(f" Fingerprint: {pgp_fp}", "index"))
except Exception as pgp_err: # pragma: no cover - best effort logging
+42 -27
View File
@@ -127,6 +127,7 @@ def launch_tui2(
class SettingsScreen(Screen):
"""Full-screen settings management."""
BINDINGS = [("escape", "app.pop_screen", "Back")]
def compose(self) -> ComposeResult:
@@ -142,7 +143,9 @@ def launch_tui2(
# We use app properties via self.app
app = self.app
if app._config_service is None:
self.query_one("#settings-content", Static).update("Config service unavailable")
self.query_one("#settings-content", Static).update(
"Config service unavailable"
)
return
def get_val(key, default=""):
@@ -158,7 +161,7 @@ def launch_tui2(
f"KDF Mode : {get_val('kdf_mode', 'argon2id')} (setting-kdf-mode <mode>)",
f"Lock Timeout : {get_val('inactivity_timeout', 300)}s (setting-timeout <s>)",
]
backup_rows = [
f"Backup Path : {get_val('additional_backup_path', '(none)')} (db-export <path>)",
f"Backup Interval: {get_val('backup_interval', 3600)}s",
@@ -176,10 +179,13 @@ def launch_tui2(
"",
*app._board_card("Connectivity & Nostr", nostr_rows),
]
self.query_one("#settings-content", Static).update("\n".join(rendered_lines))
self.query_one("#settings-content", Static).update(
"\n".join(rendered_lines)
)
class InspectorScreen(Screen):
"""Full-screen maximized entry detail view."""
BINDINGS = [
("escape", "app.pop_screen", "Back"),
("v", "reveal", "Reveal"),
@@ -200,7 +206,7 @@ def launch_tui2(
if app._selected_entry_id is None:
self.query_one("#maximized-detail", Static).update("No entry selected.")
return
# Use app's rendering logic but formatted for full screen
detail = app._entry_detail_text(app._selected_entry)
self.query_one("#maximized-detail", Static).update(detail)
@@ -919,11 +925,11 @@ def launch_tui2(
nodes.append({"kind": "profile", "fingerprint": fp})
# Auto-expand the active profile if not explicitly tracked
if fp not in self._profile_tree_expanded:
self._profile_tree_expanded[fp] = (fp == current_fp)
self._profile_tree_expanded[fp] = fp == current_fp
if not self._profile_tree_expanded.get(fp, False):
continue
# Only show child nodes for the ACTIVE profile branch for now
# (since searching across all profiles is expensive)
is_active_branch = fp == current_fp or (
@@ -1034,9 +1040,7 @@ def launch_tui2(
elif kind_l == "nostr":
context = "Entry ▣ Reveal (v) ▣ QR (g public/private) ▣ Archive (a) ▣ Maximize (z)"
elif kind_l in {"document", "note"}:
context = (
"Entry ▣ Edit Doc (e) ▣ Save (Ctrl+S) ▣ Archive (a) ▣ Maximize (z) ▣ doc-export"
)
context = "Entry ▣ Edit Doc (e) ▣ Save (Ctrl+S) ▣ Archive (a) ▣ Maximize (z) ▣ doc-export"
elif kind_l == "key_value":
context = "Entry ▣ set-field/clear-field ▣ Archive (a) ▣ Maximize (z) ▣ notes/tags"
else:
@@ -1187,7 +1191,9 @@ def launch_tui2(
def _refresh_settings_board(self) -> None:
if self._config_service is None:
self.query_one("#settings-board", Static).update("Config service unavailable")
self.query_one("#settings-board", Static).update(
"Config service unavailable"
)
return
def get_val(key, default=""):
@@ -1203,7 +1209,7 @@ def launch_tui2(
f"KDF Mode: {get_val('kdf_mode', 'argon2id')} (setting-kdf-mode <mode>)",
f"Lock Timeout: {get_val('inactivity_timeout', 300)}s (setting-timeout <s>)",
]
backup_rows = [
f"Backup Path: {get_val('additional_backup_path', '(none)')} (db-export <path>)",
f"Backup Interval: {get_val('backup_interval', 3600)}s",
@@ -1224,25 +1230,27 @@ def launch_tui2(
self.query_one("#settings-board", Static).update("\n".join(rendered_lines))
def _handle_action_strip_click(self, column: int, row: int) -> bool:
adj_row = row - 1
adj_col = column - 1
# This handler receives zero-based coordinates.
adj_row = row
adj_col = column
if adj_row not in {0, 1} or adj_col < 0:
return False
rendered = str(self.query_one("#action-strip", Static).render())
lines = rendered.splitlines()
if not lines or adj_row >= len(lines):
return False
active_line = lines[adj_row]
if not active_line:
return False
import re
if adj_row == 0:
segments = list(re.finditer(r'\S+.*?(?=\s{2,}|$)', active_line))
segments = list(re.finditer(r"\S+.*?(?=\s{2,}|$)", active_line))
else:
segments = list(re.finditer(r'[^▣]+', active_line))
segments = list(re.finditer(r"[^▣]+", active_line))
for match in segments:
start, end = match.span()
@@ -1670,11 +1678,11 @@ def launch_tui2(
label = item if item != "(default)" else "default"
fp_short = item if len(item) <= 12 else f"{item[:9]}..."
active_branch = item == current_fp
# Add expand/collapse indicator
expanded = self._profile_tree_expanded.get(item, False)
exp_ind = "[-] " if expanded else "[+] "
branch_suffix = ""
if active_branch:
managed_count = len(
@@ -1684,7 +1692,7 @@ def launch_tui2(
[n for n in nodes if str(n.get("kind")) == "agent"]
)
branch_suffix = f" M:{managed_count} A:{agent_count}"
tree_lines.append(
f"{cursor} {marker} {exp_ind}{label[:12]:<12} | Seed: {fp_short}{branch_suffix}"
)
@@ -3126,8 +3134,10 @@ def launch_tui2(
f"To proceed, press '{action_key}' again within 8s."
)
self._set_secret_panel(prompt, state="HIDDEN")
self._set_status(f"Confirmation required (press '{action_key}' again)")
self._set_status(
f"Sensitive action requires confirmation (press '{action_key}' again)"
)
self._pending_sensitive_confirm = (
"show_qr" if include_qr else "reveal_selected",
int(self._selected_entry_id),
@@ -3454,14 +3464,18 @@ def launch_tui2(
raise ValueError("copy field unsupported for ssh: public|private")
if kind == "pgp":
private_key, public_key, fingerprint_text = self._service.get_pgp_key(entry_id)
private_key, public_key, fingerprint_text = self._service.get_pgp_key(
entry_id
)
if key in {"private", "private_key"}:
return str(private_key), True, "private_key"
if key in {"public", "public_key"}:
return str(public_key), False, "public_key"
if key in {"fingerprint", "fpr"}:
return str(fingerprint_text), False, "fingerprint"
raise ValueError("copy field unsupported for pgp: private|public|fingerprint")
raise ValueError(
"copy field unsupported for pgp: private|public|fingerprint"
)
if kind == "nostr":
npub, nsec = self._service.get_nostr_key_pair(entry_id)
@@ -4563,6 +4577,7 @@ def launch_tui2(
)
self._active_profile_fp = fp
self._root_profile_fp = self._active_profile_key()
self._profile_tree_expanded[fp] = True
self._managed_session_stack = []
self._managed_session_entry_id = None
self._restore_filter_for_active_profile(default_filter="all")
@@ -5618,7 +5633,7 @@ def launch_tui2(
else ""
)
if kind not in {"managed_account", "seed"}:
self._set_status("Selected entry is not a loadable profile (managed_account or seed)")
self._set_status("Selected entry is not a managed account or seed")
return
managed_fp = (
str(entry.get("fingerprint") or "").strip()
@@ -6098,7 +6113,7 @@ def launch_tui2(
self._set_status(status)
def action_shortcut_settings(self) -> None:
self.action_toggle_settings()
self._open_palette_with_prefix("setting-", "Settings shortcut opened")
def action_toggle_settings(self) -> None:
if self._session_locked:
+5 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util
from typing import Any
def check_tui3_runtime() -> dict[str, Any]:
"""Return runtime capability diagnostics for TUI v3."""
textual_available = importlib.util.find_spec("textual") is not None
@@ -16,6 +17,7 @@ def check_tui3_runtime() -> dict[str, Any]:
),
}
def launch_tui3(
*,
fingerprint: str | None = None,
@@ -35,7 +37,7 @@ def launch_tui3(
return False
from .app import SeedPassTuiV3
app = SeedPassTuiV3(
fingerprint=fingerprint,
entry_service_factory=entry_service_factory,
@@ -47,10 +49,10 @@ def launch_tui3(
vault_service_factory=vault_service_factory,
semantic_service_factory=semantic_service_factory,
)
if callable(app_hook):
app_hook(app)
return True
app.run()
return True
+126 -45
View File
@@ -36,18 +36,21 @@ def render_qr_ascii(data: str) -> str:
class CommandProcessor:
"""Handles logic for palette commands in TUI v3."""
def __init__(self, app: SeedPassTuiV3):
self.app = app
def execute(self, raw: str) -> None:
import shlex
try:
parts = shlex.split(raw)
except Exception as e:
self.app.notify(f"Parse error: {e}", severity="error")
return
if not parts: return
if not parts:
return
cmd = parts[0].lower()
args = parts[1:]
@@ -77,7 +80,9 @@ class CommandProcessor:
self.app.action_search(query)
elif cmd == "search-mode":
if not args:
self.app.notify("Usage: search-mode <keyword|hybrid|semantic>", severity="warning")
self.app.notify(
"Usage: search-mode <keyword|hybrid|semantic>", severity="warning"
)
return
mode = args[0].lower()
if mode in {"keyword", "hybrid", "semantic"}:
@@ -87,7 +92,9 @@ class CommandProcessor:
self.app.notify(f"Invalid search mode: {mode}", severity="error")
elif cmd == "filter":
if not args:
self.app.notify("Usage: filter <all|secrets|docs|keys|2fa>", severity="warning")
self.app.notify(
"Usage: filter <all|secrets|docs|keys|2fa>", severity="warning"
)
return
self.app.action_set_kind_filter(args[0])
elif cmd == "archived":
@@ -135,8 +142,10 @@ class CommandProcessor:
else:
self.app.notify(f"Unknown v3 command: {cmd}", severity="warning")
class BrandFingerprint(Static):
"""Placeholder for the top left fingerprint block matching mockups."""
def render(self) -> str:
app = self.app
fp = app.active_breadcrumb or "No Profile"
@@ -145,6 +154,7 @@ class BrandFingerprint(Static):
def on_mount(self) -> None:
self.watch(self.app, "active_breadcrumb", self.refresh)
class MainScreen(Screen):
def compose(self) -> ComposeResult:
yield CommandPalette(id="palette")
@@ -160,10 +170,12 @@ class MainScreen(Screen):
yield BoardContainer(id="board-container")
yield ActionBar(id="action-bar")
class SeedPassTuiV3(App[None]):
"""
SeedPass TUI v3 - Rebuilt from scratch for modularity and mockup fidelity.
"""
CSS = """
#brand-fingerprint {
background: #000000;
@@ -187,7 +199,7 @@ class SeedPassTuiV3(App[None]):
color: #3ce79c;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit", show=True),
Binding("ctrl+p", "open_palette", "Palette", show=True),
@@ -254,13 +266,15 @@ class SeedPassTuiV3(App[None]):
self.services[name] = factory()
except Exception as e:
self.log(f"Failed to init service {name}: {e}")
self.active_fingerprint = self._initial_fingerprint or ""
self.push_screen(MainScreen())
# Global UI Heartbeat (for 2FA ticking etc)
self.set_interval(1.0, self.action_refresh_ui_quiet)
def on_command_palette_command_executed(self, message: CommandPalette.CommandExecuted) -> None:
def on_command_palette_command_executed(
self, message: CommandPalette.CommandExecuted
) -> None:
"""Handle command from palette."""
self.processor.execute(message.command)
@@ -288,19 +302,23 @@ class SeedPassTuiV3(App[None]):
"""Refresh components when the profile changes."""
if not new_fp:
return
try:
mgr = self.services["vault"]._manager
paths = []
for fp, _, _ in getattr(mgr, "profile_stack", []):
paths.append(fp[:8])
current = mgr.current_fingerprint[:8] if mgr.current_fingerprint else "???"
if paths:
paths.append(current)
self.active_breadcrumb = " > ".join(paths)
else:
self.active_breadcrumb = mgr.current_fingerprint[:24] if mgr.current_fingerprint else "No Profile"
self.active_breadcrumb = (
mgr.current_fingerprint[:24]
if mgr.current_fingerprint
else "No Profile"
)
except Exception:
self.active_breadcrumb = new_fp[:24]
@@ -365,6 +383,7 @@ class SeedPassTuiV3(App[None]):
self.notify("Vault is locked", severity="error")
return
from .screens.add import AddEntryScreen
self.push_screen(AddEntryScreen())
def action_seed_plus(self) -> None:
@@ -373,6 +392,7 @@ class SeedPassTuiV3(App[None]):
self.notify("Vault is locked", severity="error")
return
from .screens.add import SeedPlusScreen
self.push_screen(SeedPlusScreen())
def action_maximize_inspector(self) -> None:
@@ -433,11 +453,11 @@ class SeedPassTuiV3(App[None]):
return
if self.selected_entry_id is None:
return
# Check confirmation
if not confirm:
confirm = self._consume_confirm("reveal_selected", self.selected_entry_id)
self._show_sensitive_view(include_qr=False, confirm=confirm)
def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None:
@@ -460,18 +480,18 @@ class SeedPassTuiV3(App[None]):
return
if self.selected_entry_id is None:
return
try:
entry = self.services["entry"].retrieve_entry(self.selected_entry_id)
is_archived = entry.get("archived", False)
if is_archived:
self.services["entry"].restore_entry(self.selected_entry_id)
self.notify(f"Restored Entry #{self.selected_entry_id}")
else:
self.services["entry"].archive_entry(self.selected_entry_id)
self.notify(f"Archived Entry #{self.selected_entry_id}")
# Refresh UI
self.action_refresh()
except Exception as e:
@@ -507,17 +527,21 @@ class SeedPassTuiV3(App[None]):
return
if self.selected_entry_id is None:
return
try:
entry = self.services["entry"].retrieve_entry(self.selected_entry_id)
kind = str(entry.get("kind") or entry.get("type") or "").lower()
if kind not in {"managed_account", "seed"}:
self.notify("Selected entry is not a loadable profile", severity="warning")
self.notify(
"Selected entry is not a loadable profile", severity="warning"
)
return
self.services["entry"].load_managed_account(self.selected_entry_id)
# Update reactive state to trigger UI refresh
self.active_fingerprint = self.services["vault"]._manager.current_fingerprint
self.active_fingerprint = self.services[
"vault"
]._manager.current_fingerprint
self.notify(f"Loaded session: {self.active_fingerprint[:8]}...")
self.action_refresh()
except Exception as e:
@@ -528,7 +552,9 @@ class SeedPassTuiV3(App[None]):
try:
self.services["entry"].exit_managed_account()
# Update reactive state
self.active_fingerprint = self.services["vault"]._manager.current_fingerprint
self.active_fingerprint = self.services[
"vault"
]._manager.current_fingerprint
self.notify(f"Exited session. Back to: {self.active_fingerprint[:8]}...")
self.action_refresh()
except Exception as e:
@@ -541,9 +567,10 @@ class SeedPassTuiV3(App[None]):
return
if self.selected_entry_id is None:
return
try:
from .screens.edit import EditEntryScreen
self.push_screen(EditEntryScreen(self.selected_entry_id))
except Exception as e:
self.notify(f"Edit failed: {e}", severity="error")
@@ -561,7 +588,9 @@ class SeedPassTuiV3(App[None]):
kind = str(entry.get("kind") or entry.get("type") or "").lower()
if kind in {"document", "note"}:
path = self.services["entry"].export_document_file(self.selected_entry_id)
path = self.services["entry"].export_document_file(
self.selected_entry_id
)
self.notify(f"Document exported to: {path}")
elif kind in {"ssh", "pgp", "nostr"}:
payload = self._resolve_sensitive_payload()
@@ -569,8 +598,14 @@ class SeedPassTuiV3(App[None]):
return
# format: [copy_val, label, public_prefix, sec, pub_prefix, pub]
_, label, _, sec, _, pub = payload
safe_label = "".join(c for c in str(label) if c.isalnum() or c in ('-', '_')).strip() or f"entry_{self.selected_entry_id}"
safe_label = (
"".join(
c for c in str(label) if c.isalnum() or c in ("-", "_")
).strip()
or f"entry_{self.selected_entry_id}"
)
from pathlib import Path
base_path = Path.cwd() / f"{safe_label}_{kind}"
Path(f"{base_path}_pub.txt").write_text(str(pub), encoding="utf-8")
Path(f"{base_path}_sec.txt").write_text(str(sec), encoding="utf-8")
@@ -581,13 +616,21 @@ class SeedPassTuiV3(App[None]):
return
# format: [copy_val, label, pub_p, secret, sec_p, uri]
_, label, _, secret, _, uri = payload
safe_label = "".join(c for c in str(label) if c.isalnum() or c in ('-', '_')).strip() or f"entry_{self.selected_entry_id}"
safe_label = (
"".join(
c for c in str(label) if c.isalnum() or c in ("-", "_")
).strip()
or f"entry_{self.selected_entry_id}"
)
from pathlib import Path
path = Path.cwd() / f"{safe_label}_totp.txt"
path.write_text(f"Secret: {secret}\nURI: {uri}", encoding="utf-8")
self.notify(f"Exported TOTP info to {path.name}")
else:
self.notify(f"Export not supported for kind '{kind}'", severity="warning")
self.notify(
f"Export not supported for kind '{kind}'", severity="warning"
)
except Exception as e:
self.notify(f"Export failed: {e}", severity="error")
@@ -598,6 +641,7 @@ class SeedPassTuiV3(App[None]):
return
try:
from seedpass.core.api import VaultExportRequest
req = VaultExportRequest(path=path)
self.services["vault"].export_vault(req)
self.notify(f"Exported DB to {path}")
@@ -611,14 +655,17 @@ class SeedPassTuiV3(App[None]):
return
try:
from seedpass.core.api import VaultImportRequest
req = VaultImportRequest(path=path)
self.services["vault"].import_vault(req)
self.notify(f"Imported DB from {path}")
self.action_refresh()
except Exception as e:
self.notify(f"Import DB failed: {e}", severity="error")
def _consume_confirm(self, action: str, eid: int) -> bool:
if self._pending_sensitive_confirm is None: return False
if self._pending_sensitive_confirm is None:
return False
p_action, p_eid, p_ts = self._pending_sensitive_confirm
now = time.time()
if p_action == action and p_eid == eid and (now - p_ts) <= 8.0:
@@ -627,7 +674,9 @@ class SeedPassTuiV3(App[None]):
self._pending_sensitive_confirm = None
return False
def _show_sensitive_view(self, include_qr: bool, qr_mode: str = "default", confirm: bool = False) -> None:
def _show_sensitive_view(
self, include_qr: bool, qr_mode: str = "default", confirm: bool = False
) -> None:
try:
payload = self._resolve_sensitive_payload(qr_mode=qr_mode)
title, body, qr_data, secret, kind = payload
@@ -638,15 +687,25 @@ class SeedPassTuiV3(App[None]):
# Check if confirmation is required
requires = False
if include_qr:
requires = kind in {"seed", "managed_account", "nostr"} # for nostr qr we often show nsec if mode is private
requires = kind in {
"seed",
"managed_account",
"nostr",
} # for nostr qr we often show nsec if mode is private
else:
requires = kind in {"seed", "managed_account", "ssh", "pgp", "nostr"}
if requires and not confirm:
key = "g" if include_qr else "v"
self._pending_sensitive_confirm = ("show_qr" if include_qr else "reveal_selected", self.selected_entry_id, time.time())
self._pending_sensitive_confirm = (
"show_qr" if include_qr else "reveal_selected",
self.selected_entry_id,
time.time(),
)
# Update the board with confirmation prompt
self._update_board_sensitive(prompt=f"CONFIRMATION REQUIRED\n\nHigh-risk action for '{kind}'.\nPress '{key}' again within 8s to proceed.")
self._update_board_sensitive(
prompt=f"CONFIRMATION REQUIRED\n\nHigh-risk action for '{kind}'.\nPress '{key}' again within 8s to proceed."
)
self.notify(f"Press '{key}' again to confirm")
return
@@ -661,48 +720,68 @@ class SeedPassTuiV3(App[None]):
self._update_board_sensitive(content=body, title=title)
def _resolve_sensitive_payload(self, qr_mode="default"):
if "entry" not in self.services: raise ValueError("Service offline")
if "entry" not in self.services:
raise ValueError("Service offline")
eid = self.selected_entry_id
entry = self.services["entry"].retrieve_entry(eid)
if not entry: raise ValueError("Entry not found")
if not entry:
raise ValueError("Entry not found")
kind = str(entry.get("kind") or entry.get("type") or "").lower()
label = entry.get("label", "")
if kind == "password":
val = self.services["entry"].generate_password(int(entry.get("length", 16)), eid)
val = self.services["entry"].generate_password(
int(entry.get("length", 16)), eid
)
return ("Password Revealed", val, None, val, kind)
if kind == "totp":
secret = self.services["entry"].get_totp_secret(eid)
from seedpass.core.totp import TotpManager
uri = TotpManager.make_otpauth_uri(label, secret)
return ("TOTP Secret Revealed", secret, uri, secret, kind)
if kind in {"seed", "managed_account"}:
parent_seed = self.services["vault"]._manager.parent_seed
if kind == "seed":
phrase = self.services["entry"].get_seed_phrase(eid, parent_seed)
try:
phrase = self.services["entry"].get_seed_phrase(eid, parent_seed)
except TypeError:
phrase = self.services["entry"].get_seed_phrase(eid)
else:
phrase = self.services["entry"].get_managed_account_seed(eid, parent_seed)
try:
phrase = self.services["entry"].get_managed_account_seed(
eid, parent_seed
)
except TypeError:
phrase = self.services["entry"].get_managed_account_seed(eid)
from seedpass.core.seedqr import encode_seedqr
return ("Seed Words Revealed", phrase, encode_seedqr(phrase), phrase, kind)
if kind == "ssh":
priv, pub = self.services["entry"].get_ssh_key_pair(eid)
return ("SSH Private Key Revealed", priv, pub, pub, kind)
if kind == "pgp":
priv, pub, fp = self.services["entry"].get_pgp_key(eid)
pgp_payload = self.services["entry"].get_pgp_key(eid)
if isinstance(pgp_payload, tuple) and len(pgp_payload) == 3:
priv, pub, _fp = pgp_payload
elif isinstance(pgp_payload, tuple) and len(pgp_payload) == 2:
priv, pub = pgp_payload
else:
raise ValueError("Invalid PGP payload from entry service")
return ("PGP Private Key Revealed", priv, pub, pub, kind)
if kind == "nostr":
npub, nsec = self.services["entry"].get_nostr_key_pair(eid)
qr_data = nsec if qr_mode == "private" else f"nostr:{npub}"
return ("Nostr Secret Revealed", nsec, qr_data, nsec, kind)
if kind == "key_value":
val = entry.get("value", "")
return ("Key-Value Revealed", val, None, val, kind)
if kind in {"document", "note"}:
content = entry.get("content", "")
return ("Document Content", content, None, content, kind)
@@ -710,10 +789,12 @@ class SeedPassTuiV3(App[None]):
# Fallback
return ("Data Revealed", f"Label: {label}\nDetails: {entry}", None, None, kind)
def _update_board_sensitive(self, content: str = None, title: str = None, prompt: str = None):
def _update_board_sensitive(
self, content: str = None, title: str = None, prompt: str = None
):
"""Push sensitive data to the currently active board or screen."""
data = {"content": content, "title": title, "prompt": prompt}
# 1. Update full-screen inspector if active
if isinstance(self.screen, MaximizedInspectorScreen):
self.screen.reveal_data = data
+72 -29
View File
@@ -2,22 +2,32 @@ from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Input, Label, Button, Select, TextArea, Checkbox
from textual.widgets import (
Header,
Footer,
Input,
Label,
Button,
Select,
TextArea,
Checkbox,
)
from textual.containers import Vertical, Horizontal, Container, Grid
from textual.binding import Binding
from seedpass.core.entry_types import EntryType
class AddEntryScreen(Screen):
"""
A unified wizard-style screen for adding new entries.
Supports selecting entry kind and filling relevant fields.
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", show=True),
Binding("escape", "app.pop_screen", "Cancel", show=True),
]
CSS = """
AddEntryScreen {
background: #999999;
@@ -69,35 +79,51 @@ class AddEntryScreen(Screen):
yield Header()
with Container(id="add-container"):
yield Label("ADD NEW ENTRY", id="screen-title")
yield Label("Kind", classes="field-label")
kinds = [(k.value.title(), k.value) for k in EntryType]
# Seed/managed-account derivations are handled by SeedPlusScreen.
supported_kinds = (
EntryType.PASSWORD,
EntryType.TOTP,
EntryType.DOCUMENT,
EntryType.KEY_VALUE,
EntryType.SSH,
EntryType.PGP,
EntryType.NOSTR,
)
kinds = [(k.value.title(), k.value) for k in supported_kinds]
yield Select(kinds, value="password", id="kind-select")
yield Label(
"Use `seed-plus` for Seed / Managed Account derivations.",
id="seedplus-hint",
)
yield Label("Label", classes="field-label")
yield Input(placeholder="Label (e.g. Gmail)", id="entry-label")
# Common fields
with Vertical(id="fields-common"):
yield Label("Username / Email", classes="field-label", id="lbl-username")
yield Label(
"Username / Email", classes="field-label", id="lbl-username"
)
yield Input(placeholder="Username", id="entry-username")
yield Label("URL", classes="field-label", id="lbl-url")
yield Input(placeholder="https://...", id="entry-url")
# Specialized fields (initially hidden/shown based on kind)
with Vertical(id="fields-password", classes="field-group"):
yield Label("Password Length", classes="field-label")
yield Input(value="16", id="entry-length")
with Vertical(id="fields-totp", classes="field-group hidden"):
yield Label("TOTP Secret (Base32)", classes="field-label")
yield Input(placeholder="JBSWY3DPEHPK3PXP", id="entry-totp-secret")
with Vertical(id="fields-document", classes="field-group hidden"):
yield Label("Content", classes="field-label")
yield TextArea(id="entry-content")
with Vertical(id="fields-keyvalue", classes="field-group hidden"):
yield Label("Key", classes="field-label")
yield Input(placeholder="Key", id="entry-key")
@@ -106,7 +132,7 @@ class AddEntryScreen(Screen):
yield Label("Tags (comma separated)", classes="field-label")
yield Input(placeholder="work, personal", id="entry-tags")
with Horizontal(id="action-row"):
yield Button("Cancel (Esc)", id="btn-cancel", variant="error")
yield Button("Save (Ctrl+S)", id="btn-save", variant="success")
@@ -120,7 +146,7 @@ class AddEntryScreen(Screen):
# Hide all groups first
for group in self.query(".field-group"):
group.add_class("hidden")
# Show specific ones
if kind == "password":
self.query_one("#fields-password").remove_class("hidden")
@@ -130,7 +156,7 @@ class AddEntryScreen(Screen):
self.query_one("#fields-document").remove_class("hidden")
elif kind == "key_value":
self.query_one("#fields-keyvalue").remove_class("hidden")
# Toggle common fields visibility if needed
common = self.query_one("#fields-common")
if kind in {"ssh", "pgp", "nostr", "seed", "managed_account"}:
@@ -149,7 +175,7 @@ class AddEntryScreen(Screen):
label = self.query_one("#entry-label", Input).value
tags_raw = self.query_one("#entry-tags", Input).value
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
if not label:
self.app.notify("Label is required", severity="error")
return
@@ -160,11 +186,15 @@ class AddEntryScreen(Screen):
length = int(self.query_one("#entry-length", Input).value or 16)
username = self.query_one("#entry-username", Input).value
url = self.query_one("#entry-url", Input).value
service.add_entry(label=label, username=username, url=url, length=length, tags=tags)
service.add_entry(
label=label, username=username, url=url, length=length, tags=tags
)
elif kind == "totp":
secret = self.query_one("#entry-totp-secret", Input).value
username = self.query_one("#entry-username", Input).value
service.add_totp(label=label, secret=secret, username=username, tags=tags)
service.add_totp(
label=label, secret=secret, username=username, tags=tags
)
elif kind == "ssh":
service.add_ssh_key(label=label, tags=tags)
elif kind == "pgp":
@@ -179,7 +209,10 @@ class AddEntryScreen(Screen):
content = self.query_one("#entry-content", TextArea).text
service.add_document(label=label, content=content, tags=tags)
else:
self.app.notify(f"Adding '{kind}' not yet implemented in v3 screen", severity="warning")
self.app.notify(
f"Adding '{kind}' not yet implemented in v3 screen",
severity="warning",
)
return
self.app.notify(f"Added {kind}: {label}")
@@ -188,10 +221,12 @@ class AddEntryScreen(Screen):
except Exception as e:
self.app.notify(f"Failed to add entry: {e}", severity="error")
class SeedPlusScreen(Screen):
"""
Specialized screen for BIP-85 derivations (Seeds and Managed Accounts).
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", show=True),
Binding("escape", "app.pop_screen", "Cancel", show=True),
@@ -228,19 +263,23 @@ class SeedPlusScreen(Screen):
yield Header()
with Container(id="seed-container"):
yield Label("SEED+ / BIP-85 DERIVATION", id="screen-title")
yield Label("Derivation Type", classes="field-label")
yield Select([("New BIP-39 Seed", "seed"), ("Managed Account", "managed_account")], value="seed", id="seed-kind")
yield Select(
[("New BIP-39 Seed", "seed"), ("Managed Account", "managed_account")],
value="seed",
id="seed-kind",
)
yield Label("Label", classes="field-label")
yield Input(placeholder="Label", id="seed-label")
yield Label("Index", classes="field-label")
yield Input(value="0", id="seed-index")
yield Label("Tags", classes="field-label")
yield Input(placeholder="tags", id="seed-tags")
with Horizontal(id="action-row"):
yield Button("Cancel", id="btn-cancel", variant="error")
yield Button("Derive & Save", id="btn-save", variant="success")
@@ -260,8 +299,12 @@ class SeedPlusScreen(Screen):
except ValueError:
self.app.notify("Index must be an integer", severity="error")
return
tags = [t.strip() for t in self.query_one("#seed-tags", Input).value.split(",") if t.strip()]
tags = [
t.strip()
for t in self.query_one("#seed-tags", Input).value.split(",")
if t.strip()
]
if not label:
self.app.notify("Label is required", severity="error")
@@ -273,7 +316,7 @@ class SeedPlusScreen(Screen):
service.add_seed(label=label, index=index, tags=tags)
else:
service.add_managed_account(label=label, index=index, tags=tags)
self.app.notify(f"Derived {kind}: {label} (Index {index})")
self.app.action_refresh()
self.app.pop_screen()
+42 -24
View File
@@ -6,17 +6,18 @@ from textual.widgets import Header, Footer, Input, TextArea, Label, Button
from textual.containers import Vertical, Horizontal, Container
from textual.binding import Binding
class EditEntryScreen(Screen):
"""
Dedicated full-screen editor for all entries.
Auto-detects the entry kind and shows relative fields.
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", show=True),
Binding("escape", "app.pop_screen", "Cancel", show=True),
]
CSS = """
EditEntryScreen {
background: #999999;
@@ -67,32 +68,52 @@ class EditEntryScreen(Screen):
def on_mount(self) -> None:
if "entry" in self.app.services:
self.entry_data = self.app.services["entry"].retrieve_entry(self.entry_id)
self.kind = str(self.entry_data.get("kind") or self.entry_data.get("type") or "").lower()
self.kind = str(
self.entry_data.get("kind") or self.entry_data.get("type") or ""
).lower()
# Common fields
self.query_one("#entry-title", Input).value = self.entry_data.get("label", "")
self.query_one("#entry-tags", Input).value = ", ".join(self.entry_data.get("tags", []) or [])
self.query_one("#entry-notes", TextArea).text = self.entry_data.get("notes", "")
self.query_one("#entry-title", Input).value = self.entry_data.get(
"label", ""
)
self.query_one("#entry-tags", Input).value = ", ".join(
self.entry_data.get("tags", []) or []
)
self.query_one("#entry-notes", TextArea).text = self.entry_data.get(
"notes", ""
)
# Kind specific fields
if self.kind in {"password", "totp"}:
self.query_one("#fields-user-url").remove_class("hidden")
self.query_one("#entry-username", Input).value = self.entry_data.get("username", "")
self.query_one("#entry-url", Input).value = self.entry_data.get("url", "")
self.query_one("#entry-username", Input).value = self.entry_data.get(
"username", ""
)
self.query_one("#entry-url", Input).value = self.entry_data.get(
"url", ""
)
elif self.kind == "key_value":
self.query_one("#fields-keyvalue").remove_class("hidden")
self.query_one("#entry-key", Input).value = self.entry_data.get("key", "")
self.query_one("#entry-value", Input).value = self.entry_data.get("value", "")
self.query_one("#entry-key", Input).value = self.entry_data.get(
"key", ""
)
self.query_one("#entry-value", Input).value = self.entry_data.get(
"value", ""
)
elif self.kind in {"document", "note"}:
self.query_one("#fields-document").remove_class("hidden")
self.query_one("#doc-type", Input).value = self.entry_data.get("file_type", "txt")
self.query_one("#doc-content", TextArea).text = self.entry_data.get("content", "")
self.query_one("#doc-type", Input).value = self.entry_data.get(
"file_type", "txt"
)
self.query_one("#doc-content", TextArea).text = self.entry_data.get(
"content", ""
)
def compose(self) -> ComposeResult:
yield Header()
with Container(id="editor-container"):
yield Label(f"EDIT ENTRY #{self.entry_id}", id="screen-title")
with Horizontal(id="edit-row"):
with Vertical(id="col-left"):
yield Label("Title / Label", classes="field-label")
@@ -100,20 +121,20 @@ class EditEntryScreen(Screen):
with Vertical(id="col-right"):
yield Label("Tags (comma separated)", classes="field-label")
yield Input(placeholder="work, personal...", id="entry-tags")
# Optional fields
with Vertical(id="fields-user-url", classes="hidden"):
yield Label("Username / Email", classes="field-label")
yield Input(placeholder="Username", id="entry-username")
yield Label("URL", classes="field-label")
yield Input(placeholder="https://...", id="entry-url")
with Vertical(id="fields-keyvalue", classes="hidden"):
yield Label("Key", classes="field-label")
yield Input(placeholder="Key", id="entry-key")
yield Label("Value", classes="field-label")
yield Input(placeholder="Value", id="entry-value")
with Vertical(id="fields-document", classes="hidden"):
yield Label("File Type", classes="field-label")
yield Input(placeholder="txt", id="doc-type")
@@ -122,7 +143,7 @@ class EditEntryScreen(Screen):
yield Label("Notes", classes="field-label")
yield TextArea(id="entry-notes")
with Horizontal(id="action-row"):
yield Button("Cancel (Esc)", id="btn-cancel", variant="error")
yield Button("Save (Ctrl+S)", id="btn-save", variant="success")
@@ -139,7 +160,7 @@ class EditEntryScreen(Screen):
tags_raw = self.query_one("#entry-tags", Input).value
notes = self.query_one("#entry-notes", TextArea).text
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
update_args = {
"label": title,
"tags": tags,
@@ -156,12 +177,9 @@ class EditEntryScreen(Screen):
elif self.kind in {"document", "note"}:
update_args["file_type"] = self.query_one("#doc-type", Input).value
update_args["content"] = self.query_one("#doc-content", TextArea).text
try:
self.app.services["entry"].modify_entry(
self.entry_id,
**update_args
)
self.app.services["entry"].modify_entry(self.entry_id, **update_args)
self.app.notify(f"Entry #{self.entry_id} saved successfully")
self.app.action_refresh()
self.app.pop_screen()
+23 -13
View File
@@ -6,16 +6,18 @@ from textual.widgets import Static, Footer
from textual.containers import Vertical, Horizontal
from textual.reactive import reactive
class MaximizedInspectorScreen(Screen):
"""
Full-screen detailed view for a single entry.
Optimized for long documents, SSH keys, or PGP blocks.
"""
reveal_data = reactive[dict[str, Any]]({})
def watch_reveal_data(self, old, new):
self._refresh_detail()
BINDINGS = [
("escape", "app.pop_screen", "Back to Vault"),
("v", "reveal", "Reveal Secret"),
@@ -71,15 +73,21 @@ class MaximizedInspectorScreen(Screen):
r = self.reveal_data
if eid is None:
self.query_one("#max-inspector-content", Static).update("No entry selected.")
self.query_one("#max-inspector-content", Static).update(
"No entry selected."
)
return
if r.get("prompt"):
self.query_one("#max-inspector-content", Static).update(f"[b]SECURITY ALERT[/b]\n\n{r['prompt']}")
self.query_one("#max-inspector-content", Static).update(
f"[b]SECURITY ALERT[/b]\n\n{r['prompt']}"
)
return
if r.get("content") and "##" in r["content"]:
self.query_one("#max-inspector-content", Static).update(f"[b]SECURE QR DISPLAY[/b]\n\n{r['content']}")
self.query_one("#max-inspector-content", Static).update(
f"[b]SECURE QR DISPLAY[/b]\n\n{r['content']}"
)
return
if "entry" not in app.services:
@@ -90,13 +98,13 @@ class MaximizedInspectorScreen(Screen):
return
# Simple render for the full screen view
label = entry.get('label', 'Unknown')
kind = entry.get('kind', 'Unknown')
label = entry.get("label", "Unknown")
kind = entry.get("kind", "Unknown")
# Build a large detail view
header = f"[b]ENTRY #{eid} - {label.upper()} ({kind})[/b]\n"
divider = "=" * 60 + "\n"
# Meta info
meta_lines = [
f"Username : {entry.get('username', '(none)')}",
@@ -104,14 +112,16 @@ class MaximizedInspectorScreen(Screen):
f"Created : {entry.get('created_at', '(unknown)')}",
f"Modified : {entry.get('modified_at', '(unknown)')}",
]
# Handle revealed content
content = entry.get('content', '')
content = entry.get("content", "")
if r.get("content"):
content = f"[b][cyan]{r['content']}[/b]"
content_block = f"\n[b]CONTENT / SECURE DATA:[/b]\n{divider}{content}\n" if content else ""
content_block = (
f"\n[b]CONTENT / SECURE DATA:[/b]\n{divider}{content}\n" if content else ""
)
final_text = header + divider + "\n".join(meta_lines) + content_block
self.query_one("#max-inspector-content", Static).update(final_text)
+21 -10
View File
@@ -5,11 +5,12 @@ from textual.screen import Screen
from textual.widgets import Static, Footer, DataTable, Input, Button
from textual.containers import Vertical, Horizontal
class RelaysScreen(Screen):
"""
A dedicated screen for managing Nostr relays and triggering synchronization.
"""
BINDINGS = [
("escape", "app.pop_screen", "Back to Vault"),
("r", "refresh_relays", "Refresh"),
@@ -83,7 +84,10 @@ class RelaysScreen(Screen):
with Horizontal(id="relays-controls"):
yield Input(placeholder="wss://relay.example.com", id="relay-input")
yield Button("Add Relay", id="add-button")
yield Static("ESC: Exit | R: Refresh | D: Delete Selected | S: Sync Now", id="relays-footer")
yield Static(
"ESC: Exit | R: Refresh | D: Delete Selected | S: Sync Now",
id="relays-footer",
)
def on_mount(self) -> None:
table = self.query_one(DataTable)
@@ -107,7 +111,7 @@ class RelaysScreen(Screen):
table.clear()
for idx, url in enumerate(relays):
table.add_row("🟢 Active", url, key=str(idx))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "add-button":
self._add_relay()
@@ -121,21 +125,23 @@ class RelaysScreen(Screen):
url = inp.value.strip()
if not url:
return
if not url.startswith("ws://") and not url.startswith("wss://"):
self.app.notify("Relay URL must start with ws:// or wss://", severity="error")
self.app.notify(
"Relay URL must start with ws:// or wss://", severity="error"
)
return
service = self.app.services.get("nostr")
if not service:
return
try:
relays = service.list_relays()
if url in relays:
self.app.notify("Relay already exists", severity="warning")
return
service.add_relay(url)
inp.value = ""
self.action_refresh_relays()
@@ -160,7 +166,7 @@ class RelaysScreen(Screen):
service = self.app.services.get("nostr")
if not service:
return
try:
service.remove_relay(idx)
self.action_refresh_relays()
@@ -181,8 +187,13 @@ class RelaysScreen(Screen):
sync_service = self.app.services.get("sync")
try:
import traceback
sync_service.sync()
self.app.call_from_thread(self.app.notify, "Synchronization complete", severity="information")
self.app.call_from_thread(
self.app.notify, "Synchronization complete", severity="information"
)
self.app.call_from_thread(self.app.action_refresh)
except Exception as e:
self.app.call_from_thread(self.app.notify, f"Sync failed: {e}", severity="error")
self.app.call_from_thread(
self.app.notify, f"Sync failed: {e}", severity="error"
)
+12 -6
View File
@@ -5,12 +5,13 @@ from textual.screen import Screen
from textual.widgets import Static, Footer
from textual.containers import Vertical
class SettingsScreen(Screen):
"""
A dedicated, full-screen settings management interface.
Categorizes configuration into Security, Storage, and Connectivity.
"""
BINDINGS = [
("escape", "app.pop_screen", "Back to Vault"),
("r", "refresh", "Refresh Settings"),
@@ -54,7 +55,10 @@ class SettingsScreen(Screen):
yield Static("SeedPass ◈ System Configuration", id="settings-title")
with Vertical(id="settings-container"):
yield Static("Loading configuration...", id="settings-content")
yield Static("ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values", id="settings-footer")
yield Static(
"ESC: Exit Settings | R: Reload | Use Palette (Ctrl+P) to change values",
id="settings-footer",
)
def on_mount(self) -> None:
self.action_refresh()
@@ -63,11 +67,13 @@ class SettingsScreen(Screen):
"""Fetch live settings and render them into the content panel."""
app = self.app
if "config" not in app.services:
self.query_one("#settings-content", Static).update("[red]Config Service Offline[/red]")
self.query_one("#settings-content", Static).update(
"[red]Config Service Offline[/red]"
)
return
service = app.services["config"]
def get_val(key, default=""):
try:
val = service.get(key)
@@ -83,7 +89,7 @@ class SettingsScreen(Screen):
f"KDF Mode : {get_val('kdf_mode', 'argon2id')} [dim](setting-kdf-mode <mode>)[/dim]",
f"Lock Timeout : {get_val('inactivity_timeout', 300)}s [dim](setting-timeout <s>)[/dim]",
]
backup_rows = [
f"Backup Path : {get_val('additional_backup_path', '(none)')} [dim](db-export <path>)[/dim]",
f"Backup Interval: {get_val('backup_interval', 3600)}s",
@@ -113,6 +119,6 @@ class SettingsScreen(Screen):
"",
render_card("CONNECTIVITY & NOSTR SYNC", nostr_rows),
]
self.query_one("#settings-content", Static).update("\n".join(rendered_lines))
self.app.notify("Settings reloaded")
+26 -16
View File
@@ -4,12 +4,13 @@ from textual.app import ComposeResult
from textual.widgets import Static
from textual.reactive import reactive
class ActionBar(Static):
"""
Bottom action bar showing global shortcuts and context actions.
Matches the 'Settings (S) Add (A) ...' layout.
"""
DEFAULT_CSS = """
ActionBar {
height: auto;
@@ -25,22 +26,29 @@ class ActionBar(Static):
def render(self) -> str:
app = self.app
selected_id = app.selected_entry_id
# Global Row - mockup format
is_managed = False
try:
is_managed = len(getattr(app.services.get("vault")._manager, "profile_stack", [])) > 0
is_managed = (
len(getattr(app.services.get("vault")._manager, "profile_stack", []))
> 0
)
except:
pass
exit_hint = " [@click='app.managed_exit'][b][M][/b]anaged Exit[/]" if is_managed else ""
exit_hint = (
" [@click='app.managed_exit'][b][M][/b]anaged Exit[/]"
if is_managed
else ""
)
global_row = (
f"[@click='app.toggle_settings'][b][S][/b]ettings[/] [@click='app.add_entry'][b][A][/b]dd New Entry[/] "
f"[@click='app.seed_plus'][b][C][/b]reate New Seed[/] "
f"[@click='app.open_palette'][b][R][/b]emove Seed[/] "
f"[@click='app.action_db_export'][b][E][/b]xport Data[/] [@click='app.action_db_import'][b][I][/b]mport Data[/] [@click='app.open_palette'][b][B][/b]ackup Data[/]{exit_hint}"
)
# Context Row (added from previous phase parity)
if selected_id is None:
context = "Select an entry to view actions."
@@ -48,24 +56,26 @@ class ActionBar(Static):
try:
entry = app.services["entry"].retrieve_entry(selected_id)
kind = str(entry.get("kind") or entry.get("type") or "").lower()
actions = [
"[@click='app.reveal_selected'][b][v][/b] Reveal[/]",
"[@click='app.show_qr'][b][g][/b] QR[/]",
"[@click='app.edit_selected'][b][e][/b] Edit[/]",
"[@click='app.toggle_archive'][b][a][/b] Archive[/]",
"[@click='app.copy_selected'][b][c][/b] Copy[/]",
"[@click='app.maximize_inspector'][b][z][/b] Maximize[/]"
"[@click='app.reveal_selected'][b][v][/b] Reveal[/]",
"[@click='app.show_qr'][b][g][/b] QR[/]",
"[@click='app.edit_selected'][b][e][/b] Edit[/]",
"[@click='app.toggle_archive'][b][a][/b] Archive[/]",
"[@click='app.copy_selected'][b][c][/b] Copy[/]",
"[@click='app.maximize_inspector'][b][z][/b] Maximize[/]",
]
if kind in {"managed_account", "seed"}:
actions.insert(4, "[@click='app.managed_load'][b][m][/b] Load[/]")
if kind in {"document", "note"}:
actions.insert(5, "[@click='app.export_selected'][b][x][/b] Export[/]")
actions.insert(
5, "[@click='app.export_selected'][b][x][/b] Export[/]"
)
context = f" Context ({kind}): " + " ▣ ".join(actions)
except Exception:
context = f" Error fetching context details for Entry #{selected_id}"
return f"{global_row}\n{context}"
def on_mount(self) -> None:
+60 -28
View File
@@ -4,11 +4,13 @@ from textual.app import ComposeResult
from textual.widgets import Static, DataTable
from textual.reactive import reactive
class GridMetrics(Static):
"""
Displays pagination, row counts, and search mode state.
Matches the 'Entry Grid | Pg X/X ...' line in the mockups.
"""
DEFAULT_CSS = """
GridMetrics {
height: 1;
@@ -17,7 +19,7 @@ class GridMetrics(Static):
padding: 0 1;
}
"""
def render(self) -> str:
app = self.app
# These would eventually come from App reactives
@@ -26,21 +28,23 @@ class GridMetrics(Static):
try:
table = app.screen.query_one("#entry-data-table")
if table:
rows = f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total
rows = (
f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total
)
except Exception:
pass
density = "compact"
# Search mode highlighting
m = app.search_mode
keyword = f"[b](KEYWORD)[/b]" if m == "keyword" else "keyword"
hybrid = f"[b](HYBRID)[/b]" if m == "hybrid" else "hybrid"
semantic = f"[b](SEMANTIC)[/b]" if m == "semantic" else "semantic"
search = f"{keyword} {hybrid} {semantic}"
filter_text = f"Filter: [b]{app.filter_kind}[/b]"
arch_text = " | [reverse] ARCHIVED [/reverse]" if app.show_archived else ""
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} {filter_text}{arch_text} Search {search}"
def on_mount(self) -> None:
@@ -49,15 +53,15 @@ class GridMetrics(Static):
self.watch(self.app, "filter_kind", self.refresh)
self.watch(self.app, "show_archived", self.refresh)
class EntryDataTable(DataTable):
"""
High-fidelity data table for entry browsing.
"""
def on_mount(self) -> None:
self.cursor_type = "row"
self.add_columns(
"Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch"
)
self.add_columns("Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch")
self._refresh_data()
def on_focus(self) -> None:
@@ -75,13 +79,21 @@ class EntryDataTable(DataTable):
# Map filter presets to kind lists
kind_map = {
"secrets": ["password", "totp", "ssh", "pgp", "nostr", "seed", "managed_account"],
"secrets": [
"password",
"totp",
"ssh",
"pgp",
"nostr",
"seed",
"managed_account",
],
"docs": ["document", "note"],
"keys": ["ssh", "pgp", "nostr", "seed", "managed_account"],
"2fa": ["totp"],
}
kinds = kind_map.get(app.filter_kind)
entries = []
# Use semantic search if requested and available
if app.search_mode != "keyword" and "semantic" in app.services and query:
@@ -93,50 +105,68 @@ class EntryDataTable(DataTable):
# Fetch full entry to match table columns
entry = app.services["entry"].retrieve_entry(eid)
if entry:
kind_val = str(entry.get("kind") or entry.get("type") or "").lower()
kind_val = str(
entry.get("kind") or entry.get("type") or ""
).lower()
# Filtering for semantic results (best effort)
if kinds and kind_val not in kinds: continue
if kinds and kind_val not in kinds:
continue
is_arch = bool(entry.get("archived", False))
if app.show_archived and not is_arch: continue
if not app.show_archived and is_arch: continue
if app.show_archived and not is_arch:
continue
if not app.show_archived and is_arch:
continue
from seedpass.core.entry_types import EntryType
try:
etype = EntryType(kind_val)
except ValueError:
etype = kind_val
entries.append((
eid,
entry.get("label", ""),
entry.get("username"),
entry.get("url"),
is_arch,
etype
))
entries.append(
(
eid,
entry.get("label", ""),
entry.get("username"),
entry.get("url"),
is_arch,
etype,
)
)
except Exception as e:
app.notify(f"Semantic search failed: {e}", severity="error")
# Fallback to lexical
entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived)
entries = app.services["entry"].search_entries(
query,
kinds=kinds,
include_archived=app.show_archived,
archived_only=app.show_archived,
)
else:
# Fetch filtered data from standard service
entries = app.services["entry"].search_entries(query, kinds=kinds, include_archived=app.show_archived, archived_only=app.show_archived)
entries = app.services["entry"].search_entries(
query,
kinds=kinds,
include_archived=app.show_archived,
archived_only=app.show_archived,
)
for i, (eid, label, user, url, arch, kind) in enumerate(entries):
marker = "▶" if eid == app.selected_entry_id else " "
arch_status = "🔒" if arch else " "
# 'Meta' column logic from mockup
meta = user or url or ""
self.add_row(
marker,
str(i+1),
str(i + 1),
f"#{eid}",
label,
kind.value,
meta,
arch_status,
key=str(eid)
key=str(eid),
)
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
@@ -159,10 +189,12 @@ class EntryDataTable(DataTable):
except ValueError:
pass
class GridContainer(Static):
"""
The central workspace container.
"""
def compose(self) -> ComposeResult:
yield GridMetrics()
# Header divider
+13 -10
View File
@@ -3,14 +3,15 @@ from textual.app import ComposeResult
from textual.widgets import Static
from textual.reactive import reactive
class RibbonHeader(Static):
"""
A high-density status ribbon that displays active profile,
A high-density status ribbon that displays active profile,
session state, and vault metrics.
Matches the 'UI Board' mockup top ribbon layout.
"""
DEFAULT_CSS = """
RibbonHeader {
height: 1;
@@ -24,14 +25,16 @@ class RibbonHeader(Static):
def render(self) -> str:
app = self.app
# Gather metrics from actual services
managed = 0
try:
managed = len(getattr(app.services.get("vault")._manager, "profile_stack", []))
managed = len(
getattr(app.services.get("vault")._manager, "profile_stack", [])
)
except:
pass
entries = 0
try:
table = app.screen.query_one("#entry-data-table")
@@ -39,17 +42,17 @@ class RibbonHeader(Static):
entries = len(table.rows)
except:
pass
sync = "YYY-MM-DDThh:mm:ssTZD"
# Build the exact string parts from the mockup
left = f"Managed Users: {managed} Entries: {entries} ◀ ▶ Kind = {app.filter_kind.capitalize()}"
right = f"Last Sync: {sync}"
# Calculate spacing to push sync to the right edge
available_width = self.size.width - len(left) - len(right) - 2
spacer = " " * max(1, available_width)
return f"{left}{spacer}{right}"
def on_mount(self) -> None:
+135 -64
View File
@@ -5,8 +5,10 @@ from textual.widgets import Static, Label
from textual.containers import Vertical, Horizontal
from textual.reactive import reactive
class BaseBoard(Static):
"""Base class for all specialized entry boards."""
entry_data = reactive[dict[str, Any]]({})
reveal_data = reactive[dict[str, Any]]({})
@@ -19,8 +21,10 @@ class BaseBoard(Static):
def update_board(self) -> None:
pass
class InspectorHeader(Static):
"""Reusable high-fidelity header matching the UI Board mockups."""
DEFAULT_CSS = """
InspectorHeader {
height: 3;
@@ -39,24 +43,29 @@ class InspectorHeader(Static):
.edit-btn { background: #ffffff; color: #000000; padding: 0 2; height: 1; margin-top: 1; margin-right: 1; text-style: bold; }
"""
def compose(self) -> ComposeResult:
with Vertical(classes="title-block"):
yield Label("Title", id="hdr-title", classes="title-text")
yield Label("Type", id="hdr-type", classes="type-text")
with Horizontal(classes="meta-block"):
yield Label("[b]Date Modified[/b]\nYYYY-MM-DD", id="hdr-date", classes="meta-item")
yield Label(
"[b]Date Modified[/b]\nYYYY-MM-DD", id="hdr-date", classes="meta-item"
)
yield Label("[b]Black Listed? No[/b]", id="hdr-bl", classes="meta-item")
yield Label("[b]Index Num*:[/b]\n0", id="hdr-idx", classes="meta-item")
yield Label("[b]Entry Num:[/b]\n1", id="hdr-enum", classes="meta-item")
yield Label("[@click=app.edit_selected]Edit[/]", classes="edit-btn")
def update_data(self, entry: dict, kind_label: str):
if not self.is_mounted: return
if not self.is_mounted:
return
try:
self.query_one("#hdr-title", Label).update(f"[b]{entry.get('label', 'Untitled')}[/b]")
self.query_one("#hdr-title", Label).update(
f"[b]{entry.get('label', 'Untitled')}[/b]"
)
self.query_one("#hdr-type", Label).update(kind_label)
idx = str(entry.get('id', 'N/A'))
idx = str(entry.get("id", "N/A"))
self.query_one("#hdr-idx", Label).update(f"[b]Index Num*:[/b]\n{idx}")
self.query_one("#hdr-enum", Label).update(f"[b]Entry Num:[/b]\n{idx}")
except Exception:
@@ -65,12 +74,17 @@ class InspectorHeader(Static):
class IdleBoard(BaseBoard):
"""Shown when no entry is selected."""
def compose(self) -> ComposeResult:
yield Label("\n\n[b]Inspector Idle[/b]\n\nSelect an entry from the grid\nto view details.", id="idle-text")
yield Label(
"\n\n[b]Inspector Idle[/b]\n\nSelect an entry from the grid\nto view details.",
id="idle-text",
)
class PasswordBoard(BaseBoard):
"""Matches 'Password Board.png' mockup."""
DEFAULT_CSS = """
PasswordBoard {
background: #000000;
@@ -97,9 +111,15 @@ class PasswordBoard(BaseBoard):
with Vertical(classes="field-box highlight"):
yield Label("Password*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("**********", id="fld-pass-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label("[@click=app.add_entry]Create New[/]", classes="action-btn")
yield Label(
"**********", id="fld-pass-val", classes="field-val"
)
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
yield Label(
"[@click=app.add_entry]Create New[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Username*", classes="field-title")
yield Label("-", id="fld-user-val")
@@ -114,26 +134,29 @@ class PasswordBoard(BaseBoard):
yield Label("", id="fld-notes-val")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
try:
self.query_one("#password-header", InspectorHeader).update_data(d, "Password")
self.query_one("#password-header", InspectorHeader).update_data(
d, "Password"
)
pwd = "[b]**********[/b] (v to reveal)"
if r.get("content"):
pwd = r.get("content")
self.query_one("#fld-pass-val", Label).update(pwd)
self.query_one("#fld-user-val", Label).update(d.get("username", "-"))
self.query_one("#fld-url-val", Label).update(d.get("url", "-"))
tags = d.get('tags', [])
tags = d.get("tags", [])
tag_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
self.query_one("#fld-tags-val", Label).update(tag_str or "-")
notes = d.get('notes', '')
notes = d.get("notes", "")
self.query_one("#fld-notes-val", Label).update(notes)
except Exception:
pass
@@ -141,6 +164,7 @@ class PasswordBoard(BaseBoard):
class TotpBoard(BaseBoard):
"""Matches '2FA Board.png' mockup."""
DEFAULT_CSS = """
TotpBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; }
.board-body { layout: horizontal; height: 1fr; }
@@ -162,8 +186,12 @@ class TotpBoard(BaseBoard):
with Vertical(classes="field-box highlight"):
yield Label("2FA Code*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("------ (30s left)", id="fld-code-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"------ (30s left)", id="fld-code-val", classes="field-val"
)
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Secret", classes="field-title")
yield Label("-", id="fld-secret-val")
@@ -174,14 +202,15 @@ class TotpBoard(BaseBoard):
yield Label("QR Code (Press g)", id="fld-qr-val", classes="qr-text")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
app = self.app
try:
self.query_one("#totp-header", InspectorHeader).update_data(d, "2FA")
code = "------"
if app.services.get("entry") and not app.session_locked:
try:
@@ -189,27 +218,33 @@ class TotpBoard(BaseBoard):
except:
pass
import time
remaining = 30 - (int(time.time()) % 30)
self.query_one("#fld-code-val", Label).update(f"[b]{code}[/b] ({remaining}s left)")
self.query_one("#fld-code-val", Label).update(
f"[b]{code}[/b] ({remaining}s left)"
)
secret = "[b]**********[/b] (v to reveal)"
if r.get("content") and "##" not in r.get("content"):
secret = r.get("content")
self.query_one("#fld-secret-val", Label).update(secret)
self.query_one("#fld-algo-val", Label).update(d.get("algorithm", "SHA1").upper())
self.query_one("#fld-algo-val", Label).update(
d.get("algorithm", "SHA1").upper()
)
qr_content = "QR Code (Press g)"
if r.get("content") and "##" in r.get("content"):
qr_content = r.get("content")
self.query_one("#fld-qr-val", Label).update(qr_content)
except Exception:
pass
class NoteBoard(BaseBoard):
"""Matches 'Note Board.png' mockup."""
DEFAULT_CSS = """
NoteBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; }
.board-body { layout: horizontal; height: 1fr; border: solid #ffffff; padding: 1; }
@@ -221,18 +256,20 @@ class NoteBoard(BaseBoard):
yield Label("Content", id="fld-content-val")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
try:
kind_label = "Note" if d.get('kind', '') == 'note' else "Document"
kind_label = "Note" if d.get("kind", "") == "note" else "Document"
self.query_one("#note-header", InspectorHeader).update_data(d, kind_label)
self.query_one("#fld-content-val", Label).update(d.get('content', ''))
self.query_one("#fld-content-val", Label).update(d.get("content", ""))
except Exception:
pass
class SeedBoard(BaseBoard):
"""Matches 'BIP-39 Seed Board.png' mockup."""
DEFAULT_CSS = """
SeedBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; }
.board-body { layout: horizontal; height: 1fr; }
@@ -254,8 +291,12 @@ class SeedBoard(BaseBoard):
with Vertical(classes="field-box highlight"):
yield Label("Seed Phrase*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("[HIDDEN]", id="fld-phrase-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[HIDDEN]", id="fld-phrase-val", classes="field-val"
)
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Fingerprint", classes="field-title")
yield Label("-", id="fld-fp-val")
@@ -266,17 +307,24 @@ class SeedBoard(BaseBoard):
yield Label("QR Code (Press gg)", id="fld-qr-val", classes="qr-text")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
try:
kind_label = "BIP-39" if d.get('kind', '') in {'seed', 'managed_account'} else d.get('kind', 'Seed')
self.query_one("#seed-header", InspectorHeader).update_data(d, kind_label.capitalize())
kind_label = (
"BIP-39"
if d.get("kind", "") in {"seed", "managed_account"}
else d.get("kind", "Seed")
)
self.query_one("#seed-header", InspectorHeader).update_data(
d, kind_label.capitalize()
)
phrase = "[b]**********[/b] (vv to reveal)"
if r.get("content") and "##" not in r.get("content"):
phrase = r.get("content")
self.query_one("#fld-phrase-val", Label).update(phrase)
self.query_one("#fld-fp-val", Label).update(d.get("fingerprint", "-"))
self.query_one("#fld-wc-val", Label).update(str(d.get("word_count", 24)))
@@ -291,6 +339,7 @@ class SeedBoard(BaseBoard):
class SshBoard(BaseBoard):
"""Matches 'SSH Board.png' mockup."""
DEFAULT_CSS = """
SshBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; }
.board-body { layout: horizontal; height: 1fr; }
@@ -312,12 +361,16 @@ class SshBoard(BaseBoard):
yield Label("Private Key*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("[HIDDEN]", id="fld-priv-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Public Key", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("[PREVIEW]", id="fld-pub-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Key Type", classes="field-title")
yield Label("Ed25519", id="fld-type-val")
@@ -326,7 +379,8 @@ class SshBoard(BaseBoard):
yield Label("", id="fld-notes-val")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
try:
@@ -336,12 +390,15 @@ class SshBoard(BaseBoard):
if r.get("content"):
priv = r.get("content")
self.query_one("#fld-priv-val", Label).update(priv)
pub = d.get('public_key', '[PREVIEW]')
if len(pub) > 30: pub = pub[:30] + "..."
pub = d.get("public_key", "[PREVIEW]")
if len(pub) > 30:
pub = pub[:30] + "..."
self.query_one("#fld-pub-val", Label).update(pub)
self.query_one("#fld-type-val", Label).update(d.get("key_type", "RSA/Ed25519"))
self.query_one("#fld-type-val", Label).update(
d.get("key_type", "RSA/Ed25519")
)
self.query_one("#fld-notes-val", Label).update(d.get("notes", ""))
except Exception:
pass
@@ -349,6 +406,7 @@ class SshBoard(BaseBoard):
class PgpBoard(BaseBoard):
"""Matches 'PGP Board.png' mockup."""
DEFAULT_CSS = SshBoard.DEFAULT_CSS.replace("SshBoard", "PgpBoard")
def compose(self) -> ComposeResult:
@@ -359,7 +417,9 @@ class PgpBoard(BaseBoard):
yield Label("Private Key*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("[HIDDEN]", id="fld-priv-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Fingerprint / ID", classes="field-title")
yield Label("-", id="fld-fp-val")
@@ -371,7 +431,8 @@ class PgpBoard(BaseBoard):
yield Label("", id="fld-notes-val")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
try:
@@ -381,7 +442,7 @@ class PgpBoard(BaseBoard):
if r.get("content"):
priv = r.get("content")
self.query_one("#fld-priv-val", Label).update(priv)
self.query_one("#fld-fp-val", Label).update(d.get("fingerprint", "-"))
self.query_one("#fld-ident-val", Label).update(d.get("user_id", "-"))
self.query_one("#fld-notes-val", Label).update(d.get("notes", ""))
@@ -391,6 +452,7 @@ class PgpBoard(BaseBoard):
class NostrBoard(BaseBoard):
"""Matches 'Nostr Board.png' mockup."""
DEFAULT_CSS = TotpBoard.DEFAULT_CSS.replace("TotpBoard", "NostrBoard")
def compose(self) -> ComposeResult:
@@ -401,12 +463,16 @@ class NostrBoard(BaseBoard):
yield Label("nsec (Secret)*", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("[HIDDEN]", id="fld-nsec-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("npub (Public)", classes="field-title")
with Horizontal(classes="field-row"):
yield Label("-", id="fld-npub-val", classes="field-val")
yield Label("[@click=app.copy_selected]Copy[/]", classes="action-btn")
yield Label(
"[@click=app.copy_selected]Copy[/]", classes="action-btn"
)
with Vertical(classes="field-box"):
yield Label("Hex Key", classes="field-title")
yield Label("-", id="fld-hex-val")
@@ -414,7 +480,8 @@ class NostrBoard(BaseBoard):
yield Label("QR Code (Press g)", id="fld-qr-val", classes="qr-text")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
try:
@@ -424,11 +491,12 @@ class NostrBoard(BaseBoard):
if r.get("content") and "##" not in r.get("content"):
nsec = r.get("content")
self.query_one("#fld-nsec-val", Label).update(nsec)
npub = d.get('npub', '-')
if len(npub) > 30: npub = npub[:30] + "..."
npub = d.get("npub", "-")
if len(npub) > 30:
npub = npub[:30] + "..."
self.query_one("#fld-npub-val", Label).update(npub)
self.query_one("#fld-hex-val", Label).update(d.get("hex", "-"))
qr_content = "QR Code (Press g)"
@@ -441,6 +509,7 @@ class NostrBoard(BaseBoard):
class GenericBoard(BaseBoard):
"""Fallback high-fidelity board."""
DEFAULT_CSS = """
GenericBoard { background: #000000; color: #ffffff; padding: 0 1; height: 1fr; }
.board-body { layout: horizontal; height: 1fr; }
@@ -466,27 +535,28 @@ class GenericBoard(BaseBoard):
yield Label("", id="fld-notes-val")
def update_board(self) -> None:
if not self.is_mounted: return
if not self.is_mounted:
return
d = self.entry_data
r = self.reveal_data
kind = str(d.get('kind', d.get('type', 'Unknown'))).capitalize()
kind = str(d.get("kind", d.get("type", "Unknown"))).capitalize()
try:
self.query_one("#generic-header", InspectorHeader).update_data(d, kind)
secret = "[HIDDEN] (v to reveal)"
if r.get("content"):
secret = rf"{r.get('content')}"
self.query_one("#fld-secret-val", Label).update(secret)
details = []
for k, v in d.items():
if k not in ('label', 'notes', 'tags', 'kind', 'id', 'type', 'content'):
if k not in ("label", "notes", "tags", "kind", "id", "type", "content"):
details.append(f"[b]{k.capitalize()}:[/b] {v}")
self.query_one("#fld-details-val", Label).update("\n".join(details) or "-")
self.query_one("#fld-notes-val", Label).update(d.get('notes', ''))
self.query_one("#fld-notes-val", Label).update(d.get("notes", ""))
except Exception:
pass
@@ -495,6 +565,7 @@ class BoardContainer(Vertical):
"""
Reactive container that swaps specialized boards based on entry kind.
"""
DEFAULT_CSS = """
BoardContainer {
height: 1fr;
@@ -523,7 +594,7 @@ class BoardContainer(Vertical):
return
kind = str(entry.get("kind") or entry.get("type") or "").lower()
if kind in {"password", "stored_password"}:
board = self._show_board("password")
elif kind == "totp":
@@ -540,7 +611,7 @@ class BoardContainer(Vertical):
board = self._show_board("nostr")
else:
board = self._show_board("generic")
board.entry_data = entry
def _show_board(self, board_type: str) -> Any:
+4 -2
View File
@@ -5,12 +5,13 @@ from textual.widgets import Input, Static
from textual.containers import Vertical
from textual.message import Message
class CommandPalette(Vertical):
"""
The TUI v3 Command Palette.
Docks at the top and provides a unified entry point for all actions.
"""
DEFAULT_CSS = """
CommandPalette {
dock: top;
@@ -61,10 +62,11 @@ class CommandPalette(Vertical):
if cmd:
self.app.post_message(self.CommandExecuted(cmd))
self.input.value = ""
self.toggle() # Close after execution
self.toggle() # Close after execution
class CommandExecuted(Message):
"""Sent when a command is submitted."""
def __init__(self, command: str) -> None:
self.command = command
super().__init__()
+9 -6
View File
@@ -4,10 +4,11 @@ from textual.app import ComposeResult
from textual.widgets import Tree, Static
from textual.widgets.tree import TreeNode
class ProfileTree(Tree):
"""
A hierarchical sidebar that manages Seed Profiles, Managed Accounts, and Agents.
Structure:
Root (Hidden)
├── Fingerprint (Parent Seed)
@@ -16,7 +17,7 @@ class ProfileTree(Tree):
│ └── Managed Account 2
└── External Fingerprint
"""
def on_mount(self) -> None:
self.root.expand()
self._refresh_tree()
@@ -31,14 +32,14 @@ class ProfileTree(Tree):
service = app.services["profile"]
entry_service = app.services.get("entry")
profiles = service.list_profiles()
for fp in profiles:
is_active = fp == app.active_fingerprint
icon = "■ " if is_active else "□ "
label = f"{icon}{fp[:12]}..." if len(fp) > 12 else f"{icon}{fp}"
node = self.root.add(label, data=fp, expand=is_active)
# If this is the active profile or we have the entry service,
# If this is the active profile or we have the entry service,
# we can fetch nested managed accounts.
if entry_service and is_active:
# Find managed accounts in the current profile
@@ -46,7 +47,7 @@ class ProfileTree(Tree):
managed = entry_service.search_entries("", kinds=["managed_account"])
for mid, mlabel, _, _, _, _ in managed:
node.add_leaf(f"├─ {mlabel}", data=f"managed:{mid}")
# Find agents (nostr)
agents = entry_service.search_entries("", kinds=["nostr"])
for aid, alabel, _, _, _, _ in agents:
@@ -72,8 +73,10 @@ class ProfileTree(Tree):
self.app.active_fingerprint = value
self.app.notify(f"Switched to profile: {value[:8]}")
class SidebarContainer(Static):
"""Container for the sidebar tree and toggle."""
def compose(self) -> ComposeResult:
yield ProfileTree("Profiles", id="profile-tree")
@@ -150,7 +150,9 @@ def test_deterministic_vectors_index_352_regression():
hashlib.sha256(ssh_pub.encode("utf-8")).hexdigest() == vector.ssh_pub_sha256
)
pgp_key, pgp_fp = derive_pgp_key(bip85, 352, "ed25519", "SeedPass Regression")
pgp_key, _pgp_pub, pgp_fp = derive_pgp_key(
bip85, 352, "ed25519", "SeedPass Regression"
)
assert pgp_fp == "46A5AEB6797495ABCDC91E5687DC058A9B6AF8FA"
assert (
hashlib.sha256(pgp_key.encode("utf-8")).hexdigest()
@@ -158,7 +160,7 @@ def test_deterministic_vectors_index_352_regression():
)
# Pin deterministic RSA behavior at one known vector too.
rsa_key, rsa_fp = derive_pgp_key(bip85, 352, "rsa", "SeedPass Regression")
rsa_key, _rsa_pub, rsa_fp = derive_pgp_key(bip85, 352, "rsa", "SeedPass Regression")
assert rsa_fp == "A04FE91ED24354AC13E25C43677FE463B8C74495"
assert (
hashlib.sha256(rsa_key.encode("utf-8")).hexdigest()
+13
View File
@@ -109,3 +109,16 @@ def test_manager_workflow(monkeypatch):
assert checksum_after_modify != checksum_after_add
assert first_post in pm.nostr_client.published
assert pm.nostr_client.published[-1] != first_post
def test_terminal_document_editor_help_and_empty_listing(monkeypatch, capsys):
pm = PasswordManager.__new__(PasswordManager)
inputs = iter(["q"])
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
result = pm._terminal_document_editor("")
captured = capsys.readouterr()
assert result is None
assert "Commands: p | a | i <n> | e <n> | d <n> | w | q" in captured.out
assert "[empty]" in captured.out
+276 -64
View File
@@ -19,7 +19,6 @@ from textual.widgets import Input, ListView, Static
from seedpass.tui_v2.app import launch_tui2
# ── Fake services (copied from interaction tests to be self-contained) ──────
@@ -39,7 +38,9 @@ class FakeEntryService:
def _next_id(self) -> int:
return (max(self._entries.keys()) + 1) if self._entries else 1
def search_entries(self, query, kinds=None, *, include_archived=False, archived_only=False):
def search_entries(
self, query, kinds=None, *, include_archived=False, archived_only=False
):
q = (query or "").strip().lower()
out = []
for eid in sorted(self._entries):
@@ -64,59 +65,88 @@ class FakeEntryService:
def add_entry(self, label, length, username=None, url=None):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "password", "label": label,
"length": int(length), "username": username, "url": url, "archived": False,
"id": eid,
"kind": "password",
"label": label,
"length": int(length),
"username": username,
"url": url,
"archived": False,
}
return eid
def add_totp(self, label, *, secret=None, period=30, digits=6, deterministic=False):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "totp", "label": label,
"secret": secret or "JBSWY3DPEHPK3PXP", "period": int(period),
"digits": int(digits), "archived": False,
"id": eid,
"kind": "totp",
"label": label,
"secret": secret or "JBSWY3DPEHPK3PXP",
"period": int(period),
"digits": int(digits),
"archived": False,
}
return f"otpauth://totp/{label}"
def add_key_value(self, label, key, value):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "key_value", "label": label,
"key": key, "value": value, "archived": False,
"id": eid,
"kind": "key_value",
"label": label,
"key": key,
"value": value,
"archived": False,
}
return eid
def add_document(self, label, content, *, file_type="txt"):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "document", "label": label,
"content": content, "file_type": file_type, "archived": False,
"id": eid,
"kind": "document",
"label": label,
"content": content,
"file_type": file_type,
"archived": False,
}
return eid
def add_ssh_key(self, label, *, index=None, notes=""):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "ssh", "label": label,
"private_key": f"SSH_PRIV_{eid}", "public_key": f"ssh-ed25519 AAAA-{eid}",
"id": eid,
"kind": "ssh",
"label": label,
"private_key": f"SSH_PRIV_{eid}",
"public_key": f"ssh-ed25519 AAAA-{eid}",
"archived": False,
}
return eid
def add_pgp_key(self, label, *, index=None, key_type="ed25519", user_id="", notes=""):
def add_pgp_key(
self, label, *, index=None, key_type="ed25519", user_id="", notes=""
):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "pgp", "label": label,
"id": eid,
"kind": "pgp",
"label": label,
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----",
"fingerprint": f"FPR-{eid}", "archived": False,
"fingerprint": f"FPR-{eid}",
"archived": False,
}
return eid
def add_nostr_key(self, label, *, index=None, notes=""):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "nostr", "label": label,
"npub": f"npub{eid}", "nsec": f"nsec{eid}", "archived": False,
"id": eid,
"kind": "nostr",
"label": label,
"npub": f"npub{eid}",
"nsec": f"nsec{eid}",
"archived": False,
}
return eid
@@ -125,15 +155,20 @@ class FakeEntryService:
phrase = " ".join(["abandon"] * (wc - 1) + ["about"])
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "seed", "label": label,
"seed_phrase": phrase, "archived": False,
"id": eid,
"kind": "seed",
"label": label,
"seed_phrase": phrase,
"archived": False,
}
return eid
def add_managed_account(self, label, *, index=None, notes=""):
eid = self._next_id()
self._entries[eid] = {
"id": eid, "kind": "managed_account", "label": label,
"id": eid,
"kind": "managed_account",
"label": label,
"seed_phrase": "legal winner thank year wave sausage worth useful legal winner thank yellow",
"archived": False,
}
@@ -145,7 +180,7 @@ class FakeEntryService:
raise ValueError("not a document")
name = str(e.get("label", f"doc-{eid}")).replace(" ", "_")
ext = str(e.get("file_type", "txt")).lstrip(".") or "txt"
dest = (Path(output_path) if output_path else Path.cwd() / f"{name}.{ext}")
dest = Path(output_path) if output_path else Path.cwd() / f"{name}.{ext}"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(str(e.get("content", "")), encoding="utf-8")
return dest
@@ -160,10 +195,17 @@ class FakeEntryService:
return f"pw-{eid}-{length}"
def get_seed_phrase(self, eid):
return str(self._entries[int(eid)].get("seed_phrase", "abandon " * 11 + "about")).strip()
return str(
self._entries[int(eid)].get("seed_phrase", "abandon " * 11 + "about")
).strip()
def get_managed_account_seed(self, eid):
return str(self._entries[int(eid)].get("seed_phrase", "legal winner thank year wave sausage worth useful legal winner thank yellow")).strip()
return str(
self._entries[int(eid)].get(
"seed_phrase",
"legal winner thank year wave sausage worth useful legal winner thank yellow",
)
).strip()
def get_totp_secret(self, eid):
return str(self._entries[int(eid)].get("secret", "JBSWY3DPEHPK3PXP"))
@@ -176,16 +218,27 @@ class FakeEntryService:
for eid, e in self._entries.items():
if str(e.get("kind")) != "totp":
continue
out[str(eid)] = {"label": e.get("label"), "period": e.get("period", 30), "digits": e.get("digits", 6), "secret": e.get("secret")}
out[str(eid)] = {
"label": e.get("label"),
"period": e.get("period", 30),
"digits": e.get("digits", 6),
"secret": e.get("secret"),
}
return out
def get_ssh_key_pair(self, eid):
e = self._entries[int(eid)]
return (str(e.get("private_key", "SSH_PRIV")), str(e.get("public_key", "ssh-ed25519 AAAA...")))
return (
str(e.get("private_key", "SSH_PRIV")),
str(e.get("public_key", "ssh-ed25519 AAAA...")),
)
def get_pgp_key(self, eid):
e = self._entries[int(eid)]
return (str(e.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")), str(e.get("fingerprint", "DEADBEEF")))
return (
str(e.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")),
str(e.get("fingerprint", "DEADBEEF")),
)
def get_nostr_key_pair(self, eid):
e = self._entries[int(eid)]
@@ -217,7 +270,12 @@ class FakeEntryService:
def remove_link(self, eid, tid, *, relation=None):
src = self._links.setdefault(int(eid), [])
kept = [l for l in src if int(l.get("target", -1)) != int(tid) or (relation and str(l.get("relation")) != relation)]
kept = [
l
for l in src
if int(l.get("target", -1)) != int(tid)
or (relation and str(l.get("relation")) != relation)
]
self._links[int(eid)] = kept
return [dict(l) for l in kept]
@@ -281,7 +339,13 @@ class FakeConfigService:
def set(self, key, value):
if key == "quick_unlock":
self.quick_unlock = str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
self.quick_unlock = str(value).strip().lower() in {
"1",
"true",
"yes",
"y",
"on",
}
elif key == "inactivity_timeout":
self.inactivity_timeout = float(value)
elif key == "kdf_iterations":
@@ -343,7 +407,12 @@ class FakeSemanticService:
self.mode = "keyword"
def status(self):
return {"enabled": self.enabled, "built": self.built, "records": self.records, "mode": self.mode}
return {
"enabled": self.enabled,
"built": self.built,
"records": self.records,
"mode": self.mode,
}
def set_enabled(self, e):
self.enabled = bool(e)
@@ -423,6 +492,7 @@ class FakeNostrService:
# ── Helpers ─────────────────────────────────────────────────────────────────
def _build_full_app(service=None, **kw):
service = service or FakeEntryService()
holder: dict = {}
@@ -648,7 +718,9 @@ async def test_walkthrough_archive_filter_cycle():
await _cmd(app, pilot, "archive-filter active")
lv = app.query_one("#entry-list", ListView)
if len(lv.children) != 2:
bugs.append(f"Expected 2 active entries after restore, got {len(lv.children)}")
bugs.append(
f"Expected 2 active entries after restore, got {len(lv.children)}"
)
assert not bugs, "BUGS FOUND:\n" + "\n".join(f" - {b}" for b in bugs)
@@ -678,11 +750,15 @@ async def test_walkthrough_managed_session_lifecycle():
# Check action strip instead
action = _action(app)
if "managed-exit" not in action.lower():
bugs.append(f"Managed session not activated. status={stat}, action={action[:200]}")
bugs.append(
f"Managed session not activated. status={stat}, action={action[:200]}"
)
# Verify managed-load was called
if managed_id not in svc.managed_load_calls:
bugs.append(f"managed_load_calls missing {managed_id}: {svc.managed_load_calls}")
bugs.append(
f"managed_load_calls missing {managed_id}: {svc.managed_load_calls}"
)
# Exit managed session
await _cmd(app, pilot, "managed-exit")
@@ -695,9 +771,17 @@ async def test_walkthrough_managed_session_lifecycle():
@pytest.mark.anyio
async def test_walkthrough_vault_lock_unlock():
"""Lock vault, verify entries hidden, unlock, verify entries restored."""
svc = FakeEntryService([
{"id": 1, "kind": "password", "label": "Secret Login", "length": 16, "archived": False},
])
svc = FakeEntryService(
[
{
"id": 1,
"kind": "password",
"label": "Secret Login",
"length": 16,
"archived": False,
},
]
)
vault = FakeVaultService()
app, _ = _build_full_app(svc, vault=vault)
@@ -747,9 +831,17 @@ async def test_walkthrough_vault_lock_unlock():
@pytest.mark.anyio
async def test_walkthrough_density_modes():
"""Cycle through density modes, verify status and grid heading adapt."""
svc = FakeEntryService([
{"id": 1, "kind": "password", "label": "Test PW", "length": 16, "archived": False},
])
svc = FakeEntryService(
[
{
"id": 1,
"kind": "password",
"label": "Test PW",
"length": 16,
"archived": False,
},
]
)
app, _ = _build_full_app(svc)
bugs = []
@@ -783,12 +875,42 @@ async def test_walkthrough_density_modes():
@pytest.mark.anyio
async def test_walkthrough_kind_filter():
"""Filter by entry kind and verify grid population."""
svc = FakeEntryService([
{"id": 1, "kind": "password", "label": "PW1", "length": 16, "archived": False},
{"id": 2, "kind": "totp", "label": "OTP1", "secret": "AAA", "period": 30, "digits": 6, "archived": False},
{"id": 3, "kind": "document", "label": "Doc1", "content": "hi", "file_type": "md", "archived": False},
{"id": 4, "kind": "ssh", "label": "SSH1", "private_key": "pk", "public_key": "pub", "archived": False},
])
svc = FakeEntryService(
[
{
"id": 1,
"kind": "password",
"label": "PW1",
"length": 16,
"archived": False,
},
{
"id": 2,
"kind": "totp",
"label": "OTP1",
"secret": "AAA",
"period": 30,
"digits": 6,
"archived": False,
},
{
"id": 3,
"kind": "document",
"label": "Doc1",
"content": "hi",
"file_type": "md",
"archived": False,
},
{
"id": 4,
"kind": "ssh",
"label": "SSH1",
"private_key": "pk",
"public_key": "pub",
"archived": False,
},
]
)
app, _ = _build_full_app(svc)
bugs = []
@@ -824,10 +946,26 @@ async def test_walkthrough_kind_filter():
@pytest.mark.anyio
async def test_walkthrough_links_between_entries():
"""Add and remove links between entries."""
svc = FakeEntryService([
{"id": 1, "kind": "password", "label": "PW1", "length": 16, "archived": False},
{"id": 2, "kind": "totp", "label": "OTP for PW1", "secret": "AAA", "period": 30, "digits": 6, "archived": False},
])
svc = FakeEntryService(
[
{
"id": 1,
"kind": "password",
"label": "PW1",
"length": 16,
"archived": False,
},
{
"id": 2,
"kind": "totp",
"label": "OTP for PW1",
"secret": "AAA",
"period": 30,
"digits": 6,
"archived": False,
},
]
)
app, _ = _build_full_app(svc)
bugs = []
@@ -858,14 +996,70 @@ async def test_walkthrough_links_between_entries():
async def test_walkthrough_card_frame_rendering():
"""Verify card frames render correctly for every board type."""
entries = [
{"id": 1, "kind": "password", "label": "PW", "length": 16, "username": "u", "url": "https://x.com", "archived": False},
{"id": 2, "kind": "document", "label": "Doc", "content": "hello " * 50, "file_type": "md", "archived": False},
{"id": 3, "kind": "totp", "label": "OTP", "secret": "AAA", "period": 30, "digits": 6, "archived": False},
{"id": 4, "kind": "seed", "label": "Seed", "seed_phrase": "abandon " * 11 + "about", "archived": False},
{"id": 5, "kind": "managed_account", "label": "Mgd", "seed_phrase": "legal " * 11 + "about", "archived": False},
{"id": 6, "kind": "ssh", "label": "SSH", "private_key": "pk", "public_key": "ssh-ed25519 AAAA", "archived": False},
{"id": 7, "kind": "pgp", "label": "PGP", "private_key": "pk", "fingerprint": "FPR-7", "archived": False},
{"id": 8, "kind": "nostr", "label": "Nostr", "npub": "npub1demo", "nsec": "nsec1demo", "archived": False},
{
"id": 1,
"kind": "password",
"label": "PW",
"length": 16,
"username": "u",
"url": "https://x.com",
"archived": False,
},
{
"id": 2,
"kind": "document",
"label": "Doc",
"content": "hello " * 50,
"file_type": "md",
"archived": False,
},
{
"id": 3,
"kind": "totp",
"label": "OTP",
"secret": "AAA",
"period": 30,
"digits": 6,
"archived": False,
},
{
"id": 4,
"kind": "seed",
"label": "Seed",
"seed_phrase": "abandon " * 11 + "about",
"archived": False,
},
{
"id": 5,
"kind": "managed_account",
"label": "Mgd",
"seed_phrase": "legal " * 11 + "about",
"archived": False,
},
{
"id": 6,
"kind": "ssh",
"label": "SSH",
"private_key": "pk",
"public_key": "ssh-ed25519 AAAA",
"archived": False,
},
{
"id": 7,
"kind": "pgp",
"label": "PGP",
"private_key": "pk",
"fingerprint": "FPR-7",
"archived": False,
},
{
"id": 8,
"kind": "nostr",
"label": "Nostr",
"npub": "npub1demo",
"nsec": "nsec1demo",
"archived": False,
},
]
svc = FakeEntryService(entries)
app, _ = _build_full_app(svc)
@@ -891,7 +1085,13 @@ async def test_walkthrough_card_frame_rendering():
# The bottom line starts with "+" so count "+" lines
# Just check top/bottom pairs
top_lines = [l for l in detail.split("\n") if l.strip().startswith("+- ")]
bottom_lines = [l for l in detail.split("\n") if l.strip().startswith("+") and l.strip().endswith("+") and "- " not in l[:5]]
bottom_lines = [
l
for l in detail.split("\n")
if l.strip().startswith("+")
and l.strip().endswith("+")
and "- " not in l[:5]
]
if len(top_lines) < 1:
bugs.append(f"Entry #{eid} ({kind}): missing card frame top line")
@@ -902,7 +1102,9 @@ async def test_walkthrough_card_frame_rendering():
# Max width check — no line should exceed ~80 chars (72 card + margins)
for line in detail.split("\n"):
if len(line) > 85:
bugs.append(f"Entry #{eid} ({kind}): line too wide ({len(line)} chars): {line[:60]}...")
bugs.append(
f"Entry #{eid} ({kind}): line too wide ({len(line)} chars): {line[:60]}..."
)
break
assert not bugs, "BUGS FOUND:\n" + "\n".join(f" - {b}" for b in bugs)
@@ -979,10 +1181,18 @@ async def test_walkthrough_edge_cases_and_error_handling():
@pytest.mark.anyio
async def test_walkthrough_search_flow():
"""Search, clear, verify grid updates."""
svc = FakeEntryService([
{"id": i, "kind": "password", "label": f"Entry {i}", "length": 16, "archived": False}
for i in range(1, 21)
])
svc = FakeEntryService(
[
{
"id": i,
"kind": "password",
"label": f"Entry {i}",
"length": 16,
"archived": False,
}
for i in range(1, 21)
]
)
app, _ = _build_full_app(svc)
bugs = []
@@ -1025,7 +1235,9 @@ async def test_walkthrough_full_user_journey():
bugs.append(f"Seed creation status: {_status(app)}")
# Step 2: Add a password entry
await _cmd(app, pilot, 'add-password "GitHub Login" 24 myuser https://github.com')
await _cmd(
app, pilot, 'add-password "GitHub Login" 24 myuser https://github.com'
)
detail = _detail(app)
if "myuser" not in detail:
bugs.append("Password username missing")
+1 -1
View File
@@ -175,7 +175,7 @@ async def test_tui2_parity_archive_restore_roundtrip_non_document() -> None:
await pilot.pause()
# Explicitly open the entry to ensure selection
await _run_palette(app, pilot, "open 1")
await _run_palette(app, pilot, "archive")
assert service.retrieve_entry(1)["archived"] is True
assert "archived" in _status_text(app)
+136 -13
View File
@@ -4,73 +4,161 @@ from unittest.mock import MagicMock
from textual.widgets import DataTable
from seedpass.tui_v3.app import SeedPassTuiV3
class MockEntryService:
def __init__(self):
self.entries = {
1: {"id": 1, "label": "test-pwd", "kind": "password", "username": "user1", "length": 16},
1: {
"id": 1,
"label": "test-pwd",
"kind": "password",
"username": "user1",
"length": 16,
},
2: {"id": 2, "label": "test-ssh", "kind": "ssh", "index": 0},
3: {"id": 3, "label": "test-pgp", "kind": "pgp", "index": 0},
4: {"id": 4, "label": "test-nostr", "kind": "nostr", "index": 0},
5: {"id": 5, "label": "test-doc", "kind": "document", "content": "hello world"},
5: {
"id": 5,
"label": "test-doc",
"kind": "document",
"content": "hello world",
},
6: {"id": 6, "label": "test-seed", "kind": "seed", "index": 0},
}
def search_entries(self, query="", kinds=None, include_archived=False, archived_only=False):
def search_entries(
self, query="", kinds=None, include_archived=False, archived_only=False
):
results = []
for e in self.entries.values():
kind = e["kind"]
if kinds and kind not in kinds: continue
if kinds and kind not in kinds:
continue
is_arch = e.get("archived", False)
if archived_only and not is_arch: continue
if not include_archived and is_arch: continue
results.append((e["id"], e["label"], e.get("username"), e.get("url"), is_arch, MagicMock(value=kind)))
if archived_only and not is_arch:
continue
if not include_archived and is_arch:
continue
results.append(
(
e["id"],
e["label"],
e.get("username"),
e.get("url"),
is_arch,
MagicMock(value=kind),
)
)
return results
def retrieve_entry(self, eid):
return self.entries.get(eid)
def generate_password(self, length, eid):
return "gen-pwd"
def get_ssh_key_pair(self, eid):
return "priv", "pub"
def get_pgp_key(self, eid):
return "priv", "pub", "fp"
def get_nostr_key_pair(self, eid):
return "npub", "nsec"
def get_seed_phrase(self, eid, parent):
return "word1 word2"
def copy_to_clipboard(self, val):
return True
def archive_entry(self, eid):
self.entries[eid]["archived"] = True
def restore_entry(self, eid):
self.entries[eid]["archived"] = False
def load_managed_account(self, eid):
pass
def exit_managed_account(self):
pass
def add_entry(self, label, username=None, url=None, length=16, tags=None):
eid = max(self.entries.keys()) + 1
self.entries[eid] = {"id": eid, "label": label, "kind": "password", "username": username, "tags": tags}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "password",
"username": username,
"tags": tags,
}
return eid
def add_totp(self, label, secret, username=None, tags=None):
eid = max(self.entries.keys()) + 1
self.entries[eid] = {"id": eid, "label": label, "kind": "totp", "username": username, "tags": tags}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "totp",
"username": username,
"tags": tags,
}
return eid
def add_seed(self, label, index=0, tags=None):
eid = max(self.entries.keys()) + 1
self.entries[eid] = {"id": eid, "label": label, "kind": "seed", "index": index, "tags": tags}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "seed",
"index": index,
"tags": tags,
}
return eid
def add_managed_account(self, label, index=0, tags=None):
eid = max(self.entries.keys()) + 1
self.entries[eid] = {"id": eid, "label": label, "kind": "managed_account", "index": index, "tags": tags}
self.entries[eid] = {
"id": eid,
"label": label,
"kind": "managed_account",
"index": index,
"tags": tags,
}
return eid
class LegacyPgpEntryService(MockEntryService):
def get_pgp_key(self, eid):
_ = eid
return "legacy-priv", "legacy-pub"
class SeedArgCompatEntryService(MockEntryService):
def __init__(self):
super().__init__()
self.entries[7] = {"id": 7, "label": "seed-compat", "kind": "seed", "index": 0}
def get_seed_phrase(self, eid):
_ = eid
return "abandon ability able about"
class MockVaultService:
def __init__(self):
self._manager = MagicMock()
self._manager.current_fingerprint = "parent-fp"
self._manager.profile_stack = []
def stats(self):
return {"total_entries": len(self._manager.entries) if hasattr(self._manager, "entries") else 6}
return {
"total_entries": (
len(self._manager.entries) if hasattr(self._manager, "entries") else 6
)
}
@pytest.mark.anyio
async def test_v3_reveal_copy_parity():
@@ -87,6 +175,7 @@ async def test_v3_reveal_copy_parity():
payload = app._resolve_sensitive_payload()
assert payload[3] == "nsec"
@pytest.mark.anyio
async def test_v3_archive_restore():
app = SeedPassTuiV3(
@@ -100,6 +189,7 @@ async def test_v3_archive_restore():
app.action_toggle_archive()
assert app.services["entry"].entries[1].get("archived") is False
@pytest.mark.anyio
async def test_v3_add_entry():
app = SeedPassTuiV3(
@@ -111,11 +201,15 @@ async def test_v3_add_entry():
await pilot.pause()
screen = app.screen
from textual.widgets import Input
screen.query_one("#entry-label", Input).value = "New Password"
screen.query_one("#entry-username", Input).value = "[email protected]"
await pilot.press("ctrl+s")
await pilot.pause()
assert any(e["label"] == "New Password" for e in app.services["entry"].entries.values())
assert any(
e["label"] == "New Password" for e in app.services["entry"].entries.values()
)
@pytest.mark.anyio
async def test_v3_filters():
@@ -133,3 +227,32 @@ async def test_v3_filters():
app.action_set_kind_filter("keys")
await pilot.pause()
assert table.row_count > 0
@pytest.mark.anyio
async def test_v3_pgp_payload_accepts_legacy_two_tuple() -> None:
app = SeedPassTuiV3(
entry_service_factory=lambda: LegacyPgpEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
await pilot.pause()
app.selected_entry_id = 3
payload = app._resolve_sensitive_payload()
assert payload[1] == "legacy-priv"
assert payload[2] == "legacy-pub"
assert payload[3] == "legacy-pub"
@pytest.mark.anyio
async def test_v3_seed_payload_accepts_single_arg_seed_getter() -> None:
app = SeedPassTuiV3(
entry_service_factory=lambda: SeedArgCompatEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
await pilot.pause()
app.selected_entry_id = 7
payload = app._resolve_sensitive_payload()
assert payload[0] == "Seed Words Revealed"
assert payload[1] == "abandon ability able about"
+6 -2
View File
@@ -139,11 +139,15 @@ async def test_tui3_sidebar_child_nodes_open_entries() -> None:
async with app.run_test() as pilot:
await pilot.pause()
tree = app.screen.query_one("#profile-tree")
tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="managed:2")))
tree.on_tree_node_selected(
SimpleNamespace(node=SimpleNamespace(data="managed:2"))
)
await pilot.pause()
assert app.selected_entry_id == 2
assert app.active_fingerprint == "EFBE51E70ED1B53A"
tree.on_tree_node_selected(SimpleNamespace(node=SimpleNamespace(data="agent:3")))
tree.on_tree_node_selected(
SimpleNamespace(node=SimpleNamespace(data="agent:3"))
)
await pilot.pause()
assert app.selected_entry_id == 3
+30
View File
@@ -685,6 +685,8 @@ def test_capabilities_json():
assert payload["schema_version"] == 1
assert "cli" in payload["interfaces"]
assert "agent" in payload["interfaces"]["cli"]["root_commands"]
assert "tui3" in payload["interfaces"]["cli"]["root_commands"]
assert "legacy" in payload["interfaces"]["cli"]["root_commands"]
assert (
"/api/v1/agent/job-profiles/{job_id}/template/verify"
in payload["interfaces"]["api"]["discovery"]
@@ -744,6 +746,13 @@ def test_api_start_passes_fingerprint(monkeypatch):
assert called.get("fp") == "abc"
def test_version_flag(monkeypatch):
monkeypatch.setattr(cli, "_get_cli_version", lambda: "9.9.9-test")
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "SeedPass 9.9.9-test" in result.stdout
def test_api_start_unlock_uses_env_broker(monkeypatch):
called = {}
broker_called = {}
@@ -1111,6 +1120,27 @@ def test_tui2_fallback_legacy(monkeypatch):
assert "falling back to legacy TUI" in result.stdout
def test_tui2_fallback_legacy_strips_subcommand_argv(monkeypatch):
called = {}
monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object())
monkeypatch.setattr(cli, "launch_tui2", lambda **_: False)
def fake_main(*, argv=None, fingerprint=None):
called["fp"] = fingerprint
called["argv"] = argv
return 0
monkeypatch.setattr(
cli,
"importlib",
SimpleNamespace(import_module=lambda _: SimpleNamespace(main=fake_main)),
)
result = runner.invoke(app, ["--fingerprint", "abc", "tui2"])
assert result.exit_code == 0
assert called.get("fp") == "abc"
assert called.get("argv") == []
def test_root_fallback_legacy_when_tui2_unavailable(monkeypatch):
called = {}
monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object())