diff --git a/docs/tui_v2_ui_refresh_plan.md b/docs/tui_v2_ui_refresh_plan.md index c428a269..f6c36f9c 100644 --- a/docs/tui_v2_ui_refresh_plan.md +++ b/docs/tui_v2_ui_refresh_plan.md @@ -362,3 +362,57 @@ Notes: 1. This is a layout/chrome scaffold only; no table/board template conversion yet. 2. Next slice should implement Phase 2 grid modernization without regressing interaction tests. + +### 2026-03-02 - Phase 2 grid modernization (initial pass) landed + +Implemented in `src/seedpass/tui_v2/app.py`: + +1. Upgraded center section heading to a multi-line table scaffold: +- column header row (`Idx`, `Entry`, `Title`, `Kind`, `Meta`, `Arch`) +- sort-indicator placeholder row +- live page/row counts + +2. Updated entry list row formatter to aligned, dense table-style rows: +- fixed-width columns for index/entry/title/kind/meta/archive. +- meta column now uses URL or username preview when present. + +3. Kept behavior and command flows unchanged (visual/data-presentational slice only). + +Validation: + +1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` +2. Result: `28 passed`. + +Next recommended slice: + +1. Phase 3 inspector board templates for core kinds (`password`, `document/note`, `seed`, `totp`). + +### 2026-03-02 - Phase 3 inspector board templates (core kinds) landed + +Implemented in `src/seedpass/tui_v2/app.py`: + +1. Replaced raw JSON detail rendering for core kinds with structured board layouts: +- `password` / `stored_password` +- `document` / `note` +- `seed` / `managed_account` +- `totp` + +2. Added reusable board helpers: +- entry kind normalization +- common board header (label/kind/id/modified/archived) +- tags and notes normalization + +3. Added secure-by-default board copy: +- sensitive values remain hidden in board body +- reveal/QR actions remain the explicit path for sensitive disclosure + +4. Kept fallback generic JSON board for non-templated kinds to preserve coverage while templates are expanded. + +Validation: + +1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` +2. Result: `28 passed`. + +Next recommended slice: + +1. Phase 4 inspector board templates for advanced kinds (`ssh`, `pgp`, `nostr`, `key_value`) and stronger action-strip context hints. diff --git a/memory-update.md b/memory-update.md index 9a805d2e..04f539f7 100644 --- a/memory-update.md +++ b/memory-update.md @@ -918,3 +918,39 @@ - Result: `28 passed`. - Refresh plan updated with explicit phase progress and next step: - `docs/tui_v2_ui_refresh_plan.md` (Phase 2 grid modernization next). + +## 2026-03-02 UI refresh implementation slice: Phase 2 grid modernization (initial pass) +- Implemented denser table-style grid presentation in `src/seedpass/tui_v2/app.py`: + - `#grid-heading` expanded to show: + - column header scaffold + - sort-indicator placeholder row + - live page/row counters + - `_render_entry_label(...)` now outputs aligned fixed-width columns: + - `Idx`, `Entry`, `Title`, `Kind`, `Meta`, `Arch` + - row `Meta` column uses URL/username preview when present. +- No command or service-flow behavior changes; visual presentation only. +- Validation: + - `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` + - Result: `28 passed`. +- Refresh plan updated: + - `docs/tui_v2_ui_refresh_plan.md` now records Phase 2 progress and sets Phase 3 (inspector board templates) as next slice. + +## 2026-03-02 UI refresh implementation slice: Phase 3 inspector board templates (core kinds) +- Implemented structured inspector-board rendering in `src/seedpass/tui_v2/app.py` for core kinds: + - `password` / `stored_password` + - `document` / `note` + - `seed` / `managed_account` + - `totp` +- Added reusable board helper methods: + - `_entry_kind(...)` + - `_entry_tags_text(...)` + - `_entry_notes_text(...)` + - `_entry_board_header(...)` + - `_entry_board_text(...)` +- Entry details now default to board format for covered kinds and keep generic JSON fallback for remaining kinds. +- Sensitive fields remain hidden in board body; existing reveal/QR flows are still the explicit disclosure path. +- Validation: + - `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` + - Result: `28 passed`. +- Refresh plan updated: + - `docs/tui_v2_ui_refresh_plan.md` now records Phase 3 progress and sets Phase 4 advanced-kind board templates as next slice. diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index 9455f9d6..0b4f4708 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -70,7 +70,8 @@ class ConfigManager: logger.info("Config file not found; returning defaults") return { "relays": list(DEFAULT_NOSTR_RELAYS), - "offline_mode": True, + "offline_mode": False, + "online_mode_notice_seen": False, "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, @@ -101,7 +102,8 @@ class ConfigManager: raise ValueError("Config data must be a dictionary") # Ensure defaults for missing keys data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS)) - data.setdefault("offline_mode", True) + data.setdefault("offline_mode", False) + data.setdefault("online_mode_notice_seen", False) data.setdefault("pin_hash", "") data.setdefault("password_hash", "") data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT) @@ -277,7 +279,7 @@ class ConfigManager: def get_offline_mode(self) -> bool: """Retrieve the offline mode setting.""" config = self.load_config(require_pin=False) - return bool(config.get("offline_mode", True)) + return bool(config.get("offline_mode", False)) def set_clipboard_clear_delay(self, delay: int) -> None: """Persist clipboard clear timeout in seconds.""" diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 59582c10..1114156b 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -292,7 +292,7 @@ class PasswordManager: self.secret_mode_enabled: bool = False self.deterministic_totp: bool = False self.clipboard_clear_delay: int = 45 - self.offline_mode: bool = True + self.offline_mode: bool = False self.profile_stack: list[tuple[str, Path, str]] = [] self.last_unlock_duration: float | None = None self.verbose_timing: bool = False @@ -1771,13 +1771,28 @@ class PasswordManager: self.manifest_id = None self.delta_since = 0 self.nostr_account_idx = 0 - self.offline_mode = bool(config.get("offline_mode", True)) + self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get("inactivity_timeout", INACTIVITY_TIMEOUT) self.secret_mode_enabled = bool(config.get("secret_mode_enabled", False)) self.clipboard_clear_delay = int(config.get("clipboard_clear_delay", 45)) self.verbose_timing = bool(config.get("verbose_timing", False)) if not self.offline_mode: print("Connecting to relays...") + if not self.offline_mode and not bool( + config.get("online_mode_notice_seen", False) + ): + self.notify( + "Profile is ONLINE by default. Switch to offline mode in Settings if needed.", + level="INFO", + ) + config["online_mode_notice_seen"] = True + try: + self.config_manager.save_config(config) + except Exception as exc: + logger.warning( + "Failed to persist online mode onboarding notice state: %s", + exc, + ) self.nostr_client = NostrClient( encryption_manager=self.encryption_manager, fingerprint=self.current_fingerprint, diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 7535bf9e..3654d671 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -198,13 +198,14 @@ def launch_tui2( #center { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; } #right { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; } #grid-heading { - height: 1; + height: 3; margin-bottom: 1; border: solid #274533; background: #0b0f13; color: #daf2e5; text-style: bold; padding: 0 1; + overflow: hidden; } #inspector-heading { height: 1; @@ -359,7 +360,7 @@ def launch_tui2( yield Static("", id="filters") yield Static("", id="activity") with Vertical(id="center"): - yield Static("Entry Grid", id="grid-heading") + yield Static("", id="grid-heading") yield Input( placeholder="Search entries (Enter to apply)", id="search" ) @@ -561,11 +562,23 @@ def launch_tui2( or self._selected_entry.get("type") or "" ).strip() - context = ( - f"V:Reveal G:QR A:Archive E:Edit ({kind})" - if kind - else "V:Reveal G:QR A:Archive E:Edit" - ) + kind_l = kind.lower() + if kind_l in {"password", "stored_password", "seed", "managed_account"}: + context = "V:Reveal G:QR A:Archive E:Edit" + elif kind_l == "totp": + context = "6:2FABoard V:Reveal G:QR A:Archive" + elif kind_l in {"ssh", "pgp"}: + context = "V:Reveal A:Archive DocExport Palette: field/tag/note" + elif kind_l == "nostr": + context = "V:Reveal G:QR(public/private) A:Archive" + elif kind_l in {"document", "note"}: + context = "E:EditDoc Ctrl+S:Save A:Archive DocExport" + elif kind_l == "key_value": + context = "SetField/ClearField A:Archive Note/Tag via palette" + else: + context = "V:Reveal G:QR A:Archive E:Edit" + if kind: + context = f"{context} ({kind_l})" text = ( "/:Search J:Jump F:Filter H:ArchiveScope L:LinkFilter " "Ctrl+P:Palette X:Retry " @@ -573,6 +586,19 @@ def launch_tui2( ) self.query_one("#action-strip", Static).update(text) + def _update_grid_heading(self) -> None: + table_cols = ( + "Idx Entry Title Kind Meta Arch" + ) + sort_line = ( + "Sort: index ▲▼ | entry ▲▼ | title ▲▼ | kind ▲▼ " + f"Page {self._result_page + 1}/{self._total_pages()} " + f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)}" + ) + self.query_one("#grid-heading", Static).update( + f"Entry Grid\n{table_cols}\n{sort_line}" + ) + def _quickstart_text(self) -> str: return self._onboarding_text() @@ -812,12 +838,29 @@ def launch_tui2( self.query_one("#filters", Static).update(text) self._update_top_ribbon() self._update_action_strip() + self._update_grid_heading() def _render_entry_label( - self, idx: int, label: str, etype: str, archived: bool + self, + idx: int, + label: str, + etype: str, + archived: bool, + username: str | None = None, + url: str | None = None, ) -> str: - arch = " [archived]" if archived else "" - return f"{idx:>4} {etype:<15} {label}{arch}" + entry_num = idx + title = (label or "").replace("\n", " ").strip()[:25] + kind = (etype or "unknown")[:14] + if url: + meta = str(url).replace("\n", " ").strip() + elif username: + meta = str(username).replace("\n", " ").strip() + else: + meta = "-" + meta = meta[:20] + arch = "YES" if archived else "NO" + return f"{idx:<8} {entry_num:<6} {title:<25} {kind:<14} {meta:<20} {arch:<4}" def _total_pages(self) -> int: _page, _start, _end, total_pages = pagination_window( @@ -948,11 +991,18 @@ def launch_tui2( total, self.RESULT_PAGE_SIZE, self._result_page ) page_rows = self._all_results[start:end] - for idx, label, _username, _url, archived, etype in page_rows: + for idx, label, username, url, archived, etype in page_rows: kind = getattr(etype, "value", str(etype)) item = EntryListItem( idx, - self._render_entry_label(idx, label, kind, bool(archived)), + self._render_entry_label( + idx, + label, + kind, + bool(archived), + username=username, + url=url, + ), ) self._entry_ids_in_view.append(int(idx)) list_view.append(item) @@ -970,14 +1020,207 @@ def launch_tui2( payload = truncate_entry_for_display( entry, self.DETAIL_CONTENT_PREVIEW_LIMIT ) + return self._entry_board_text(payload) + + @staticmethod + def _entry_kind(entry: dict[str, Any]) -> str: + return str(entry.get("kind") or entry.get("type") or "unknown").strip().lower() + + @staticmethod + def _entry_tags_text(entry: dict[str, Any]) -> str: + tags = entry.get("tags") + if isinstance(tags, list) and tags: + return ", ".join(str(tag).strip() for tag in tags if str(tag).strip()) + if isinstance(tags, str) and tags.strip(): + return tags.strip() + return "(none)" + + @staticmethod + def _entry_notes_text(entry: dict[str, Any]) -> str: + notes = entry.get("notes") + if isinstance(notes, str) and notes.strip(): + return notes.strip() + return "(none)" + + def _entry_board_header(self, entry: dict[str, Any]) -> list[str]: entry_id = ( self._selected_entry_id if self._selected_entry_id is not None - else payload.get("id", "?") + else entry.get("id", "?") + ) + label = str(entry.get("label") or "(untitled)") + kind = self._entry_kind(entry) or "unknown" + modified = str( + entry.get("modified_at") + or entry.get("updated_at") + or entry.get("date_modified") + or "(unknown)" + ) + archived = "Yes" if bool(entry.get("archived", False)) else "No" + return [ + f"{label}", + ( + f"Kind: {kind} | Entry: #{entry_id} | Modified: {modified} | " + f"Archived: {archived}" + ), + "-" * 64, + ] + + def _entry_board_text(self, entry: dict[str, Any]) -> str: + kind = self._entry_kind(entry) + header = self._entry_board_header(entry) + tags_text = self._entry_tags_text(entry) + notes_text = self._entry_notes_text(entry) + + if kind in {"password", "stored_password"}: + username = str(entry.get("username") or "(none)") + url = str(entry.get("url") or "(none)") + length = str(entry.get("length") or "(auto)") + lines = header + [ + "Credentials", + f"- Password: (hidden) use 'v' to reveal", + f"- Username: {username}", + f"- URL: {url}", + f"- Length: {length}", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind in {"document", "note"}: + file_type = str(entry.get("file_type") or "txt") + content = str(entry.get("content") or "") + lines = header + [ + "Document", + f"- File type: {file_type}", + f"- Content length: {len(content)} chars", + "", + "Content", + content if content.strip() else "(empty)", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind in {"seed", "managed_account"}: + words = str(entry.get("words") or "(unknown)") + index = str(entry.get("index") or "(auto)") + lines = header + [ + "Seed Material", + "- Seed phrase: (hidden) use 'v confirm' to reveal", + f"- Word count: {words}", + f"- Index: {index}", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind == "totp": + period = str(entry.get("period") or 30) + digits = str(entry.get("digits") or 6) + lines = header + [ + "TOTP", + "- Current code: use '6' (2FA board) or 'v' to inspect details", + f"- Period: {period}s", + f"- Digits: {digits}", + "- Secret: (hidden)", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind == "ssh": + lines = header + [ + "SSH Keys", + "- Public key: available", + "- Private key: (hidden) use 'v confirm' to reveal", + "- Export: use palette + document export workflow", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind == "pgp": + fingerprint_text = str(entry.get("fingerprint") or "(unknown)") + lines = header + [ + "PGP Keys", + f"- Fingerprint: {fingerprint_text}", + "- Public key: available", + "- Private key: (hidden) use 'v confirm' to reveal", + "- Export: use palette + document export workflow", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind == "nostr": + npub = str(entry.get("npub") or "(unavailable)") + lines = header + [ + "Nostr Keys", + f"- npub: {npub}", + "- nsec: (hidden) use 'v confirm' to reveal", + "- QR: use 'g' for public, or 'qr private confirm' for nsec", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + if kind == "key_value": + key_name = str(entry.get("key") or "(none)") + value = str(entry.get("value") or "") + value_preview = value[:80] + ("..." if len(value) > 80 else "") + lines = header + [ + "Key/Value", + f"- Key: {key_name}", + f"- Value: {value_preview if value_preview else '(empty)'}", + "", + "Tags", + f"- {tags_text}", + "", + "Notes", + notes_text, + ] + return "\n".join(lines) + + entry_id = ( + self._selected_entry_id + if self._selected_entry_id is not None + else entry.get("id", "?") + ) + fallback_kind = entry.get("kind") or entry.get("type") or "unknown" + title = f"Entry #{entry_id} [{fallback_kind}]" + return ( + f"{title}\n{'-' * len(title)}\n" + f"{json.dumps(entry, indent=2, sort_keys=True)}" ) - kind = payload.get("kind") or payload.get("type") or "unknown" - header = f"Entry #{entry_id} [{kind}]" - return f"{header}\n{'-' * len(header)}\n{json.dumps(payload, indent=2, sort_keys=True)}" def _load_entries(self, query: str = "", *, reset_page: bool = False) -> None: self._last_query = query diff --git a/src/tests/test_offline_mode_default_enabled.py b/src/tests/test_offline_mode_default_enabled.py index a3a667ea..6b0f5fbb 100644 --- a/src/tests/test_offline_mode_default_enabled.py +++ b/src/tests/test_offline_mode_default_enabled.py @@ -5,10 +5,10 @@ from seedpass.core.config_manager import ConfigManager from helpers import create_vault, TEST_SEED, TEST_PASSWORD -def test_offline_mode_default_enabled(): +def test_offline_mode_default_disabled(): with TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) cfg_mgr = ConfigManager(vault, tmp_path) config = cfg_mgr.load_config(require_pin=False) - assert config["offline_mode"] is True + assert config["offline_mode"] is False diff --git a/src/tests/test_online_mode_first_login_notice.py b/src/tests/test_online_mode_first_login_notice.py new file mode 100644 index 00000000..dacd9888 --- /dev/null +++ b/src/tests/test_online_mode_first_login_notice.py @@ -0,0 +1,74 @@ +import queue + +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from seedpass.core.manager import PasswordManager + + +class _DummyConfigManager: + def __init__(self, cfg: dict) -> None: + self.cfg = dict(cfg) + self.saved: list[dict] = [] + + def load_config(self) -> dict: + return dict(self.cfg) + + def save_config(self, cfg: dict) -> None: + snapshot = dict(cfg) + self.cfg = snapshot + self.saved.append(snapshot) + + +class _DummyNostrClient: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + self.current_manifest_id = None + self.current_manifest = None + + +def _make_pm(cfg: dict) -> PasswordManager: + pm = PasswordManager.__new__(PasswordManager) + pm.config_manager = _DummyConfigManager(cfg) + pm.state_manager = None + pm.encryption_manager = object() + pm.current_fingerprint = "fp-test" + pm.parent_seed = "seed" + pm.KEY_INDEX = b"index-key" + pm.notifications = queue.Queue() + pm._current_notification = None + pm._notification_expiry = 0.0 + return pm + + +def test_first_online_login_emits_notice_and_persists_seen(monkeypatch): + pm = _make_pm({"offline_mode": False, "relays": ["wss://relay.example"]}) + monkeypatch.setattr("seedpass.core.manager.NostrClient", _DummyNostrClient) + monkeypatch.setattr("builtins.print", lambda *a, **k: None) + + pm._initialize_nostr_client() + + note = pm.get_current_notification() + assert note is not None + assert "ONLINE by default" in note.message + assert pm.config_manager.cfg.get("online_mode_notice_seen") is True + assert len(pm.config_manager.saved) == 1 + + +def test_online_notice_not_repeated_when_seen(monkeypatch): + pm = _make_pm( + { + "offline_mode": False, + "online_mode_notice_seen": True, + "relays": ["wss://relay.example"], + } + ) + monkeypatch.setattr("seedpass.core.manager.NostrClient", _DummyNostrClient) + monkeypatch.setattr("builtins.print", lambda *a, **k: None) + + pm._initialize_nostr_client() + + assert pm.get_current_notification() is None + assert pm.config_manager.saved == []