feat(tui-v3): implement core functional parity (Phase A/B)

- Implement secure data parity (Reveal/QR/Copy) for SSH, PGP, Nostr, Key-Value, and Documents.
- Update derive_pgp_key and API to support PGP Public Key retrieval and copying.
- Implement managed account loading/exiting (ml/mx) with reactive profile updates.
- Integrate semantic search (keyword/hybrid/semantic) into the entry grid.
- Create AddEntryScreen (unified wizard), SeedPlusScreen (BIP-85), and DocumentEditScreen.
- Add grid filter presets (secrets, docs, keys, 2fa) and archive view toggle.
- Update ActionBar and GridMetrics to be dynamic and context-aware.
- Add comprehensive v3 parity tests and update existing core tests for PGP tuple changes.
This commit is contained in:
thePR0M3TH3AN committed 2026-03-03 07:40:34 -05:00
1 parent d3920ccc4c
commit c822eeb602
17 files changed
+855 -89

No files matched your search

+1 -1
View File
@@ -279,7 +279,7 @@ def _resolve_secret_for_kind(
secret, _pub = pm.entry_manager.get_ssh_key_pair(index, pm.parent_seed)
return secret
if kind == EntryType.PGP.value:
secret, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed)
secret, _pub, _fp = pm.entry_manager.get_pgp_key(index, pm.parent_seed)
return secret
if kind == EntryType.NOSTR.value:
_npub, secret = pm.entry_manager.get_nostr_key_pair(index, pm.parent_seed)
+2 -2
View File
@@ -358,8 +358,8 @@ class EntryService:
entry_id, self._manager.parent_seed
)
def get_pgp_key(self, entry_id: int) -> tuple[str, str]:
"""Return ``(private_key_armored, fingerprint)`` for a PGP entry."""
def get_pgp_key(self, entry_id: int) -> tuple[str, str, str]:
"""Return ``(private_key_armored, public_key_armored, fingerprint)`` for a PGP entry."""
with self._lock:
return self._manager.entry_manager.get_pgp_key(
entry_id, self._manager.parent_seed
+1 -1
View File
@@ -234,7 +234,7 @@ class DisplayService:
self.manager.notify("PGP key display cancelled.", level="WARNING")
return
try:
priv_key, fingerprint = self.manager.entry_manager.get_pgp_key(
priv_key, pub_key, fingerprint = self.manager.entry_manager.get_pgp_key(
index, self.manager.parent_seed
)
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
+3 -3
View File
@@ -486,7 +486,7 @@ class EntryManager:
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
priv_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
priv_key, pub_key, fp = derive_pgp_key(bip85, index, key_type, user_id)
if not validate_pgp_private_key(priv_key, fp):
raise ValueError("Derived PGP key failed validation")
now_unix = self._now_unix()
@@ -514,8 +514,8 @@ class EntryManager:
self.backup_manager.create_backup()
return index
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str]:
"""Return the armored PGP private key and fingerprint for the entry."""
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str, str]:
"""Return the armored PGP private key, public key, and fingerprint for the entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
+2 -2
View File
@@ -2567,7 +2567,7 @@ class PasswordManager:
notes=notes,
tags=tags,
)
priv_key, fingerprint = self.entry_manager.get_pgp_key(
priv_key, pub_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
self._mark_dirty()
@@ -3876,7 +3876,7 @@ class PasswordManager:
color_text(f" Derivation Index: {entry.get('index', index)}", "index")
)
try:
_priv, 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
+1 -1
View File
@@ -623,4 +623,4 @@ def derive_pgp_key(
compression=[CompressionAlgorithm.ZLIB],
created=created,
)
return str(key), key.fingerprint
return str(key), str(key.pubkey), key.fingerprint
+5 -3
View File
@@ -3033,7 +3033,7 @@ def launch_tui2(
)
if kind == "pgp":
priv_key, fingerprint_text = self._service.get_pgp_key(
priv_key, _pub_key, fingerprint_text = self._service.get_pgp_key(
self._selected_entry_id
)
return (
@@ -3454,12 +3454,14 @@ def launch_tui2(
raise ValueError("copy field unsupported for ssh: public|private")
if kind == "pgp":
private_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|fingerprint")
raise ValueError("copy field unsupported for pgp: private|public|fingerprint")
if kind == "nostr":
npub, nsec = self._service.get_nostr_key_pair(entry_id)
+172 -6
View File
@@ -52,7 +52,7 @@ class CommandProcessor:
if cmd == "help":
self.app.notify(
"v3 commands: help, stats, session-status, lock, unlock <password>, refresh, search <query>, open <id>, settings, maximize, copy, archive, restore"
"v3 commands: help, stats, session-status, lock, unlock <password>, refresh, search <query>, search-mode <keyword|hybrid|semantic>, filter <all|secrets|docs|keys|2fa>, archived, open <id>, settings, maximize, copy, edit, export, add, seed-plus, archive, restore, ml, mx"
)
elif cmd == "stats":
self.app.notify("Calculating stats...")
@@ -74,6 +74,23 @@ class CommandProcessor:
elif cmd == "search":
query = " ".join(args)
self.app.action_search(query)
elif cmd == "search-mode":
if not args:
self.app.notify("Usage: search-mode <keyword|hybrid|semantic>", severity="warning")
return
mode = args[0].lower()
if mode in {"keyword", "hybrid", "semantic"}:
self.app.search_mode = mode
self.app.notify(f"Search mode set to: {mode}")
else:
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")
return
self.app.action_set_kind_filter(args[0])
elif cmd == "archived":
self.app.action_toggle_archived_view()
elif cmd == "open":
if not args:
self.app.notify("Usage: open <id>", severity="warning")
@@ -88,8 +105,20 @@ class CommandProcessor:
self.app.action_toggle_settings()
elif cmd == "maximize":
self.app.action_maximize_inspector()
elif cmd == "add":
self.app.action_add_entry()
elif cmd == "seed-plus":
self.app.action_seed_plus()
elif cmd == "copy":
self.app.action_copy_selected()
elif cmd == "edit":
self.app.action_edit_selected()
elif cmd == "export":
self.app.action_export_selected()
elif cmd == "ml":
self.app.action_managed_load()
elif cmd == "mx":
self.app.action_managed_exit()
elif cmd in {"archive", "restore"}:
self.app.action_toggle_archive()
else:
@@ -150,7 +179,13 @@ class SeedPassTuiV3(App[None]):
Binding("q", "quit", "Quit", show=True),
Binding("ctrl+p", "open_palette", "Palette", show=True),
Binding("shift+s", "toggle_settings", "Settings", show=True),
Binding("shift+a", "add_entry", "Add", show=True),
Binding("shift+c", "seed_plus", "Seed+", show=True),
Binding("z", "maximize_inspector", "Maximize", show=True),
Binding("m", "managed_load", "Load", show=True),
Binding("shift+m", "managed_exit", "Exit", show=True),
Binding("e", "edit_selected", "Edit", show=True),
Binding("x", "export_selected", "Export", show=True),
Binding("v", "reveal_selected", "Reveal", show=False),
Binding("g", "show_qr", "QR", show=False),
Binding("a", "toggle_archive", "Archive", show=False),
@@ -161,6 +196,9 @@ class SeedPassTuiV3(App[None]):
active_fingerprint = reactive("")
selected_entry_id = reactive[int | None](None)
session_locked = reactive(False)
search_mode = reactive("keyword")
filter_kind = reactive("all")
show_archived = reactive(False)
# Internal state for sensitive actions
_pending_sensitive_confirm: tuple[str, int, float] | None = None
@@ -264,6 +302,18 @@ class SeedPassTuiV3(App[None]):
except Exception:
pass
def action_toggle_archived_view(self) -> None:
"""Toggle between active entries and archived entries."""
self.show_archived = not self.show_archived
self.notify(f"Showing archived entries: {self.show_archived}")
self.action_refresh()
def action_set_kind_filter(self, kind: str) -> None:
"""Set a specific entry kind filter (all, secrets, docs, keys, 2fa)."""
self.filter_kind = kind.lower()
self.notify(f"Applied filter: {self.filter_kind}")
self.action_refresh()
def action_open_palette(self) -> None:
"""Toggle the command palette."""
try:
@@ -275,6 +325,22 @@ class SeedPassTuiV3(App[None]):
"""Push the full-screen settings screen."""
self.push_screen(SettingsScreen())
def action_add_entry(self) -> None:
"""Open the add entry wizard."""
if self.session_locked:
self.notify("Vault is locked", severity="error")
return
from .screens.add import AddEntryScreen
self.push_screen(AddEntryScreen())
def action_seed_plus(self) -> None:
"""Open the Seed+ / BIP-85 derivation screen."""
if self.session_locked:
self.notify("Vault is locked", severity="error")
return
from .screens.add import SeedPlusScreen
self.push_screen(SeedPlusScreen())
def action_maximize_inspector(self) -> None:
"""Push the full-screen maximized entry detail screen."""
if self.selected_entry_id is None:
@@ -400,6 +466,85 @@ class SeedPassTuiV3(App[None]):
except Exception as e:
self.notify(f"Copy failed: {e}", severity="error")
def action_managed_load(self) -> None:
"""Load the selected managed account or seed profile as the active session."""
if self.session_locked:
self.notify("Vault is locked", severity="error")
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")
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.notify(f"Loaded session: {self.active_fingerprint[:8]}...")
self.action_refresh()
except Exception as e:
self.notify(f"Load failed: {e}", severity="error")
def action_managed_exit(self) -> None:
"""Exit the current managed session and return to the parent profile."""
try:
self.services["entry"].exit_managed_account()
# Update reactive state
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:
self.notify(f"Exit failed: {e}", severity="error")
def action_edit_selected(self) -> None:
"""Open the appropriate edit screen for the selected entry."""
if self.session_locked:
self.notify("Vault is locked", severity="error")
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 in {"document", "note"}:
from .screens.edit import DocumentEditScreen
self.push_screen(DocumentEditScreen(self.selected_entry_id))
else:
self.notify(f"Edit mode for '{kind}' not yet implemented in v3", severity="warning")
except Exception as e:
self.notify(f"Edit failed: {e}", severity="error")
def action_export_selected(self) -> None:
"""Export the selected entry to a file if supported."""
if self.session_locked:
self.notify("Vault is locked", severity="error")
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 in {"document", "note"}:
path = self.services["entry"].export_document_file(self.selected_entry_id)
self.notify(f"Document exported to: {path}")
elif kind in {"ssh", "pgp"}:
# For SSH/PGP, we might want to export to a file too.
# Currently EntryService doesn't have a direct file export for these,
# but we could implement it.
self.notify(f"Export for '{kind}' not yet implemented in v3", severity="warning")
else:
self.notify(f"Export not supported for kind '{kind}'", severity="warning")
except Exception as e:
self.notify(f"Export failed: {e}", severity="error")
def _consume_confirm(self, action: str, eid: int) -> bool:
if self._pending_sensitive_confirm is None: return False
p_action, p_eid, p_ts = self._pending_sensitive_confirm
@@ -421,9 +566,9 @@ class SeedPassTuiV3(App[None]):
# Check if confirmation is required
requires = False
if include_qr:
requires = (kind == "nostr" and qr_mode == "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"}
requires = kind in {"seed", "managed_account", "ssh", "pgp", "nostr"}
if requires and not confirm:
key = "g" if include_qr else "v"
@@ -454,12 +599,12 @@ class SeedPassTuiV3(App[None]):
if kind == "password":
val = self.services["entry"].generate_password(int(entry.get("length", 16)), eid)
return ("Password Revealed", f"Label: {label}\nPassword: {val}", None, val, kind)
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", f"Label: {label}\nSecret: {secret}", uri, secret, kind)
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":
@@ -467,8 +612,29 @@ class SeedPassTuiV3(App[None]):
else:
phrase = self.services["entry"].get_managed_account_seed(eid, parent_seed)
from seedpass.core.seedqr import encode_seedqr
return ("Seed Words Revealed", f"Label: {label}\nSeed: {phrase}", encode_seedqr(phrase), phrase, kind)
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)
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)
# Fallback
return ("Data Revealed", f"Label: {label}\nDetails: {entry}", None, None, kind)
+273
View File
@@ -0,0 +1,273 @@
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.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: #0d1114;
}
#add-container {
padding: 1 2;
border: heavy #2abf75;
margin: 1 2;
height: auto;
min-height: 20;
}
.field-label {
color: #58f29d;
text-style: bold;
margin-top: 1;
}
Input, Select, TextArea {
background: #11191f;
color: #e4fff2;
border: solid #1a3024;
}
#kind-select {
margin-bottom: 1;
}
.hidden {
display: none;
}
#action-row {
height: 3;
margin-top: 2;
content-align: right middle;
}
#btn-save {
background: #2abf75;
color: #0b0f13;
text-style: bold;
}
"""
def compose(self) -> ComposeResult:
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]
yield Select(kinds, value="password", id="kind-select")
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 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")
yield Label("Value", classes="field-label")
yield Input(placeholder="Value", id="entry-value")
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")
yield Footer()
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "kind-select":
self._update_visible_fields(str(event.value))
def _update_visible_fields(self, kind: str) -> None:
# 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")
elif kind == "totp":
self.query_one("#fields-totp").remove_class("hidden")
elif kind in {"document", "note"}:
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"}:
common.add_class("hidden")
else:
common.remove_class("hidden")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-save":
self.action_save()
else:
self.app.pop_screen()
def action_save(self) -> None:
kind = str(self.query_one("#kind-select", Select).value)
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
try:
service = self.app.services["entry"]
if kind == "password":
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)
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)
elif kind == "ssh":
service.add_ssh_key(label=label, tags=tags)
elif kind == "pgp":
service.add_pgp_key(label=label, tags=tags)
elif kind == "nostr":
service.add_nostr_key(label=label, tags=tags)
elif kind == "key_value":
key = self.query_one("#entry-key", Input).value
val = self.query_one("#entry-value", Input).value
service.add_key_value(label=label, key=key, value=val, tags=tags)
elif kind in {"document", "note"}:
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")
return
self.app.notify(f"Added {kind}: {label}")
self.app.action_refresh()
self.app.pop_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),
]
CSS = """
SeedPlusScreen {
background: #0d1114;
}
#seed-container {
padding: 1 2;
border: heavy #58f29d;
margin: 1 2;
height: auto;
}
.field-label {
color: #58f29d;
text-style: bold;
margin-top: 1;
}
Input, Select {
background: #11191f;
color: #e4fff2;
border: solid #1a3024;
}
#action-row {
height: 3;
margin-top: 2;
content-align: right middle;
}
"""
def compose(self) -> ComposeResult:
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 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")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-save":
self.action_save()
else:
self.app.pop_screen()
def action_save(self) -> None:
kind = str(self.query_one("#seed-kind", Select).value)
label = self.query_one("#seed-label", Input).value
try:
index = int(self.query_one("#seed-index", Input).value or 0)
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()]
if not label:
self.app.notify("Label is required", severity="error")
return
try:
service = self.app.services["entry"]
if kind == "seed":
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()
except Exception as e:
self.app.notify(f"Derivation failed: {e}", severity="error")
+122
View File
@@ -0,0 +1,122 @@
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, TextArea, Label, Button
from textual.containers import Vertical, Horizontal, Container
from textual.binding import Binding
class DocumentEditScreen(Screen):
"""
Dedicated full-screen editor for document/note entries.
Matches the 'Document Editor' mockup style.
"""
BINDINGS = [
Binding("ctrl+s", "save", "Save", show=True),
Binding("escape", "app.pop_screen", "Cancel", show=True),
]
CSS = """
DocumentEditScreen {
background: #0d1114;
}
#editor-container {
padding: 1 2;
border: heavy #2abf75;
margin: 1 2;
height: 1fr;
}
.field-label {
color: #58f29d;
text-style: bold;
margin-top: 1;
}
#doc-title, #doc-tags, #doc-type {
background: #11191f;
color: #e4fff2;
border: solid #1a3024;
}
#doc-content {
height: 1fr;
margin-top: 1;
background: #11191f;
color: #e4fff2;
border: solid #1a3024;
}
#action-row {
height: 3;
margin-top: 1;
content-align: right middle;
}
#btn-save {
background: #2abf75;
color: #0b0f13;
text-style: bold;
}
"""
def __init__(self, entry_id: int, **kwargs) -> None:
super().__init__(**kwargs)
self.entry_id = entry_id
self.entry_data: dict[str, Any] = {}
def on_mount(self) -> None:
if "entry" in self.app.services:
self.entry_data = self.app.services["entry"].retrieve_entry(self.entry_id)
self.query_one("#doc-title", Input).value = self.entry_data.get("label", "")
self.query_one("#doc-type", Input).value = self.entry_data.get("file_type", "txt")
self.query_one("#doc-tags", Input).value = ", ".join(self.entry_data.get("tags", []))
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("DOCUMENT EDITOR", id="screen-title")
with Horizontal(height=4):
with Vertical(id="col-left"):
yield Label("Title", classes="field-label")
yield Input(placeholder="Title", id="doc-title")
with Vertical(id="col-right", margin_left=2):
yield Label("Type", classes="field-label")
yield Input(placeholder="txt", id="doc-type")
yield Label("Tags (comma separated)", classes="field-label")
yield Input(placeholder="work, personal...", id="doc-tags")
yield Label("Content", classes="field-label")
yield TextArea(id="doc-content")
with Horizontal(id="action-row"):
yield Button("Cancel (Esc)", id="btn-cancel", variant="error")
yield Button("Save (Ctrl+S)", id="btn-save", variant="success")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-save":
self.action_save()
else:
self.app.pop_screen()
def action_save(self) -> None:
title = self.query_one("#doc-title", Input).value
file_type = self.query_one("#doc-type", Input).value
tags_raw = self.query_one("#doc-tags", Input).value
content = self.query_one("#doc-content", TextArea).text
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
try:
self.app.services["entry"].modify_entry(
self.entry_id,
label=title,
file_type=file_type,
tags=tags,
content=content
)
self.app.notify(f"Document #{self.entry_id} saved successfully")
self.app.action_refresh()
self.app.pop_screen()
except Exception as e:
self.app.notify(f"Save failed: {e}", severity="error")
+23 -4
View File
@@ -26,19 +26,38 @@ class ActionBar(Static):
selected_id = app.selected_entry_id
# Global Row
is_managed = False
try:
is_managed = len(getattr(app.services.get("vault")._manager, "profile_stack", [])) > 0
except:
pass
exit_hint = " Exit (Shift+M)" if is_managed else ""
global_row = (
"Settings (Shift+S) Add (Shift+A) Seed+ (Shift+C) "
"Reveal (Shift+H) Backup (B) Cmd (Ctrl+P)"
f"Settings (Shift+S) Add (Shift+A) Seed+ (Shift+C) "
f"Backup (B) Cmd (Ctrl+P){exit_hint}"
)
# Context Row
if selected_id is None:
context = "Select an entry to view actions."
else:
# We would normally determine kind here
context = f"Entry #{selected_id} ▣ Reveal (v) ▣ QR (g) ▣ Edit (e) ▣ Archive (a) ▣ Max (z)"
try:
entry = app.services["entry"].retrieve_entry(selected_id)
kind = str(entry.get("kind") or entry.get("type") or "").lower()
actions = ["Reveal (v)", "QR (g)", "Edit (e)", "Archive (a)", "Copy (c)", "Max (z)"]
if kind in {"managed_account", "seed"}:
actions.insert(4, "Load (m)")
if kind in {"document", "note"}:
actions.insert(5, "Export (x)")
context = f"Entry #{selected_id} ({kind}) " + " ▣ ".join(actions)
except Exception:
context = f"Entry #{selected_id} [Error fetching details]"
return f"{global_row}\n{context}"
def on_mount(self) -> None:
self.watch(self.app, "selected_entry_id", self.refresh)
self.watch(self.app, "active_fingerprint", self.refresh)
+72 -4
View File
@@ -19,14 +19,35 @@ class GridMetrics(Static):
"""
def render(self) -> str:
app = self.app
# These would eventually come from App reactives
pg = "1/1"
rows = "0/0"
try:
table = app.screen.query_one("#entry-data-table")
if table:
rows = f"{len(table.rows)}/{len(table.rows)}" # TODO: handle actual total
except Exception:
pass
density = "compact"
# Search mode highlighting
search = "[b](KEYWORD)[/b] hybrid semantic"
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"
return f"Entry Grid | Pg {pg} Rows {rows} Density {density} Search {search}"
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:
self.watch(self.app, "search_mode", self.refresh)
self.watch(self.app, "selected_entry_id", self.refresh)
self.watch(self.app, "filter_kind", self.refresh)
self.watch(self.app, "show_archived", self.refresh)
class EntryDataTable(DataTable):
"""
@@ -52,8 +73,55 @@ class EntryDataTable(DataTable):
if "entry" not in app.services:
return
# Fetch filtered data from service
entries = app.services["entry"].search_entries(query)
# Map filter presets to kind lists
kind_map = {
"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:
try:
results = app.services["semantic"].search(query, mode=app.search_mode)
# results is list of {entry_id, kind, label, score, excerpt}
for r in results:
eid = r["entry_id"]
# 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()
# Filtering for semantic results (best effort)
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
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
))
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)
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)
for i, (eid, label, user, url, arch, kind) in enumerate(entries):
marker = "▶" if eid == app.selected_entry_id else " "
arch_status = "🔒" if arch else " "
+2 -2
View File
@@ -337,12 +337,12 @@ class TestEntryService:
assert result == ("priv", "pub")
def test_get_pgp_key(self, service, mock_manager):
mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "fp")
mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "pub", "fp")
result = service.get_pgp_key(4)
mock_manager.entry_manager.get_pgp_key.assert_called_once_with(
4, mock_manager.parent_seed
)
assert result == ("priv", "fp")
assert result == ("priv", "pub", "fp")
def test_get_nostr_key_pair(self, service, mock_manager):
mock_manager.entry_manager.get_nostr_key_pair.return_value = ("npub", "nsec")
+2 -2
View File
@@ -227,7 +227,7 @@ def test_show_pgp_entry_details(monkeypatch, capsys):
tmp_path = Path(tmpdir)
pm, entry_mgr = _setup_manager(tmp_path)
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
_k, _pub, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
called = _detail_common(monkeypatch, pm)
@@ -335,7 +335,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
extra = pub
elif entry_type == "pgp":
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
priv, pub, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
expected = priv
extra = fp
elif entry_type == "nostr":
+4 -4
View File
@@ -25,8 +25,8 @@ def test_pgp_key_determinism():
idx = entry_mgr.add_pgp_key(
"pgp", TEST_SEED, key_type="ed25519", user_id="Test"
)
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key1, pub1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, pub2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
assert fp1 == fp2
assert key1 == key2
@@ -55,8 +55,8 @@ def test_pgp_rsa_key_determinism():
entry_mgr = EntryManager(vault, backup_mgr)
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, key_type="rsa", user_id="Test")
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key1, pub1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, pub2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
assert fp1 == fp2
assert key1 == key2
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import pytest
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},
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"},
6: {"id": 6, "label": "test-seed", "kind": "seed", "index": 0},
}
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
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)))
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}
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}
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}
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}
return eid
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}
@pytest.mark.anyio
async def test_v3_reveal_copy_parity():
app = SeedPassTuiV3(
entry_service_factory=lambda: MockEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
app.selected_entry_id = 1
app.action_reveal_selected(confirm=True)
payload = app._resolve_sensitive_payload()
assert payload[3] == "gen-pwd"
app.selected_entry_id = 4
payload = app._resolve_sensitive_payload()
assert payload[3] == "nsec"
@pytest.mark.anyio
async def test_v3_archive_restore():
app = SeedPassTuiV3(
entry_service_factory=lambda: MockEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
app.selected_entry_id = 1
app.action_toggle_archive()
assert app.services["entry"].entries[1].get("archived") is True
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(
entry_service_factory=lambda: MockEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
app.action_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())
@pytest.mark.anyio
async def test_v3_filters():
app = SeedPassTuiV3(
entry_service_factory=lambda: MockEntryService(),
vault_service_factory=lambda: MockVaultService(),
)
async with app.run_test() as pilot:
await pilot.pause()
table = app.screen.query_one("#entry-data-table", DataTable)
initial_count = table.row_count
app.action_set_kind_filter("2fa")
await pilot.pause()
assert table.row_count == 0 # No TOTP in mock
app.action_set_kind_filter("keys")
await pilot.pause()
assert table.row_count > 0