From 4011464c31f2eb30b1de58add6e2baa61ec5a8b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:26:05 -0500 Subject: [PATCH] Advance v3 search, atlas, and comms planning --- .scheduler-memory/memory-update.md | 90 ++- ...earch_graph_integration_plan_2026-03-05.md | 498 ++++++++++++++++ docs/dev_control_center.md | 140 +++-- .../index0_atlas_execution_plan_2026-03-05.md | 44 +- docs/index0_phase0_spec_2026-03-05.md | 26 +- ...erence_and_future_capability_2026-03-05.md | 244 ++++++++ docs/tui_v3_plan.md | 55 +- src/nostr/snapshot.py | 2 + src/seedpass/core/api.py | 522 ++++++++++++++++- src/seedpass/core/entry_management.py | 168 ++++++ src/seedpass/core/index0.py | 546 ++++++++++++++++++ src/seedpass/core/manager.py | 21 + src/seedpass/core/vault.py | 4 +- src/seedpass/tui_v3/__init__.py | 4 + src/seedpass/tui_v3/app.py | 380 +++++++++++- src/seedpass/tui_v3/screens/atlas.py | 145 +++++ src/seedpass/tui_v3/screens/maintenance.py | 121 ++++ src/seedpass/tui_v3/screens/profile.py | 168 ++++++ src/seedpass/tui_v3/screens/pubkey.py | 97 ++++ src/seedpass/tui_v3/screens/relays.py | 98 ++-- src/seedpass/tui_v3/screens/security.py | 144 +++++ src/seedpass/tui_v3/screens/settings.py | 6 + src/seedpass/tui_v3/widgets/action_bar.py | 121 +++- src/seedpass/tui_v3/widgets/grid.py | 43 +- src/seedpass/tui_v3/widgets/header.py | 51 ++ src/tests/test_core_api_services.py | 212 +++++++ src/tests/test_index0_checkpoints.py | 162 ++++++ src/tests/test_index0_events.py | 98 ++++ src/tests/test_index0_views.py | 77 +++ src/tests/test_tui_v3_parity.py | 56 ++ src/tests/test_tui_v3_smoke.py | 413 ++++++++++++- 31 files changed, 4627 insertions(+), 129 deletions(-) create mode 100644 docs/atlas_search_graph_integration_plan_2026-03-05.md create mode 100644 docs/nostr_comms_reference_and_future_capability_2026-03-05.md create mode 100644 src/seedpass/tui_v3/screens/atlas.py create mode 100644 src/seedpass/tui_v3/screens/maintenance.py create mode 100644 src/seedpass/tui_v3/screens/profile.py create mode 100644 src/seedpass/tui_v3/screens/pubkey.py create mode 100644 src/seedpass/tui_v3/screens/security.py create mode 100644 src/tests/test_index0_checkpoints.py create mode 100644 src/tests/test_index0_events.py create mode 100644 src/tests/test_index0_views.py diff --git a/.scheduler-memory/memory-update.md b/.scheduler-memory/memory-update.md index 3f98e57d..0c6023ed 100644 --- a/.scheduler-memory/memory-update.md +++ b/.scheduler-memory/memory-update.md @@ -1 +1,89 @@ -Simulated memory update +# Memory Update (2026-03-05) + +## TUI v3 onboarding parity +- TUI v3 now owns profile selection, unlock, add-new profile, blank-index recovery, Nostr restore, and local-backup restore flows inside the app, using non-interactive bootstrap helpers in `PasswordManager` instead of CLI-side prompts. +- `docs/dev_control_center.md` now reflects that the Index0 track is beyond spec-only status: Phase 1 foundation is partially implemented and the next deferred slice is event-emission hooks. +- Remaining onboarding parity gap is mostly UX fidelity: a true word-by-word seed entry experience and richer restore guidance, not missing core bootstrapping capability. + +## TUI v3 inspector behavior +- The v3 inspector should stay fully collapsed until the user explicitly selects an entry; Textual `DataTable` can emit an automatic initial `RowHighlighted` event on mount, so that first highlight must be suppressed or it will look like the inspector auto-opens on startup. +- Action-bar hotkey labels should avoid markup like `[b][S][/b]ettings`; Rich/Textual interprets `[S]` as markup, which drops the visible first letter. Use `[b]S[/b]ettings` instead. + +## TUI v3 parity/docs alignment +- `docs/dev_control_center.md` had drifted back to a TUI v2-first execution order even though `seedpass` now launches TUI v3 by default; the control center should treat v3 as the active UI track and v2 as reference-only unless explicitly reactivated. +- The v3 plan previously overstated lifecycle parity; delete-entry workflow is still missing from the v3 shell and should remain an explicit open item until it is actually wired and tested. +- The v3 action strip should be kind-aware instead of generic. Good defaults: documents/notes should not advertise QR or reveal, seeds/managed accounts should show load, and export should only appear for kinds with a real export path. + +## TUI v3 delete workflow +- The core delete path already existed in `entry_management`, but the UI-facing thread-safe `EntryService` wrapper in `core/api.py` did not expose it. V3 delete support required adding that wrapper method rather than inventing a separate code path. +- For v3, delete fits the same safety model as other high-risk actions: first press shows an in-board confirmation prompt, second `d` within 8 seconds executes the delete, then clears selection so the collapsed-inspector behavior remains consistent. + +## TUI modularity pattern +- The current UI modularity is still healthy when new capabilities are added through `core/api.py` service wrappers first, then surfaced in TUI v3 actions/screens. This keeps future GUIs from depending on `PasswordManager` internals or Textual-specific state. +- Recent example: `npub`, `nostr-reset-sync-state`, and `nostr-fresh-namespace` were already available in `NostrService`, so v3 could add palette/screen affordances without pushing new UI logic into core. + +## TUI v3 security maintenance flows +- `change-password` and `backup-parent-seed` are good examples of future-GUI-safe carryover: keep input collection in dedicated v3 screens, then submit typed request models to `VaultService` instead of embedding vault mutation logic in the UI. +- The settings screen is currently a useful discoverability hub for these maintenance commands even when the actual mutation happens in separate screens; that pattern scales better than overloading the main workspace. + +## TUI v3 profile maintenance +- Profile switch/remove belongs in a dedicated maintenance screen rather than the startup flow only. Using `ProfileService` request models keeps this future-GUI-safe and avoids direct UI coupling to fingerprint-manager internals. +- As more maintenance screens land, app-level refresh helpers cannot assume the main workspace is always the active screen; refresh paths should degrade gracefully when called from modal or utility screens. +- Maintenance-flow polish matters as much as raw parity: profile lists should prefer display labels over raw fingerprints when available, and destructive actions like profile removal should require an explicit second action inside the screen rather than firing on the first button press. +- Restore/import flows benefit from mode-specific guidance text before submit; even simple status hints reduce operator error on Nostr vs local-backup recovery paths. + +## Maintenance-screen UX pattern +- For v3 maintenance utilities, good UX defaults are: an intro block explaining the operation, an in-screen status area for progress/errors, and a confirmation step for destructive actions. Toasts alone are not enough once these screens become part of the main product flow. +- Relay management benefits from the same confirmation posture as entry/profile deletion; consistency matters more than treating relay removal as a special case. +- Once those patterns are in place, the next level of polish is visual consistency: heavy outer cards, bordered intro/status sections, and consistent action-row/button treatment make maintenance screens feel like part of the same product instead of disconnected utility dialogs. +- A shared maintenance-screen helper (`screens/maintenance.py`) is now the right place for common CSS/status phrasing; future maintenance flows should use it instead of copying screen-local variants. +- Footer chrome is part of that consistency too; custom black maintenance footers fit the SeedPass shell better than the default Textual `Footer` widget for these screens. + +## 2026-03-05 index0 emission slice +- Implemented `index0` CRUD/link event emission in `src/seedpass/core/entry_management.py` using new shared helpers in `src/seedpass/core/index0.py`. +- `index0` now appends deterministic events for create/modify/archive/restore/delete and link add/remove, updates per-writer heads, and derives hierarchy-aware `scope_path` from the active fingerprint directory (`seed/` or `seed//managed/`). +- Added focused regression coverage in `src/tests/test_index0_events.py`. +- Updated roadmap docs so the next `index0` slice is checkpoints/compaction plus canonical view rebuilders and manifest publication, not CRUD/link emission. + +## 2026-03-05 index0 checkpoint and manifest slice +- Added deterministic daily checkpoint rebuild in `src/seedpass/core/index0.py`, with bounded retention per writer and manifest export metadata (`checkpoint_ids`, `checkpoint_hashes`, `stream_heads`). +- `Vault.save_index()` now compacts `_system.index0` before encrypting, so synced payloads carry rebuilt checkpoints consistently. +- `PasswordManager.sync_vault_async()` now computes/persists compacted `index0` state before publish and passes manifest atlas metadata to the Nostr snapshot publisher, keeping manifest validation data aligned with the encrypted payload. +- Added focused regressions in `src/tests/test_index0_checkpoints.py`; next `index0` slice is canonical view rebuilders/read helpers, not checkpoint generation. + +## 2026-03-05 index0 canonical views slice +- Added deterministic synced canonical views in `src/seedpass/core/index0.py`: `children_of`, `counts_by_kind`, and `recent_activity`. +- Canonical view rebuild now runs inside `compact_index0_payload(...)` alongside checkpoint rebuild; `Vault.save_index()` passes the active fingerprint path so scope-aware views can be rebuilt even for sparse/legacy payloads. +- Added lightweight read helpers `list_canonical_views(...)` and `get_canonical_view(...)` for later service/UI consumers. +- Focused coverage lives in `src/tests/test_index0_views.py`; next `index0` work should be atlas read services and v3/UI consumers, not more storage plumbing. + +## 2026-03-05 index0 atlas consumer slice +- Added `AtlasService` in `src/seedpass/core/api.py` to expose `index0` status, view lookup, view listing, and a combined `wayfinder()` payload through the same service boundary used by TUI/GUI clients. +- Added the first v3 atlas consumer in `src/seedpass/tui_v3/screens/atlas.py` and wired `atlas` / `wayfinder` palette commands in `src/seedpass/tui_v3/app.py`. +- The right next `index0` step is deeper v3 integration (main shell wayfinder/stateful landing surfaces) and agent-facing atlas workflows, not more low-level storage plumbing. + +## 2026-03-05 index0 embedded v3 workspace slice +- Added `AtlasStrip` in `src/seedpass/tui_v3/widgets/header.py` and mounted it in the main v3 workspace so atlas wayfinder data is visible even with no active selection. +- The first embedded shell integration is intentionally light: scope path, top counts, and most recent event. The next `index0` UI step should be richer navigation actions from this strip or a docked wayfinder panel, not more exposure-only widgets. + +## 2026-03-05 index0 actionable v3 wayfinder slice +- The v3 atlas screen in `src/seedpass/tui_v3/screens/atlas.py` now supports direct entry jumps and quick kind-filter jumps, making `index0` wayfinder data actionable instead of read-only. +- Added `w` as a first-class v3 binding for the wayfinder and surfaced it in the action bar. +- The next meaningful `index0` step is not more exposure widgets; it is deeper search/navigation handoff and agent-facing atlas workflows built on the existing `AtlasService`. + +## 2026-03-05 atlas/search/graph planning slice +- Added `docs/atlas_search_graph_integration_plan_2026-03-05.md` to define how `index0`, tags, links, and local semantic search should integrate. +- The agreed next implementation target is `SearchService` plus unified result schema, deterministic filters/sorting, and linked-item navigation, not more foundational `index0` storage work. +- Updated central docs so current shipped accomplishments are recorded consistently: v3 default UI, semantic index integration, canonical tags/links, and `index0` foundations plus current v3 atlas consumers. + +## 2026-03-05 unified search service slice +- Added `SearchService` in `src/seedpass/core/api.py` as the new service-layer search contract above raw entry search and local semantic search. +- The normalized result payload now carries score breakdowns, match reasons, safe excerpts, tags, linked-hit summaries, archived state, and scope path so TUI/GUI clients do not need to reconstruct search metadata themselves. +- TUI v3 grid should prefer `SearchService` instead of branching directly between `EntryService` and `SemanticIndexService`; keep semantic search as an implementation detail behind the unified service boundary. +- The next practical search/graph slice is linked-neighbor APIs plus explicit v3 sort/filter controls, not another round of search-contract work. +- `docs/dev_control_center.md` should treat that linked-navigation + explicit sort/filter slice as the immediate next step so future runs do not fall back to older TUI carryover wording. + +## 2026-03-05 Nostr communications research +- Added `docs/nostr_comms_reference_and_future_capability_2026-03-05.md` as a future-capability reference for DMs and team/community chat. +- SeedPass-specific protocol direction recorded there: prefer `NIP-17` + `NIP-44` + `NIP-59` for private managed DMs, evaluate `NIP-29` for institutional group chat, and treat `NIP-28` / `NIP-72` as public/community-oriented layers. +- The control center now links this as a future capability track and explicitly keeps it behind current v3/search/index0/security priorities. diff --git a/docs/atlas_search_graph_integration_plan_2026-03-05.md b/docs/atlas_search_graph_integration_plan_2026-03-05.md new file mode 100644 index 00000000..d22d2ac1 --- /dev/null +++ b/docs/atlas_search_graph_integration_plan_2026-03-05.md @@ -0,0 +1,498 @@ +# Atlas, Search, and Graph Integration Plan (2026-03-05) + +Status: In Progress +Branch target: `beta` +Depends on: +- `docs/dev_control_center.md` +- `docs/index0_atlas_execution_plan_2026-03-05.md` +- `docs/index0_phase0_spec_2026-03-05.md` +- `docs/semantic_vector_index_plan.md` + +## 1) Goal + +Define the next architecture layer above the current `index0` foundation so SeedPass can deliver: + +1. one robust search surface +2. deterministic browsing and sorting +3. first-class linked-item navigation +4. clean integration between: + - `index0` atlas + - canonical tags + - canonical links + - local semantic/vector search + +Working slogan: + +`Index0 is the atlas, links are the graph, tags are the human taxonomy, and semantic search is the local retrieval accelerator.` + +## 2) Current Baseline + +As of this branch state, SeedPass already has: + +1. canonical encrypted entries in the synced payload +2. canonical tags on entries +3. canonical typed links between entries +4. local semantic/vector indexing +5. `index0` inside `_system.index0` with: + - deterministic event emission + - deterministic checkpoints + - manifest head/checkpoint publication + - canonical synced views: + - `children_of` + - `counts_by_kind` + - `recent_activity` +6. `AtlasService` +7. first v3 atlas consumers: + - dedicated wayfinder screen + - always-visible workspace strip + - basic actionable entry/filter jumps + +What is still missing is the integration layer that turns these ingredients into one coherent navigation and search system. + +## 3) Architecture Principles + +### 3.1 Keep the roles distinct + +Use the following separation strictly: + +1. `index0` + - canonical synced atlas + - hierarchy, activity, lightweight structural summaries +2. tags + - canonical human classification + - workflow labels and topical labels +3. links + - canonical explicit graph edges + - typed relationships between entries +4. semantic index + - local derived retrieval structure + - embeddings, semantic neighbors, ranking aids + +### 3.2 Do not duplicate heavy data into `index0` + +`index0` should not store: + +1. embeddings +2. full-text indexes +3. large semantic neighbor caches +4. personalization-heavy ranking state + +Those stay local and rebuildable. + +### 3.3 Make one search surface, not four + +Users should not need to choose between: + +1. tag search +2. link search +3. semantic search +4. atlas search + +The UI should expose one main search command and one structured filter/sort system. Internally, that search can use multiple ranking channels. + +## 4) Target Search Model + +## 4.1 Unified search modes + +SeedPass should expose these modes: + +1. `keyword` +2. `hybrid` +3. `semantic` +4. later: `graph` + +Behavior: + +1. `keyword` + - lexical only + - deterministic and explainable +2. `hybrid` + - lexical + semantic + structural re-ranking + - default when semantic index is enabled +3. `semantic` + - semantic-first results with metadata re-ranking +4. `graph` + - neighborhood-driven traversal and link expansion + - later phase only + +## 4.2 Ranking channels + +Unified ranking should combine these channels: + +1. lexical + - label + - notes + - document content + - custom fields + - tags + - link relation text +2. structural + - same subtree / same scope + - direct link proximity + - shared tags + - recent atlas activity + - node importance from link degree and change frequency +3. semantic + - vector similarity from local semantic index +4. recency + - recent updates from `modified_ts` and `recent_activity` + +Recommended baseline weighting in `hybrid` mode: + +1. lexical: `0.40` +2. semantic: `0.35` +3. structural: `0.20` +4. recency: `0.05` + +These weights should remain configurable in code, but not initially user-configurable. + +## 4.3 Result contract + +Create a single result contract used by TUI, CLI, future GUI, and agents: + +```json +{ + "entry_id": 42, + "label": "Project Plan", + "kind": "document", + "scope_path": "seed/", + "archived": false, + "score": 0.8123, + "score_breakdown": { + "lexical": 0.40, + "semantic": 0.27, + "structural": 0.11, + "recency": 0.03 + }, + "match_reasons": [ + "label_exact", + "tag:docs", + "linked_to:selected", + "same_scope" + ], + "excerpt": "Short safe text excerpt", + "linked_hits": [ + { + "target_id": 17, + "relation": "references" + } + ], + "tags": ["docs", "planning"], + "modified_ts": 1760086400 +} +``` + +Rules: + +1. `score_breakdown` is optional in compact UI lists, but must be available to the service layer +2. `match_reasons` should be user-safe and explainable +3. `excerpt` must never reveal secrets for secret-bearing kinds unless explicitly requested + +## 5) Sorting and Filtering Spec + +## 5.1 Filters + +SeedPass should support these first-class filters: + +1. kind +2. archived state +3. tags +4. relation type +5. subtree / scope path +6. root profile vs managed scope +7. linked-to current item +8. has-links +9. has-tags +10. recently modified + +Later filters: + +1. changed-by actor +2. high-activity nodes +3. semantic index available / unavailable + +## 5.2 Sorting + +Supported sort modes: + +1. `relevance` +2. `modified_desc` +3. `modified_asc` +4. `label_asc` +5. `kind` +6. `created_desc` +7. `most_linked` +8. `most_active` + +Rules: + +1. `relevance` is default only when a query exists +2. without a query, default browsing sort should be structural: + - current scope + - recent activity + - stable secondary ordering by label or entry id +3. ties must always resolve deterministically: + - first by score + - then by `modified_ts` + - then by label + - then by numeric `entry_id` + +## 6) Linked Navigation Model + +Linked item navigation should become first-class, not hidden in raw entry metadata. + +Required linked views: + +1. outgoing links + - “this item links to” +2. incoming links + - “linked from” +3. neighborhood summary + - grouped by relation type + +Required linked item card data: + +1. `entry_id` +2. `label` +3. `kind` +4. `relation` +5. `scope_path` +6. `archived` +7. `modified_ts` + +Required actions: + +1. open target +2. jump in grid +3. filter by relation +4. expand neighborhood +5. search within neighbors + +## 7) How `index0` Should Integrate With Tags, Links, and Semantic Search + +## 7.1 Canonical atlas additions + +Add these `index0` canonical views next: + +1. `linked_neighbors:` + - compact summary of link density and high-value connected nodes +2. `tag_counts:` + - counts for tags within current scope +3. `link_counts_by_relation:` + - counts for relation types in the current scope +4. later: `incoming_links:` + - only if bounded and deterministic + +These should stay compact and summary-oriented. + +## 7.2 Local-only derived additions + +Keep these local-only: + +1. semantic neighbor expansions +2. ranking caches +3. user history / frequently opened nodes +4. personalized hotlists + +## 7.3 Re-ranking semantic results with atlas data + +Semantic results should be re-ranked using: + +1. same scope +2. direct links to current item +3. shared tags +4. recent activity +5. link-degree / node importance + +This makes semantic results feel grounded in SeedPass structure instead of fuzzy. + +## 8) Service-Layer Plan + +Add a new service layer path above `AtlasService`, not inside the TUI: + +### 8.1 `SearchService` + +Responsibilities: + +1. expose unified search API +2. merge lexical, semantic, and structural scoring +3. apply filters and sorting +4. explain ranking reasons + +Recommended methods: + +1. `search(query, mode, filters, sort, scope_path=None, selected_entry_id=None)` +2. `suggest(query, scope_path=None)` +3. `neighbors(entry_id, relation=None, direction="both")` +4. `related(entry_id, mode="hybrid")` + +### 8.2 `AtlasService` expansion + +Add: + +1. `neighbors(entry_id)` +2. `incoming_links(entry_id)` +3. `outgoing_links(entry_id)` +4. `tag_summary(scope_path=None)` +5. `relation_summary(scope_path=None)` + +## 9) TUI v3 Plan + +## 9.1 Phase 1: Search and filter foundation + +Deliverables: + +1. unified search command path using `SearchService` +2. explicit sort control +3. explicit filter panel/state model +4. result rows show: + - label + - kind + - tag summary + - linked indicator + - relevance or modified hint + +## 9.2 Phase 2: Linked item navigation + +Deliverables: + +1. linked-items section in inspector +2. incoming/outgoing relation browsing +3. open-linked-item shortcuts +4. relation filters + +## 9.3 Phase 3: Atlas-driven navigation + +Deliverables: + +1. promote wayfinder from helper to true landing/navigation layer +2. make atlas strip clickable or command-driven for: + - recent activity jump + - kind counts jump + - scope drilldown +3. integrate “search in current scope” and “search neighbors of current item” + +## 9.4 Phase 4: Agent workflows + +Deliverables: + +1. structured atlas query helpers for agents +2. explainable result payloads +3. graph-neighborhood traversal helpers +4. safe summaries that avoid secret leakage by default + +## 10) Development Phases + +### Phase A: Search contract and service + +Implement: + +1. `SearchService` +2. unified result schema +3. deterministic filter/sort pipeline +4. hybrid scorer baseline + +Exit criteria: + +1. one service call can perform keyword/hybrid/semantic search +2. results include stable ordering and match reasons + +Current status: + +1. complete baseline in `src/seedpass/core/api.py` +2. unified result schema landed with score breakdown, match reasons, tags, linked-hit summaries, and safe excerpts +3. deterministic filter/sort pipeline landed for baseline modes and common sort orders +4. v3 entry grid now consumes `SearchService` as the primary search path +5. focused regression coverage landed in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py` + +### Phase B: Atlas view expansion + +Implement: + +1. `tag_counts` +2. `link_counts_by_relation` +3. compact `linked_neighbors` + +Exit criteria: + +1. atlas can summarize the graph and tag topology for a scope + +### Phase C: Linked navigation + +Implement: + +1. incoming/outgoing link APIs +2. inspector linked-items board +3. jump-to-linked-item flows + +Exit criteria: + +1. linked navigation works without raw JSON inspection + +### Phase D: TUI search/sort/filter UX + +Implement: + +1. search mode selector +2. sort selector +3. filter panel or command model +4. scoped search and linked-neighbor search + +Exit criteria: + +1. search and browse feel like one system + +### Phase E: Agent workflows + +Implement: + +1. explainable atlas/search payloads +2. graph traversal helpers +3. scoped knowledge lookup APIs + +Exit criteria: + +1. agents can navigate institutional knowledge without bespoke UI logic + +## 11) Test Plan + +Required test categories: + +1. deterministic ordering tests +2. hybrid scoring tests +3. filter/sort interaction tests +4. link navigation tests +5. atlas view rebuild tests +6. v3 interaction tests +7. no-secret-leakage tests for excerpts and summaries + +Minimum new suites: + +1. `src/tests/test_search_service.py` +2. `src/tests/test_index0_graph_views.py` +3. `src/tests/test_link_navigation.py` +4. extend `src/tests/test_tui_v3_smoke.py` +5. extend `src/tests/test_core_api_services.py` + +## 12) Recommended Immediate Next Slice + +Implement Phase A first: + +1. add `SearchService` +2. define unified result schema +3. support filters + sorting + mode selection +4. thread it into v3 search path without removing current semantic mode support + +Completed baseline: + +1. `SearchService` exists and is wired into v3 +2. unified result schema exists +3. baseline filters + sorting + mode selection exist +4. v3 now uses the unified search path + +Recommended next slice: + +1. add linked-neighbor and relation-summary service methods +2. expose explicit v3 sort/filter controls instead of implicit command-state only +3. add inspector linked-items board and jump-to-linked-item flows +4. expand atlas views with tag and relation summaries for graph browsing diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index c548e306..69adbdb0 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -1,6 +1,6 @@ # SeedPass Dev Control Center -Last updated: 2026-03-02 +Last updated: 2026-03-05 Branch baseline: `beta` This is the single source-of-truth document to decide what to work on next. @@ -21,23 +21,21 @@ Latest verification in this session: - full suite: `979 passed, 16 skipped` - 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: `35 passed` - - includes profile-tree keyboard navigation/select command coverage + - `./.venv/bin/pytest -q src/tests/test_tui_v3_smoke.py src/tests/test_tui_v3_parity.py src/tests/test_typer_cli.py` + - latest result in the current v3/default-launch track: `77 passed` + - latest focused v3 UX/context regression slice: `18 passed` ## 2) Canonical Status Sources Use these docs as inputs, but treat this file as the decision layer: +- TUI v3 plan (current working UI roadmap): `docs/tui_v3_plan.md` - TUI v2 plan: `docs/tui_v2_plan.md` -- TUI parity backlog: `docs/tui_v2_parity_backlog.md` -- TUI parity checklist: `docs/tui_v2_parity_checklist.md` -- TUI legacy parity matrix: `docs/tui_v2_legacy_parity_matrix.md` -- TUI v2 UI refresh plan (mockup-aligned): `docs/tui_v2_ui_refresh_plan.md` -- TUI v2 integration execution plan (current working plan): `docs/tui_v2_integration_execution_plan_2026-03-02.md` -- TUI v2 mockup gap audit: `docs/tui_v2_mockup_gap_audit_2026-03-02.md` +- TUI v2 artifacts remain logic/reference material only unless explicitly reactivated - Semantic vector index integration plan: `docs/semantic_vector_index_plan.md` - Index0 atlas execution plan: `docs/index0_atlas_execution_plan_2026-03-05.md` +- Atlas/search/graph integration plan: `docs/atlas_search_graph_integration_plan_2026-03-05.md` +- Nostr communications future capability reference: `docs/nostr_comms_reference_and_future_capability_2026-03-05.md` - Cutover memo: `docs/tui_v2_cutover_decision.md` - Security readiness: `docs/security_readiness_checklist.md` - Supply-chain integrity: `docs/supply_chain_release_integrity.md` @@ -49,11 +47,19 @@ Use these docs as inputs, but treat this file as the decision layer: ## 3) What Is Still Open ### Parity / Product -- TUI v2 legacy parity closure is now documented as complete: - - `docs/tui_v2_parity_checklist.md` (“Full legacy workflow coverage: Yes”) - - bug-bash evidence reports: - - `artifacts/agent_tui_test/20260302T135526Z/report.json` - - `artifacts/agent_tui2_test/20260302T135526Z/report.json` +- TUI v3 is now the default launch target (`seedpass` -> v3). +- TUI v3 now owns: + - startup profile selection / unlock + - add-new / recover / restore onboarding + - inspector boards and context-aware lower pane behavior + - default-shell UX work +- TUI v2 remains available via `seedpass tui2`, but is no longer the active UI development target. +- Remaining v3 parity/hardening gaps still visible from the shipped code: + - context-aware action strip should continue being tightened per entry kind + - restore / import guidance and final visual polish still need iteration + - additional legacy utility and maintenance affordances should continue moving into v3 screens where they improve discoverability + - profile-management flows now exist in-app, but still need broader polish around naming, confirmations, and operator guidance + - maintenance screens now have shared styling/status helpers, but still need final mockup-level polish and any future screens should adopt the shared pattern from the start ### Security Readiness - `docs/security_readiness_checklist.md` still marks: @@ -80,6 +86,35 @@ Use these docs as inputs, but treat this file as the decision layer: - Phase C complete baseline (legacy + TUI v2 semantic workflows and mode toggles) - Next: incremental mutation hooks + hybrid quality hardening +### Search / Graph Integration Roadmap +- New cross-system plan drafted and linked: + - `docs/atlas_search_graph_integration_plan_2026-03-05.md` +- Purpose: + - unify `index0`, tags, links, and local semantic search into one search/navigation model +- Phase A baseline landed: + - `SearchService` added in `src/seedpass/core/api.py` + - unified result payload with score breakdown, match reasons, safe excerpts, tags, and linked-hit summaries + - deterministic filter/sort pipeline added for keyword / hybrid / semantic search modes + - v3 entry grid now routes through the unified search path instead of branching search logic in the widget layer + - focused regression coverage added in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py` +- Next planned implementation: + - richer sort/filter controls in v3 + - linked-item navigation service methods and inspector consumers + - deeper atlas/search handoff and graph-aware result workflows + +### Future Capability: Nostr Communications +- Research/reference doc added: + - `docs/nostr_comms_reference_and_future_capability_2026-03-05.md` +- Purpose: + - capture the relevant NIPs for future DMs and team/community chat in SeedPass + - align that capability with managed deterministic identities, `index0`, and future institutional knowledge workflows +- Current planning direction: + - prefer `NIP-17` + `NIP-44` + `NIP-59` for private DMs + - evaluate `NIP-29` as the strongest candidate for managed institutional group chat + - treat `NIP-28` and `NIP-72` as public/community-oriented layers +- Not current implementation work: + - this is documented as a future addition and should not preempt the current v3/search/index0/security priorities + ### Index0 Atlas Roadmap - New architecture/spec track drafted: - `docs/index0_atlas_execution_plan_2026-03-05.md` @@ -90,6 +125,32 @@ Use these docs as inputs, but treat this file as the decision layer: - deterministic `index0` merge helper - Nostr manifest optional `index0` metadata support - focused tests in `src/tests/test_index0_core.py` +- Phase 1 CRUD/link emission slice landed: + - deterministic `index0_event` emission from entry create/modify/archive/restore/delete flows + - deterministic `index0_event` emission from link add/remove flows + - writer-head updates and hierarchy-aware scope derivation from active profile path + - focused regression coverage in `src/tests/test_index0_events.py` +- Phase 1 checkpoint/manifest slice landed: + - deterministic daily checkpoint rebuild from canonical `index0_event` streams + - bounded checkpoint retention per writer + - manifest `index0` publication of checkpoint hashes and stream heads during sync + - focused regression coverage in `src/tests/test_index0_checkpoints.py` +- Phase 1 canonical views slice landed: + - deterministic synced `children_of`, `counts_by_kind`, and `recent_activity` views + - view rebuild runs in the same compaction path as checkpoints + - lightweight atlas read helpers landed in core + - focused regression coverage in `src/tests/test_index0_views.py` +- Atlas consumer slice landed: + - `AtlasService` added in `src/seedpass/core/api.py` + - v3 palette can open an atlas/wayfinder screen backed by the service layer + - v3 main workspace now shows an always-visible atlas strip in the shell chrome + - v3 atlas screen now supports direct entry jumps and quick filter jumps + - focused regression coverage updated in `src/tests/test_core_api_services.py` and `src/tests/test_tui_v3_smoke.py` +- Current accomplishments summary: + - v3 is the default UI and owns unlock/onboarding/maintenance/profile/security flows + - semantic index is integrated as a local derived retrieval layer + - canonical tags and typed links already exist at the entry model level + - `index0` now covers canonical atlas events, checkpoints, manifest metadata, synced views, service reads, and first v3 consumers - Intent: - add canonical encrypted atlas state inside the existing payload for audit, hierarchy wayfinding, and future KB/chat navigation - Key implementation direction: @@ -98,42 +159,41 @@ Use these docs as inputs, but treat this file as the decision layer: - rebuildable wayfinder views layered on top - Sequencing: - Phase 0 spec/schema is drafted - - Phase 1 foundation is partially implemented - - next implementation slice: event emission hooks from entry CRUD/link flows + - Phase 1 foundation + CRUD/link emission + checkpoint/manifest publication + first canonical views are implemented + - atlas read service layer and first v3 consumers are implemented + - first actionable v3 wayfinder navigation flows are implemented + - next implementation slice: broader agent-facing atlas workflows and deeper search/navigation handoff, plus any later event-pruning compaction policy - implementation phases should not preempt current TUI cutover-critical or security-readiness blockers ## 4) Recommended Next Steps (Priority Order) -1. Execute Phases A-C from `docs/tui_v2_integration_execution_plan_2026-03-02.md`: -: selection lifecycle, real hierarchy tree, and dense header/footer rebuild. -2. Execute Phases D and G: -: filter menu model and reveal/QR reliability hardening. -3. Execute Phase H: -: responsive and density polish for high-resolution and compact terminals. -4. Execute Phase E: -: unified lexical+tag+semantic search integration. -5. Execute Phase F: -: final per-board fidelity pass and strict mockup closeout. -6. Start Index0 Atlas Phase 0 from `docs/index0_atlas_execution_plan_2026-03-05.md`: -: lock schema, merge contract extension, manifest checkpoint fields, and compaction rules. -7. After cutover-critical TUI/security slices stabilize, execute Index0 Atlas Phases 1-3: -: canonical storage, event emission hooks, and manifest checkpoint validation. -8. After semantic hardening resumes, execute Index0 Atlas Phase 5: -: atlas read APIs/views and semantic interplay. +1. Execute the remaining active v3 parity slices from `docs/tui_v3_plan.md`: +: context-aware actions, maintenance affordances, and final board fidelity polish. +2. Continue TUI v3 utility/maintenance carryover: +: remaining settings-linked operational flows, discoverable security maintenance, and final maintenance-screen mockup polish passes. +3. Continue v3 startup/restore hardening: +: clearer restore warnings, success summaries, and import/recovery guidance. +4. Continue Phase B/C from `docs/atlas_search_graph_integration_plan_2026-03-05.md`: +: linked-item navigation, richer filter/sort controls, and deeper v3 search/navigation handoff. +5. Resume Index0 Atlas and graph follow-on work after current v3 UX slices stabilize: +: agent-facing atlas workflows, linked-item navigation, deeper search/navigation handoff, and any later event-pruning compaction policy. +6. Continue security-readiness blockers in parallel where they do not conflict with the v3 path. ## 5) Next Slice Definition (Recommended Immediate Work) -### Slice A: “Post-Parity Cutover Readiness” +### Slice A: “Search Graph Navigation” Deliverables: -- Keep parity docs in sync with shipped behavior and evidence. -- Raise test gates/coverage thresholds for the TUI v2 critical path. -- Capture remaining cutover blockers as explicit release criteria. +- Add linked-neighbor and relation-summary methods above the current atlas/search foundations. +- Expose explicit sort and filter controls in v3 on top of the existing unified `SearchService`. +- Add inspector linked-item navigation so users can browse outgoing/incoming relationships without raw JSON inspection. +- Keep the atlas/search/graph plan and control center synchronized with shipped behavior as this slice lands. Exit criteria: -- Matrix exists, reviewed, and linked from this document. -- New tests pass in CI-equivalent run. -- Remaining parity gaps are explicit and severity-ranked. +- one service-layer path can return linked neighbors and relation summaries for an entry +- v3 exposes explicit sort/filter affordances instead of relying only on command-state +- linked items can be viewed and opened from the inspector +- focused search/atlas/v3 suites stay green Progress update (2026-03-02): - Matrix added: `docs/tui_v2_legacy_parity_matrix.md` diff --git a/docs/index0_atlas_execution_plan_2026-03-05.md b/docs/index0_atlas_execution_plan_2026-03-05.md index 8159e62b..76ce3e48 100644 --- a/docs/index0_atlas_execution_plan_2026-03-05.md +++ b/docs/index0_atlas_execution_plan_2026-03-05.md @@ -1,8 +1,50 @@ # Index0 Atlas Execution Plan (2026-03-05) -Status: Proposed +Status: In Progress Branch target: `beta` Scope: Core index payload, Nostr sync, hierarchy navigation, future KB and agent workflows +Companion plan: +- `docs/atlas_search_graph_integration_plan_2026-03-05.md` + +Progress update (2026-03-05): + +1. Phase 1 foundation landed: + - reserved `_system.index0` payload namespace + - deterministic normalization and merge helpers + - optional manifest metadata model support +2. Phase 1 CRUD/link emission landed: + - `index0_event` emission for entry create/modify/archive/restore/delete + - `index0_event` emission for link add/remove + - writer head updates and profile-path-derived scope stamping +3. Phase 1 checkpoint/manifest slice landed: + - deterministic daily checkpoint rebuild from canonical event streams + - bounded checkpoint retention per writer + - manifest publication of current `index0` checkpoint hashes and stream heads +4. Phase 1 canonical views slice landed: + - deterministic synced `children_of`, `counts_by_kind`, and `recent_activity` + - view rebuild integrated into the same payload compaction path as checkpoints + - lightweight atlas read helpers for future clients +5. Atlas consumer slice landed: + - `AtlasService` added at the core API boundary + - first v3 wayfinder/atlas consumer landed via palette command and screen + - main workspace now shows an always-visible atlas strip + - atlas screen now supports direct entry jumps and quick filter jumps +6. Next: + - deeper search/navigation handoff + - agent-facing atlas workflows + - later event-pruning compaction policy if needed + +Current accomplished scope summary: + +1. canonical atlas storage exists inside `_system.index0` +2. canonical event emission exists for CRUD and link flows +3. checkpoint rebuild and manifest validation metadata exist +4. first synced canonical views exist +5. service-layer atlas reads exist +6. v3 already exposes atlas data in: + - dedicated wayfinder screen + - workspace strip + - actionable entry and filter jumps ## 1) Goal diff --git a/docs/index0_phase0_spec_2026-03-05.md b/docs/index0_phase0_spec_2026-03-05.md index 2e3ade42..6101df4b 100644 --- a/docs/index0_phase0_spec_2026-03-05.md +++ b/docs/index0_phase0_spec_2026-03-05.md @@ -1,13 +1,37 @@ # Index0 Phase 0 Implementation Spec (2026-03-05) -Status: Proposed +Status: In Progress Branch target: `beta` Depends on: `docs/index0_atlas_execution_plan_2026-03-05.md` +Companion next-layer plan: `docs/atlas_search_graph_integration_plan_2026-03-05.md` ## 1) Goal Lock the Phase 0 implementation contract for `index0` so coding can begin without schema drift. +Implementation update (2026-03-05): + +1. namespace normalization is implemented +2. deterministic merge helpers are implemented +3. manifest model round-trip support is implemented +4. CRUD/link event emission is implemented +5. daily checkpoint rebuild and bounded checkpoint retention are implemented +6. manifest head/checkpoint publication from live vault state is implemented +7. canonical synced `children_of`, `counts_by_kind`, and `recent_activity` views are implemented +8. atlas read service and first v3 wayfinder consumer are implemented +9. first actionable v3 wayfinder navigation flows are implemented +Remaining follow-on work for this spec: + - deeper search/navigation handoff from atlas wayfinder data + - agent-facing atlas workflows over canonical views/events + - any later event-pruning compaction policy + +Delivered foundation summary: + +1. `index0` is no longer speculative in `beta` +2. canonical storage, merge, event emission, checkpoints, manifest metadata, and first synced views are implemented +3. atlas reads are exposed via `AtlasService` +4. v3 already consumes atlas data in both passive and actionable ways + This document defines: 1. canonical payload schema diff --git a/docs/nostr_comms_reference_and_future_capability_2026-03-05.md b/docs/nostr_comms_reference_and_future_capability_2026-03-05.md new file mode 100644 index 00000000..e3bcd5a6 --- /dev/null +++ b/docs/nostr_comms_reference_and_future_capability_2026-03-05.md @@ -0,0 +1,244 @@ +# Nostr Communications Reference and Future Capability (2026-03-05) + +Status: Research / Future Addition +Branch target: `beta` +Depends on: +- `docs/dev_control_center.md` +- `docs/index0_atlas_execution_plan_2026-03-05.md` +- `docs/atlas_search_graph_integration_plan_2026-03-05.md` + +## 1) Goal + +Capture the Nostr protocol references most relevant to a future SeedPass communications layer, then define how a managed-identity communication system could fit the current SeedPass architecture. + +This document is for future implementation planning, not a statement of current shipped functionality. + +## 2) Current SeedPass Baseline + +SeedPass already has enough Nostr groundwork to make this future capability realistic: + +1. deterministic Nostr key generation and storage as entry kind `nostr` +2. relay configuration and relay maintenance flows +3. active profile `npub` utilities +4. a current Nostr client used for SeedPass vault sync +5. `index0` atlas foundations for future communication metadata and navigation + +What SeedPass does not currently have: + +1. DM inbox/outbox/thread models +2. community or group chat clients +3. canonical message/conversation entry types +4. communication indexing into atlas/search +5. policy-aware communication access controls in the UI/service layer + +## 3) Protocol References + +Use these NIPs as the canonical starting points. + +### 3.1 Private messaging + +1. `NIP-04` Encrypted Direct Message + - legacy `kind:4` DM scheme + - explicitly marked as deprecated in favor of `NIP-17` + - uses ECDH-derived shared secret plus AES-256-CBC payload format + - carries significant metadata leakage warnings + - reference: + - https://github.com/nostr-protocol/nips/blob/master/04.md + +2. `NIP-44` Encrypted Payloads (Versioned) + - encryption format, not a messaging standard by itself + - versioned payload format intended for signed-event contexts + - should not be treated as a direct drop-in replacement for `NIP-04` + - reference: + - https://github.com/nostr-protocol/nips/blob/master/44.md + +3. `NIP-59` Gift Wrap + - metadata-obscuring event encapsulation layer + - introduces rumors, seals, and gift wraps + - depends on `NIP-44` + - does not itself define the messaging UX/protocol + - reference: + - https://github.com/nostr-protocol/nips/blob/master/59.md + +4. `NIP-17` Private Direct Messages + - newer encrypted chat approach + - uses `NIP-44` encryption and `NIP-59` seals/gift wraps + - defines the direction SeedPass should prefer for future private DM support + - reference: + - https://github.com/nostr-protocol/nips/blob/master/17.md + +### 3.2 Group and community chat + +1. `NIP-28` Public Chat + - public channel model + - reserves event kinds `40-44` for channel create/metadata/message and basic moderation + - useful as a baseline for open team channels or public-facing communities + - reference: + - https://github.com/nostr-protocol/nips/blob/master/28.md + +2. `NIP-29` Relay-based Groups + - closed-writer group model with relay-enforced rules + - groups can be public or private for reading + - strong candidate for institution/team chat semantics where relay policy matters + - reference: + - https://github.com/nostr-protocol/nips/blob/master/29.md + +3. `NIP-72` Moderated Communities (Reddit Style) + - public community model + - defines `kind:34550` community definition and `kind:4550` approval events + - likely more relevant for public communities than for private institutional chat + - reference: + - https://github.com/nostr-protocol/nips/blob/master/72.md + +## 4) SeedPass-Oriented Interpretation + +For SeedPass, the most relevant practical stack is: + +1. private managed DMs: + - `NIP-17` + `NIP-44` + `NIP-59` +2. public/open channels: + - `NIP-28` +3. controlled team/group chat: + - `NIP-29` +4. public community publishing/moderation: + - `NIP-72` + +This means: + +1. do not build new private DM support on top of `NIP-04` unless compatibility mode is required +2. treat `NIP-04` as legacy interop +3. prefer `NIP-17` for future direct-message architecture +4. evaluate `NIP-29` as the strongest candidate for managed institutional group chat + +## 5) Managed Identity Model + +SeedPass has a unique advantage here because it can deterministically derive managed child identities. + +If a Nostr identity is a managed deterministic child account: + +1. the child user can operate that identity normally +2. an authorized higher-level SeedPass holder can derive the same child identity again +3. that higher-level holder can regenerate the same Nostr private key +4. supervisory recovery/access follows from key custody, not from a separate escrow system + +This implies two communication classes should exist in the future: + +1. personal identities + - user-owned + - not intended for management derivation/access +2. managed institutional identities + - org-controlled + - derivable by authorized higher-level key holders + - eligible for institutional indexing, search, and oversight + +## 6) How This Fits `index0` + +`index0` should not store full communication bodies inline. + +Recommended split: + +1. canonical message or conversation content: + - future entry kinds or encrypted conversation/message payloads +2. `index0` atlas metadata: + - account created + - conversation created + - participants + - channel/community membership metadata + - last activity + - message counts + - linked documents/artifacts + - policy/classification/visibility metadata +3. local semantic layer: + - derived search over message bodies and linked content + +That preserves the atlas role: + +`Index0 is the map, not the message store.` + +## 7) Recommended Future Capability Scope + +### Phase A: Managed Nostr account workspace + +1. load a SeedPass Nostr entry into an active comms session +2. show identity metadata, relay state, and conversation summaries +3. expose public posting and account-session controls + +### Phase B: DM dashboard + +1. inbox +2. conversation list +3. thread view +4. send/receive support for managed identities +5. atlas metadata indexing for conversations and message activity + +Preferred protocol direction: + +1. `NIP-17` +2. `NIP-44` +3. `NIP-59` +4. optional `NIP-04` compatibility only if needed + +### Phase C: Institutional team chat + +1. managed group/channel model +2. likely protocol evaluation centered on `NIP-29` +3. optional public/open channel support using `NIP-28` +4. public community support later via `NIP-72` if needed + +### Phase D: Knowledge integration + +1. link conversations to docs, secrets, tasks, and artifacts +2. surface chat activity in `index0` +3. unify search across notes, docs, links, and comms +4. support scoped agent context loading from conversations plus linked artifacts + +## 8) Access Control Direction + +Future communication access should remain policy-driven and consistent with SeedPass hierarchy. + +Recommended rules: + +1. managed institutional identities are readable by authorized higher-level key holders who can derive the same child identity +2. personal identities are not treated as institutionally recoverable by default +3. UI and service layers should make identity class explicit +4. search, export, summaries, and agent access must respect classification and partition policies +5. message excerpts shown in search/atlas should be safe by default, especially for high-risk scopes + +## 9) Open Design Questions + +Resolve these before implementation: + +1. should full message bodies be stored as dedicated entry kinds, chunked conversation documents, or both? +2. should SeedPass mirror only managed-identity communications into canonical vault storage, or also index external/public references? +3. how should relay/group policy state be represented for `NIP-29` groups? +4. what is the minimum safe atlas metadata for private institutional conversations? +5. what should agent access look like for communication content versus conversation summaries? + +## 10) Practical Recommendation + +The best future direction appears to be: + +1. `NIP-17` private DMs for managed identities +2. `NIP-29` for controlled team/group chat +3. `NIP-28` and `NIP-72` as optional public/community layers +4. `index0` for metadata, navigation, and oversight +5. separate canonical storage for actual message content + +## 11) Source Links + +Primary sources used for this reference: + +1. NIP-04: + - https://github.com/nostr-protocol/nips/blob/master/04.md +2. NIP-17: + - https://github.com/nostr-protocol/nips/blob/master/17.md +3. NIP-28: + - https://github.com/nostr-protocol/nips/blob/master/28.md +4. NIP-29: + - https://github.com/nostr-protocol/nips/blob/master/29.md +5. NIP-44: + - https://github.com/nostr-protocol/nips/blob/master/44.md +6. NIP-59: + - https://github.com/nostr-protocol/nips/blob/master/59.md +7. NIP-72: + - https://github.com/nostr-protocol/nips/blob/master/72.md diff --git a/docs/tui_v3_plan.md b/docs/tui_v3_plan.md index 844b17a7..fb9f49bc 100644 --- a/docs/tui_v3_plan.md +++ b/docs/tui_v3_plan.md @@ -1,8 +1,9 @@ # TUI v3 Modular Architecture & Parity Plan -Last updated: 2026-03-03 +Last updated: 2026-03-05 Branch target: `beta` -Status: Active (Parity execution in progress) +Status: Active (Default UI / parity hardening in progress) +Companion search/atlas plan: `docs/atlas_search_graph_integration_plan_2026-03-05.md` ## 1. Context & Rationale SeedPass has transitioned from TUI v2 to **TUI v3 (Scratch Rebuild)**. @@ -36,7 +37,7 @@ We have successfully scaffolded the core v3 shell: - [x] Reactive Search and Selection. - [x] **Secure Data Parity:** Full support for reveal/QR/copy across Passwords, TOTP, SSH, PGP, Nostr, Seeds, and Documents. - [x] **Managed Sessions:** `ml` (Load) and `mx` (Exit) parity for BIP-85 sub-profiles. - - [x] **Semantic Search:** Integration of `SemanticIndexService` into the TUI v3 grid with mode switching (Keyword, Hybrid, Semantic). + - [x] **Semantic Search:** Baseline `SemanticIndexService` integration landed and the main grid now routes through unified `SearchService` for keyword / hybrid / semantic search. - [x] **Entry Lifecycle:** Unified `AddEntryScreen`, `SeedPlusScreen` for derivations, and `DocumentEditScreen`. - [x] **Grid Filtering:** Filter presets (secrets, docs, keys, 2fa) and Archive toggle (`archived`). @@ -81,6 +82,54 @@ To finalize v3, we must verify 100% functional parity with the **Legacy TUI**. ## 5. Progress Log (2026-03-03) ### Completed in this session +- **Atlas / wayfinder progress:** + - added a dedicated atlas wayfinder screen in v3 + - added an always-visible atlas strip to the main workspace shell + - wired `AtlasService` through the service-layer boundary so v3 does not read raw atlas payloads directly + - atlas screen now supports direct entry jumps and quick filter jumps +- **Unified search progress:** + - added `SearchService` in the core service layer as the single search contract for v3 and future GUIs + - v3 grid now uses the unified search path instead of branching between entry-service and semantic-service logic in the widget + - baseline deterministic sorting, filtering, match reasons, and safe excerpts are now available in the normalized result payload +- **Index0 status alignment:** + - current docs now reflect that `index0` foundations are implemented, not just planned + - the next integration target is unified search, linked navigation, and deeper atlas/search handoff +- **Entry lifecycle parity:** + - added `d` hotkey and `delete` palette command to v3. + - delete now uses explicit double-press confirmation before removal. + - successful delete clears the active selection and collapses the inspector again. +- **Nostr maintenance parity:** + - added `npub` / `nostr-pubkey` utility flow in v3 with a dedicated screen showing the active profile pubkey and public QR payload. + - added `nostr-reset-sync-state` and `nostr-fresh-namespace` palette commands to v3. + - kept the implementation on top of `NostrService` rather than adding UI-specific core logic. +- **Security maintenance parity:** + - added dedicated v3 flows for `change-password` and `backup-parent-seed`. + - both screens submit through `VaultService` request models instead of reaching into manager internals. + - settings now advertise the new security and backup maintenance commands for discoverability. +- **Profile management parity:** + - added an in-app `profiles` flow for listing, switching, and removing non-active profiles. + - profile mutations are routed through `ProfileService` request models. + - app-level refresh behavior was hardened so maintenance screens can update state without assuming the main workspace is the active screen. + - profile management now prefers display labels when available and requires a second remove action before deleting a non-active profile. +- **Restore/import guidance polish:** + - create/recover flows now show more explicit mode guidance and restore intent messaging before Nostr/backup recovery paths execute. +- **Maintenance UX polish:** + - maintenance screens now include more explicit operator guidance instead of relying only on toast notifications. + - relay deletion now mirrors the safer confirmation style used elsewhere in v3. + - profile/security maintenance screens now surface clearer success and failure summaries in-screen. + - maintenance screens now share stronger visual hierarchy: bordered intro cards, status panels, and more consistent action-row styling. + - shared maintenance-screen styling/status helpers now reduce drift across profile, security, pubkey, and relay flows. + - maintenance screens now also share custom footer strips, removing the remaining mixed use of default Textual footer chrome. +- **Default UI status alignment:** + - `seedpass` now launches TUI v3 by default. + - v3 onboarding now owns unlock/create/recover/restore flows inside the app. +- **Inspector UX:** + - lower inspector now stays collapsed until an entry is selected. + - close button clears selection and collapses the lower pane again. +- **Context-aware actions:** + - Action bar now advertises only actions that are meaningful for the selected entry kind. + - Removed misleading generic QR/reveal hints from entry kinds that do not use them. + - **Secure Data Parity:** - Completed `_resolve_sensitive_payload` for SSH, PGP, Nostr, Key-Value, and Documents. - Updated `derive_pgp_key` and API to support armored PGP Public Key retrieval/copying. diff --git a/src/nostr/snapshot.py b/src/nostr/snapshot.py index 2a238f5b..56ab78d9 100644 --- a/src/nostr/snapshot.py +++ b/src/nostr/snapshot.py @@ -71,6 +71,8 @@ class SnapshotHandler: await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) + if isinstance(getattr(self, "manifest_index0_metadata", None), dict): + manifest.index0 = dict(self.manifest_index0_metadata) existing: dict[str, str] = {} if self.current_manifest: diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 75d2ff19..fef6bef4 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -12,12 +12,14 @@ from threading import Lock from typing import List, Optional, Dict, Any import dataclasses import json +import time -from pydantic import BaseModel +from pydantic import BaseModel, Field from .manager import PasswordManager from .pubsub import bus from .entry_types import EntryType +from .index0 import derive_index0_context, get_canonical_view, list_canonical_views from .semantic_index import SemanticIndex from utils import copy_to_clipboard @@ -115,6 +117,24 @@ class GeneratePasswordResponse(BaseModel): password: str +class SearchResult(BaseModel): + """Normalized search result used across UI surfaces.""" + + entry_id: int + label: str + kind: str + scope_path: str + archived: bool = False + score: float = 0.0 + score_breakdown: dict[str, float] = Field(default_factory=dict) + match_reasons: list[str] = Field(default_factory=list) + excerpt: str = "" + linked_hits: list[dict[str, Any]] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + modified_ts: int = 0 + meta: str = "" + + class VaultService: """Thread-safe wrapper around vault operations.""" @@ -715,6 +735,11 @@ class EntryService: self._manager.entry_manager.restore_entry(entry_id) self._manager.start_background_vault_sync() + def delete_entry(self, entry_id: int) -> None: + with self._lock: + self._manager.entry_manager.delete_entry(entry_id) + self._manager.start_background_vault_sync() + def export_totp_entries(self) -> dict: with self._lock: key = getattr(self._manager, "KEY_TOTP_DET", None) or getattr( @@ -1049,6 +1074,501 @@ class SemanticIndexService: return items[: max(1, int(k))] +class SearchService: + """Thread-safe unified search wrapper for lexical, semantic, and atlas-aware ranking.""" + + _SECRET_EXCERPT_KINDS = { + "password", + "stored_password", + "totp", + "seed", + "managed_account", + "ssh", + "pgp", + "nostr", + } + _SEMANTIC_MODES = {"hybrid", "semantic"} + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def _scope_path(self) -> str: + profile_dir = getattr(self._manager, "fingerprint_dir", None) + if profile_dir is None: + return "" + return derive_index0_context(profile_dir)["scope_path"] + + def _all_entries(self) -> list[dict[str, Any]]: + em = getattr(self._manager, "entry_manager", None) + if em is None: + return [] + try: + rows = em.search_entries( + "", + kinds=None, + include_archived=True, + archived_only=False, + ) + except TypeError: + rows = em.search_entries("", kinds=None) + 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: + payload = dict(entry) + payload.setdefault("id", entry_id) + entries.append(payload) + entries.sort(key=lambda item: int(item.get("id", 0) or 0)) + return entries + + @staticmethod + def _entry_kind(entry: dict[str, Any]) -> str: + return str(entry.get("kind") or entry.get("type") or "").strip().lower() + + @staticmethod + def _normalize_kind_filters(kinds: list[str] | None) -> set[str]: + if not kinds: + return set() + return { + str(kind).strip().lower() for kind in kinds if str(kind).strip().lower() + } + + @staticmethod + def _safe_excerpt(entry: dict[str, Any], kind: str) -> str: + fields = ["notes"] + if kind in {"document", "note"}: + fields.append("content") + elif kind in {"password", "stored_password"}: + fields.extend(["username", "url"]) + elif kind == "key_value": + fields.append("key") + elif kind == "totp": + fields.append("issuer") + elif kind == "nostr": + fields.append("npub") + elif kind in {"ssh", "pgp"}: + fields.append("fingerprint") + for field in fields: + value = str(entry.get(field, "")).strip() + if value: + return value[:220] + return "" + + @staticmethod + def _meta(entry: dict[str, Any], kind: str) -> str: + for field in ("username", "url", "key", "file_type"): + value = str(entry.get(field, "")).strip() + if value: + return value + if kind == "managed_account": + return "managed" + return "" + + @staticmethod + def _custom_field_text(entry: dict[str, Any]) -> str: + parts: list[str] = [] + raw_fields = entry.get("custom_fields", []) + if not isinstance(raw_fields, list): + return "" + for item in raw_fields: + if not isinstance(item, dict): + continue + for key in ("name", "label", "key", "value"): + value = str(item.get(key, "")).strip() + if value: + parts.append(value) + return "\n".join(parts) + + @staticmethod + def _normalized_tags(entry: dict[str, Any]) -> list[str]: + raw = entry.get("tags", []) + if not isinstance(raw, list): + return [] + tags = sorted({str(tag).strip() for tag in raw if str(tag).strip()}) + return tags + + @staticmethod + def _normalized_links(entry: dict[str, Any]) -> list[dict[str, Any]]: + raw = entry.get("links", []) + if not isinstance(raw, list): + return [] + links: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + try: + target_id = int(item.get("target_id", 0)) + except Exception: + continue + relation = str(item.get("relation", "")).strip() + note = str(item.get("note", "")).strip() + if target_id <= 0 or not relation: + continue + link = {"target_id": target_id, "relation": relation} + if note: + link["note"] = note + links.append(link) + links.sort( + key=lambda link: ( + str(link.get("relation", "")), + int(link.get("target_id", 0)), + str(link.get("note", "")), + ) + ) + return links + + def _entry_search_text(self, entry: dict[str, Any], kind: str) -> str: + parts = [ + SemanticIndex._extract_text(entry, kind), + self._custom_field_text(entry), + ] + return "\n".join(part for part in parts if str(part).strip()) + + def _semantic_scores( + self, + query: str, + *, + limit: int, + mode: str, + ) -> dict[int, dict[str, Any]]: + if not query or mode not in self._SEMANTIC_MODES: + return {} + cfg = getattr(self._manager, "config_manager", None) + if cfg is not None: + try: + if not bool(cfg.get_semantic_index_enabled()): + return {} + except Exception: + pass + rows = SemanticIndexService(self._manager).search( + query, k=max(10, int(limit)), mode="semantic" + ) + out: dict[int, dict[str, Any]] = {} + for row in rows: + try: + entry_id = int(row.get("entry_id", 0)) + except Exception: + continue + if entry_id <= 0: + continue + out[entry_id] = row + return out + + @staticmethod + def _recency_score(modified_ts: int) -> float: + ts = int(modified_ts or 0) + if ts <= 0: + return 0.0 + age_days = max(0.0, (time.time() - float(ts)) / 86400.0) + return round(1.0 / (1.0 + age_days), 6) + + def search( + self, + query: str, + *, + kinds: list[str] | None = None, + include_archived: bool = False, + archived_only: bool = False, + mode: str | None = None, + sort: str = "relevance", + limit: int = 200, + tags: list[str] | None = None, + linked_to: int | None = None, + ) -> list[dict[str, Any]]: + with self._lock: + scope_path = self._scope_path() + active_mode = str(mode or "").strip().lower() + if active_mode not in {"keyword", "hybrid", "semantic"}: + cfg = getattr(self._manager, "config_manager", None) + if cfg is not None: + try: + active_mode = ( + str(cfg.get_semantic_search_mode()).strip().lower() + ) + except Exception: + active_mode = "keyword" + else: + active_mode = "keyword" + if active_mode not in {"keyword", "hybrid", "semantic"}: + active_mode = "keyword" + + semantic_scores = self._semantic_scores( + query, limit=max(10, int(limit)), mode=active_mode + ) + query_tokens = SemanticIndex._tokenize(str(query or "")) + kind_filters = self._normalize_kind_filters(kinds) + tag_filters = { + str(tag).strip().lower() for tag in (tags or []) if str(tag).strip() + } + atlas_recent = {} + try: + payload = self._manager.vault.load_index() + index0 = payload.get("_system", {}).get("index0", {}) + recent = get_canonical_view( + index0, view_type="recent_activity", scope_path=scope_path + ) + for idx, item in enumerate(recent.get("data", {}).get("items", [])): + try: + subject_id = int(item.get("subject_id", 0)) + except Exception: + continue + atlas_recent[subject_id] = max( + atlas_recent.get(subject_id, 0.0), 1.0 - (idx * 0.1) + ) + except Exception: + atlas_recent = {} + + results: list[dict[str, Any]] = [] + for entry in self._all_entries(): + entry_id = int(entry.get("id", 0) or 0) + if entry_id <= 0: + continue + kind = self._entry_kind(entry) + if kind_filters and kind not in kind_filters: + continue + archived = bool(entry.get("archived", False)) + if archived_only and not archived: + continue + if not include_archived and archived: + continue + tags_list = self._normalized_tags(entry) + tags_lower = {tag.lower() for tag in tags_list} + if tag_filters and not tag_filters.issubset(tags_lower): + continue + links = self._normalized_links(entry) + if linked_to is not None and not any( + int(link.get("target_id", 0)) == int(linked_to) for link in links + ): + continue + + lexical_score = 0.0 + structural_score = 0.0 + semantic_score = 0.0 + recency_score = 0.0 + reasons: list[str] = [] + linked_hits: list[dict[str, Any]] = [] + + modified_ts = int(entry.get("modified_ts", 0) or 0) + if query_tokens: + label = str(entry.get("label", "")).strip() + label_tokens = SemanticIndex._tokenize(label) + search_text = self._entry_search_text(entry, kind) + text_tokens = SemanticIndex._tokenize(search_text) + text_overlap = len(query_tokens.intersection(text_tokens)) + lexical_score = min( + 1.0, text_overlap / float(len(query_tokens) or 1) + ) + if label and str(query).strip().lower() == label.lower(): + lexical_score = max(lexical_score, 1.0) + reasons.append("label_exact") + elif query_tokens.intersection(label_tokens): + reasons.append("label_match") + matching_tags = sorted( + tag for tag in tags_list if tag.lower() in query_tokens + ) + if matching_tags: + structural_score += min(1.0, 0.25 * len(matching_tags)) + reasons.extend(f"tag:{tag}" for tag in matching_tags) + for link in links: + relation_tokens = SemanticIndex._tokenize( + " ".join( + [ + str(link.get("relation", "")), + str(link.get("note", "")), + ] + ) + ) + if query_tokens.intersection(relation_tokens): + linked_hits.append( + { + "target_id": int(link.get("target_id", 0)), + "relation": str(link.get("relation", "")), + } + ) + if linked_hits: + structural_score += min(1.0, 0.2 + (0.1 * len(linked_hits))) + reasons.append("link_match") + semantic_row = semantic_scores.get(entry_id) + if semantic_row is not None: + semantic_score = float(semantic_row.get("score", 0.0)) + reasons.append("semantic_match") + recency_score = min( + 1.0, + max( + atlas_recent.get(entry_id, 0.0), + self._recency_score(modified_ts), + ), + ) + else: + linked_hits = [ + { + "target_id": int(link.get("target_id", 0)), + "relation": str(link.get("relation", "")), + } + for link in links[:5] + ] + structural_score = min(1.0, 0.1 * len(links)) + recency_score = atlas_recent.get(entry_id, 0.0) + + if linked_to is not None: + reasons.append(f"linked_to:{linked_to}") + structural_score = min(1.0, structural_score + 0.35) + + if query_tokens and not any( + score > 0.0 + for score in ( + lexical_score, + structural_score, + semantic_score, + ) + ): + continue + + if active_mode == "keyword": + total_score = (lexical_score * 0.8) + (structural_score * 0.15) + total_score += recency_score * 0.05 + semantic_score = 0.0 + elif active_mode == "semantic": + total_score = (semantic_score * 0.7) + (structural_score * 0.2) + total_score += recency_score * 0.1 + total_score += lexical_score * 0.0 + else: + total_score = (lexical_score * 0.4) + (semantic_score * 0.35) + total_score += structural_score * 0.2 + total_score += recency_score * 0.05 + + result = SearchResult( + entry_id=entry_id, + label=str(entry.get("label", "")).strip(), + kind=kind, + scope_path=scope_path, + archived=archived, + score=round(total_score, 6), + score_breakdown={ + "lexical": round(lexical_score, 6), + "semantic": round(semantic_score, 6), + "structural": round(structural_score, 6), + "recency": round(recency_score, 6), + }, + match_reasons=sorted(set(reasons)), + excerpt=( + "" + if kind in self._SECRET_EXCERPT_KINDS + else self._safe_excerpt(entry, kind) + ), + linked_hits=linked_hits, + tags=tags_list, + modified_ts=modified_ts, + meta=self._meta(entry, kind), + ).model_dump() + results.append(result) + + sort_key = str(sort or "relevance").strip().lower() + + def _result_key(item: dict[str, Any]) -> tuple[Any, ...]: + label = str(item.get("label", "")).lower() + kind = str(item.get("kind", "")).lower() + modified_ts = int(item.get("modified_ts", 0) or 0) + linked_count = len(item.get("linked_hits", [])) + entry_id = int(item.get("entry_id", 0)) + if sort_key == "modified_desc": + return (-modified_ts, label, entry_id) + if sort_key == "modified_asc": + return (modified_ts, label, entry_id) + if sort_key == "label_asc": + return (label, entry_id) + if sort_key == "kind": + return (kind, label, entry_id) + if sort_key == "most_linked": + return (-linked_count, label, entry_id) + if sort_key == "created_desc": + created_ts = int(item.get("created_ts", modified_ts) or modified_ts) + return (-created_ts, label, entry_id) + if query_tokens: + return ( + -float(item.get("score", 0.0)), + -modified_ts, + label, + entry_id, + ) + return (entry_id,) + + results.sort(key=_result_key) + return results[: max(1, int(limit))] + + +class AtlasService: + """Thread-safe wrapper around canonical atlas/index0 read operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def _payload(self) -> dict[str, Any]: + vault = getattr(self._manager, "vault", None) + if vault is None: + raise ValueError("vault unavailable") + return vault.load_index() + + def _scope_path(self) -> str: + profile_dir = getattr(self._manager, "fingerprint_dir", None) + if profile_dir is None: + raise ValueError("active profile directory unavailable") + return derive_index0_context(profile_dir)["scope_path"] + + def status(self) -> Dict[str, Any]: + with self._lock: + payload = self._payload() + index0 = payload.get("_system", {}).get("index0", {}) + return { + "scope_path": self._scope_path(), + "stats": dict(index0.get("stats", {})), + "view_count": len(index0.get("canonical_views", {})), + "view_types": list( + index0.get("view_manifest", {}).get("canonical_view_types", []) + ), + } + + def list_views(self) -> list[dict[str, Any]]: + with self._lock: + payload = self._payload() + index0 = payload.get("_system", {}).get("index0", {}) + return list_canonical_views(index0) + + def get_view( + self, view_type: str, *, scope_path: str | None = None + ) -> dict[str, Any] | None: + with self._lock: + payload = self._payload() + index0 = payload.get("_system", {}).get("index0", {}) + scope = scope_path or self._scope_path() + return get_canonical_view(index0, view_type=view_type, scope_path=scope) + + def wayfinder(self, *, scope_path: str | None = None) -> Dict[str, Any]: + with self._lock: + payload = self._payload() + index0 = payload.get("_system", {}).get("index0", {}) + scope = scope_path or self._scope_path() + return { + "scope_path": scope, + "stats": dict(index0.get("stats", {})), + "children_of": get_canonical_view( + index0, view_type="children_of", scope_path=scope + ), + "counts_by_kind": get_canonical_view( + index0, view_type="counts_by_kind", scope_path=scope + ), + "recent_activity": get_canonical_view( + index0, view_type="recent_activity", scope_path=scope + ), + } + + class UtilityService: """Miscellaneous helper operations.""" diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 8c7eb4cf..36be53ee 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -50,6 +50,7 @@ from utils.key_validation import ( from .vault import Vault from .backup import BackupManager from .errors import SeedPassError +from .index0 import append_index0_event # Instantiate the logger logger = logging.getLogger(__name__) @@ -193,6 +194,43 @@ class EntryManager: logger.error(f"Failed to save index: {e}") raise + def _entry_kind(self, entry: dict[str, Any]) -> str: + return str( + entry.get("kind", entry.get("type", EntryType.PASSWORD.value)) + ).strip() + + def _emit_index0_event( + self, + data: Dict[str, Any], + *, + event_type: str, + entry_id: int | str, + entry: dict[str, Any], + modified_ts: int | None = None, + payload_ref: dict[str, Any] | None = None, + links: list[dict[str, Any]] | None = None, + tags: list[str] | None = None, + summary: str = "", + ) -> Dict[str, Any]: + entry_kind = self._entry_kind(entry) + payload_ref = dict(payload_ref or {}) + payload_ref.setdefault("entry_id", str(entry_id)) + return append_index0_event( + data, + event_type=event_type, + subject_type="entry", + subject_id=str(entry_id), + subject_kind=entry_kind, + modified_ts=modified_ts + or int(entry.get("modified_ts", 0) or self._now_unix()), + fingerprint_dir=self.fingerprint_dir, + payload_ref=payload_ref, + links=links if links is not None else entry.get("links", []), + tags=tags if tags is not None else entry.get("tags", []), + summary=summary, + source="entry_management", + ) + def get_next_index(self) -> int: """ Retrieves the next available index for a new entry. @@ -289,6 +327,14 @@ class EntryManager: entry["policy"] = policy data["entries"][str(index)] = entry + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=entry, + modified_ts=now_unix, + summary=f"Created {entry.get('kind', entry.get('type', 'entry'))} entry", + ) logger.debug( f"Added entry at index {index} with label '{entry.get('label', '')}'." @@ -390,6 +436,14 @@ class EntryManager: } data["entries"][str(entry_id)] = entry + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=entry_id, + entry=entry, + modified_ts=now_unix, + summary=f"Created {entry.get('kind', entry.get('type', 'entry'))} entry", + ) self._save_index(data) self.update_checksum() @@ -444,6 +498,14 @@ class EntryManager: "custom_fields": [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created ssh entry", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -509,6 +571,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created pgp entry", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -579,6 +649,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created nostr entry", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -618,6 +696,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created key_value entry", + ) self._save_index(data) self.update_checksum() @@ -656,6 +742,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created document entry", + ) self._save_index(data) self.update_checksum() @@ -797,6 +891,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created seed entry", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -878,6 +980,14 @@ class EntryManager: "tags": tags or [], "links": [], } + data = self._emit_index0_event( + data, + event_type="entry_created", + entry_id=index, + entry=data["entries"][str(index)], + modified_ts=now_unix, + summary="Created managed_account entry", + ) self._save_index(data) self.update_checksum() @@ -1093,6 +1203,11 @@ class EntryManager: try: data = self._load_index() entry = data.get("entries", {}).get(str(index)) + previous_archived = ( + bool(entry.get("archived", entry.get("blacklisted", False))) + if isinstance(entry, dict) + else False + ) if not entry: logger.warning( @@ -1331,6 +1446,18 @@ class EntryManager: entry["date_modified"] = self._iso_from_unix(entry["modified_ts"]) data["entries"][str(index)] = entry + event_type = "entry_modified" + current_archived = bool(entry.get("archived", False)) + if current_archived != previous_archived: + event_type = "entry_archived" if current_archived else "entry_restored" + data = self._emit_index0_event( + data, + event_type=event_type, + entry_id=index, + entry=entry, + modified_ts=entry["modified_ts"], + summary=f"{event_type.replace('_', ' ')} for {self._entry_kind(entry)}", + ) logger.debug( f"Modified entry at index {index} with label '{entry.get('label', '')}'." ) @@ -1386,6 +1513,20 @@ class EntryManager: ) src["modified_ts"] = self._now_unix() src["date_modified"] = self._iso_from_unix(src["modified_ts"]) + data = self._emit_index0_event( + data, + event_type="link_added", + entry_id=index, + entry=src, + modified_ts=src["modified_ts"], + payload_ref={ + "entry_id": str(index), + "target_id": str(target_id), + "relation": relation_norm, + }, + links=[candidate], + summary="Added entry link", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -1417,6 +1558,7 @@ class EntryManager: continue filtered.append(link) if len(filtered) != len(links): + removed_links = [link for link in links if link not in filtered] src["links"] = filtered src.setdefault( "date_added", @@ -1424,6 +1566,20 @@ class EntryManager: ) src["modified_ts"] = self._now_unix() src["date_modified"] = self._iso_from_unix(src["modified_ts"]) + data = self._emit_index0_event( + data, + event_type="link_removed", + entry_id=index, + entry=src, + modified_ts=src["modified_ts"], + payload_ref={ + "entry_id": str(index), + "target_id": str(target_id), + "relation": relation_norm or "", + }, + links=removed_links, + summary="Removed entry link", + ) self._save_index(data) self.update_checksum() self.backup_manager.create_backup() @@ -1743,6 +1899,18 @@ class EntryManager: "event_hash": event_hash, "source": "local-delete", } + data = self._emit_index0_event( + data, + event_type="entry_deleted", + entry_id=index, + entry=entry, + modified_ts=deleted_ts, + payload_ref={ + "entry_id": str(index), + "tombstone_event_hash": event_hash, + }, + summary=f"Deleted {self._entry_kind(entry)} entry", + ) del data["entries"][str(index)] logger.debug(f"Deleted entry at index {index}.") self._save_index(data) diff --git a/src/seedpass/core/index0.py b/src/seedpass/core/index0.py index d7907f50..ac7d92ac 100644 --- a/src/seedpass/core/index0.py +++ b/src/seedpass/core/index0.py @@ -2,10 +2,17 @@ from __future__ import annotations import hashlib import json +from datetime import datetime, timezone +from pathlib import Path from typing import Any INDEX0_SCHEMA_VERSION = 1 LOCAL_ONLY_VIEW_TYPES = {"hot_nodes", "conversation_index", "semantic_neighbors"} +INDEX0_CHECKPOINT_SUBJECT_CAP = 64 +INDEX0_MAX_CHECKPOINTS_PER_WRITER = 30 +INDEX0_MANIFEST_CHECKPOINT_LIMIT = 32 +INDEX0_RECENT_ACTIVITY_LIMIT = 20 +INDEX0_CANONICAL_VIEW_TYPES = ("children_of", "counts_by_kind", "recent_activity") def _canonical(value: Any) -> str: @@ -103,6 +110,30 @@ def normalize_index0(raw: Any) -> dict[str, Any]: return normalized +def derive_index0_context( + fingerprint_dir: str | Path, + *, + actor_type: str = "user", +) -> dict[str, str]: + path = Path(fingerprint_dir) + current_fp = path.name.strip() + parent = path.parent + if parent.name == "accounts": + root_fp = parent.parent.name.strip() + scope_path = f"seed/{root_fp}/managed/{current_fp}" + else: + root_fp = current_fp + scope_path = f"seed/{current_fp}" + return { + "actor_type": _normalize_string(actor_type) or "user", + "actor_id": current_fp, + "writer_id": f"writer:profile:{current_fp}", + "scope_path": scope_path, + "root_fingerprint": root_fp, + "current_fingerprint": current_fp, + } + + def ensure_index0_payload(data: dict[str, Any]) -> dict[str, Any]: out = dict(data) if isinstance(data, dict) else {} system = dict(out.get("_system")) if isinstance(out.get("_system"), dict) else {} @@ -175,6 +206,521 @@ def compute_view_hash(view: dict[str, Any]) -> str: return _sha256_hex(body) +def _window_key_for_ts(ts: int) -> str: + return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d") + + +def _event_sort_key(event: dict[str, Any]) -> tuple[int, str]: + return ( + _safe_int(event.get("modified_ts", 0), default=0), + _normalize_string(event.get("event_id", "")), + ) + + +def make_index0_event( + *, + event_type: str, + subject_type: str, + subject_id: Any, + subject_kind: str, + modified_ts: int, + writer_id: str, + actor_id: str, + scope_path: str, + actor_type: str = "user", + payload_ref: dict[str, Any] | None = None, + links: list[dict[str, Any]] | None = None, + tags: list[str] | None = None, + prev_hash: str = "", + classification: str = "internal", + partition: str = "standard", + visibility: str = "private", + policy_ref: str = "", + source: str = "", + source_event_id: str = "", + summary: str = "", +) -> dict[str, Any]: + event = { + "event_type": _normalize_string(event_type), + "subject_type": _normalize_string(subject_type), + "subject_id": _normalize_string(subject_id), + "subject_kind": _normalize_string(subject_kind), + "scope_path": _normalize_string(scope_path), + "actor_type": _normalize_string(actor_type) or "user", + "actor_id": _normalize_string(actor_id), + "writer_id": _normalize_string(writer_id), + "modified_ts": max(1, _safe_int(modified_ts, default=1)), + "prev_hash": _normalize_string(prev_hash), + "classification": _normalize_string(classification) or "internal", + "partition": _normalize_string(partition) or "standard", + "payload_ref": _normalize_mapping(payload_ref), + "links": _normalize_links(links), + "tags": _normalize_tags(tags), + "visibility": _normalize_string(visibility) or "private", + } + for key, value in { + "policy_ref": policy_ref, + "source": source, + "source_event_id": source_event_id, + "summary": summary, + }.items(): + normalized = _normalize_string(value) + if normalized: + event[key] = normalized + event["integrity_hash"] = compute_event_hash(event) + event["event_id"] = compute_event_id(event) + return normalize_index0_event(event) or event + + +def append_index0_event( + payload: dict[str, Any], + *, + event_type: str, + subject_type: str, + subject_id: Any, + subject_kind: str, + modified_ts: int, + fingerprint_dir: str | Path, + actor_type: str = "user", + payload_ref: dict[str, Any] | None = None, + links: list[dict[str, Any]] | None = None, + tags: list[str] | None = None, + classification: str = "internal", + partition: str = "standard", + visibility: str = "private", + policy_ref: str = "", + source: str = "", + source_event_id: str = "", + summary: str = "", +) -> dict[str, Any]: + out = ensure_index0_payload(payload) + system_index0 = out["_system"]["index0"] + context = derive_index0_context(fingerprint_dir, actor_type=actor_type) + head = system_index0.get("heads", {}).get(context["writer_id"], {}) + prev_hash = "" + if isinstance(head, dict): + prev_hash = _normalize_string(head.get("head_hash", "")) + event = make_index0_event( + event_type=event_type, + subject_type=subject_type, + subject_id=subject_id, + subject_kind=subject_kind, + modified_ts=modified_ts, + writer_id=context["writer_id"], + actor_id=context["actor_id"], + scope_path=context["scope_path"], + actor_type=context["actor_type"], + payload_ref=payload_ref, + links=links, + tags=tags, + prev_hash=prev_hash, + classification=classification, + partition=partition, + visibility=visibility, + policy_ref=policy_ref, + source=source, + source_event_id=source_event_id, + summary=summary, + ) + system_index0["events"][event["event_id"]] = event + system_index0["heads"][context["writer_id"]] = { + "event_id": event["event_id"], + "head_hash": compute_head_hash(event), + "modified_ts": event["modified_ts"], + } + system_index0["stats"] = recompute_index0_stats(system_index0) + return out + + +def build_daily_checkpoint( + writer_id: str, + window_key: str, + events: list[dict[str, Any]], +) -> dict[str, Any] | None: + ordered = sorted( + [ + normalize_index0_event(event) + for event in events + if isinstance(event, dict) and normalize_index0_event(event) is not None + ], + key=_event_sort_key, + ) + if not ordered: + return None + latest = ordered[-1] + events_by_type: dict[str, int] = {} + subjects_by_kind: dict[str, int] = {} + subjects = sorted( + { + _normalize_string(event.get("subject_id", "")) + for event in ordered + if _normalize_string(event.get("subject_id", "")) + } + )[:INDEX0_CHECKPOINT_SUBJECT_CAP] + for event in ordered: + event_type = _normalize_string(event.get("event_type", "")) + subject_kind = _normalize_string(event.get("subject_kind", "")) + if event_type: + events_by_type[event_type] = events_by_type.get(event_type, 0) + 1 + if subject_kind: + subjects_by_kind[subject_kind] = subjects_by_kind.get(subject_kind, 0) + 1 + checkpoint = { + "checkpoint_id": f"cp:day:{window_key}:{writer_id}", + "window_type": "day", + "window_key": window_key, + "writer_id": writer_id, + "window_start_ts": _safe_int(ordered[0].get("modified_ts", 0), default=0), + "window_end_ts": _safe_int(latest.get("modified_ts", 0), default=0), + "event_count": len(ordered), + "head_hash": compute_head_hash(latest), + "rollup": { + "events_by_type": dict(sorted(events_by_type.items())), + "subjects_by_kind": dict(sorted(subjects_by_kind.items())), + "subjects": subjects, + }, + "modified_ts": _safe_int(latest.get("modified_ts", 0), default=0), + } + checkpoint["summary_hash"] = compute_checkpoint_hash(checkpoint) + return normalize_index0_checkpoint(checkpoint) + + +def rebuild_index0_checkpoints( + index0: dict[str, Any], + *, + max_checkpoints_per_writer: int = INDEX0_MAX_CHECKPOINTS_PER_WRITER, +) -> dict[str, dict[str, Any]]: + events = _normalize_mapping(index0.get("events")) + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for event in events.values(): + normalized = normalize_index0_event(event) + if normalized is None: + continue + writer_id = _normalize_string(normalized.get("writer_id", "")) + if not writer_id: + continue + window_key = _window_key_for_ts( + _safe_int(normalized.get("modified_ts", 0), default=0) + ) + grouped.setdefault((writer_id, window_key), []).append(normalized) + + checkpoints: dict[str, dict[str, Any]] = {} + windows_by_writer: dict[str, list[str]] = {} + for (writer_id, window_key), writer_events in grouped.items(): + checkpoint = build_daily_checkpoint(writer_id, window_key, writer_events) + if checkpoint is None: + continue + checkpoints[checkpoint["checkpoint_id"]] = checkpoint + windows_by_writer.setdefault(writer_id, []).append(window_key) + + retained_ids: set[str] = set() + for writer_id, window_keys in windows_by_writer.items(): + keep = sorted(set(window_keys), reverse=True)[ + : max(1, max_checkpoints_per_writer) + ] + for window_key in keep: + retained_ids.add(f"cp:day:{window_key}:{writer_id}") + return { + checkpoint_id: checkpoints[checkpoint_id] + for checkpoint_id in sorted(retained_ids) + if checkpoint_id in checkpoints + } + + +def compact_index0( + index0: dict[str, Any], + *, + max_checkpoints_per_writer: int = INDEX0_MAX_CHECKPOINTS_PER_WRITER, +) -> dict[str, Any]: + normalized = normalize_index0(index0) + normalized["checkpoints"] = rebuild_index0_checkpoints( + normalized, max_checkpoints_per_writer=max_checkpoints_per_writer + ) + normalized["stats"] = recompute_index0_stats(normalized) + return normalized + + +def _normalize_entry_summary(entry_id: str, entry: dict[str, Any]) -> dict[str, Any]: + kind = ( + _normalize_string(entry.get("kind", entry.get("type", "password"))) + or "password" + ) + label = _normalize_string(entry.get("label", entry.get("website", ""))) + return { + "entry_id": _normalize_string(entry_id), + "kind": kind, + "label": label, + "archived": bool(entry.get("archived", entry.get("blacklisted", False))), + "modified_ts": _safe_int( + entry.get("modified_ts", entry.get("updated", 0)), default=0 + ), + "link_count": len(_normalize_links(entry.get("links", []))), + "tag_count": len(_normalize_tags(entry.get("tags", []))), + } + + +def _build_children_view( + scope_path: str, + entries: dict[str, Any], + event_ids: list[str], + checkpoint_ids: list[str], +) -> dict[str, Any]: + items = [ + _normalize_entry_summary(entry_id, entry) + for entry_id, entry in sorted( + _normalize_mapping(entries).items(), key=lambda item: int(str(item[0])) + ) + if isinstance(entry, dict) + ] + modified_ts = max( + [item["modified_ts"] for item in items if item["modified_ts"] > 0] or [0] + ) + return { + "view_id": f"children_of:{scope_path}", + "view_type": "children_of", + "scope_path": scope_path, + "source_checkpoint_ids": checkpoint_ids, + "source_event_ids": event_ids, + "data": { + "children": items, + "total_children": len(items), + }, + "modified_ts": max(1, modified_ts), + } + + +def _build_counts_view( + scope_path: str, + entries: dict[str, Any], + event_ids: list[str], + checkpoint_ids: list[str], +) -> dict[str, Any]: + counts: dict[str, int] = {} + archived_count = 0 + for entry in _normalize_mapping(entries).values(): + if not isinstance(entry, dict): + continue + kind = _normalize_string(entry.get("kind", entry.get("type", "password"))) + if kind: + counts[kind] = counts.get(kind, 0) + 1 + if bool(entry.get("archived", entry.get("blacklisted", False))): + archived_count += 1 + modified_ts = max( + [ + _safe_int(entry.get("modified_ts", entry.get("updated", 0)), default=0) + for entry in _normalize_mapping(entries).values() + if isinstance(entry, dict) + ] + or [0] + ) + return { + "view_id": f"counts_by_kind:{scope_path}", + "view_type": "counts_by_kind", + "scope_path": scope_path, + "source_checkpoint_ids": checkpoint_ids, + "source_event_ids": event_ids, + "data": { + "counts": dict(sorted(counts.items())), + "archived_count": archived_count, + "total_entries": sum(counts.values()), + }, + "modified_ts": max(1, modified_ts), + } + + +def _build_recent_activity_view( + scope_path: str, + events: list[dict[str, Any]], + checkpoint_ids: list[str], +) -> dict[str, Any]: + ordered = sorted(events, key=_event_sort_key, reverse=True)[ + :INDEX0_RECENT_ACTIVITY_LIMIT + ] + items = [ + { + "event_id": _normalize_string(event.get("event_id", "")), + "event_type": _normalize_string(event.get("event_type", "")), + "subject_id": _normalize_string(event.get("subject_id", "")), + "subject_kind": _normalize_string(event.get("subject_kind", "")), + "modified_ts": _safe_int(event.get("modified_ts", 0), default=0), + "summary": _normalize_string(event.get("summary", "")), + } + for event in ordered + ] + modified_ts = max( + [item["modified_ts"] for item in items if item["modified_ts"] > 0] or [0] + ) + return { + "view_id": f"recent_activity:{scope_path}", + "view_type": "recent_activity", + "scope_path": scope_path, + "source_checkpoint_ids": checkpoint_ids, + "source_event_ids": [item["event_id"] for item in items if item["event_id"]], + "data": { + "items": items, + "total_items": len(items), + }, + "modified_ts": max(1, modified_ts), + } + + +def rebuild_canonical_views_payload( + payload: dict[str, Any], + *, + fingerprint_dir: str | Path | None = None, +) -> dict[str, Any]: + out = ensure_index0_payload(payload) + index0 = out["_system"]["index0"] + entries = _normalize_mapping(out.get("entries")) + events = [ + normalize_index0_event(event) + for event in index0.get("events", {}).values() + if normalize_index0_event(event) is not None + ] + checkpoints = _normalize_mapping(index0.get("checkpoints")) + + scope_paths = { + _normalize_string(event.get("scope_path", "")) + for event in events + if _normalize_string(event.get("scope_path", "")) + } + if fingerprint_dir is not None: + scope_paths.add(derive_index0_context(fingerprint_dir)["scope_path"]) + scope_paths = {scope for scope in scope_paths if scope} + + views: dict[str, Any] = {} + for scope_path in sorted(scope_paths): + scope_events = [ + event + for event in events + if _normalize_string(event.get("scope_path", "")) == scope_path + ] + checkpoint_ids = sorted( + { + checkpoint_id + for checkpoint_id, checkpoint in checkpoints.items() + if isinstance(checkpoint, dict) + and any( + _normalize_string(event.get("writer_id", "")) + == _normalize_string(checkpoint.get("writer_id", "")) + for event in scope_events + ) + } + ) + source_event_ids = sorted( + { + _normalize_string(event.get("event_id", "")) + for event in scope_events + if _normalize_string(event.get("event_id", "")) + } + ) + for raw_view in ( + _build_children_view(scope_path, entries, source_event_ids, checkpoint_ids), + _build_counts_view(scope_path, entries, source_event_ids, checkpoint_ids), + _build_recent_activity_view(scope_path, scope_events, checkpoint_ids), + ): + raw_view["view_hash"] = compute_view_hash(raw_view) + normalized_view = normalize_canonical_view( + raw_view, + view_manifest={ + "local_only_view_types": sorted(LOCAL_ONLY_VIEW_TYPES), + }, + ) + if normalized_view is not None: + views[normalized_view["view_id"]] = normalized_view + + index0["view_manifest"] = normalize_view_manifest( + { + "version": 1, + "canonical_view_types": list(INDEX0_CANONICAL_VIEW_TYPES), + "local_only_view_types": sorted(LOCAL_ONLY_VIEW_TYPES), + "builder_versions": { + view_type: 1 for view_type in INDEX0_CANONICAL_VIEW_TYPES + }, + } + ) + index0["canonical_views"] = dict(sorted(views.items())) + index0["stats"] = recompute_index0_stats(index0) + out["_system"]["index0"] = index0 + return out + + +def compact_index0_payload( + payload: dict[str, Any], + *, + max_checkpoints_per_writer: int = INDEX0_MAX_CHECKPOINTS_PER_WRITER, + fingerprint_dir: str | Path | None = None, +) -> dict[str, Any]: + out = ensure_index0_payload(payload) + out["_system"]["index0"] = compact_index0( + out["_system"]["index0"], + max_checkpoints_per_writer=max_checkpoints_per_writer, + ) + return rebuild_canonical_views_payload(out, fingerprint_dir=fingerprint_dir) + + +def build_manifest_index0_metadata( + payload: dict[str, Any], + *, + checkpoint_limit: int = INDEX0_MANIFEST_CHECKPOINT_LIMIT, + fingerprint_dir: str | Path | None = None, +) -> dict[str, Any]: + compacted = compact_index0_payload(payload, fingerprint_dir=fingerprint_dir) + index0 = compacted["_system"]["index0"] + checkpoints = [ + checkpoint + for checkpoint in index0.get("checkpoints", {}).values() + if isinstance(checkpoint, dict) + ] + selected = sorted( + checkpoints, + key=lambda checkpoint: ( + _safe_int(checkpoint.get("modified_ts", 0), default=0), + _normalize_string(checkpoint.get("checkpoint_id", "")), + ), + reverse=True, + )[: max(0, checkpoint_limit)] + checkpoint_ids = [ + _normalize_string(checkpoint.get("checkpoint_id", "")) + for checkpoint in selected + if _normalize_string(checkpoint.get("checkpoint_id", "")) + ] + return { + "schema_version": INDEX0_SCHEMA_VERSION, + "checkpoint_ids": checkpoint_ids, + "checkpoint_hashes": { + checkpoint_id: _normalize_string( + index0["checkpoints"][checkpoint_id].get("summary_hash", "") + ) + for checkpoint_id in checkpoint_ids + if checkpoint_id in index0.get("checkpoints", {}) + }, + "stream_heads": { + writer_id: _normalize_string(head.get("head_hash", "")) + for writer_id, head in sorted( + _normalize_mapping(index0.get("heads")).items() + ) + if isinstance(head, dict) and _normalize_string(head.get("head_hash", "")) + }, + } + + +def list_canonical_views(index0: dict[str, Any]) -> list[dict[str, Any]]: + normalized = normalize_index0(index0) + return [ + normalized["canonical_views"][key] + for key in sorted(normalized["canonical_views"]) + ] + + +def get_canonical_view( + index0: dict[str, Any], *, view_type: str, scope_path: str +) -> dict[str, Any] | None: + normalized = normalize_index0(index0) + view_id = f"{_normalize_string(view_type)}:{_normalize_string(scope_path)}" + view = normalized["canonical_views"].get(view_id) + return dict(view) if isinstance(view, dict) else None + + def normalize_index0_event(raw: Any) -> dict[str, Any] | None: if not isinstance(raw, dict): return None diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index c490c425..fdf34c4b 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -4404,6 +4404,27 @@ class PasswordManager: "Offline mode is enabled. Disable it in Settings to sync." ) return None + try: + from .index0 import ( + build_manifest_index0_metadata, + compact_index0_payload, + ) + + current_index = self.vault.load_index() + compacted_index = compact_index0_payload(current_index) + if compacted_index != current_index: + self.vault.save_index(compacted_index) + current_index = compacted_index + if client is not None: + client.manifest_index0_metadata = build_manifest_index0_metadata( + current_index, + fingerprint_dir=self.fingerprint_dir, + ) + except Exception as index0_error: + logging.warning( + "Failed to build manifest index0 metadata: %s", + index0_error, + ) encrypted = self.get_encrypted_data() if not encrypted: if client is not None and hasattr(client, "last_error"): diff --git a/src/seedpass/core/vault.py b/src/seedpass/core/vault.py index 2124e25b..8d228db1 100644 --- a/src/seedpass/core/vault.py +++ b/src/seedpass/core/vault.py @@ -14,7 +14,7 @@ from .encryption import ( USE_ORJSON, json_lib, ) -from .index0 import ensure_index0_payload +from .index0 import compact_index0_payload, ensure_index0_payload from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION from utils.password_prompt import prompt_existing_password @@ -240,7 +240,7 @@ class Vault: def save_index(self, data: dict) -> None: """Encrypt and write password index.""" - data = ensure_index0_payload(data) + data = compact_index0_payload(data, fingerprint_dir=self.fingerprint_dir) self.encryption_manager.save_json_data( data, self.index_file, kdf=self._hkdf_kdf() ) diff --git a/src/seedpass/tui_v3/__init__.py b/src/seedpass/tui_v3/__init__.py index a746919e..33f8c793 100644 --- a/src/seedpass/tui_v3/__init__.py +++ b/src/seedpass/tui_v3/__init__.py @@ -29,6 +29,8 @@ def launch_tui3( utility_service_factory: Any | None = None, vault_service_factory: Any | None = None, semantic_service_factory: Any | None = None, + atlas_service_factory: Any | None = None, + search_service_factory: Any | None = None, app_hook: Any | None = None, ) -> bool: """Launch TUI v3 from scratch architecture.""" @@ -48,6 +50,8 @@ def launch_tui3( utility_service_factory=utility_service_factory, vault_service_factory=vault_service_factory, semantic_service_factory=semantic_service_factory, + atlas_service_factory=atlas_service_factory, + search_service_factory=search_service_factory, ) if callable(app_hook): diff --git a/src/seedpass/tui_v3/app.py b/src/seedpass/tui_v3/app.py index 3b9e3853..b625cf23 100644 --- a/src/seedpass/tui_v3/app.py +++ b/src/seedpass/tui_v3/app.py @@ -11,15 +11,20 @@ from textual.widgets import Button, Input, Label, Static from textual.containers import Horizontal, Vertical from utils.fingerprint_manager import FingerprintManager -from .widgets.header import RibbonHeader +from .widgets.header import AtlasStrip, RibbonHeader from .widgets.sidebar import SidebarContainer from .widgets.grid import GridContainer from .widgets.inspector import BoardContainer from .widgets.palette import CommandPalette from .widgets.action_bar import ActionBar +from .screens.atlas import AtlasWayfinderScreen from .screens.settings import SettingsScreen from .screens.inspector import MaximizedInspectorScreen +from .screens.maintenance import format_status +from .screens.pubkey import NostrPubkeyScreen +from .screens.profile import ProfileManagementScreen from .screens.relays import RelaysScreen +from .screens.security import BackupParentSeedScreen, ChangePasswordScreen def render_qr_ascii(data: str) -> str: @@ -58,7 +63,7 @@ class CommandProcessor: if cmd == "help": self.app.notify( - "v3 commands: help, stats, session-status, lock, unlock , refresh, search , search-mode , filter , archived, open , settings, relay-list, maximize, copy, edit, export, add, seed-plus, archive, restore, ml, mx, db-export , db-import " + "v3 commands: help, stats, atlas, wayfinder, session-status, lock, unlock , refresh, search , search-mode , filter , archived, open , settings, profiles, relay-list, npub, nostr-pubkey, nostr-reset-sync-state, nostr-fresh-namespace, change-password, backup-parent-seed (optional: password), maximize, copy, edit, export, add, seed-plus, archive, restore, delete, ml, mx, db-export , db-import " ) elif cmd == "stats": self.app.notify("Calculating stats...") @@ -66,6 +71,8 @@ class CommandProcessor: if "vault" in self.app.services: stats = self.app.services["vault"].stats() self.app.notify(f"Total entries: {stats.get('total_entries', 0)}") + elif cmd in {"atlas", "wayfinder"}: + self.app.action_open_atlas_wayfinder() elif cmd == "session-status": self.app.action_session_status() elif cmd == "lock": @@ -113,8 +120,37 @@ class CommandProcessor: self.app.notify("Entry ID must be an integer", severity="error") elif cmd == "settings": self.app.action_toggle_settings() + elif cmd == "profiles": + self.app.action_open_profile_management() elif cmd == "relay-list": self.app.action_toggle_relays() + elif cmd in {"npub", "nostr-pubkey"}: + if args: + self.app.notify("Usage: npub", severity="warning") + return + self.app.action_show_profile_pubkey() + elif cmd == "nostr-reset-sync-state": + if args: + self.app.notify("Usage: nostr-reset-sync-state", severity="warning") + return + self.app.action_nostr_reset_sync_state() + elif cmd == "nostr-fresh-namespace": + if args: + self.app.notify("Usage: nostr-fresh-namespace", severity="warning") + return + self.app.action_nostr_fresh_namespace() + elif cmd == "change-password": + if args: + self.app.notify("Usage: change-password", severity="warning") + return + self.app.action_open_change_password() + elif cmd == "backup-parent-seed": + if not args: + self.app.action_open_backup_parent_seed() + return + path = args[0] + password = args[1] if len(args) > 1 else None + self.app.action_backup_parent_seed(path, password) elif cmd == "maximize": self.app.action_maximize_inspector() elif cmd == "add": @@ -133,6 +169,8 @@ class CommandProcessor: self.app.action_managed_exit() elif cmd in {"archive", "restore"}: self.app.action_toggle_archive() + elif cmd == "delete": + self.app.action_delete_selected() elif cmd == "db-export": path = args[0] if args else "backup.enc" self.app.action_db_export(path) @@ -166,9 +204,12 @@ class MainScreen(Screen): yield SidebarContainer(id="sidebar-container") with Vertical(id="right-pane"): yield RibbonHeader(id="ribbon-header") + yield AtlasStrip(id="atlas-strip") yield GridContainer(id="grid-container") - with Vertical(id="inspector-pane"): - yield Static("Inspector Board", id="inspector-heading") + with Vertical(id="inspector-pane", classes="hidden"): + with Horizontal(id="inspector-header"): + yield Static("Inspector Board", id="inspector-heading") + yield Button("Close", id="inspector-close", variant="default") yield BoardContainer(id="board-container") yield ActionBar(id="action-bar") @@ -376,6 +417,9 @@ class CreateProfileScreen(Screen): def on_mount(self) -> None: self.query_one("#create-mode", Input).value = "existing" self.query_one("#create-nostr-empty-ok", Input).value = "no" + self._set_status( + "Import an existing seed, generate a new one, or restore from Nostr/local backup." + ) def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id @@ -394,6 +438,23 @@ class CreateProfileScreen(Screen): def on_input_submitted(self, _event: Input.Submitted) -> None: self._submit() + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id != "create-mode": + return + mode = event.value.strip().lower() + guidance = { + "existing": "Import an existing seed phrase to create a local profile.", + "words": "Use guided word entry or paste the full phrase once all 12 words are confirmed.", + "generate": "Generate a new deterministic seed, then store it safely before finishing setup.", + "nostr": "Restore from Nostr using the seed phrase. Use 'yes' only if you want to continue without a remote backup present.", + "backup": "Restore from a local encrypted backup. Provide both the seed phrase and the backup file path.", + } + self._set_status( + guidance.get( + mode, "Choose a mode: existing, words, generate, nostr, or backup." + ) + ) + def _set_status(self, message: str) -> None: self.query_one("#create-status", Static).update(message) @@ -435,6 +496,9 @@ class CreateProfileScreen(Screen): if not seed: self._set_status("Enter the seed phrase for Nostr restore.") return + self._set_status( + "Attempting Nostr restore for the supplied seed profile..." + ) fingerprint = self.app._restore_from_nostr_profile( seed=seed, password=password, @@ -445,8 +509,11 @@ class CreateProfileScreen(Screen): self._set_status("Enter the seed phrase for backup restore.") return if not backup_path: - self._set_status("Enter the backup path for backup mode.") + self._set_status("Enter the encrypted backup path for backup mode.") return + self._set_status( + "Attempting local backup restore from the supplied path..." + ) fingerprint = self.app._restore_from_backup_profile( seed=seed, password=password, @@ -625,6 +692,9 @@ class RecoverProfileScreen(Screen): def on_mount(self) -> None: self.refresh_profiles() + self._set_status( + "Recovery resets the local index for the selected profile and rebinds it to the supplied seed phrase." + ) def refresh_profiles(self) -> None: profiles = self.app._list_boot_profiles() @@ -693,7 +763,31 @@ class SeedPassTuiV3(App[None]): #left-pane { width: 35; border: solid black; background: #999999; } #right-pane { width: 1fr; border: solid black; background: #999999; margin-left: 0; } #inspector-pane { height: 5fr; border-top: heavy black; background: #000000; margin-top: 0; } - #inspector-heading { background: #000000; color: #ffffff; padding: 0 1; } + #inspector-pane.hidden { display: none; } + #inspector-header { + height: 3; + layout: horizontal; + background: #000000; + color: #ffffff; + border-bottom: solid #ffffff; + } + #inspector-heading { + width: 1fr; + background: #000000; + color: #ffffff; + padding: 1 1 0 1; + text-style: bold; + } + #inspector-close { + width: 10; + min-width: 10; + height: 1; + margin: 1 1 0 0; + background: #ffffff; + color: #000000; + border: none; + text-style: bold; + } /* Global classes that might be used by children */ #sidebar-placeholder, #grid-placeholder, #inspector-placeholder { @@ -706,6 +800,7 @@ class SeedPassTuiV3(App[None]): BINDINGS = [ Binding("q", "quit", "Quit", show=True), Binding("ctrl+p", "open_palette", "Palette", show=True), + Binding("w", "open_atlas_wayfinder", "Wayfinder", 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), @@ -714,6 +809,7 @@ class SeedPassTuiV3(App[None]): Binding("shift+m", "managed_exit", "Exit", show=True), Binding("e", "edit_selected", "Edit", show=True), Binding("x", "export_selected", "Export", show=True), + Binding("d", "delete_selected", "Delete", show=False), Binding("v", "reveal_selected", "Reveal", show=False), Binding("g", "show_qr", "QR", show=False), Binding("a", "toggle_archive", "Archive", show=False), @@ -732,6 +828,10 @@ class SeedPassTuiV3(App[None]): # Internal state for sensitive actions _pending_sensitive_confirm: tuple[str, int, float] | None = None + @staticmethod + def render_qr_ascii(data: str) -> str: + return render_qr_ascii(data) + def __init__( self, fingerprint: str | None = None, @@ -743,6 +843,8 @@ class SeedPassTuiV3(App[None]): utility_service_factory: Callable | None = None, vault_service_factory: Callable | None = None, semantic_service_factory: Callable | None = None, + atlas_service_factory: Callable | None = None, + search_service_factory: Callable | None = None, ) -> None: super().__init__() # Store factories @@ -755,6 +857,8 @@ class SeedPassTuiV3(App[None]): "utility": utility_service_factory, "vault": vault_service_factory, "semantic": semantic_service_factory, + "atlas": atlas_service_factory, + "search": search_service_factory, } # Initialized services self.services: dict[str, Any] = {} @@ -817,10 +921,12 @@ class SeedPassTuiV3(App[None]): EntryService, NostrService, ProfileService, + SearchService, SemanticIndexService, SyncService, UtilityService, VaultService, + AtlasService, ) from seedpass.core.manager import PasswordManager @@ -834,6 +940,8 @@ class SeedPassTuiV3(App[None]): "utility": UtilityService(pm), "vault": VaultService(pm), "semantic": SemanticIndexService(pm), + "atlas": AtlasService(pm), + "search": SearchService(pm), } self.active_fingerprint = pm.current_fingerprint or fingerprint self.session_locked = False @@ -990,16 +1098,34 @@ class SeedPassTuiV3(App[None]): def watch_selected_entry_id(self, old_id: int | None, new_id: int | None) -> None: """Update inspectors when an entry is selected.""" try: + inspector = self.screen.query_one("#inspector-pane") + if new_id is None: + inspector.add_class("hidden") + else: + inspector.remove_class("hidden") self.screen.query_one("#board-container").update_entry(new_id) except Exception: pass + def action_close_inspector(self) -> None: + """Collapse the inspector and clear the active selection.""" + self.selected_entry_id = None + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle local button actions that should not require a full screen.""" + if event.button.id == "inspector-close": + self.action_close_inspector() + event.stop() + def action_refresh(self) -> None: """Force a global UI refresh.""" if not self._main_screen_initialized: return - self.screen.query_one("#profile-tree")._refresh_tree() - self.screen.query_one("#entry-data-table")._refresh_data() + try: + self.screen.query_one("#profile-tree")._refresh_tree() + self.screen.query_one("#entry-data-table")._refresh_data() + except Exception: + pass self.notify("UI Refreshed") def action_search(self, query: str) -> None: @@ -1033,10 +1159,206 @@ class SeedPassTuiV3(App[None]): """Push the full-screen settings screen.""" self.push_screen(SettingsScreen()) + def action_open_atlas_wayfinder(self) -> None: + atlas = self.services.get("atlas") + if atlas is None: + self.notify("Atlas service unavailable.", severity="warning") + return + try: + payload = atlas.wayfinder() + except Exception as exc: + self.notify(f"Atlas load failed: {exc}", severity="error") + return + self.push_screen(AtlasWayfinderScreen(payload)) + + def action_open_profile_management(self) -> None: + """Open the in-app profile management screen.""" + if "profile" not in self.services: + self.notify("Profile service offline", severity="error") + return + self.push_screen(ProfileManagementScreen()) + + def action_switch_profile( + self, fingerprint: str | None, password: str | None = None + ) -> None: + """Switch the active profile through the service layer.""" + if not fingerprint: + return + profile_service = self.services.get("profile") + if not profile_service: + self.notify("Profile service offline", severity="error") + return + try: + from seedpass.core.api import ProfileSwitchRequest + + profile_service.switch_profile( + ProfileSwitchRequest(fingerprint=fingerprint, password=password) + ) + self.active_fingerprint = fingerprint + self.notify(f"Switched to profile {fingerprint[:12]}") + if isinstance(self.screen, ProfileManagementScreen): + self.screen._set_status( + format_status( + "success", + f"Switched to profile {fingerprint[:12]}. Refreshing workspace...", + ) + ) + self.action_refresh() + except Exception as e: + self.notify(f"Profile switch failed: {e}", severity="error") + if isinstance(self.screen, ProfileManagementScreen): + self.screen._set_status( + format_status("error", f"Profile switch failed: {e}") + ) + + def action_remove_profile(self, fingerprint: str | None) -> None: + """Remove a profile through the service layer.""" + if not fingerprint: + return + current = self.active_fingerprint + if fingerprint == current: + self.notify("Cannot remove the active profile", severity="warning") + return + profile_service = self.services.get("profile") + if not profile_service: + self.notify("Profile service offline", severity="error") + return + try: + from seedpass.core.api import ProfileRemoveRequest + + profile_service.remove_profile( + ProfileRemoveRequest(fingerprint=fingerprint) + ) + self.notify(f"Removed profile {fingerprint[:12]}") + if isinstance(self.screen, ProfileManagementScreen): + self.screen._set_status( + format_status( + "success", + f"Removed profile {fingerprint[:12]}. List refreshed.", + ) + ) + self.screen.action_refresh_profiles() + except Exception as e: + self.notify(f"Profile removal failed: {e}", severity="error") + if isinstance(self.screen, ProfileManagementScreen): + self.screen._set_status( + format_status("error", f"Profile removal failed: {e}") + ) + def action_toggle_relays(self) -> None: """Push the Nostr Relay Management screen.""" self.push_screen(RelaysScreen()) + def action_show_profile_pubkey(self) -> None: + """Show the active profile npub and public QR payload.""" + if "nostr" not in self.services: + self.notify("Nostr service offline", severity="error") + return + self.push_screen(NostrPubkeyScreen()) + + def action_open_change_password(self) -> None: + """Open the vault password-change flow.""" + if "vault" not in self.services: + self.notify("Vault service offline", severity="error") + return + self.push_screen(ChangePasswordScreen()) + + def action_change_password(self, old_password: str, new_password: str) -> None: + """Change the active vault password via the service layer.""" + vault = self.services.get("vault") + if not vault: + self.notify("Vault service offline", severity="error") + return + try: + from seedpass.core.api import ChangePasswordRequest + + vault.change_password( + ChangePasswordRequest( + old_password=old_password, + new_password=new_password, + ) + ) + self.notify("Vault password updated") + if isinstance(self.screen, ChangePasswordScreen): + self.screen._set_status( + format_status( + "success", + "Vault password updated successfully. Returning to the previous screen.", + ) + ) + self.pop_screen() + except Exception as e: + self.notify(f"Change password failed: {e}", severity="error") + if isinstance(self.screen, ChangePasswordScreen): + self.screen._set_status( + format_status("error", f"Change password failed: {e}") + ) + + def action_open_backup_parent_seed(self) -> None: + """Open the encrypted parent-seed backup flow.""" + if "vault" not in self.services: + self.notify("Vault service offline", severity="error") + return + self.push_screen(BackupParentSeedScreen()) + + def action_backup_parent_seed( + self, path: str | None, password: str | None = None + ) -> None: + """Export an encrypted parent-seed backup via the service layer.""" + vault = self.services.get("vault") + if not vault: + self.notify("Vault service offline", severity="error") + return + try: + from pathlib import Path + from seedpass.core.api import BackupParentSeedRequest + + req = BackupParentSeedRequest( + path=Path(path) if path else None, + password=password, + ) + vault.backup_parent_seed(req) + target = str(req.path) if req.path is not None else "(default path)" + self.notify(f"Parent seed backup exported to {target}") + if isinstance(self.screen, BackupParentSeedScreen): + self.screen._set_status( + format_status( + "success", + f"Parent seed backup exported to {target}. Returning to the previous screen.", + ) + ) + self.pop_screen() + except Exception as e: + self.notify(f"Parent seed backup failed: {e}", severity="error") + if isinstance(self.screen, BackupParentSeedScreen): + self.screen._set_status( + format_status("error", f"Parent seed backup failed: {e}") + ) + + def action_nostr_reset_sync_state(self) -> None: + """Reset manifest and delta sync metadata for the active profile.""" + nostr = self.services.get("nostr") + if not nostr: + self.notify("Nostr service offline", severity="error") + return + try: + idx = nostr.reset_sync_state() + self.notify(f"Reset Nostr sync state at account index {idx}") + except Exception as e: + self.notify(f"Reset sync state failed: {e}", severity="error") + + def action_nostr_fresh_namespace(self) -> None: + """Advance to a fresh deterministic Nostr namespace.""" + nostr = self.services.get("nostr") + if not nostr: + self.notify("Nostr service offline", severity="error") + return + try: + idx = nostr.start_fresh_namespace() + self.notify(f"Started fresh Nostr namespace at account index {idx}") + except Exception as e: + self.notify(f"Fresh namespace failed: {e}", severity="error") + def action_add_entry(self) -> None: """Open the add entry wizard.""" if self.session_locked: @@ -1187,6 +1509,48 @@ class SeedPassTuiV3(App[None]): except Exception as e: self.notify(f"Copy failed: {e}", severity="error") + def action_delete_selected(self, confirm: bool = False) -> None: + """Delete the selected entry after explicit confirmation.""" + 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 "entry").lower() + label = str(entry.get("label") or f"Entry #{self.selected_entry_id}") + + if not confirm: + confirm = self._consume_confirm( + "delete_selected", self.selected_entry_id + ) + + if not confirm: + self._pending_sensitive_confirm = ( + "delete_selected", + self.selected_entry_id, + time.time(), + ) + self._update_board_sensitive( + prompt=( + "CONFIRMATION REQUIRED\n\n" + f"Delete '{label}' ({kind}).\n" + "Press 'd' again within 8s to permanently delete." + ) + ) + self.notify("Press 'd' again to confirm deletion") + return + + delete_id = self.selected_entry_id + self.services["entry"].delete_entry(delete_id) + self.selected_entry_id = None + self.notify(f"Deleted Entry #{delete_id}") + self.action_refresh() + except Exception as e: + self.notify(f"Delete 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: diff --git a/src/seedpass/tui_v3/screens/atlas.py b/src/seedpass/tui_v3/screens/atlas.py new file mode 100644 index 00000000..72fb52d9 --- /dev/null +++ b/src/seedpass/tui_v3/screens/atlas.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Button +from textual.containers import Vertical, Horizontal + +from .maintenance import MAINTENANCE_CSS + + +def render_wayfinder(payload: dict) -> str: + scope_path = str(payload.get("scope_path", "unknown")) + stats = payload.get("stats", {}) or {} + counts = ((payload.get("counts_by_kind") or {}).get("data") or {}).get("counts", {}) + children = ((payload.get("children_of") or {}).get("data") or {}).get( + "children", [] + ) + recent = ((payload.get("recent_activity") or {}).get("data") or {}).get("items", []) + + lines = [ + f"Scope: {scope_path}", + f"Events: {int(stats.get('event_count', 0))} Checkpoints: {int(stats.get('checkpoint_count', 0))} Views: {int(stats.get('writer_count', 0))} writers", + "", + "Counts By Kind:", + ] + if counts: + for kind, count in sorted(counts.items()): + lines.append(f"- {kind}: {count}") + else: + lines.append("- No entries indexed") + lines.append("") + lines.append("Children:") + if children: + for child in children[:12]: + archived = " [archived]" if child.get("archived") else "" + lines.append( + f"- #{child.get('entry_id')} {child.get('label') or '(unnamed)'} ({child.get('kind')}){archived}" + ) + else: + lines.append("- No children") + lines.append("") + lines.append("Recent Activity:") + if recent: + for item in recent[:10]: + summary = str(item.get("summary", "")).strip() + suffix = f" :: {summary}" if summary else "" + lines.append( + f"- {item.get('event_type')} #{item.get('subject_id')} ({item.get('subject_kind')}){suffix}" + ) + else: + lines.append("- No recent activity") + return "\n".join(lines) + + +class AtlasWayfinderScreen(Screen): + CSS = MAINTENANCE_CSS + """ + #atlas-wayfinder-shell { background: #101010; } + #atlas-wayfinder-actions { + height: auto; + margin: 0 2; + } + #atlas-wayfinder-actions Button { + margin-right: 1; + margin-bottom: 1; + } + #atlas-wayfinder-content { + padding: 1 2; + border: heavy #000000; + background: #f4f4f4; + color: #000000; + margin: 1 2; + } + """ + + def __init__(self, payload: dict) -> None: + super().__init__() + self.payload = payload + + def compose(self) -> ComposeResult: + children = ((self.payload.get("children_of") or {}).get("data") or {}).get( + "children", [] + ) + recent = ((self.payload.get("recent_activity") or {}).get("data") or {}).get( + "items", [] + ) + with Vertical(id="atlas-wayfinder-shell"): + yield Static("SeedPass ◈ Atlas Wayfinder", classes="maintenance-title") + yield Static( + "Index0 atlas views for the active scope. This is a synced navigation summary, not a separate database.", + classes="maintenance-intro-dark", + ) + with Horizontal(id="atlas-wayfinder-actions"): + yield Button("All", id="atlas-filter-all", variant="default") + yield Button("Docs", id="atlas-filter-docs", variant="default") + yield Button("Secrets", id="atlas-filter-secrets", variant="default") + if children: + first_child = children[0] + yield Button( + f"Open #{first_child.get('entry_id')}", + id=f"atlas-open-entry-{first_child.get('entry_id')}", + variant="primary", + ) + if recent: + recent_subject = recent[0].get("subject_id") + if recent_subject: + yield Button( + f"Open Recent #{recent_subject}", + id=f"atlas-open-recent-{recent_subject}", + variant="default", + ) + yield Static(render_wayfinder(self.payload), id="atlas-wayfinder-content") + yield Static( + "ESC: Back | ctrl+p: Palette | Buttons jump into entries and filters", + classes="maintenance-footer", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id or "" + if button_id == "atlas-filter-all": + self.app.action_set_kind_filter("all") + self.app.pop_screen() + return + if button_id == "atlas-filter-docs": + self.app.action_set_kind_filter("docs") + self.app.pop_screen() + return + if button_id == "atlas-filter-secrets": + self.app.action_set_kind_filter("secrets") + self.app.pop_screen() + return + if button_id.startswith("atlas-open-entry-"): + try: + entry_id = int(button_id.removeprefix("atlas-open-entry-")) + except ValueError: + return + self.app.selected_entry_id = entry_id + self.app.pop_screen() + return + if button_id.startswith("atlas-open-recent-"): + try: + entry_id = int(button_id.removeprefix("atlas-open-recent-")) + except ValueError: + return + self.app.selected_entry_id = entry_id + self.app.pop_screen() diff --git a/src/seedpass/tui_v3/screens/maintenance.py b/src/seedpass/tui_v3/screens/maintenance.py new file mode 100644 index 00000000..be1bc40f --- /dev/null +++ b/src/seedpass/tui_v3/screens/maintenance.py @@ -0,0 +1,121 @@ +from __future__ import annotations + + +MAINTENANCE_CSS = """ +.maintenance-title { + background: #000000; + color: #ffffff; + text-style: bold; + text-align: center; + height: 3; + border: solid #000000; + padding: 0 1; + margin: 1 2; +} + +.maintenance-footer { + background: #000000; + color: #ffffff; + text-align: center; + height: 3; + border: solid #000000; + padding: 0 1; + margin: 1 2; +} + +.maintenance-panel-dark { + padding: 1 2; + border: heavy black; + background: #000000; + margin: 1 2; +} + +.maintenance-panel-light { + padding: 1 2; + border: heavy #000000; + background: #f3f3f3; + margin: 1 2; +} + +.maintenance-intro-dark { + color: #d7d7d7; + background: #111111; + border: solid #666666; + padding: 1; + margin-bottom: 1; +} + +.maintenance-intro-light { + color: #222222; + background: #e6e6e6; + border: solid #666666; + padding: 1; + margin-bottom: 1; +} + +.maintenance-status-dark { + color: #ffffff; + background: #111111; + border: solid #666666; + padding: 1; + margin-top: 1; + min-height: 3; +} + +.maintenance-status-light { + color: #000000; + background: #e6e6e6; + border: solid #666666; + padding: 1; + margin-top: 1; + min-height: 3; +} + +.maintenance-label { + color: #ffffff; + text-style: bold; + margin-top: 1; + margin-bottom: 0; +} + +.maintenance-input { + background: #ffffff; + color: #000000; + border: tall #222222; + margin-bottom: 1; +} + +.maintenance-actions { + height: 3; + margin-top: 2; + content-align: right middle; +} + +.maintenance-actions Button { + margin-left: 1; + text-style: bold; +} + +.maintenance-primary { + background: #ffffff; + color: #000000; + border: solid black; +} + +.maintenance-danger { + background: #000000; + color: #ffffff; + border: solid #ffffff; +} + +.maintenance-secondary { + background: #222222; + color: #ffffff; + border: solid #888888; +} +""" + + +def format_status(level: str, message: str) -> str: + prefix = level.strip().capitalize() or "Info" + return f"{prefix}: {message}" diff --git a/src/seedpass/tui_v3/screens/profile.py b/src/seedpass/tui_v3/screens/profile.py new file mode 100644 index 00000000..9cc23c55 --- /dev/null +++ b/src/seedpass/tui_v3/screens/profile.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Static + +from .maintenance import MAINTENANCE_CSS, format_status + + +class ProfileManagementScreen(Screen): + """Manage profile switching and removal inside TUI v3.""" + + def __init__(self) -> None: + super().__init__() + self._pending_remove_fingerprint: str | None = None + + BINDINGS = [ + Binding("ctrl+r", "refresh_profiles", "Refresh", show=True), + Binding("escape", "app.pop_screen", "Back", show=True), + ] + + CSS = ( + MAINTENANCE_CSS + + """ + ProfileManagementScreen { + background: #999999; + } + #profile-container { + height: auto; + min-height: 20; + } + #profile-list { + border: solid #666666; + margin-top: 1; + min-height: 8; + } + #profile-actions Button { + min-width: 14; + } + """ + ) + + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ Profile Management", classes="maintenance-title") + with Container(id="profile-container", classes="maintenance-panel-dark"): + yield Static( + "Switch profiles from inside the active session, or remove a non-active profile after confirming the action.", + id="profile-intro", + classes="maintenance-intro-dark", + ) + yield Static("", id="profile-list", classes="maintenance-intro-dark") + yield Label("Profile Number", classes="maintenance-label") + yield Input(placeholder="1", id="profile-choice", classes="maintenance-input") + yield Label("Password (optional for switch)", classes="maintenance-label") + yield Input(password=True, id="profile-password", classes="maintenance-input") + yield Static( + format_status("ready", "Select a profile number, then switch or remove it."), + id="profile-status", + classes="maintenance-status-dark", + ) + with Horizontal(id="profile-actions", classes="maintenance-actions"): + yield Button("Switch", id="profile-switch", classes="maintenance-primary") + yield Button("Remove", id="profile-remove", classes="maintenance-danger") + yield Button("Back", id="profile-back", classes="maintenance-secondary") + yield Static( + "ESC: Back | Ctrl+R: Refresh | Switch/Remove use the selected profile number", + classes="maintenance-footer", + ) + + def on_mount(self) -> None: + self.action_refresh_profiles() + + def action_refresh_profiles(self) -> None: + self._pending_remove_fingerprint = None + self._render_profiles() + + def _available_profiles(self) -> list[dict[str, str]]: + service = self.app.services.get("profile") + if not service: + return [] + raw_profiles = list(service.list_profiles()) + display_map = { + item.get("fingerprint", ""): item.get("label", item.get("fingerprint", "")) + for item in self.app._list_boot_profiles() + } + out: list[dict[str, str]] = [] + for fingerprint in raw_profiles: + out.append( + { + "fingerprint": fingerprint, + "label": display_map.get(fingerprint, fingerprint), + } + ) + return out + + def _render_profiles(self) -> None: + if "profile" not in self.app.services: + self.query_one("#profile-list", Static).update("Profile service offline.") + return + current = self.app.active_fingerprint + profiles = self._available_profiles() + lines = ["Available Profiles:"] + for idx, profile in enumerate(profiles, start=1): + fingerprint = profile["fingerprint"] + label = profile["label"] + marker = "*" if fingerprint == current else " " + lines.append(f"{idx}. {marker} {label}") + if not profiles: + lines.append("(none)") + self.query_one("#profile-list", Static).update("\n".join(lines)) + if profiles: + self.query_one("#profile-choice", Input).value = "1" + self._set_status( + format_status("ready", "Select a profile number, then switch or remove it.") + ) + + def _selected_profile(self) -> dict[str, str] | None: + profiles = self._available_profiles() + choice = self.query_one("#profile-choice", Input).value.strip() + if not choice.isdigit(): + self._set_status(format_status("warning", "Enter a valid profile number.")) + return None + idx = int(choice) + if idx < 1 or idx > len(profiles): + self._set_status(format_status("warning", "Profile number is out of range.")) + return None + return profiles[idx - 1] + + def _set_status(self, message: str) -> None: + self.query_one("#profile-status", Static).update(message) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "profile-back": + self.app.pop_screen() + return + if button_id == "profile-switch": + self._pending_remove_fingerprint = None + selected = self._selected_profile() + if selected is not None: + self._set_status( + format_status( + "working", f"Switching to profile '{selected['label']}'..." + ) + ) + self.app.action_switch_profile( + None if selected is None else selected["fingerprint"], + self.query_one("#profile-password", Input).value or None, + ) + return + if button_id == "profile-remove": + selected = self._selected_profile() + if selected is None: + return + fingerprint = selected["fingerprint"] + label = selected["label"] + if self._pending_remove_fingerprint != fingerprint: + self._pending_remove_fingerprint = fingerprint + self._set_status( + format_status( + "warning", f"Press Remove again to delete profile '{label}'." + ) + ) + return + self._pending_remove_fingerprint = None + self.app.action_remove_profile(fingerprint) diff --git a/src/seedpass/tui_v3/screens/pubkey.py b/src/seedpass/tui_v3/screens/pubkey.py new file mode 100644 index 00000000..81ce597d --- /dev/null +++ b/src/seedpass/tui_v3/screens/pubkey.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Button, Static +from textual.containers import Horizontal, Vertical + +from .maintenance import MAINTENANCE_CSS, format_status + + +class NostrPubkeyScreen(Screen): + """Display the active profile npub and its public QR payload.""" + + BINDINGS = [ + ("escape", "app.pop_screen", "Back"), + ("c", "copy_pubkey", "Copy npub"), + ] + + DEFAULT_CSS = ( + MAINTENANCE_CSS + + """ + NostrPubkeyScreen { + background: #999999; + } + #pubkey-container { + height: 1fr; + } + #pubkey-content { + border: solid #666666; + } + #pubkey-actions { + align: right middle; + } + #pubkey-copy { + min-width: 16; + } + """ + ) + + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ Active Profile npub", classes="maintenance-title") + with Vertical(id="pubkey-container", classes="maintenance-panel-light"): + yield Static( + "This is the active profile's public Nostr identifier and QR payload. Sharing the npub is safe; do not share private keys.", + id="pubkey-intro", + classes="maintenance-intro-light", + ) + yield Static( + format_status("working", "Loading npub..."), + id="pubkey-content", + classes="maintenance-status-light", + ) + with Horizontal(id="pubkey-actions", classes="maintenance-actions"): + yield Button("Copy npub", id="pubkey-copy", classes="maintenance-primary") + yield Static("ESC: Back | C: Copy npub", classes="maintenance-footer") + + def on_mount(self) -> None: + self._refresh_view() + + def _refresh_view(self) -> None: + nostr = self.app.services.get("nostr") + if not nostr: + self.query_one("#pubkey-content", Static).update( + format_status("error", "Nostr service offline.") + ) + return + try: + npub = nostr.get_pubkey() + qr = self.app.render_qr_ascii(f"nostr:{npub}") + text = ( + f"{format_status('success', 'Active profile npub loaded.')}\n\n" + f"[b]npub[/b]\n{npub}\n\n[b]Public QR[/b]\n{qr}" + ) + self.query_one("#pubkey-content", Static).update(text) + except Exception as e: + self.query_one("#pubkey-content", Static).update( + format_status("error", f"Failed to load npub: {e}") + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "pubkey-copy": + self.action_copy_pubkey() + + def action_copy_pubkey(self) -> None: + nostr = self.app.services.get("nostr") + entry = self.app.services.get("entry") + if not nostr or not entry: + self.app.notify("Clipboard or Nostr service unavailable", severity="warning") + return + try: + npub = nostr.get_pubkey() + if entry.copy_to_clipboard(npub): + self.app.notify("Copied npub to clipboard") + else: + self.app.notify("Clipboard copy failed", severity="warning") + except Exception as e: + self.app.notify(f"Copy failed: {e}", severity="error") diff --git a/src/seedpass/tui_v3/screens/relays.py b/src/seedpass/tui_v3/screens/relays.py index 2c4e6fb9..333706d9 100644 --- a/src/seedpass/tui_v3/screens/relays.py +++ b/src/seedpass/tui_v3/screens/relays.py @@ -5,12 +5,18 @@ from textual.screen import Screen from textual.widgets import Static, Footer, DataTable, Input, Button from textual.containers import Vertical, Horizontal +from .maintenance import MAINTENANCE_CSS, format_status + class RelaysScreen(Screen): """ A dedicated screen for managing Nostr relays and triggering synchronization. """ + def __init__(self) -> None: + super().__init__() + self._pending_delete_idx: int | None = None + BINDINGS = [ ("escape", "app.pop_screen", "Back to Vault"), ("r", "refresh_relays", "Refresh"), @@ -18,75 +24,59 @@ class RelaysScreen(Screen): ("s", "sync_now", "Sync Now"), ] - DEFAULT_CSS = """ + DEFAULT_CSS = ( + MAINTENANCE_CSS + + """ RelaysScreen { background: #999999; } - #relays-title { - background: #000000; - color: #ffffff; - text-style: bold; - text-align: center; - height: 3; - border: solid #000000; - padding: 0 1; - margin: 1 2; - } #relays-container { height: 1fr; - margin: 0 2; - border: solid #000000; - padding: 1; - background: #999999; } #relays-table { height: 1fr; - border: solid #000000; - background: #ffffff; - color: #000000; + border: solid #666666; } #relays-controls { height: 3; margin-top: 1; align: left middle; } + #relays-status { + min-height: 3; + } #relay-input { width: 1fr; - border: solid black; - background: #ffffff; - color: #000000; } #add-button { - background: #000000; - color: #ffffff; - border: solid black; margin-left: 1; - } - #add-button:hover { - background: #ffffff; - color: #000000; - } - #relays-footer { - height: 3; - background: #000000; - color: #ffffff; - text-align: center; - border: solid #000000; - padding: 0 1; - margin: 1 2; + min-width: 16; } """ + ) def compose(self) -> ComposeResult: - yield Static("SeedPass ◈ Nostr Relay Management", id="relays-title") - with Vertical(id="relays-container"): + yield Static("SeedPass ◈ Nostr Relay Management", classes="maintenance-title") + with Vertical(id="relays-container", classes="maintenance-panel-light"): yield DataTable(id="relays-table") with Horizontal(id="relays-controls"): - yield Input(placeholder="wss://relay.example.com", id="relay-input") - yield Button("Add Relay", id="add-button") + yield Input( + placeholder="wss://relay.example.com", + id="relay-input", + classes="maintenance-input", + ) + yield Button("Add Relay", id="add-button", classes="maintenance-primary") + yield Static( + format_status( + "ready", + "Manage relay endpoints for this profile. Deleting a relay requires a second confirm action.", + ), + id="relays-status", + classes="maintenance-status-light", + ) yield Static( "ESC: Exit | R: Refresh | D: Delete Selected | S: Sync Now", - id="relays-footer", + classes="maintenance-footer", ) def on_mount(self) -> None: @@ -96,6 +86,7 @@ class RelaysScreen(Screen): self.action_refresh_relays() def action_refresh_relays(self) -> None: + self._pending_delete_idx = None app = self.app if "nostr" not in app.services: self.app.notify("Nostr Service Offline", severity="error") @@ -111,6 +102,10 @@ class RelaysScreen(Screen): table.clear() for idx, url in enumerate(relays): table.add_row("🟢 Active", url, key=str(idx)) + self._set_status(format_status("ready", f"{len(relays)} relay(s) loaded.")) + + def _set_status(self, message: str) -> None: + self.query_one("#relays-status", Static).update(message) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "add-button": @@ -146,8 +141,10 @@ class RelaysScreen(Screen): inp.value = "" self.action_refresh_relays() self.app.notify(f"Added relay: {url}") + self._set_status(format_status("success", f"Added relay {url}")) except Exception as e: self.app.notify(f"Failed to add relay: {e}", severity="error") + self._set_status(format_status("error", f"Add relay failed: {e}")) def action_delete_relay(self) -> None: table = self.query_one(DataTable) @@ -163,16 +160,26 @@ class RelaysScreen(Screen): self.app.notify("No relay selected", severity="warning") return + if self._pending_delete_idx != idx: + self._pending_delete_idx = idx + self._set_status( + format_status("warning", f"Press Delete again to remove relay {url}") + ) + return + service = self.app.services.get("nostr") if not service: return try: + self._pending_delete_idx = None service.remove_relay(idx) self.action_refresh_relays() self.app.notify(f"Removed relay: {url}") + self._set_status(format_status("success", f"Removed relay {url}")) except Exception as e: self.app.notify(f"Failed to remove relay: {e}", severity="error") + self._set_status(format_status("error", f"Delete relay failed: {e}")) def action_sync_now(self) -> None: sync_service = self.app.services.get("sync") @@ -181,6 +188,7 @@ class RelaysScreen(Screen): return self.app.notify("Starting Nostr synchronization...", severity="information") + self._set_status(format_status("working", "Starting Nostr synchronization...")) self.run_worker(self._perform_sync, exclusive=True) async def _perform_sync(self) -> None: @@ -192,8 +200,14 @@ class RelaysScreen(Screen): self.app.call_from_thread( self.app.notify, "Synchronization complete", severity="information" ) + self.app.call_from_thread( + self._set_status, format_status("success", "Synchronization complete.") + ) 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._set_status, format_status("error", f"Sync failed: {e}") + ) diff --git a/src/seedpass/tui_v3/screens/security.py b/src/seedpass/tui_v3/screens/security.py new file mode 100644 index 00000000..a811ef51 --- /dev/null +++ b/src/seedpass/tui_v3/screens/security.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Static + +from .maintenance import MAINTENANCE_CSS, format_status + + +class ChangePasswordScreen(Screen): + """Dedicated flow for changing the active vault password.""" + + BINDINGS = [ + Binding("ctrl+s", "submit", "Submit", show=True), + Binding("escape", "app.pop_screen", "Cancel", show=True), + ] + + CSS = ( + MAINTENANCE_CSS + + """ + ChangePasswordScreen { + background: #999999; + } + #change-password-container { + height: auto; + min-height: 18; + } + #change-password-actions Button { + min-width: 18; + } + """ + ) + + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ Change Vault Password", classes="maintenance-title") + with Container(id="change-password-container", classes="maintenance-panel-dark"): + yield Static( + "Update the active vault password for this profile. The current password is required to re-encrypt the vault.", + id="change-password-intro", + classes="maintenance-intro-dark", + ) + yield Label("Current Password", classes="maintenance-label") + yield Input(password=True, id="change-password-old", classes="maintenance-input") + yield Label("New Password", classes="maintenance-label") + yield Input(password=True, id="change-password-new", classes="maintenance-input") + yield Label("Confirm New Password", classes="maintenance-label") + yield Input(password=True, id="change-password-confirm", classes="maintenance-input") + yield Static( + format_status("ready", "Enter the current and new password to continue."), + id="change-password-status", + classes="maintenance-status-dark", + ) + with Horizontal(id="change-password-actions", classes="maintenance-actions"): + yield Button("Cancel", id="change-password-cancel", classes="maintenance-danger") + yield Button("Update Password", id="change-password-submit", classes="maintenance-primary") + yield Static("ESC: Cancel | Ctrl+S: Update Password", classes="maintenance-footer") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "change-password-submit": + self.action_submit() + else: + self.app.pop_screen() + + def _set_status(self, message: str) -> None: + self.query_one("#change-password-status", Static).update(message) + + def action_submit(self) -> None: + old_password = self.query_one("#change-password-old", Input).value + new_password = self.query_one("#change-password-new", Input).value + confirm = self.query_one("#change-password-confirm", Input).value + + if not old_password or not new_password: + self._set_status(format_status("warning", "Current and new passwords are required.")) + return + if new_password != confirm: + self._set_status(format_status("warning", "New password confirmation does not match.")) + return + self.app.action_change_password(old_password, new_password) + + +class BackupParentSeedScreen(Screen): + """Dedicated flow for encrypted parent-seed backup export.""" + + BINDINGS = [ + Binding("ctrl+s", "submit", "Submit", show=True), + Binding("escape", "app.pop_screen", "Cancel", show=True), + ] + + CSS = ( + MAINTENANCE_CSS + + """ + BackupParentSeedScreen { + background: #999999; + } + #backup-seed-container { + height: auto; + min-height: 18; + } + #backup-seed-actions Button { + min-width: 18; + } + """ + ) + + def compose(self) -> ComposeResult: + yield Static("SeedPass ◈ Backup Parent Seed", classes="maintenance-title") + with Container(id="backup-seed-container", classes="maintenance-panel-dark"): + yield Static( + "Export an encrypted backup of the parent seed. Store the destination path and backup password securely.", + id="backup-seed-intro", + classes="maintenance-intro-dark", + ) + yield Label("Destination Path", classes="maintenance-label") + yield Input(placeholder="seed-backup.enc", id="backup-seed-path", classes="maintenance-input") + yield Label("Backup Password (optional)", classes="maintenance-label") + yield Input(password=True, id="backup-seed-password", classes="maintenance-input") + yield Static( + format_status("ready", "Enter the backup destination path to export an encrypted seed bundle."), + id="backup-seed-status", + classes="maintenance-status-dark", + ) + with Horizontal(id="backup-seed-actions", classes="maintenance-actions"): + yield Button("Cancel", id="backup-seed-cancel", classes="maintenance-danger") + yield Button("Export Backup", id="backup-seed-submit", classes="maintenance-primary") + yield Static("ESC: Cancel | Ctrl+S: Export Backup", classes="maintenance-footer") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "backup-seed-submit": + self.action_submit() + else: + self.app.pop_screen() + + def _set_status(self, message: str) -> None: + self.query_one("#backup-seed-status", Static).update(message) + + def action_submit(self) -> None: + path = self.query_one("#backup-seed-path", Input).value.strip() + password = self.query_one("#backup-seed-password", Input).value + if not path: + self._set_status(format_status("warning", "Destination path is required.")) + return + self.app.action_backup_parent_seed(path, password or None) diff --git a/src/seedpass/tui_v3/screens/settings.py b/src/seedpass/tui_v3/screens/settings.py index 235bef54..6e68f879 100644 --- a/src/seedpass/tui_v3/screens/settings.py +++ b/src/seedpass/tui_v3/screens/settings.py @@ -88,16 +88,22 @@ class SettingsScreen(Screen): f"KDF Iterations : {get_val('kdf_iterations', 100000)} [dim](setting-kdf-iterations )[/dim]", f"KDF Mode : {get_val('kdf_mode', 'argon2id')} [dim](setting-kdf-mode )[/dim]", f"Lock Timeout : {get_val('inactivity_timeout', 300)}s [dim](setting-timeout )[/dim]", + "Password Flow : [dim](change-password)[/dim]", + "Profiles : [dim](profiles)[/dim]", ] backup_rows = [ f"Backup Path : {get_val('additional_backup_path', '(none)')} [dim](db-export )[/dim]", f"Backup Interval: {get_val('backup_interval', 3600)}s", + "Seed Backup : [dim](backup-parent-seed )[/dim]", ] nostr_rows = [ f"Sync Mode : {get_val('semantic_search_mode', 'keyword')} [dim](search-mode ...)[/dim]", f"Relays : {len(get_val('relays', []))} connected [dim](relay-list)[/dim]", + "Profile npub : [dim](npub)[/dim]", + "Reset Sync : [dim](nostr-reset-sync-state)[/dim]", + "Fresh Namespace: [dim](nostr-fresh-namespace)[/dim]", ] # Use the app's card rendering utility if available, or fallback diff --git a/src/seedpass/tui_v3/widgets/action_bar.py b/src/seedpass/tui_v3/widgets/action_bar.py index 02a3f2ea..7ad113f0 100644 --- a/src/seedpass/tui_v3/widgets/action_bar.py +++ b/src/seedpass/tui_v3/widgets/action_bar.py @@ -38,15 +38,16 @@ class ActionBar(Static): pass exit_hint = ( - " [@click='app.managed_exit'][b][M][/b]anaged Exit[/]" + " [@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}" + 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_atlas_wayfinder'][b]W[/b]ayfinder[/] " + 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) @@ -56,28 +57,106 @@ 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[/]", - ] - 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 = self._context_actions(kind, entry) 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 _context_actions(self, kind: str, entry: dict[str, Any]) -> list[str]: + is_archived = bool(entry.get("archived", False)) + archive_label = "Restore" if is_archived else "Archive" + archive_action = f"[@click='app.toggle_archive'][b]a[/b] {archive_label}[/]" + + action_map: dict[str, str] = { + "reveal": "[@click='app.reveal_selected'][b]v[/b] Reveal[/]", + "qr": "[@click='app.show_qr'][b]g[/b] QR[/]", + "edit": "[@click='app.edit_selected'][b]e[/b] Edit[/]", + "archive": archive_action, + "delete": "[@click='app.delete_selected'][b]d[/b] Delete[/]", + "copy": "[@click='app.copy_selected'][b]c[/b] Copy[/]", + "maximize": "[@click='app.maximize_inspector'][b]z[/b] Maximize[/]", + "load": "[@click='app.managed_load'][b]m[/b] Load[/]", + "export": "[@click='app.export_selected'][b]x[/b] Export[/]", + } + + kind_actions: dict[str, list[str]] = { + "password": ["reveal", "edit", "archive", "delete", "copy", "maximize"], + "stored_password": [ + "reveal", + "edit", + "archive", + "delete", + "copy", + "maximize", + ], + "totp": [ + "reveal", + "qr", + "edit", + "archive", + "delete", + "copy", + "export", + "maximize", + ], + "seed": [ + "reveal", + "qr", + "edit", + "archive", + "delete", + "copy", + "load", + "maximize", + ], + "managed_account": [ + "reveal", + "qr", + "edit", + "archive", + "delete", + "copy", + "load", + "maximize", + ], + "ssh": [ + "reveal", + "edit", + "archive", + "delete", + "copy", + "export", + "maximize", + ], + "pgp": [ + "reveal", + "edit", + "archive", + "delete", + "copy", + "export", + "maximize", + ], + "nostr": [ + "reveal", + "qr", + "edit", + "archive", + "delete", + "copy", + "export", + "maximize", + ], + "document": ["edit", "archive", "delete", "copy", "export", "maximize"], + "note": ["edit", "archive", "delete", "copy", "export", "maximize"], + "key_value": ["reveal", "edit", "archive", "delete", "copy", "maximize"], + } + + keys = kind_actions.get(kind, ["edit", "archive", "delete", "maximize"]) + return [action_map[key] for key in keys] + def on_mount(self) -> None: self.watch(self.app, "selected_entry_id", self.refresh) self.watch(self.app, "active_fingerprint", self.refresh) diff --git a/src/seedpass/tui_v3/widgets/grid.py b/src/seedpass/tui_v3/widgets/grid.py index 70517246..29b8c64e 100644 --- a/src/seedpass/tui_v3/widgets/grid.py +++ b/src/seedpass/tui_v3/widgets/grid.py @@ -1,5 +1,6 @@ from __future__ import annotations from typing import Any +from types import SimpleNamespace from textual.app import ComposeResult from textual.widgets import Static, DataTable from textual.reactive import reactive @@ -59,6 +60,10 @@ class EntryDataTable(DataTable): High-fidelity data table for entry browsing. """ + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._suppress_next_highlight = True + def on_mount(self) -> None: self.cursor_type = "row" self.add_columns("Sel", "Id", "Entry#", "Label", "Kind", "Meta", "Arch") @@ -76,6 +81,8 @@ class EntryDataTable(DataTable): app = self.app if "entry" not in app.services: return + if app.selected_entry_id is None: + self._suppress_next_highlight = True # Map filter presets to kind lists kind_map = { @@ -95,8 +102,36 @@ class EntryDataTable(DataTable): 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: + if "search" in app.services: + try: + results = app.services["search"].search( + query, + kinds=kinds, + include_archived=app.show_archived, + archived_only=app.show_archived, + mode=app.search_mode, + ) + for result in results: + try: + kind_val = str(result.get("kind", "")).lower() + entry_id = int(result.get("entry_id", 0)) + except Exception: + continue + entries.append( + ( + entry_id, + str(result.get("label", "")), + None, + str(result.get("meta", "")) or None, + bool(result.get("archived", False)), + SimpleNamespace(value=kind_val), + ) + ) + except Exception as e: + app.notify(f"Search failed: {e}", severity="error") + entries = [] + # Fallback to direct semantic search if requested and available + elif 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} @@ -173,6 +208,9 @@ class EntryDataTable(DataTable): """Update selection when moving cursor.""" rk = str(event.row_key.value) if event.row_key else None if rk: + if self._suppress_next_highlight and self.app.selected_entry_id is None: + self._suppress_next_highlight = False + return try: self.app.selected_entry_id = int(rk) except ValueError: @@ -182,6 +220,7 @@ class EntryDataTable(DataTable): """Handle explicit entry selection (Enter).""" rk = str(event.row_key.value) if event.row_key else None if rk: + self._suppress_next_highlight = False try: eid = int(rk) self.app.selected_entry_id = eid diff --git a/src/seedpass/tui_v3/widgets/header.py b/src/seedpass/tui_v3/widgets/header.py index 5dc95819..dbe12574 100644 --- a/src/seedpass/tui_v3/widgets/header.py +++ b/src/seedpass/tui_v3/widgets/header.py @@ -60,3 +60,54 @@ class RibbonHeader(Static): self.watch(self.app, "active_fingerprint", self.refresh) self.watch(self.app, "session_locked", self.refresh) self.watch(self.app, "filter_kind", self.refresh) + + +class AtlasStrip(Static): + """Compact wayfinder strip for the active scope.""" + + DEFAULT_CSS = """ + AtlasStrip { + height: 2; + background: #d9d9d9; + color: #000000; + padding: 0 1; + border-top: solid black; + border-bottom: solid black; + text-style: bold; + } + """ + + def render(self) -> str: + app = self.app + atlas = app.services.get("atlas") + if atlas is None: + return "Wayfinder | Atlas unavailable" + try: + payload = atlas.wayfinder() + except Exception: + return "Wayfinder | Atlas unavailable" + + scope = str(payload.get("scope_path", "unknown")) + counts = ((payload.get("counts_by_kind") or {}).get("data") or {}).get( + "counts", {} + ) + recent_items = ((payload.get("recent_activity") or {}).get("data") or {}).get( + "items", [] + ) + top_counts = ", ".join( + f"{kind}:{count}" for kind, count in sorted(counts.items())[:3] + ) + if not top_counts: + top_counts = "no entries" + recent = "no recent activity" + if recent_items: + item = recent_items[0] + recent = ( + f"{item.get('event_type', 'event')} #{item.get('subject_id', '?')} " + f"({item.get('subject_kind', 'entry')})" + ) + return f"Wayfinder | {scope} | Counts {top_counts} | Recent {recent}" + + def on_mount(self) -> None: + self.watch(self.app, "active_fingerprint", self.refresh) + self.watch(self.app, "selected_entry_id", self.refresh) diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py index 034fb393..67e336e1 100644 --- a/src/tests/test_core_api_services.py +++ b/src/tests/test_core_api_services.py @@ -10,9 +10,11 @@ from seedpass.core.api import ( ProfileService, SyncService, EntryService, + SearchService, ConfigService, UtilityService, NostrService, + AtlasService, VaultExportRequest, VaultExportResponse, VaultImportRequest, @@ -256,6 +258,216 @@ class TestEntryService: results = service.search_entries("q") assert [row[0] for row in results] == [1] + +class TestAtlasService: + @pytest.fixture + def service(self, mock_manager): + return AtlasService(mock_manager) + + def test_status(self, service, mock_manager, tmp_path): + mock_manager.fingerprint_dir = tmp_path + mock_manager.vault.load_index.return_value = { + "_system": { + "index0": { + "stats": {"event_count": 3}, + "canonical_views": {"a": {}, "b": {}}, + "view_manifest": {"canonical_view_types": ["children_of"]}, + } + } + } + + status = service.status() + + assert status["stats"]["event_count"] == 3 + assert status["view_count"] == 2 + assert status["view_types"] == ["children_of"] + + def test_wayfinder(self, service, mock_manager, tmp_path): + mock_manager.fingerprint_dir = tmp_path + scope_path = f"seed/{tmp_path.name}" + mock_manager.vault.load_index.return_value = { + "_system": { + "index0": { + "canonical_views": { + f"children_of:{scope_path}": { + "view_id": f"children_of:{scope_path}", + "view_type": "children_of", + "scope_path": scope_path, + "source_checkpoint_ids": [], + "source_event_ids": [], + "data": {"children": [{"entry_id": "1"}]}, + "modified_ts": 1, + "view_hash": "abc", + }, + f"counts_by_kind:{scope_path}": { + "view_id": f"counts_by_kind:{scope_path}", + "view_type": "counts_by_kind", + "scope_path": scope_path, + "source_checkpoint_ids": [], + "source_event_ids": [], + "data": {"counts": {"document": 1}}, + "modified_ts": 1, + "view_hash": "def", + }, + }, + "stats": {"event_count": 2}, + } + } + } + + payload = service.wayfinder() + + assert payload["scope_path"] == scope_path + assert payload["children_of"]["data"]["children"][0]["entry_id"] == "1" + assert payload["counts_by_kind"]["data"]["counts"]["document"] == 1 + assert payload["recent_activity"] is None + + +class TestSearchService: + @pytest.fixture + def service(self, mock_manager): + return SearchService(mock_manager) + + def test_keyword_search_returns_unified_results( + self, service, mock_manager, tmp_path + ): + mock_manager.fingerprint_dir = tmp_path + mock_manager.entry_manager.search_entries.return_value = [ + (1, "Project Plan", None, None, False, EntryType.DOCUMENT), + (2, "Ops Vault", "ops", "https://ops", False, EntryType.PASSWORD), + ] + entries = { + 1: { + "id": 1, + "kind": "document", + "label": "Project Plan", + "content": "SeedPass atlas project plan and roadmap", + "tags": ["planning", "docs"], + "links": [{"target_id": 2, "relation": "references"}], + "modified_ts": 200, + }, + 2: { + "id": 2, + "kind": "password", + "label": "Ops Vault", + "username": "ops", + "url": "https://ops", + "notes": "team login", + "tags": ["ops"], + "modified_ts": 100, + }, + } + mock_manager.entry_manager.retrieve_entry.side_effect = lambda eid: entries[eid] + scope_path = f"seed/{tmp_path.name}" + mock_manager.vault.load_index.return_value = { + "_system": { + "index0": { + "canonical_views": { + f"recent_activity:{scope_path}": { + "view_id": f"recent_activity:{scope_path}", + "view_type": "recent_activity", + "scope_path": scope_path, + "source_checkpoint_ids": [], + "source_event_ids": [], + "data": { + "items": [ + {"subject_id": "1", "event_type": "entry_modified"} + ] + }, + "modified_ts": 1, + "view_hash": "abc", + } + } + } + } + } + + results = service.search("references plan", mode="keyword") + + assert [row["entry_id"] for row in results] == [1] + assert results[0]["scope_path"] == scope_path + assert results[0]["score_breakdown"]["lexical"] > 0 + assert "planning" in results[0]["tags"] + assert results[0]["linked_hits"][0]["relation"] == "references" + assert results[0]["excerpt"].startswith("SeedPass atlas") + + @patch("seedpass.core.api.SemanticIndexService.search") + def test_hybrid_search_combines_semantic_and_filters( + self, semantic_search, service, mock_manager, tmp_path + ): + mock_manager.fingerprint_dir = tmp_path + mock_manager.config_manager.get_semantic_index_enabled.return_value = True + mock_manager.config_manager.get_semantic_search_mode.return_value = "hybrid" + mock_manager.entry_manager.search_entries.return_value = [ + (1, "Alpha Notes", None, None, False, EntryType.DOCUMENT), + (2, "Beta Notes", None, None, True, EntryType.DOCUMENT), + ] + entries = { + 1: { + "id": 1, + "kind": "document", + "label": "Alpha Notes", + "content": "hello atlas world", + "tags": ["alpha"], + "modified_ts": 100, + }, + 2: { + "id": 2, + "kind": "document", + "label": "Beta Notes", + "content": "hello beta world", + "tags": ["beta"], + "archived": True, + "modified_ts": 200, + }, + } + mock_manager.entry_manager.retrieve_entry.side_effect = lambda eid: entries[eid] + mock_manager.vault.load_index.return_value = {"_system": {"index0": {}}} + semantic_search.return_value = [ + { + "entry_id": 1, + "kind": "document", + "label": "Alpha Notes", + "score": 0.9, + "excerpt": "hello atlas world", + } + ] + + results = service.search( + "hello alpha atlas", + mode="hybrid", + include_archived=False, + tags=["alpha"], + ) + + assert [row["entry_id"] for row in results] == [1] + assert results[0]["score_breakdown"]["semantic"] == 0.9 + assert "semantic_match" in results[0]["match_reasons"] + assert "tag:alpha" in results[0]["match_reasons"] + + def test_sort_by_modified_desc_without_query(self, service, mock_manager, tmp_path): + mock_manager.fingerprint_dir = tmp_path + mock_manager.entry_manager.search_entries.return_value = [ + (1, "Older", None, None, False, EntryType.DOCUMENT), + (2, "Newer", None, None, False, EntryType.DOCUMENT), + ] + entries = { + 1: {"id": 1, "kind": "document", "label": "Older", "modified_ts": 100}, + 2: {"id": 2, "kind": "document", "label": "Newer", "modified_ts": 200}, + } + mock_manager.entry_manager.retrieve_entry.side_effect = lambda eid: entries[eid] + mock_manager.vault.load_index.return_value = {"_system": {"index0": {}}} + + results = service.search("", sort="modified_desc") + + assert [row["entry_id"] for row in results[:2]] == [2, 1] + + +class TestEntryServiceExtended: + @pytest.fixture + def service(self, mock_manager): + return EntryService(mock_manager) + def test_search_entries_include_archived(self, service, mock_manager): mock_manager.entry_manager.search_entries.return_value = [ (1, "active", None, None, False, EntryType.PASSWORD), diff --git a/src/tests/test_index0_checkpoints.py b/src/tests/test_index0_checkpoints.py new file mode 100644 index 00000000..3714de4c --- /dev/null +++ b/src/tests/test_index0_checkpoints.py @@ -0,0 +1,162 @@ +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault + +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.index0 import ( + INDEX0_MAX_CHECKPOINTS_PER_WRITER, + build_manifest_index0_metadata, + compact_index0_payload, + compute_head_hash, + derive_index0_context, + make_index0_event, +) +from seedpass.core.manager import EncryptionMode, PasswordManager + + +def _entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_compact_index0_payload_builds_daily_checkpoints_and_manifest_metadata(): + payload = {"schema_version": 4, "entries": {}, "_system": {"index0": {}}} + context = derive_index0_context("root-fp") + event_day1 = make_index0_event( + event_type="entry_created", + subject_type="entry", + subject_id="1", + subject_kind="document", + modified_ts=1709251200, + writer_id=context["writer_id"], + actor_id=context["actor_id"], + scope_path=context["scope_path"], + payload_ref={"entry_id": "1"}, + source="test", + ) + event_day2 = make_index0_event( + event_type="entry_modified", + subject_type="entry", + subject_id="1", + subject_kind="document", + modified_ts=1709337600, + writer_id=context["writer_id"], + actor_id=context["actor_id"], + scope_path=context["scope_path"], + prev_hash=compute_head_hash(event_day1), + payload_ref={"entry_id": "1"}, + source="test", + ) + payload["_system"]["index0"] = { + "events": { + event_day1["event_id"]: event_day1, + event_day2["event_id"]: event_day2, + }, + "heads": { + context["writer_id"]: { + "event_id": event_day2["event_id"], + "head_hash": compute_head_hash(event_day2), + "modified_ts": event_day2["modified_ts"], + } + }, + } + + compacted = compact_index0_payload(payload) + checkpoints = compacted["_system"]["index0"]["checkpoints"] + + assert sorted(checkpoints) == [ + f"cp:day:2024-03-01:{context['writer_id']}", + f"cp:day:2024-03-02:{context['writer_id']}", + ] + assert checkpoints[f"cp:day:2024-03-02:{context['writer_id']}"][ + "head_hash" + ] == compute_head_hash(event_day2) + + metadata = build_manifest_index0_metadata(compacted) + assert metadata["schema_version"] == 1 + assert metadata["checkpoint_ids"][0] == f"cp:day:2024-03-02:{context['writer_id']}" + assert metadata["stream_heads"][context["writer_id"]] == compute_head_hash( + event_day2 + ) + + +def test_compact_index0_payload_applies_checkpoint_retention_per_writer(): + payload = {"schema_version": 4, "entries": {}, "_system": {"index0": {}}} + context = derive_index0_context("root-fp") + events = {} + prev_hash = "" + for day in range(1, INDEX0_MAX_CHECKPOINTS_PER_WRITER + 6): + ts = 1704067200 + (day * 86400) + event = make_index0_event( + event_type="entry_modified", + subject_type="entry", + subject_id=str(day), + subject_kind="document", + modified_ts=ts, + writer_id=context["writer_id"], + actor_id=context["actor_id"], + scope_path=context["scope_path"], + prev_hash=prev_hash, + payload_ref={"entry_id": str(day)}, + source="test", + ) + prev_hash = compute_head_hash(event) + events[event["event_id"]] = event + payload["_system"]["index0"] = { + "events": events, + "heads": { + context["writer_id"]: { + "event_id": event["event_id"], + "head_hash": prev_hash, + "modified_ts": event["modified_ts"], + } + }, + } + + compacted = compact_index0_payload(payload) + checkpoints = compacted["_system"]["index0"]["checkpoints"] + + assert len(checkpoints) == INDEX0_MAX_CHECKPOINTS_PER_WRITER + assert f"cp:day:2024-01-02:{context['writer_id']}" not in checkpoints + + +def test_sync_vault_publishes_manifest_index0_metadata(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + vault, enc_mgr = create_vault(fp_dir) + cfg_mgr = ConfigManager(vault, fp_dir) + backup_mgr = BackupManager(fp_dir, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = fp_dir + pm.current_fingerprint = fp_dir.name + pm.parent_seed = TEST_SEED + pm.is_dirty = False + pm.offline_mode = False + pm.state_manager = None + + pm.entry_manager.add_entry("site", 8) + result = pm.sync_vault() + + manifest_payload = json.loads(relay.manifests[-1].content()) + + assert result is not None + assert "index0" in manifest_payload + assert manifest_payload["index0"]["schema_version"] == 1 + assert manifest_payload["index0"]["checkpoint_ids"] + assert manifest_payload["index0"]["stream_heads"] diff --git a/src/tests/test_index0_events.py b/src/tests/test_index0_events.py new file mode 100644 index 00000000..f9de8bec --- /dev/null +++ b/src/tests/test_index0_events.py @@ -0,0 +1,98 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault + +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.index0 import compute_head_hash + + +def _entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def _index0_for(em: EntryManager) -> dict: + return em.vault.load_index()["_system"]["index0"] + + +def test_index0_emits_create_modify_archive_restore_delete_events(): + with TemporaryDirectory() as tmpdir: + em = _entry_manager(Path(tmpdir)) + + idx = em.add_entry("example.com", 16, username="alice", tags=["ops"]) + em.modify_entry(idx, label="example.org", notes="updated") + em.archive_entry(idx) + em.restore_entry(idx) + em.delete_entry(idx) + + index0 = _index0_for(em) + events = list(index0["events"].values()) + event_types = [event["event_type"] for event in events] + + assert event_types == [ + "entry_created", + "entry_modified", + "entry_archived", + "entry_restored", + "entry_deleted", + ] + assert all(event["subject_id"] == str(idx) for event in events) + assert events[0]["writer_id"] == f"writer:profile:{Path(tmpdir).name}" + assert events[0]["scope_path"] == f"seed/{Path(tmpdir).name}" + assert index0["stats"]["event_count"] == 5 + assert ( + index0["heads"][events[-1]["writer_id"]]["event_id"] + == events[-1]["event_id"] + ) + assert events[1]["prev_hash"] == compute_head_hash(events[0]) + assert events[2]["prev_hash"] == compute_head_hash(events[1]) + assert events[3]["prev_hash"] == compute_head_hash(events[2]) + assert events[4]["prev_hash"] == compute_head_hash(events[3]) + + +def test_index0_emits_link_add_and_remove_events(): + with TemporaryDirectory() as tmpdir: + em = _entry_manager(Path(tmpdir)) + + src = em.add_document("Project Plan", "v1", file_type="md") + dst = em.add_key_value("API Token", "token", "abc") + em.add_link(src, dst, relation="references", note="used by deploy") + em.remove_link(src, dst, relation="references") + + index0 = _index0_for(em) + link_events = [ + event + for event in index0["events"].values() + if event["event_type"] in {"link_added", "link_removed"} + ] + + assert [event["event_type"] for event in link_events] == [ + "link_added", + "link_removed", + ] + assert link_events[0]["links"] == [ + {"target_id": str(dst), "relation": "references", "note": "used by deploy"} + ] + assert link_events[1]["payload_ref"]["target_id"] == str(dst) + + +def test_index0_managed_account_scope_uses_hierarchy_path(): + with TemporaryDirectory() as tmpdir: + root_dir = Path(tmpdir) + managed_dir = root_dir / "accounts" / "child-fp" + managed_dir.mkdir(parents=True, exist_ok=True) + em = _entry_manager(managed_dir) + + idx = em.add_document("Managed Doc", "body") + + index0 = _index0_for(em) + event = next(iter(index0["events"].values())) + + assert event["subject_id"] == str(idx) + assert event["writer_id"] == "writer:profile:child-fp" + assert event["scope_path"] == f"seed/{root_dir.name}/managed/child-fp" diff --git a/src/tests/test_index0_views.py b/src/tests/test_index0_views.py new file mode 100644 index 00000000..4c3731f4 --- /dev/null +++ b/src/tests/test_index0_views.py @@ -0,0 +1,77 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import TEST_PASSWORD, TEST_SEED, create_vault + +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.index0 import ( + compact_index0_payload, + derive_index0_context, + get_canonical_view, + list_canonical_views, +) + + +def _entry_manager(tmp_path: Path) -> EntryManager: + vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + return EntryManager(vault, backup_mgr) + + +def test_compact_index0_payload_builds_default_canonical_views(): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) + em = _entry_manager(path) + doc_id = em.add_document("Project Plan", "v1", file_type="md", tags=["docs"]) + pw_id = em.add_entry("example.com", 12, username="alice") + em.archive_entry(pw_id) + payload = compact_index0_payload(em.vault.load_index(), fingerprint_dir=path) + index0 = payload["_system"]["index0"] + scope_path = derive_index0_context(path)["scope_path"] + + children = get_canonical_view( + index0, view_type="children_of", scope_path=scope_path + ) + counts = get_canonical_view( + index0, view_type="counts_by_kind", scope_path=scope_path + ) + recent = get_canonical_view( + index0, view_type="recent_activity", scope_path=scope_path + ) + + assert children is not None + assert counts is not None + assert recent is not None + assert [child["entry_id"] for child in children["data"]["children"]] == [ + str(doc_id), + str(pw_id), + ] + assert counts["data"]["counts"]["document"] == 1 + assert counts["data"]["counts"]["password"] == 1 + assert counts["data"]["archived_count"] == 1 + assert "entry_archived" in [ + item["event_type"] for item in recent["data"]["items"] + ] + assert index0["view_manifest"]["canonical_view_types"] == [ + "children_of", + "counts_by_kind", + "recent_activity", + ] + + +def test_list_canonical_views_returns_sorted_views(): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) + em = _entry_manager(path) + em.add_document("Doc", "body") + index0 = em.vault.load_index()["_system"]["index0"] + + views = list_canonical_views(index0) + + assert [view["view_id"] for view in views] == sorted( + view["view_id"] for view in views + ) + assert len(views) == 3 diff --git a/src/tests/test_tui_v3_parity.py b/src/tests/test_tui_v3_parity.py index 15c81662..894050d5 100644 --- a/src/tests/test_tui_v3_parity.py +++ b/src/tests/test_tui_v3_parity.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest from unittest.mock import MagicMock +from rich.text import Text from textual.widgets import DataTable from seedpass.tui_v3.app import SeedPassTuiV3 @@ -79,6 +80,9 @@ class MockEntryService: def restore_entry(self, eid): self.entries[eid]["archived"] = False + def delete_entry(self, eid): + self.entries.pop(eid, None) + def load_managed_account(self, eid): pass @@ -256,3 +260,55 @@ async def test_v3_seed_payload_accepts_single_arg_seed_getter() -> None: payload = app._resolve_sensitive_payload() assert payload[0] == "Seed Words Revealed" assert payload[1] == "abandon ability able about" + + +@pytest.mark.anyio +async def test_v3_action_bar_labels_include_leading_hotkey_letters() -> None: + app = SeedPassTuiV3( + entry_service_factory=lambda: MockEntryService(), + vault_service_factory=lambda: MockVaultService(), + ) + async with app.run_test() as pilot: + await pilot.pause() + bar = app.screen.query_one("#action-bar") + rendered = Text.from_markup(bar.render()).plain + assert "Settings" in rendered + assert "Add New Entry" in rendered + assert "Create New Seed" in rendered + assert "Remove Seed" in rendered + assert "Export Data" in rendered + assert "Import Data" in rendered + assert "Backup Data" in rendered + + +@pytest.mark.anyio +async def test_v3_action_bar_only_shows_context_actions_for_selected_kind() -> None: + app = SeedPassTuiV3( + entry_service_factory=lambda: MockEntryService(), + vault_service_factory=lambda: MockVaultService(), + ) + async with app.run_test() as pilot: + await pilot.pause() + bar = app.screen.query_one("#action-bar") + + app.selected_entry_id = 5 + await pilot.pause() + rendered = Text.from_markup(bar.render()).plain.splitlines()[1] + assert "Context (document):" in rendered + assert "Edit" in rendered + assert "Export" in rendered + assert "Copy" in rendered + assert "Delete" in rendered + assert "Reveal" not in rendered + assert " QR" not in rendered + assert "Load" not in rendered + + app.selected_entry_id = 6 + await pilot.pause() + rendered = Text.from_markup(bar.render()).plain.splitlines()[1] + assert "Context (seed):" in rendered + assert "Reveal" in rendered + assert "QR" in rendered + assert "Load" in rendered + assert "Delete" in rendered + assert "Export" not in rendered diff --git a/src/tests/test_tui_v3_smoke.py b/src/tests/test_tui_v3_smoke.py index a897e66c..344a5700 100644 --- a/src/tests/test_tui_v3_smoke.py +++ b/src/tests/test_tui_v3_smoke.py @@ -5,10 +5,11 @@ from types import SimpleNamespace import pytest pytest.importorskip("textual") -from textual.widgets import DataTable, Input +from textual.widgets import Button, DataTable, Input, Static from seedpass.tui_v3 import launch_tui3 from seedpass.tui_v3.app import ( + BackupParentSeedScreen, CreateProfileScreen, RecoverProfileScreen, SeedPassTuiV3, @@ -16,6 +17,11 @@ from seedpass.tui_v3.app import ( SeedWordsReviewScreen, StartupScreen, ) +from seedpass.tui_v3.screens.profile import ProfileManagementScreen +from seedpass.tui_v3.screens.atlas import AtlasWayfinderScreen +from seedpass.tui_v3.screens.pubkey import NostrPubkeyScreen +from seedpass.tui_v3.screens.relays import RelaysScreen +from seedpass.tui_v3.screens.security import ChangePasswordScreen class V3EntryService: @@ -70,14 +76,29 @@ class V3EntryService: def restore_entry(self, entry_id: int) -> None: self._entries[int(entry_id)]["archived"] = False + def delete_entry(self, entry_id: int) -> None: + self._entries.pop(int(entry_id), None) + def copy_to_clipboard(self, value: str) -> bool: self.copied.append(value) return True class V3ProfileService: + def __init__(self) -> None: + self.profiles = ["EFBE51E70ED1B53A", "AABBCCDDEEFF0011"] + self.switch_calls: list[tuple[str, str | None]] = [] + self.remove_calls: list[str] = [] + def list_profiles(self) -> list[str]: - return ["EFBE51E70ED1B53A"] + return list(self.profiles) + + def switch_profile(self, req) -> None: + self.switch_calls.append((req.fingerprint, req.password)) + + def remove_profile(self, req) -> None: + self.remove_calls.append(req.fingerprint) + self.profiles = [fp for fp in self.profiles if fp != req.fingerprint] class V3VaultService: @@ -85,6 +106,8 @@ class V3VaultService: self.locked = False self.last_unlock_password: str | None = None self._manager = SimpleNamespace(parent_seed="PARENT_SEED") + self.changed_passwords: list[tuple[str, str]] = [] + self.backup_requests: list[tuple[str | None, str | None]] = [] def lock(self) -> None: self.locked = True @@ -100,12 +123,151 @@ class V3VaultService: def stats(self) -> dict[str, int]: return {"total_entries": 3} + def change_password(self, request) -> None: + self.changed_passwords.append((request.old_password, request.new_password)) -def _build_app() -> tuple[object, V3EntryService, V3VaultService]: + def backup_parent_seed(self, request) -> None: + path = None if request.path is None else str(request.path) + self.backup_requests.append((path, request.password)) + + +class V3NostrService: + def __init__(self) -> None: + self.reset_calls = 0 + self.fresh_calls = 0 + self.relays = ["wss://relay.one", "wss://relay.two"] + self.removed_relays: list[int] = [] + + def get_pubkey(self) -> str: + return "npub1seedpassprofile" + + def list_relays(self) -> list[str]: + return list(self.relays) + + def add_relay(self, url: str) -> None: + self.relays.append(url) + + def remove_relay(self, idx: int) -> None: + self.removed_relays.append(idx) + self.relays.pop(idx) + + def reset_sync_state(self) -> int: + self.reset_calls += 1 + return 0 + + def start_fresh_namespace(self) -> int: + self.fresh_calls += 1 + return 1 + + +class V3AtlasService: + def wayfinder(self) -> dict: + return { + "scope_path": "seed/EFBE51E70ED1B53A", + "stats": {"event_count": 4, "checkpoint_count": 1, "writer_count": 1}, + "counts_by_kind": { + "data": {"counts": {"password": 1, "nostr": 1, "managed_account": 1}} + }, + "children_of": { + "data": { + "children": [ + {"entry_id": "1", "label": "Email", "kind": "password"}, + { + "entry_id": "2", + "label": "Managed Ops", + "kind": "managed_account", + }, + ] + } + }, + "recent_activity": { + "data": { + "items": [ + { + "event_type": "entry_created", + "subject_id": "1", + "subject_kind": "password", + "summary": "Created password entry", + } + ] + } + }, + } + + +class V3SearchService: + def __init__(self, entry: V3EntryService) -> None: + self.entry = entry + self.calls: list[dict] = [] + + def search( + self, + query: str, + *, + kinds: list[str] | None = None, + include_archived: bool = False, + archived_only: bool = False, + mode: str | None = None, + sort: str = "relevance", + limit: int = 200, + tags: list[str] | None = None, + linked_to: int | None = None, + ) -> list[dict]: + self.calls.append( + { + "query": query, + "kinds": kinds, + "include_archived": include_archived, + "archived_only": archived_only, + "mode": mode, + "sort": sort, + "limit": limit, + "tags": tags, + "linked_to": linked_to, + } + ) + rows = self.entry.search_entries( + query, + kinds=kinds, + include_archived=include_archived, + archived_only=archived_only, + ) + out: list[dict] = [] + for row in rows: + entry_id, label, user, url, archived, kind = row + out.append( + { + "entry_id": entry_id, + "label": label, + "kind": kind.value, + "scope_path": "seed/EFBE51E70ED1B53A", + "archived": archived, + "score": 1.0, + "score_breakdown": { + "lexical": 1.0, + "semantic": 0.0, + "structural": 0.0, + "recency": 0.0, + }, + "match_reasons": ["label_match"] if query else [], + "excerpt": "", + "linked_hits": [], + "tags": [], + "modified_ts": 0, + "meta": user or url or "", + } + ) + return out + + +def _build_app() -> tuple[object, V3EntryService, V3VaultService, V3SearchService]: holder: dict[str, object] = {} entry = V3EntryService() vault = V3VaultService() profile = V3ProfileService() + nostr = V3NostrService() + atlas = V3AtlasService() + search = V3SearchService(entry) def _hook(app): holder["app"] = app @@ -115,17 +277,20 @@ def _build_app() -> tuple[object, V3EntryService, V3VaultService]: entry_service_factory=lambda: entry, profile_service_factory=lambda: profile, vault_service_factory=lambda: vault, + nostr_service_factory=lambda: nostr, + atlas_service_factory=lambda: atlas, + search_service_factory=lambda: search, app_hook=_hook, ) assert launched is True app = holder.get("app") assert app is not None - return app, entry, vault + return app, entry, vault, search @pytest.mark.anyio async def test_tui3_palette_lock_unlock_and_session_status() -> None: - app, _entry, vault = _build_app() + app, _entry, vault, _search = _build_app() async with app.run_test() as pilot: await pilot.pause() assert app.session_locked is False @@ -144,7 +309,7 @@ async def test_tui3_palette_lock_unlock_and_session_status() -> None: @pytest.mark.anyio async def test_tui3_sidebar_child_nodes_open_entries() -> None: - app, _entry, _vault = _build_app() + app, _entry, _vault, _search = _build_app() async with app.run_test() as pilot: await pilot.pause() tree = app.screen.query_one("#profile-tree") @@ -163,7 +328,7 @@ async def test_tui3_sidebar_child_nodes_open_entries() -> None: @pytest.mark.anyio async def test_tui3_grid_focus_refresh_enables_selection_after_runtime_add() -> None: - app, entry, _vault = _build_app() + app, entry, _vault, _search = _build_app() async with app.run_test() as pilot: await pilot.pause() entry._entries[4] = { @@ -180,6 +345,237 @@ async def test_tui3_grid_focus_refresh_enables_selection_after_runtime_add() -> assert table.row_count >= 4 +@pytest.mark.anyio +async def test_tui3_grid_uses_search_service_for_query_and_mode() -> None: + app, _entry, _vault, search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + table = app.screen.query_one("#entry-data-table", DataTable) + app.search_mode = "hybrid" + table._refresh_data("Email") + await pilot.pause() + assert search.calls + assert search.calls[-1]["query"] == "Email" + assert search.calls[-1]["mode"] == "hybrid" + assert table.row_count >= 1 + + +@pytest.mark.anyio +async def test_tui3_inspector_stays_collapsed_until_selection_and_can_close() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + inspector = app.screen.query_one("#inspector-pane") + close_button = app.screen.query_one("#inspector-close", Button) + assert close_button.label.plain == "Close" + assert inspector.has_class("hidden") + + app.selected_entry_id = 1 + await pilot.pause() + assert not inspector.has_class("hidden") + + app.action_close_inspector() + await pilot.pause() + assert app.selected_entry_id is None + assert inspector.has_class("hidden") + + +@pytest.mark.anyio +async def test_tui3_palette_can_open_atlas_wayfinder() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.processor.execute("atlas") + await pilot.pause() + assert isinstance(app.screen, AtlasWayfinderScreen) + content = str(app.screen.query_one("#atlas-wayfinder-content", Static).render()) + assert "Counts By Kind" in str(content) + assert "Managed Ops" in str(content) + + +@pytest.mark.anyio +async def test_tui3_wayfinder_screen_can_open_entry_and_apply_filter() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.action_open_atlas_wayfinder() + await pilot.pause() + assert isinstance(app.screen, AtlasWayfinderScreen) + + app.screen.on_button_pressed( + SimpleNamespace(button=SimpleNamespace(id="atlas-open-entry-1")) + ) + await pilot.pause() + assert app.selected_entry_id == 1 + assert not isinstance(app.screen, AtlasWayfinderScreen) + + app.action_open_atlas_wayfinder() + await pilot.pause() + app.screen.on_button_pressed( + SimpleNamespace(button=SimpleNamespace(id="atlas-filter-docs")) + ) + await pilot.pause() + assert app.filter_kind == "docs" + assert not isinstance(app.screen, AtlasWayfinderScreen) + + +@pytest.mark.anyio +async def test_tui3_main_workspace_shows_atlas_strip() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + content = str(app.screen.query_one("#atlas-strip", Static).render()) + assert "Wayfinder" in content + assert "password:1" in content + assert "entry_created #1" in content + assert "open_atlas_wayfinder" in str( + app.screen.query_one("#action-bar", Static).render() + ) + + +@pytest.mark.anyio +async def test_tui3_delete_selected_requires_confirm_and_clears_selection() -> None: + app, entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + app.selected_entry_id = 1 + await pilot.pause() + + app.action_delete_selected() + await pilot.pause() + assert 1 in entry._entries + assert app.selected_entry_id == 1 + + app.action_delete_selected() + await pilot.pause() + assert 1 not in entry._entries + assert app.selected_entry_id is None + + +@pytest.mark.anyio +async def test_tui3_nostr_maintenance_commands_open_pubkey_and_call_services() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + nostr = app.services["nostr"] + + app.processor.execute("npub") + await pilot.pause() + assert isinstance(app.screen, NostrPubkeyScreen) + content = app.screen.query_one("#pubkey-content", Static).render() + assert "npub1seedpassprofile" in str(content) + + app.pop_screen() + await pilot.pause() + app.processor.execute("nostr-reset-sync-state") + app.processor.execute("nostr-fresh-namespace") + await pilot.pause() + assert nostr.reset_calls == 1 + assert nostr.fresh_calls == 1 + + +@pytest.mark.anyio +async def test_tui3_change_password_and_seed_backup_flows_use_vault_service() -> None: + app, _entry, vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + + app.processor.execute("change-password") + await pilot.pause() + assert isinstance(app.screen, ChangePasswordScreen) + assert ( + "current password is required" + in str( + app.screen.query_one("#change-password-intro", Static).render() + ).lower() + ) + app.screen.query_one("#change-password-old", Input).value = "old-pass" + app.screen.query_one("#change-password-new", Input).value = "new-pass" + app.screen.query_one("#change-password-confirm", Input).value = "new-pass" + await pilot.press("ctrl+s") + await pilot.pause() + assert vault.changed_passwords == [("old-pass", "new-pass")] + + app.processor.execute("backup-parent-seed /tmp/seed-backup.enc pw123") + await pilot.pause() + assert vault.backup_requests[-1] == ("/tmp/seed-backup.enc", "pw123") + + app.processor.execute("backup-parent-seed") + await pilot.pause() + assert isinstance(app.screen, BackupParentSeedScreen) + app.screen.query_one("#backup-seed-path", Input).value = "/tmp/seed-second.enc" + app.screen.query_one("#backup-seed-password", Input).value = "pw456" + await pilot.press("ctrl+s") + await pilot.pause() + assert vault.backup_requests[-1] == ("/tmp/seed-second.enc", "pw456") + + +@pytest.mark.anyio +async def test_tui3_profile_management_switch_and_remove_flow_use_profile_service() -> ( + None +): + app, _entry, _vault, _search = _build_app() + app._list_boot_profiles = lambda: [ + {"fingerprint": "EFBE51E70ED1B53A", "label": "Primary Ops Seed"}, + {"fingerprint": "AABBCCDDEEFF0011", "label": "Recovery Seed"}, + ] + async with app.run_test() as pilot: + await pilot.pause() + profile = app.services["profile"] + + app.processor.execute("profiles") + await pilot.pause() + assert isinstance(app.screen, ProfileManagementScreen) + intro = str(app.screen.query_one("#profile-intro", Static).render()).lower() + assert "remove a non-active profile" in intro + listing = str(app.screen.query_one("#profile-list", Static).render()) + assert "Primary Ops Seed" in listing + assert "Recovery Seed" in listing + app.screen.query_one("#profile-choice", Input).value = "2" + app.screen.query_one("#profile-password", Input).value = "hunter2" + app.screen.on_button_pressed( + SimpleNamespace(button=SimpleNamespace(id="profile-switch")) + ) + await pilot.pause() + assert profile.switch_calls == [("AABBCCDDEEFF0011", "hunter2")] + assert app.active_fingerprint == "AABBCCDDEEFF0011" + + app.screen.query_one("#profile-choice", Input).value = "1" + app.screen.on_button_pressed( + SimpleNamespace(button=SimpleNamespace(id="profile-remove")) + ) + await pilot.pause() + assert profile.remove_calls == [] + status = str(app.screen.query_one("#profile-status", Static).render()) + assert "Press Remove again" in status + app.screen.on_button_pressed( + SimpleNamespace(button=SimpleNamespace(id="profile-remove")) + ) + await pilot.pause() + assert profile.remove_calls == ["EFBE51E70ED1B53A"] + + +@pytest.mark.anyio +async def test_tui3_relay_delete_requires_second_confirm() -> None: + app, _entry, _vault, _search = _build_app() + async with app.run_test() as pilot: + await pilot.pause() + nostr = app.services["nostr"] + + app.processor.execute("relay-list") + await pilot.pause() + assert isinstance(app.screen, RelaysScreen) + app.screen.action_delete_relay() + await pilot.pause() + assert nostr.removed_relays == [] + status = str(app.screen.query_one("#relays-status", Static).render()) + assert "Press Delete again" in status + + app.screen.action_delete_relay() + await pilot.pause() + assert nostr.removed_relays == [0] + + @pytest.mark.anyio async def test_tui3_starts_with_unlock_screen_when_services_are_not_preloaded() -> None: app = SeedPassTuiV3() @@ -231,6 +627,9 @@ async def test_tui3_create_profile_screen_uses_in_app_bootstrap_helpers() -> Non app.push_screen(CreateProfileScreen()) await pilot.pause() screen = app.screen + assert "Import an existing seed" in str( + screen.query_one("#create-status", Static).render() + ) screen.query_one("#create-mode", Input).value = "existing" screen.query_one("#create-seed", Input).value = ( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"