feat: add semantic index scaffolding, cli/api surfaces, and tui tree nav polish

This commit is contained in:
thePR0M3TH3AN committed 2026-03-02 11:44:17 -05:00
1 parent 7c9b6253b2
commit 165e2db204
16 files changed
+1087 -67

No files matched your search

+13 -14
View File
@@ -22,8 +22,8 @@ Latest verification in this session:
- coverage: `85.65%`
- Focused TUI validation slices:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- latest result: `29 passed`
- plus online-default notice coverage run: `31 passed` (adds config/notice tests)
- latest result: `31 passed`
- includes profile-tree keyboard navigation/select command coverage
## 2) Canonical Status Sources
@@ -71,10 +71,10 @@ Use these docs as inputs, but treat this file as the decision layer:
### Semantic Search Roadmap
- Semantic index plan drafted and linked:
- `docs/semantic_vector_index_plan.md`
- Current recommendation:
- local per-device/profile derived vector index
- canonical sync unchanged (vectors not synced in Phase 1)
- add semantic/hybrid retrieval across KB-centric fields for agents and users
- Current implementation status:
- Phase A complete (local derived index + profile-scoped manifest/records + config flag)
- Phase B baseline complete (CLI + API surfaces + initial tests)
- Phase C pending (legacy TUI + TUI v2 semantic workflow integration)
## 4) Recommended Next Steps (Priority Order)
@@ -82,11 +82,11 @@ Use these docs as inputs, but treat this file as the decision layer:
: prioritized gaps from side-by-side legacy vs v2 run:
: onboarding + stats/ops + lock/session affordances now landed; focus now is mockup-aligned UI refresh completion.
2. Complete Phase 5 TUI v2 UI refresh queue:
: interactive profile tree navigation + profile-select actions + final visual polish.
3. Begin semantic index Phase A scaffolding (feature-flagged local derived index, CLI-first controls).
4. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`).
5. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites.
6. Execute a tagged release integrity run and attach evidence URLs in readiness checklist.
: final visual polish pass (profile tree navigation/select is now landed).
3. Begin semantic index Phase C (legacy TUI + TUI v2 integration with status/build/search controls).
4. Add semantic incremental update hooks on entry/link/tag mutations.
5. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`).
6. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites.
## 5) Next Slice Definition (Recommended Immediate Work)
@@ -162,9 +162,8 @@ Progress update (2026-03-02):
- one-time onboarding notice added for first online profile load with settings guidance
Current UI refresh remaining queue:
1. Add keyboard navigation/select behavior inside left profile tree.
2. Wire tree selection to profile context switch actions.
3. Final visual polish pass against mockups (spacing, headers, action strip clarity).
1. Final visual polish pass against mockups (spacing, headers, action strip clarity).
2. Semantic indicator slot in top ribbon/action strip (for upcoming Phase C semantic integration).
## 6) Working Rule
+43 -17
View File
@@ -1,6 +1,6 @@
# Semantic Vector Index Integration Plan
Status: Proposed (2026-03-02)
Status: In Progress (updated 2026-03-02)
Branch target: `beta`
Scope: Legacy TUI + Textual TUI v2 + CLI + API
@@ -225,29 +225,31 @@ Scale tests:
## Phase A: Core scaffolding (no UI)
1. add service interfaces + local store abstraction
2. add index manifest + per-profile storage layout
3. add offline build/rebuild/status operations
1. [x] add service interfaces + local store abstraction
2. [x] add index manifest + per-profile storage layout
3. [x] add offline build/rebuild/status operations
Exit:
1. CLI-only smoke works (`semantic status/build/search`)
1. [x] CLI-only smoke works (`semantic status/build/search`)
## Phase B: CLI + API
1. expose semantic commands in CLI
2. expose semantic endpoints in API
3. add tests and policy guards
1. [x] expose semantic commands in CLI
2. [x] expose semantic endpoints in API
3. [x] add initial tests for core + CLI + API
4. [ ] add lock/policy guard refinements for high-risk partitions and audit logging
Exit:
1. interface tests pass
1. [x] interface tests pass for current endpoints/commands
2. [ ] audit and lock-policy behavior validated end-to-end
## Phase C: TUI integration
1. wire legacy TUI settings/search hooks
2. wire TUI v2 palette commands + ribbon/status indicators
3. add hybrid search mode toggles
1. [ ] wire legacy TUI settings/search hooks
2. [ ] wire TUI v2 palette commands + ribbon/status indicators
3. [ ] add hybrid search mode toggles
Exit:
@@ -272,8 +274,32 @@ Exit:
## 14) Recommended Immediate Next Steps
1. Approve local-only (non-synced embeddings) as Phase A baseline.
2. Finalize field-level allowlist/denylist defaults with security review.
3. Implement Phase A core scaffolding behind feature flag:
- `semantic_index_enabled` (default `false`)
4. Add CLI `seedpass semantic status/build/rebuild/search` as first user-facing slice.
1. Complete Phase C integration in both TUIs (`semantic-status/build/rebuild/search` + status indicator).
2. Add incremental update hooks on entry/link/tag mutations (avoid full rebuild for small edits).
3. Add hybrid ranking mode (`keyword|hybrid|semantic`) with explicit config + API contract.
4. Add audit logging and lock-policy tests for semantic operations in agent/API contexts.
5. Run KB scale benchmark with semantic index enabled and record latency/memory evidence.
## 15) Progress Snapshot (2026-03-02)
Implemented:
1. `seedpass.core.semantic_index` local derived index with profile-local manifest/records.
2. `semantic_index_enabled` profile config flag and config service wrappers.
3. `SemanticIndexService` in core API layer.
4. CLI commands:
- `seedpass semantic status|enable|disable|build|rebuild|search`
5. API endpoints:
- `GET /api/v1/semantic/status`
- `POST /api/v1/semantic/build`
- `POST /api/v1/semantic/rebuild`
- `POST /api/v1/semantic/search`
- `POST /api/v1/semantic/config`
6. Initial tests for core service/index, CLI commands, and API endpoints.
Not yet implemented:
1. Legacy TUI semantic controls.
2. TUI v2 semantic palette integration/ribbon indicator.
3. True embedding backend and vector DB adapter (current implementation is token-overlap baseline).
4. Incremental mutation hooks and hybrid ranking mode.
+36 -4
View File
@@ -500,6 +500,38 @@ Next recommended slice:
1. Continue Phase 5 by adding explicit keyboard navigation inside the left profile tree and wiring profile-selection actions to update active context.
### 2026-03-02 - Phase 5 polish (interactive profile tree navigation) landed
Implemented in `src/seedpass/tui_v2/app.py`:
1. Added keyboard bindings for left profile tree interactions:
- `Up` / `Down`: move profile cursor
- `Ctrl+O`: open/switch to selected profile
2. Added palette command aliases:
- `profile-tree-next`
- `profile-tree-prev`
- `profile-tree-open`
3. Updated left-panel rendering:
- cursor marker (`▶`) and active profile marker (`*`) now render together
- status text confirms selection movement and profile-switch outcomes
4. Wired profile mutation commands to refresh tree state:
- `profile-switch`
- `profile-add`
- `profile-remove`
- `profile-rename`
Validation:
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
2. Result: `31 passed`.
Next recommended slice:
1. Final visual alignment pass to mockups (spacing, headers, action strip hierarchy) and semantic indicator slot prep.
## 14) Current Status Snapshot
Completed:
@@ -509,10 +541,10 @@ Completed:
3. Phase 3 core inspector board templates.
4. Phase 4 advanced inspector actions (`copy`, `export-field` with confirm gates).
5. Phase 5 initial polish (`density` controls + profile tree scaffold).
6. Phase 5 interactive profile tree navigation and open/switch behavior.
Remaining (active queue):
1. Implement explicit keyboard navigation/select actions in left profile tree.
2. Wire tree selection to active profile context updates (safe, service-backed path).
3. Final alignment pass to mockup spacing/rhythm and action-strip clarity.
4. Run focused TUI tests and coverage gate after each polish slice.
1. Final alignment pass to mockup spacing/rhythm and action-strip clarity.
2. Add semantic-state indicator slot in ribbon/action strip for upcoming semantic Phase C.
3. Run focused TUI tests and coverage gate after each polish slice.
+64 -1
View File
@@ -63,7 +63,7 @@ from seedpass.core.agent_recovery import (
verify_recovery_drills,
)
from seedpass.core.high_risk_partition_store import load_partition_entry
from seedpass.core.api import UtilityService
from seedpass.core.api import SemanticIndexService, UtilityService
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
@@ -218,6 +218,10 @@ def _require_unlocked(request: Request) -> None:
raise HTTPException(status_code=423, detail="Vault is locked")
def _semantic_service(request: Request) -> SemanticIndexService:
return SemanticIndexService(_get_pm(request))
def _validate_encryption_path(request: Request, path: Path) -> Path:
"""Validate and normalize ``path`` within the active fingerprint directory.
@@ -1406,6 +1410,65 @@ def set_secret_mode(
return {"status": "ok"}
@app.get("/api/v1/semantic/status")
def semantic_status(
request: Request, authorization: str | None = Header(None)
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
return _semantic_service(request).status()
@app.post("/api/v1/semantic/build")
def semantic_build(
request: Request, authorization: str | None = Header(None)
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
return _semantic_service(request).build()
@app.post("/api/v1/semantic/rebuild")
def semantic_rebuild(
request: Request, authorization: str | None = Header(None)
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
return _semantic_service(request).rebuild()
@app.post("/api/v1/semantic/search")
def semantic_search(
request: Request,
data: dict[str, Any],
authorization: str | None = Header(None),
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
query = str(data.get("query", "")).strip()
if not query:
raise HTTPException(status_code=400, detail="Missing query")
k = int(data.get("k", 10))
kind_raw = data.get("kind")
kind = str(kind_raw).strip() if kind_raw is not None else None
results = _semantic_service(request).search(query, k=max(1, k), kind=kind)
return {"results": results}
@app.post("/api/v1/semantic/config")
def semantic_config(
request: Request,
data: dict[str, Any],
authorization: str | None = Header(None),
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
if "enabled" not in data:
raise HTTPException(status_code=400, detail="Missing enabled")
payload = _semantic_service(request).set_enabled(bool(data.get("enabled")))
return {"enabled": bool(payload.get("enabled", False))}
@app.get("/api/v1/fingerprint")
def list_fingerprints(
request: Request, authorization: str | None = Header(None)
+2 -1
View File
@@ -51,7 +51,7 @@ legacy_tui_option = typer.Option(
)
# Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api, agent
from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic
from .capabilities import register_capabilities_command
app.add_typer(entry.app, name="entry")
@@ -62,6 +62,7 @@ app.add_typer(fingerprint.app, name="fingerprint")
app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api")
app.add_typer(agent.app, name="agent")
app.add_typer(semantic.app, name="semantic")
register_capabilities_command(app)
+26
View File
@@ -27,6 +27,7 @@ def build_capabilities() -> dict[str, Any]:
"util",
"api",
"agent",
"semantic",
],
"global_options": [
"--fingerprint",
@@ -53,6 +54,10 @@ def build_capabilities() -> dict[str, Any]:
"/api/v1/high-risk/status",
"/api/v1/high-risk/unlock",
"/api/v1/high-risk/lock",
"/api/v1/semantic/status",
"/api/v1/semantic/build",
"/api/v1/semantic/rebuild",
"/api/v1/semantic/search",
],
"auth_headers": ["Authorization: Bearer <token>"],
"sensitive_auth_headers": [
@@ -206,6 +211,25 @@ def build_capabilities() -> dict[str, Any]:
"search_includes_link_metadata": True,
"sync_union_merge_fields": ["tags", "custom_fields", "links"],
},
"semantic_index": {
"commands": [
"semantic status",
"semantic enable",
"semantic disable",
"semantic build",
"semantic rebuild",
"semantic search",
],
"api_endpoints": [
"GET /api/v1/semantic/status",
"POST /api/v1/semantic/build",
"POST /api/v1/semantic/rebuild",
"POST /api/v1/semantic/search",
"POST /api/v1/semantic/config",
],
"default_enabled": False,
"sync_behavior": "local_derived_only",
},
"redaction": {
"default_safe_output": True,
"reveal_override": "agent get --reveal",
@@ -244,6 +268,7 @@ def build_capabilities() -> dict[str, Any]:
"Use `seedpass --legacy-tui` to force the legacy interface during cutover.",
"In TUI v2, use `?` for help, `j` to jump to entry id, `1/2/3` to focus panes, `Ctrl+P` for palette, `p/n` for paging, and `x` to retry.",
"For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.",
"For semantic KB retrieval, run `seedpass semantic --help` and check `seedpass semantic status`.",
"For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.",
"After unlock/login, run `seedpass capabilities` before autonomous runs.",
"For agent bootstrap context, run `seedpass agent bootstrap-context`.",
@@ -284,6 +309,7 @@ def emit_capabilities(output_format: str) -> str:
lines.append("- Security posture checks and remediation bundles")
lines.append("- Audited access log with chained HMAC signatures")
lines.append("- Knowledge graph links: entry link-add/links/link-remove")
lines.append("- Semantic KB retrieval: semantic status/build/rebuild/search")
lines.append("- Document file workflows for users and agents")
lines.append("")
lines.append("Discovery hints:")
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import json
from typing import Optional
import typer
import click
from .common import _get_pm
from seedpass.core.api import SemanticIndexService
from seedpass.core.entry_types import ALL_ENTRY_TYPES
app = typer.Typer(help="Manage local semantic vector index and search")
def _get_semantic_service(ctx: typer.Context) -> SemanticIndexService:
return SemanticIndexService(_get_pm(ctx))
@app.command("status")
def semantic_status(ctx: typer.Context) -> None:
"""Show semantic index status for the active profile."""
service = _get_semantic_service(ctx)
payload = service.status()
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
@app.command("enable")
def semantic_enable(ctx: typer.Context) -> None:
"""Enable semantic index for this profile."""
service = _get_semantic_service(ctx)
payload = service.set_enabled(True)
typer.echo(
f"semantic index enabled (built={payload.get('built', False)}, records={payload.get('records', 0)})"
)
@app.command("disable")
def semantic_disable(ctx: typer.Context) -> None:
"""Disable semantic index for this profile."""
service = _get_semantic_service(ctx)
payload = service.set_enabled(False)
typer.echo(
f"semantic index disabled (built={payload.get('built', False)}, records={payload.get('records', 0)})"
)
@app.command("build")
def semantic_build(ctx: typer.Context) -> None:
"""Build semantic index incrementally from current entries."""
service = _get_semantic_service(ctx)
payload = service.build()
typer.echo(
f"semantic index built (records={payload.get('records', 0)}, enabled={payload.get('enabled', False)})"
)
@app.command("rebuild")
def semantic_rebuild(ctx: typer.Context) -> None:
"""Rebuild semantic index from scratch."""
service = _get_semantic_service(ctx)
payload = service.rebuild()
typer.echo(
f"semantic index rebuilt (records={payload.get('records', 0)}, enabled={payload.get('enabled', False)})"
)
@app.command("search")
def semantic_search(
ctx: typer.Context,
query: str,
k: int = typer.Option(10, "--k", help="Max results"),
kind: Optional[str] = typer.Option(
None,
"--kind",
help="Restrict to entry kind",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
as_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
) -> None:
"""Run semantic search over locally indexed KB content."""
service = _get_semantic_service(ctx)
results = service.search(query, k=max(1, int(k)), kind=kind)
if as_json:
typer.echo(json.dumps(results, indent=2, sort_keys=True))
return
if not results:
typer.echo("No semantic matches")
return
for item in results:
entry_id = int(item.get("entry_id", 0))
label = str(item.get("label", ""))
entry_kind = str(item.get("kind", ""))
score = float(item.get("score", 0.0))
excerpt = str(item.get("excerpt", ""))
typer.echo(f"{entry_id}: [{entry_kind}] {label} (score={score:.3f})")
if excerpt:
typer.echo(f" {excerpt}")
+82
View File
@@ -18,6 +18,7 @@ from pydantic import BaseModel
from .manager import PasswordManager
from .pubsub import bus
from .entry_types import EntryType
from .semantic_index import SemanticIndex
from utils import copy_to_clipboard
@@ -825,6 +826,87 @@ class ConfigService:
cfg.set_offline_mode(enabled)
self._manager.offline_mode = enabled
def set_semantic_index_enabled(self, enabled: bool) -> None:
with self._lock:
cfg = self._manager.config_manager
cfg.set_semantic_index_enabled(enabled)
def get_semantic_index_enabled(self) -> bool:
with self._lock:
return self._manager.config_manager.get_semantic_index_enabled()
class SemanticIndexService:
"""Thread-safe wrapper around local semantic index operations."""
def __init__(self, manager: PasswordManager) -> None:
self._manager = manager
self._lock = Lock()
def _index(self) -> SemanticIndex:
profile_dir = getattr(self._manager, "fingerprint_dir", None)
if profile_dir is None:
raise ValueError("active profile directory unavailable")
return SemanticIndex(Path(profile_dir))
def _all_entries(self) -> list[dict[str, Any]]:
em = getattr(self._manager, "entry_manager", None)
if em is None:
return []
rows = em.search_entries(
"",
kinds=None,
include_archived=True,
archived_only=False,
)
entries: list[dict[str, Any]] = []
for row in rows:
try:
entry_id = int(row[0])
except Exception:
continue
entry = em.retrieve_entry(entry_id)
if isinstance(entry, dict) and entry:
entries.append(dict(entry))
return entries
def status(self) -> Dict[str, Any]:
with self._lock:
idx = self._index()
payload = idx.status()
payload["enabled"] = bool(
self._manager.config_manager.get_semantic_index_enabled()
)
return payload
def set_enabled(self, enabled: bool) -> Dict[str, Any]:
with self._lock:
self._manager.config_manager.set_semantic_index_enabled(bool(enabled))
idx = self._index()
idx.set_enabled(bool(enabled))
payload = idx.status()
payload["enabled"] = bool(enabled)
return payload
def build(self) -> Dict[str, Any]:
with self._lock:
idx = self._index()
idx.set_enabled(self._manager.config_manager.get_semantic_index_enabled())
return idx.build(self._all_entries())
def rebuild(self) -> Dict[str, Any]:
with self._lock:
idx = self._index()
idx.set_enabled(self._manager.config_manager.get_semantic_index_enabled())
return idx.rebuild(self._all_entries())
def search(
self, query: str, *, k: int = 10, kind: str | None = None
) -> list[Dict[str, Any]]:
with self._lock:
idx = self._index()
return idx.search(query, k=k, kind=kind)
class UtilityService:
"""Miscellaneous helper operations."""
+13
View File
@@ -94,6 +94,7 @@ class ConfigManager:
"special_mode": "standard",
"exclude_ambiguous": False,
"verbose_timing": False,
"semantic_index_enabled": False,
"secret_class_partitions": dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
}
try:
@@ -126,6 +127,7 @@ class ConfigManager:
data.setdefault("special_mode", "standard")
data.setdefault("exclude_ambiguous", False)
data.setdefault("verbose_timing", False)
data.setdefault("semantic_index_enabled", False)
data.setdefault(
"secret_class_partitions",
dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
@@ -281,6 +283,17 @@ class ConfigManager:
config = self.load_config(require_pin=False)
return bool(config.get("offline_mode", False))
def set_semantic_index_enabled(self, enabled: bool) -> None:
"""Persist whether semantic index is enabled for this profile."""
config = self.load_config(require_pin=False)
config["semantic_index_enabled"] = bool(enabled)
self.save_config(config)
def get_semantic_index_enabled(self) -> bool:
"""Return whether semantic index is enabled for this profile."""
config = self.load_config(require_pin=False)
return bool(config.get("semantic_index_enabled", False))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
if delay <= 0:
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WORD_RE = re.compile(r"[a-z0-9_]+")
@dataclass
class SemanticRecord:
entry_id: int
kind: str
label: str
text: str
tokens: set[str]
class SemanticIndex:
"""Local derived semantic index for KB-centric entry retrieval."""
INDEX_DIRNAME = "semantic_index"
MANIFEST_FILENAME = "manifest.json"
RECORDS_FILENAME = "records.json"
SCHEMA_VERSION = 1
MODEL_ID = "seedpass-token-overlap-v1"
ALLOWED_KINDS = {
"document",
"note",
"key_value",
"password",
"stored_password",
"totp",
"nostr",
"ssh",
"pgp",
}
def __init__(self, profile_dir: Path) -> None:
self.profile_dir = Path(profile_dir)
self.index_dir = self.profile_dir / self.INDEX_DIRNAME
self.manifest_path = self.index_dir / self.MANIFEST_FILENAME
self.records_path = self.index_dir / self.RECORDS_FILENAME
def status(self) -> dict[str, Any]:
manifest = self._load_manifest()
records = self._load_records()
enabled = bool(manifest.get("enabled", False))
built = bool(manifest.get("built", False))
return {
"enabled": enabled,
"built": built,
"records": len(records),
"schema_version": int(manifest.get("schema_version", self.SCHEMA_VERSION)),
"model_id": str(manifest.get("model_id", self.MODEL_ID)),
"updated_at": float(manifest.get("updated_at", 0.0)),
}
def set_enabled(self, enabled: bool) -> None:
manifest = self._load_manifest()
manifest["enabled"] = bool(enabled)
manifest.setdefault("schema_version", self.SCHEMA_VERSION)
manifest.setdefault("model_id", self.MODEL_ID)
manifest["updated_at"] = time.time()
self._save_manifest(manifest)
def build(self, entries: list[dict[str, Any]]) -> dict[str, Any]:
records = self._records_from_entries(entries)
self.index_dir.mkdir(parents=True, exist_ok=True)
serializable = [
{
"entry_id": rec.entry_id,
"kind": rec.kind,
"label": rec.label,
"text": rec.text,
"tokens": sorted(rec.tokens),
}
for rec in records
]
self.records_path.write_text(
json.dumps(serializable, ensure_ascii=True, sort_keys=True, indent=2),
encoding="utf-8",
)
manifest = self._load_manifest()
manifest.update(
{
"enabled": bool(manifest.get("enabled", False)),
"built": True,
"schema_version": self.SCHEMA_VERSION,
"model_id": self.MODEL_ID,
"updated_at": time.time(),
"record_count": len(serializable),
}
)
self._save_manifest(manifest)
return self.status()
def rebuild(self, entries: list[dict[str, Any]]) -> dict[str, Any]:
if self.index_dir.exists():
for path in (self.records_path, self.manifest_path):
if path.exists():
path.unlink()
return self.build(entries)
def search(
self,
query: str,
*,
k: int = 10,
kind: str | None = None,
) -> list[dict[str, Any]]:
records = self._load_records()
if not records:
return []
q_tokens = self._tokenize(query)
if not q_tokens:
return []
wanted_kind = kind.strip().lower() if kind else None
scored: list[tuple[float, dict[str, Any]]] = []
for rec in records:
rec_kind = str(rec.get("kind", "")).strip().lower()
if wanted_kind and rec_kind != wanted_kind:
continue
tokens = {
str(item).strip().lower()
for item in rec.get("tokens", [])
if str(item).strip()
}
if not tokens:
continue
inter = len(q_tokens.intersection(tokens))
if inter <= 0:
continue
union = len(q_tokens.union(tokens))
score = float(inter) / float(union or 1)
scored.append((score, rec))
scored.sort(key=lambda item: (-item[0], int(item[1].get("entry_id", 0))))
out: list[dict[str, Any]] = []
for score, rec in scored[: max(1, int(k))]:
out.append(
{
"entry_id": int(rec.get("entry_id", 0)),
"kind": str(rec.get("kind", "")),
"label": str(rec.get("label", "")),
"score": round(score, 6),
"excerpt": str(rec.get("text", ""))[:220],
}
)
return out
def _records_from_entries(
self, entries: list[dict[str, Any]]
) -> list[SemanticRecord]:
records: list[SemanticRecord] = []
for entry in entries:
entry_id = int(entry.get("id", 0) or 0)
if entry_id <= 0:
continue
kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
if kind not in self.ALLOWED_KINDS:
continue
text = self._extract_text(entry, kind)
tokens = self._tokenize(text)
if not text.strip() or not tokens:
continue
records.append(
SemanticRecord(
entry_id=entry_id,
kind=kind,
label=str(entry.get("label", "")),
text=text,
tokens=tokens,
)
)
return records
@staticmethod
def _extract_text(entry: dict[str, Any], kind: str) -> str:
label = str(entry.get("label", "")).strip()
notes = str(entry.get("notes", "")).strip()
tags_raw = entry.get("tags")
if isinstance(tags_raw, list):
tags = " ".join(str(tag).strip() for tag in tags_raw if str(tag).strip())
else:
tags = str(tags_raw or "").strip()
parts = [label, notes, tags]
if kind in {"document", "note"}:
parts.append(str(entry.get("content", "")).strip())
parts.append(str(entry.get("file_type", "")).strip())
elif kind in {"password", "stored_password"}:
parts.append(str(entry.get("username", "")).strip())
parts.append(str(entry.get("url", "")).strip())
elif kind == "key_value":
parts.append(str(entry.get("key", "")).strip())
parts.append(str(entry.get("value", "")).strip())
elif kind == "totp":
parts.append(str(entry.get("issuer", "")).strip())
elif kind == "nostr":
parts.append(str(entry.get("npub", "")).strip())
elif kind in {"ssh", "pgp"}:
parts.append(str(entry.get("fingerprint", "")).strip())
links = entry.get("links")
if isinstance(links, list):
for link in links:
if not isinstance(link, dict):
continue
parts.append(str(link.get("relation", "")).strip())
parts.append(str(link.get("note", "")).strip())
return "\n".join(part for part in parts if part)
@staticmethod
def _tokenize(text: str) -> set[str]:
lowered = text.strip().lower()
if not lowered:
return set()
return {match.group(0) for match in WORD_RE.finditer(lowered)}
def _load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.exists():
return {}
try:
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _save_manifest(self, manifest: dict[str, Any]) -> None:
self.index_dir.mkdir(parents=True, exist_ok=True)
self.manifest_path.write_text(
json.dumps(manifest, ensure_ascii=True, sort_keys=True, indent=2),
encoding="utf-8",
)
def _load_records(self) -> list[dict[str, Any]]:
if not self.records_path.exists():
return []
try:
data = json.loads(self.records_path.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception:
return []
+142 -24
View File
@@ -290,6 +290,9 @@ def launch_tui2(
("f", "cycle_filter", "Filter"),
("d", "toggle_density", "Density"),
("h", "cycle_archive_scope", "Archive View"),
("up", "profile_tree_prev", "Profile Prev"),
("down", "profile_tree_next", "Profile Next"),
("ctrl+o", "profile_tree_open", "Profile Open"),
("p", "prev_page", "Prev Page"),
("n", "next_page", "Next Page"),
("l", "cycle_link_filter", "Link Filter"),
@@ -336,6 +339,7 @@ def launch_tui2(
"2fa-board | 2fa-hide | 2fa-refresh | 2fa-copy <entry_id> | "
"profiles-list | profile-switch <fp> [password] | profile-add | "
"profile-remove <fp> | profile-rename <fp> <name> | "
"profile-tree-next | profile-tree-prev | profile-tree-open | "
"setting-secret <on|off> [delay] | setting-offline <on|off> | "
"setting-quick-unlock <on|off> | setting-timeout <seconds> | "
"setting-kdf-iterations <n> | setting-kdf-mode <mode> | "
@@ -430,6 +434,8 @@ def launch_tui2(
self._managed_session_entry_id: int | None = None
self._last_sync_text = "(none)"
self._density_mode = "compact"
self._profile_tree_items: list[str] = []
self._profile_tree_cursor = 0
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
try:
self._service = (
@@ -498,6 +504,7 @@ def launch_tui2(
self._update_help_overlay()
self._update_activity_panel()
self._apply_focus_style()
self._refresh_profile_tree()
self._update_top_ribbon()
self._update_action_strip()
self._set_secret_panel(
@@ -560,6 +567,26 @@ def launch_tui2(
)
self.query_one("#top-ribbon", Static).update(text)
def _refresh_profile_tree(self) -> None:
profiles: list[str] = []
if self._profile_service is not None:
lister = getattr(self._profile_service, "list_profiles", None)
if callable(lister):
try:
profiles = [str(item) for item in lister() if str(item).strip()]
except Exception:
profiles = []
if not profiles:
profiles = [fingerprint or "(default)"]
self._profile_tree_items = profiles
active = fingerprint or ""
if active and active in profiles:
self._profile_tree_cursor = profiles.index(active)
else:
self._profile_tree_cursor = min(
max(0, self._profile_tree_cursor), max(0, len(profiles) - 1)
)
def _update_action_strip(self) -> None:
kind = ""
if isinstance(self._selected_entry, dict):
@@ -593,9 +620,7 @@ def launch_tui2(
self.query_one("#action-strip", Static).update(text)
def _update_grid_heading(self) -> None:
table_cols = (
"Idx Entry Title Kind Meta Arch"
)
table_cols = "Idx Entry Title Kind Meta Arch"
sort_line = (
"Sort: index ▲▼ | entry ▲▼ | title ▲▼ | kind ▲▼ "
f"Page {self._result_page + 1}/{self._total_pages()} "
@@ -642,7 +667,7 @@ def launch_tui2(
return (
"Palette commands: help, help-commands, open, search, filter, "
"onboarding, quickstart, stats, session-status, lock/unlock, "
"density, "
"density, profile-tree-next/prev/open, "
"archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, "
"link-add/link-rm/link-filter/link-next/link-prev/link-open, "
"add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, "
@@ -671,7 +696,7 @@ def launch_tui2(
"Entry Fields: notes-set | notes-clear | tag-add | tag-rm | tags-set | tags-clear | field-add | field-rm | set-field | clear-field",
"",
"2FA: 2fa-board | 2fa-hide | 2fa-refresh | 2fa-copy <entry_id>",
"Profiles: profiles-list | profile-switch | profile-add | profile-remove | profile-rename",
"Profiles: profiles-list | profile-switch | profile-add | profile-remove | profile-rename | profile-tree-next | profile-tree-prev | profile-tree-open",
"Settings: setting-secret | setting-offline | setting-quick-unlock | setting-timeout | setting-kdf-iterations | setting-kdf-mode",
"Nostr: relay-list | relay-add | relay-rm | relay-reset | npub | nostr-reset-sync-state | nostr-fresh-namespace",
"Sync/Utility: sync-now | sync-bg | checksum-verify | checksum-update | db-export | db-import | totp-export | parent-seed-backup",
@@ -782,22 +807,17 @@ def launch_tui2(
return f"Selected: #{self._selected_entry_id} [{kind}] {label}"
def _update_filters_panel(self) -> None:
self._refresh_profile_tree()
tree_lines = ["Profiles", "--------"]
profiles: list[str] = []
if self._profile_service is not None:
lister = getattr(self._profile_service, "list_profiles", None)
if callable(lister):
try:
profiles = [str(item) for item in lister()]
except Exception:
profiles = []
profiles = list(self._profile_tree_items)
current_fp = fingerprint or ""
if profiles:
for item in profiles[:8]:
for idx, item in enumerate(profiles[:8]):
marker = "*" if item == current_fp else "-"
tree_lines.append(f"{marker} {item}")
cursor = "▶" if idx == self._profile_tree_cursor else " "
tree_lines.append(f"{cursor} {marker} {item}")
else:
tree_lines.append(f"* {current_fp or '(default)'}")
tree_lines.append(f"▶ * {current_fp or '(default)'}")
fp_line = (
f"Fingerprint: {fingerprint}"
@@ -834,6 +854,8 @@ def launch_tui2(
"- j jump to id",
"- f cycle kind filter",
"- d density toggle",
"- ↑/↓ profile tree",
"- Ctrl+O open profile",
"- h cycle archive scope",
"- p/n prev/next page",
"- 1/2/3 focus pane",
@@ -1060,7 +1082,9 @@ def launch_tui2(
@staticmethod
def _entry_kind(entry: dict[str, Any]) -> str:
return str(entry.get("kind") or entry.get("type") or "unknown").strip().lower()
return (
str(entry.get("kind") or entry.get("type") or "unknown").strip().lower()
)
@staticmethod
def _entry_tags_text(entry: dict[str, Any]) -> str:
@@ -1897,9 +1921,7 @@ def launch_tui2(
raise ValueError("Selected entry not found")
return dict(entry)
def _resolve_copy_field_value(
self, field: str
) -> tuple[str, bool, str]:
def _resolve_copy_field_value(self, field: str) -> tuple[str, bool, str]:
if self._service is None:
raise ValueError("Entry service unavailable")
entry = self._selected_entry_payload()
@@ -1919,7 +1941,9 @@ def launch_tui2(
return str(entry.get("username") or ""), False, "username"
if key == "url":
return str(entry.get("url") or ""), False, "url"
raise ValueError("copy field unsupported for password: password|username|url")
raise ValueError(
"copy field unsupported for password: password|username|url"
)
if kind in {"seed", "managed_account"}:
if key not in {"seed", "phrase"}:
@@ -1971,7 +1995,9 @@ def launch_tui2(
if kind in {"document", "note"}:
if key not in {"content", "text"}:
raise ValueError("copy field unsupported for document: content|text")
raise ValueError(
"copy field unsupported for document: content|text"
)
return str(entry.get("content") or ""), False, "content"
raise ValueError(f"copy not supported for kind '{kind or 'unknown'}'")
@@ -2986,6 +3012,7 @@ def launch_tui2(
)
self._clear_failure()
self._load_entries(self._last_query, reset_page=True)
self._refresh_profile_tree()
self._set_status(f"Switched profile to {fp}")
except Exception as exc:
self._record_failure(
@@ -3006,6 +3033,7 @@ def launch_tui2(
try:
fp = self._profile_service.add_profile()
self._clear_failure()
self._refresh_profile_tree()
if fp:
self._set_status(f"Created profile {fp}")
else:
@@ -3037,6 +3065,7 @@ def launch_tui2(
ProfileRemoveRequest(fingerprint=fp)
)
self._clear_failure()
self._refresh_profile_tree()
self._set_status(f"Removed profile {fp}")
except Exception as exc:
self._record_failure(
@@ -3065,6 +3094,7 @@ def launch_tui2(
try:
self._profile_service.rename_profile(fp, name)
self._clear_failure()
self._refresh_profile_tree()
self._set_status(f"Renamed profile {fp} to '{name}'")
except Exception as exc:
self._record_failure(
@@ -3075,6 +3105,27 @@ def launch_tui2(
)
return
if cmd == "profile-tree-next":
if args:
self._set_status("Usage: profile-tree-next")
return
self.action_profile_tree_next()
return
if cmd == "profile-tree-prev":
if args:
self._set_status("Usage: profile-tree-prev")
return
self.action_profile_tree_prev()
return
if cmd == "profile-tree-open":
if args:
self._set_status("Usage: profile-tree-open")
return
self.action_profile_tree_open()
return
if cmd == "setting-secret":
if self._config_service is None:
self._set_status("Config service unavailable")
@@ -3712,7 +3763,9 @@ def launch_tui2(
self._set_status("Entry service unavailable")
return
if len(args) not in {2, 3}:
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
self._set_status(
"Usage: export-field <field> <path> (optional: confirm)"
)
return
field = args[0].strip()
output_path = args[1].strip()
@@ -3722,7 +3775,9 @@ def launch_tui2(
confirm = False
if len(args) == 3:
if args[2].strip().lower() != "confirm":
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
self._set_status(
"Usage: export-field <field> <path> (optional: confirm)"
)
return
confirm = True
try:
@@ -4034,6 +4089,69 @@ def launch_tui2(
self._apply_focus_style()
self._set_status("Focused left pane")
def action_profile_tree_next(self) -> None:
if self._focus_pane != "left":
return
self._refresh_profile_tree()
if not self._profile_tree_items:
self._set_status("No profiles available")
return
self._profile_tree_cursor = (self._profile_tree_cursor + 1) % len(
self._profile_tree_items
)
selected = self._profile_tree_items[self._profile_tree_cursor]
self._update_filters_panel()
self._set_status(f"Profile selection: {selected}")
def action_profile_tree_prev(self) -> None:
if self._focus_pane != "left":
return
self._refresh_profile_tree()
if not self._profile_tree_items:
self._set_status("No profiles available")
return
self._profile_tree_cursor = (self._profile_tree_cursor - 1) % len(
self._profile_tree_items
)
selected = self._profile_tree_items[self._profile_tree_cursor]
self._update_filters_panel()
self._set_status(f"Profile selection: {selected}")
def action_profile_tree_open(self) -> None:
if self._focus_pane != "left":
return
if self._profile_service is None:
self._set_status("Profile service unavailable")
return
self._refresh_profile_tree()
if not self._profile_tree_items:
self._set_status("No profiles available")
return
target = self._profile_tree_items[self._profile_tree_cursor]
if target == "(default)":
self._set_status("No switch needed for default profile")
return
if target == (fingerprint or ""):
self._set_status(f"Profile already active: {target}")
return
try:
from seedpass.core.api import ProfileSwitchRequest
self._profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=target, password=None)
)
self._clear_failure()
self._load_entries(self._last_query, reset_page=True)
self._refresh_profile_tree()
self._set_status(f"Switched profile to {target}")
except Exception as exc:
self._record_failure(
"profile-tree-open failed",
exc,
retry=self.action_profile_tree_open,
hint="Press 'x' to retry.",
)
def action_focus_center(self) -> None:
self._focus_pane = "center"
self._apply_focus_style()
+79
View File
@@ -1717,3 +1717,82 @@ async def test_generate_password_allowed_chars(client):
pw = res.json()["password"]
specials = [c for c in pw if c in string.punctuation]
assert specials and all(c in allowed for c in specials)
@pytest.mark.anyio
async def test_semantic_endpoints(client, monkeypatch):
cl, token = client
class DummySemanticService:
def __init__(self, _pm):
self.last_enabled = None
self.last_search = None
def status(self):
return {"enabled": True, "built": True, "records": 4}
def build(self):
return {"enabled": True, "built": True, "records": 4}
def rebuild(self):
return {"enabled": True, "built": True, "records": 4}
def set_enabled(self, enabled: bool):
self.last_enabled = bool(enabled)
return {"enabled": bool(enabled)}
def search(self, query: str, *, k: int = 10, kind: str | None = None):
self.last_search = (query, k, kind)
return [{"entry_id": 10, "kind": kind or "document", "score": 0.5}]
service = DummySemanticService(None)
monkeypatch.setattr(api, "SemanticIndexService", lambda _pm: service)
headers = {"Authorization": f"Bearer {token}"}
status = await cl.get("/api/v1/semantic/status", headers=headers)
assert status.status_code == 200
assert status.json()["records"] == 4
built = await cl.post("/api/v1/semantic/build", headers=headers)
assert built.status_code == 200
assert built.json()["built"] is True
rebuilt = await cl.post("/api/v1/semantic/rebuild", headers=headers)
assert rebuilt.status_code == 200
assert rebuilt.json()["built"] is True
configured = await cl.post(
"/api/v1/semantic/config",
json={"enabled": False},
headers=headers,
)
assert configured.status_code == 200
assert configured.json() == {"enabled": False}
assert service.last_enabled is False
searched = await cl.post(
"/api/v1/semantic/search",
json={"query": "relay notes", "k": 6, "kind": "document"},
headers=headers,
)
assert searched.status_code == 200
assert searched.json()["results"][0]["entry_id"] == 10
assert service.last_search == ("relay notes", 6, "document")
@pytest.mark.anyio
async def test_semantic_search_requires_query(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post("/api/v1/semantic/search", json={}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "Missing query"}
@pytest.mark.anyio
async def test_semantic_config_requires_enabled_flag(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post("/api/v1/semantic/config", json={}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "Missing enabled"}
+76
View File
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass.cli import semantic as semantic_cli
runner = CliRunner()
class _DummySemanticService:
def status(self):
return {"enabled": True, "built": True, "records": 2}
def set_enabled(self, enabled: bool):
return {"enabled": bool(enabled), "built": True, "records": 2}
def build(self):
return {"enabled": True, "built": True, "records": 3}
def rebuild(self):
return {"enabled": True, "built": True, "records": 3}
def search(self, query: str, *, k: int = 10, kind: str | None = None):
if not query:
return []
return [
{
"entry_id": 7,
"kind": kind or "document",
"label": "Runbook",
"score": 0.8,
"excerpt": f"query={query} k={k}",
}
]
def test_semantic_cli_commands(monkeypatch):
monkeypatch.setattr(
semantic_cli,
"_get_semantic_service",
lambda _ctx: _DummySemanticService(),
)
monkeypatch.setattr(semantic_cli, "_get_pm", lambda _ctx: SimpleNamespace())
result = runner.invoke(app, ["semantic", "status"])
assert result.exit_code == 0
assert '"enabled": true' in result.stdout.lower()
result = runner.invoke(app, ["semantic", "enable"])
assert result.exit_code == 0
assert "semantic index enabled" in result.stdout.lower()
result = runner.invoke(app, ["semantic", "disable"])
assert result.exit_code == 0
assert "semantic index disabled" in result.stdout.lower()
result = runner.invoke(app, ["semantic", "build"])
assert result.exit_code == 0
assert "semantic index built" in result.stdout.lower()
result = runner.invoke(app, ["semantic", "rebuild"])
assert result.exit_code == 0
assert "semantic index rebuilt" in result.stdout.lower()
result = runner.invoke(app, ["semantic", "search", "relay recovery"])
assert result.exit_code == 0
assert "Runbook" in result.stdout
assert "score=0.800" in result.stdout
result = runner.invoke(
app,
["semantic", "search", "relay", "--k", "3", "--kind", "document", "--json"],
)
assert result.exit_code == 0
assert '"entry_id": 7' in result.stdout
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from seedpass.core.semantic_index import SemanticIndex
from seedpass.core.api import SemanticIndexService
def test_semantic_index_build_and_search(tmp_path: Path) -> None:
idx = SemanticIndex(tmp_path)
assert idx.status()["built"] is False
idx.set_enabled(True)
payload = idx.build(
[
{
"id": 1,
"kind": "document",
"label": "Runbook",
"content": "Nostr relay recovery process and checklist",
"tags": ["ops", "relay"],
},
{
"id": 2,
"kind": "password",
"label": "GitHub",
"username": "alice",
"url": "https://github.com",
},
{
"id": 3,
"kind": "seed",
"label": "Seed",
"seed_phrase": "abandon " * 11 + "about",
},
]
)
assert payload["built"] is True
assert payload["enabled"] is True
assert payload["records"] == 2
results = idx.search("relay checklist", k=5)
assert len(results) >= 1
assert results[0]["entry_id"] == 1
assert "Runbook" in results[0]["label"]
class _DummyConfig:
def __init__(self) -> None:
self.enabled = False
def set_semantic_index_enabled(self, enabled: bool) -> None:
self.enabled = bool(enabled)
def get_semantic_index_enabled(self) -> bool:
return bool(self.enabled)
class _DummyEntryManager:
def __init__(self) -> None:
self._entries = {
1: {
"id": 1,
"kind": "document",
"label": "Doc",
"content": "agent context",
},
2: {
"id": 2,
"kind": "key_value",
"label": "Env",
"key": "REGION",
"value": "us-east",
},
}
def search_entries(self, *_args, **_kwargs):
return [
(1, "Doc", None, None, False, SimpleNamespace(value="document")),
(2, "Env", None, None, False, SimpleNamespace(value="key_value")),
]
def retrieve_entry(self, entry_id: int):
return dict(self._entries[int(entry_id)])
def test_semantic_index_service_build_and_status(tmp_path: Path) -> None:
manager = SimpleNamespace(
fingerprint_dir=tmp_path,
config_manager=_DummyConfig(),
entry_manager=_DummyEntryManager(),
)
service = SemanticIndexService(manager)
status = service.status()
assert status["enabled"] is False
assert status["built"] is False
enabled = service.set_enabled(True)
assert enabled["enabled"] is True
built = service.build()
assert built["built"] is True
assert built["records"] == 2
results = service.search("agent", k=3)
assert len(results) >= 1
assert results[0]["entry_id"] == 1
+13 -2
View File
@@ -551,6 +551,9 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("profile-add")
app._run_palette_command("profile-remove")
app._run_palette_command("profile-rename")
app._run_palette_command("profile-tree-next now")
app._run_palette_command("profile-tree-prev now")
app._run_palette_command("profile-tree-open now")
app._run_palette_command("setting-secret on")
app._run_palette_command("setting-offline on")
app._run_palette_command("setting-quick-unlock on")
@@ -598,6 +601,10 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("lock")
app._run_palette_command("unlock hunter2")
app._run_palette_command("search Doc")
app.action_focus_left()
app._run_palette_command("profile-tree-next")
app._run_palette_command("profile-tree-prev")
app._run_palette_command("profile-tree-open")
app._run_palette_command("density comfortable")
app._run_palette_command("density compact")
app._run_palette_command("filter document")
@@ -633,12 +640,16 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("2fa-hide")
app._run_palette_command("doc-export /tmp/seedpass-matrix-export")
app._run_palette_command("copy content")
app._run_palette_command("export-field content /tmp/seedpass-matrix-content.txt")
app._run_palette_command(
"export-field content /tmp/seedpass-matrix-content.txt"
)
await pilot.pause()
app._run_palette_command("open 2")
app._run_palette_command("copy password confirm")
app._run_palette_command("export-field password /tmp/seedpass-matrix-password.txt confirm")
app._run_palette_command(
"export-field password /tmp/seedpass-matrix-password.txt confirm"
)
app.action_reveal_selected()
await pilot.pause()
assert "pw-2-" in str(app.query_one("#secret-detail").render())
+43 -4
View File
@@ -1007,8 +1007,20 @@ async def test_tui2_textual_copy_command_for_core_and_advanced_fields(tmp_path)
"private_key": "SSH_PRIVATE_2",
"public_key": "ssh-ed25519 AAAA-2",
},
{"id": 3, "kind": "nostr", "label": "Nostr 3", "npub": "npub3", "nsec": "nsec3"},
{"id": 4, "kind": "key_value", "label": "KV 4", "key": "TOKEN", "value": "abc123"},
{
"id": 3,
"kind": "nostr",
"label": "Nostr 3",
"npub": "npub3",
"nsec": "nsec3",
},
{
"id": 4,
"kind": "key_value",
"label": "KV 4",
"key": "TOKEN",
"value": "abc123",
},
]
)
app = _build_app(service)
@@ -1552,6 +1564,17 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None:
await pilot.pause()
assert "Profiles (2): fp-a, fp-b" in _status_text(app)
app.action_focus_left()
await pilot.pause()
app.action_profile_tree_next()
await pilot.pause()
assert "Profile selection: fp-b" in _status_text(app)
app.action_profile_tree_open()
await pilot.pause()
assert profiles.last_switch == ("fp-b", None)
assert "Switched profile to fp-b" in _status_text(app)
app._run_palette_command("profile-switch fp-b")
await pilot.pause()
assert profiles.last_switch == ("fp-b", None)
@@ -1792,6 +1815,18 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
await pilot.pause()
assert "Usage: profile-rename <fingerprint> <name>" in _status_text(app)
app._run_palette_command("profile-tree-next now")
await pilot.pause()
assert "Usage: profile-tree-next" in _status_text(app)
app._run_palette_command("profile-tree-prev now")
await pilot.pause()
assert "Usage: profile-tree-prev" in _status_text(app)
app._run_palette_command("profile-tree-open now")
await pilot.pause()
assert "Usage: profile-tree-open" in _status_text(app)
app._run_palette_command("relay-list")
await pilot.pause()
assert "Nostr service unavailable" in _status_text(app)
@@ -1905,11 +1940,15 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
app._run_palette_command("export-field")
await pilot.pause()
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(
app
)
app._run_palette_command("export-field password /tmp/x nope")
await pilot.pause()
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(
app
)
app._run_palette_command("density")
await pilot.pause()