tui2: add export-field actions and density/tree ui polish

This commit is contained in:
thePR0M3TH3AN committed 2026-03-02 11:04:20 -05:00
1 parent 93cf78b2c8
commit a67ee2d9da
5 files changed
+519 -7

No files matched your search

+83
View File
@@ -416,3 +416,86 @@ Validation:
Next recommended slice:
1. Phase 4 inspector board templates for advanced kinds (`ssh`, `pgp`, `nostr`, `key_value`) and stronger action-strip context hints.
### 2026-03-02 - Phase 4 advanced actions (copy workflow) landed
Implemented in `src/seedpass/tui_v2/app.py`:
1. Added palette command:
- `copy <field> (optional: confirm)`
2. Added kind-aware copy field resolution for:
- `password/stored_password`: `password`, `username`, `url`
- `seed/managed_account`: `seed|phrase`
- `totp`: `code`, `secret`
- `ssh`: `public`, `private`
- `pgp`: `private`, `fingerprint`
- `nostr`: `npub`, `nsec`
- `key_value`: `key`, `value`
- `document/note`: `content|text`
3. Sensitive copy targets now require explicit confirmation (`confirm`) before clipboard action.
4. Updated command reference/help coverage to include `copy`.
Validation:
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
2. Result: `29 passed`.
Next recommended slice:
1. Extend Phase 4 with explicit file export actions for advanced key kinds (SSH/PGP/Nostr material export paths with confirmation and tests).
### 2026-03-02 - Phase 4 advanced actions (export workflow) landed
Implemented in `src/seedpass/tui_v2/app.py`:
1. Added palette command:
- `export-field <field> <path> (optional: confirm)`
2. Reused kind-aware field resolution from copy workflow and added file export writing:
- supports same kind/field matrix as `copy`.
- writes UTF-8 payloads to absolute or relative paths (relative paths resolve from cwd).
3. Sensitive export targets now require explicit confirmation (`confirm`) before writing.
4. Updated command discoverability/help:
- palette placeholder
- help summary
- full palette reference
Validation:
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
2. Result: `29 passed`.
Next recommended slice:
1. Continue Phase 5 polish: left tree navigation model + compact/expanded density toggle and final visual alignment pass to mockups.
### 2026-03-02 - Phase 5 polish (density + profile tree scaffold) landed
Implemented in `src/seedpass/tui_v2/app.py`:
1. Added density controls:
- keybind: `d` (toggle compact/comfortable)
- palette: `density <compact|comfortable>`
- top ribbon, left panel, and grid heading now show active density mode
2. Added left-panel profile tree scaffold:
- new `Profiles` section in left panel
- shows known profiles from profile service when available
- marks active fingerprint line
3. Expanded command discoverability:
- palette placeholder/help/reference include `density` command.
Validation:
1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
2. Result: `29 passed`.
Next recommended slice:
1. Continue Phase 5 by adding explicit keyboard navigation inside the left profile tree and wiring profile-selection actions to update active context.
+73
View File
@@ -954,3 +954,76 @@
- 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.
## 2026-03-02 UI refresh implementation slice: Phase 4 advanced-kind actions (copy workflow)
- Implemented advanced-kind inspector action command in `src/seedpass/tui_v2/app.py`:
- `copy <field> (optional: confirm)`
- Added kind-specific copy field resolution:
- password/stored_password: `password`, `username`, `url`
- seed/managed_account: `seed|phrase`
- totp: `code`, `secret`
- ssh: `public`, `private`
- pgp: `private`, `fingerprint`
- nostr: `npub`, `nsec`
- key_value: `key`, `value`
- document/note: `content|text`
- Sensitive field copies now require explicit `confirm`.
- Updated palette help/reference strings to include copy action.
- Tests added/updated:
- `src/tests/test_tui_v2_textual_interactions.py`
- new test: `test_tui2_textual_copy_command_for_core_and_advanced_fields`
- validation assertions for copy usage errors
- `src/tests/test_tui_v2_action_matrix.py`
- matrix now exercises copy usage and success paths
- Validation:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- Result: `29 passed`.
- Refresh plan updated:
- `docs/tui_v2_ui_refresh_plan.md` marks this Phase 4 action slice and sets advanced-key export actions as next recommendation.
## 2026-03-02 UI refresh implementation slice: Phase 4 advanced-kind actions (export workflow)
- Implemented export action command in `src/seedpass/tui_v2/app.py`:
- `export-field <field> <path> (optional: confirm)`
- Export workflow details:
- uses the same field/kind resolver as `copy`
- writes UTF-8 payloads to requested file path
- supports relative paths (resolved from current working directory)
- creates parent directories when needed
- Sensitive field exports now require explicit `confirm`.
- Discoverability updates:
- command palette placeholder now includes `export-field`
- help summary and palette reference include `export-field`
- Tests added/updated:
- `src/tests/test_tui_v2_textual_interactions.py`
- extended copy test to cover `export-field` success and confirmation gating
- validation test covers usage errors
- `src/tests/test_tui_v2_action_matrix.py`
- matrix now exercises export-field usage + success paths
- Validation:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- Result: `29 passed`.
- Refresh plan updated:
- `docs/tui_v2_ui_refresh_plan.md` marks export workflow landed and points next to Phase 5 polish (tree nav + density toggle + final mockup alignment).
## 2026-03-02 UI refresh implementation slice: Phase 5 polish (density + profile tree scaffold)
- Implemented density controls in `src/seedpass/tui_v2/app.py`:
- keybind: `d` via `action_toggle_density`
- palette command: `density <compact|comfortable>`
- density state shown in top ribbon, left panel, and grid heading
- Implemented left-panel profile tree scaffold:
- new `Profiles` section in left panel
- uses profile service list when available
- marks active fingerprint entry
- Discoverability updates:
- command palette placeholder/help/reference include density command
- Tests updated:
- `src/tests/test_tui_v2_textual_interactions.py`
- pagination flow now verifies keybind/palette density success
- settings validation now covers density usage/invalid-mode errors
- `src/tests/test_tui_v2_action_matrix.py`
- matrix now exercises density usage error + valid mode switches
- Validation:
- `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py`
- Result: `29 passed`.
- Refresh plan updated:
- `docs/tui_v2_ui_refresh_plan.md` now tracks Phase 5 density/tree scaffold progress and points next to explicit tree keyboard navigation + selection actions.
+224 -7
View File
@@ -288,6 +288,7 @@ def launch_tui2(
("2", "focus_center", "Center"),
("3", "focus_right", "Right"),
("f", "cycle_filter", "Filter"),
("d", "toggle_density", "Density"),
("h", "cycle_archive_scope", "Archive View"),
("p", "prev_page", "Prev Page"),
("n", "next_page", "Next Page"),
@@ -319,6 +320,7 @@ def launch_tui2(
placeholder=(
"Command palette: help | open <id> | search <q> | "
"help-commands | onboarding | quickstart | stats | session-status | lock | unlock <password> | "
"density <compact|comfortable> | "
"filter <kind> | archive | restore | "
"archive-filter <active|all|archived> | "
"add-password <label> <length> [username] [url] | "
@@ -344,6 +346,8 @@ def launch_tui2(
"totp-export <path> | parent-seed-backup [path] [password] | "
"managed-load [entry_id] | managed-exit | session-status | lock | unlock <password> | "
"doc-export [output_path] | "
"copy <field> [confirm] | "
"export-field <field> <path> [confirm] | "
"link-add <target> [relation] [note] | "
"link-rm <target> [relation] | "
"reveal [confirm] | qr [public|private] [confirm] | "
@@ -425,6 +429,7 @@ def launch_tui2(
self._session_locked = False
self._managed_session_entry_id: int | None = None
self._last_sync_text = "(none)"
self._density_mode = "compact"
self._totp_tick = self.set_interval(1.0, self._tick_totp_board)
try:
self._service = (
@@ -550,6 +555,7 @@ def launch_tui2(
f" | Kind: {self.filter_kind}"
f" | Archive: {self.archive_scope}"
f" | Session: {lock_state}"
f" | Density: {self._density_mode}"
f" | Last Sync: {self._last_sync_text}"
)
self.query_one("#top-ribbon", Static).update(text)
@@ -593,7 +599,8 @@ def launch_tui2(
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)}"
f"Rows {len(self._entry_ids_in_view)}/{len(self._all_results)} "
f"Density: {self._density_mode}"
)
self.query_one("#grid-heading", Static).update(
f"Entry Grid\n{table_cols}\n{sort_line}"
@@ -635,12 +642,13 @@ def launch_tui2(
return (
"Palette commands: help, help-commands, open, search, filter, "
"onboarding, quickstart, stats, session-status, lock/unlock, "
"density, "
"archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, "
"link-add/link-rm/link-filter/link-next/link-prev/link-open, "
"add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, "
"npub, nostr-reset-sync-state, nostr-fresh-namespace, sync-now/sync-bg, "
"checksum-*, db-*, totp-export, parent-seed-backup, managed-load/exit, "
"reveal/qr, page-*, retry, jump."
"reveal/qr/copy/export-field, page-*, retry, jump."
)
def _palette_reference_text(self) -> str:
@@ -650,12 +658,12 @@ def launch_tui2(
"-----------------",
"",
"Core: help | help-commands | open <id> | jump <id> | search <q>",
"Core Ops: onboarding | quickstart | stats | session-status | lock | unlock <password>",
"Core Ops: onboarding | quickstart | stats | session-status | lock | unlock <password> | density <compact|comfortable>",
"Filters: filter <kind|all> | archive-filter <active|all|archived>",
"Pages: page-next | page-prev | page <n>",
"",
"Entry Actions: archive | restore | reveal [confirm] | qr [public|private] [confirm]",
"Docs: edit-doc | save-doc | cancel-edit | doc-export [output_path]",
"Docs: edit-doc | save-doc | cancel-edit | doc-export [output_path] | copy <field> [confirm] | export-field <field> <path> [confirm]",
"Graph: link-add <target> [relation] [note] | link-rm <target> [relation]",
"Graph Nav: link-filter <relation|all> | link-next | link-prev | link-open",
"",
@@ -774,6 +782,23 @@ def launch_tui2(
return f"Selected: #{self._selected_entry_id} [{kind}] {label}"
def _update_filters_panel(self) -> None:
tree_lines = ["Profiles", "--------"]
profiles: list[str] = []
if self._profile_service is not None:
lister = getattr(self._profile_service, "list_profiles", None)
if callable(lister):
try:
profiles = [str(item) for item in lister()]
except Exception:
profiles = []
current_fp = fingerprint or ""
if profiles:
for item in profiles[:8]:
marker = "*" if item == current_fp else "-"
tree_lines.append(f"{marker} {item}")
else:
tree_lines.append(f"* {current_fp or '(default)'}")
fp_line = (
f"Fingerprint: {fingerprint}"
if fingerprint
@@ -794,6 +819,7 @@ def launch_tui2(
f"Filter : {self.filter_kind}",
f"Archive: {self.archive_scope}",
f"Links : {self.link_relation_filter}",
f"Density: {self._density_mode}",
f"Session: {session_state}",
f"Managed: {managed_state}",
(
@@ -801,10 +827,13 @@ def launch_tui2(
f"Page: {self._result_page + 1}/{self._total_pages()}"
),
"",
*tree_lines,
"",
"Nav ⌘",
"- / search",
"- j jump to id",
"- f cycle kind filter",
"- d density toggle",
"- h cycle archive scope",
"- p/n prev/next page",
"- 1/2/3 focus pane",
@@ -850,7 +879,11 @@ def launch_tui2(
url: str | None = None,
) -> str:
entry_num = idx
title = (label or "").replace("\n", " ").strip()[:25]
title_limit = 25 if self._density_mode == "compact" else 34
meta_limit = 20 if self._density_mode == "compact" else 28
title_width = title_limit
meta_width = meta_limit
title = (label or "").replace("\n", " ").strip()[:title_limit]
kind = (etype or "unknown")[:14]
if url:
meta = str(url).replace("\n", " ").strip()
@@ -858,9 +891,12 @@ def launch_tui2(
meta = str(username).replace("\n", " ").strip()
else:
meta = "-"
meta = meta[:20]
meta = meta[:meta_limit]
arch = "YES" if archived else "NO"
return f"{idx:<8} {entry_num:<6} {title:<25} {kind:<14} {meta:<20} {arch:<4}"
return (
f"{idx:<8} {entry_num:<6} {title:<{title_width}} "
f"{kind:<14} {meta:<{meta_width}} {arch:<4}"
)
def _total_pages(self) -> int:
_page, _start, _end, total_pages = pagination_window(
@@ -1861,6 +1897,93 @@ def launch_tui2(
raise ValueError("Selected entry not found")
return dict(entry)
def _resolve_copy_field_value(
self, field: str
) -> tuple[str, bool, str]:
if self._service is None:
raise ValueError("Entry service unavailable")
entry = self._selected_entry_payload()
entry_id = int(self._selected_entry_id or 0)
kind = self._entry_kind(entry)
key = field.strip().lower()
if kind in {"password", "stored_password"}:
if key in {"password", "pass"}:
length = int(entry.get("length", 16))
return (
str(self._service.generate_password(length, entry_id)),
True,
"password",
)
if key == "username":
return str(entry.get("username") or ""), False, "username"
if key == "url":
return str(entry.get("url") or ""), False, "url"
raise ValueError("copy field unsupported for password: password|username|url")
if kind in {"seed", "managed_account"}:
if key not in {"seed", "phrase"}:
raise ValueError("copy field unsupported for seed: seed|phrase")
getter = (
self._service.get_managed_account_seed
if kind == "managed_account"
else self._service.get_seed_phrase
)
return str(getter(entry_id)), True, "seed"
if kind == "totp":
if key == "code":
return str(self._service.get_totp_code(entry_id)), True, "code"
if key == "secret":
return str(self._service.get_totp_secret(entry_id)), True, "secret"
raise ValueError("copy field unsupported for totp: code|secret")
if kind == "ssh":
private_key, public_key = self._service.get_ssh_key_pair(entry_id)
if key in {"public", "public_key"}:
return str(public_key), False, "public_key"
if key in {"private", "private_key"}:
return str(private_key), True, "private_key"
raise ValueError("copy field unsupported for ssh: public|private")
if kind == "pgp":
private_key, fingerprint_text = self._service.get_pgp_key(entry_id)
if key in {"private", "private_key"}:
return str(private_key), True, "private_key"
if key in {"fingerprint", "fpr"}:
return str(fingerprint_text), False, "fingerprint"
raise ValueError("copy field unsupported for pgp: private|fingerprint")
if kind == "nostr":
npub, nsec = self._service.get_nostr_key_pair(entry_id)
if key == "npub":
return str(npub), False, "npub"
if key == "nsec":
return str(nsec), True, "nsec"
raise ValueError("copy field unsupported for nostr: npub|nsec")
if kind == "key_value":
if key == "key":
return str(entry.get("key") or ""), False, "key"
if key == "value":
return str(entry.get("value") or ""), True, "value"
raise ValueError("copy field unsupported for key_value: key|value")
if kind in {"document", "note"}:
if key not in {"content", "text"}:
raise ValueError("copy field unsupported for document: content|text")
return str(entry.get("content") or ""), False, "content"
raise ValueError(f"copy not supported for kind '{kind or 'unknown'}'")
def _export_value_to_path(self, value: str, output_path: str) -> Path:
raw = Path(output_path).expanduser()
if not raw.is_absolute():
raw = Path.cwd() / raw
raw.parent.mkdir(parents=True, exist_ok=True)
raw.write_text(value, encoding="utf-8")
return raw
def _apply_selected_modify(
self,
*,
@@ -1977,6 +2100,19 @@ def launch_tui2(
self.query_one("#entry-detail", Static).update(self._stats_text())
self._set_status("Displayed vault stats")
return
if cmd == "density":
if len(args) != 1:
self._set_status("Usage: density <compact|comfortable>")
return
mode = args[0].strip().lower()
if mode not in {"compact", "comfortable"}:
self._set_status("density must be one of: compact, comfortable")
return
self._density_mode = mode
self._render_current_page(preserve_selected=True)
self._update_filters_panel()
self._set_status(f"Density set to {mode}")
return
if cmd == "session-status":
if args:
self._set_status("Usage: session-status")
@@ -3541,6 +3677,76 @@ def launch_tui2(
)
return
if cmd == "copy":
if self._service is None:
self._set_status("Entry service unavailable")
return
if len(args) < 1 or len(args) > 2:
self._set_status("Usage: copy <field> (optional: confirm)")
return
field = args[0].strip()
confirm = False
if len(args) == 2:
if args[1].strip().lower() != "confirm":
self._set_status("Usage: copy <field> (optional: confirm)")
return
confirm = True
try:
value, sensitive, canonical = self._resolve_copy_field_value(field)
except Exception as exc:
self._set_status(str(exc))
return
if sensitive and not confirm:
self._set_status(
f"copy {canonical} is sensitive. Re-run with: copy {field} confirm"
)
return
if not self._copy_to_clipboard(value):
self._set_status("Clipboard unavailable")
return
self._set_status(f"Copied {canonical} to clipboard")
return
if cmd == "export-field":
if self._service is None:
self._set_status("Entry service unavailable")
return
if len(args) not in {2, 3}:
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
return
field = args[0].strip()
output_path = args[1].strip()
if not output_path:
self._set_status("export-field path is required")
return
confirm = False
if len(args) == 3:
if args[2].strip().lower() != "confirm":
self._set_status("Usage: export-field <field> <path> (optional: confirm)")
return
confirm = True
try:
value, sensitive, canonical = self._resolve_copy_field_value(field)
except Exception as exc:
self._set_status(str(exc))
return
if sensitive and not confirm:
self._set_status(
f"export-field {canonical} is sensitive. Re-run with: export-field {field} {output_path} confirm"
)
return
try:
destination = self._export_value_to_path(value, output_path)
self._set_status(f"Exported {canonical} to {destination}")
except Exception as exc:
self._record_failure(
"export-field failed",
exc,
retry=lambda: self._run_palette_command(raw),
hint="Press 'x' to retry.",
)
return
if cmd in ("archive", "restore"):
if self._service is None or self._selected_entry_id is None:
self._set_status("No entry selected")
@@ -3903,6 +4109,17 @@ def launch_tui2(
self._load_entries(query=self._last_query, reset_page=True)
self._set_status(f"Applied archive filter: {self.archive_scope}")
def action_toggle_density(self) -> None:
if self.editing_document:
self._set_status("Finish document edit before changing density")
return
self._density_mode = (
"comfortable" if self._density_mode == "compact" else "compact"
)
self._render_current_page(preserve_selected=True)
self._update_filters_panel()
self._set_status(f"Density: {self._density_mode}")
def action_next_page(self) -> None:
if not self._all_results:
self._set_status("No entries loaded")
+12
View File
@@ -504,6 +504,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("page")
app._run_palette_command("page abc")
app._run_palette_command("page 0")
app._run_palette_command("density")
app._run_palette_command("density noisy")
app._run_palette_command("help-commands now")
app._run_palette_command("onboarding now")
app._run_palette_command("quickstart now")
@@ -577,6 +579,10 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("lock now")
app._run_palette_command("unlock")
app._run_palette_command("doc-export one two")
app._run_palette_command("copy")
app._run_palette_command("copy password nope")
app._run_palette_command("export-field")
app._run_palette_command("export-field private /tmp/matrix-key.bad nope")
app._run_palette_command("filter")
app._run_palette_command("archive-filter")
app._run_palette_command("archive-filter bogus")
@@ -592,6 +598,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("lock")
app._run_palette_command("unlock hunter2")
app._run_palette_command("search Doc")
app._run_palette_command("density comfortable")
app._run_palette_command("density compact")
app._run_palette_command("filter document")
app._run_palette_command("archive-filter all")
app._run_palette_command("archive-filter archived")
@@ -624,9 +632,13 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("2fa-refresh")
app._run_palette_command("2fa-hide")
app._run_palette_command("doc-export /tmp/seedpass-matrix-export")
app._run_palette_command("copy content")
app._run_palette_command("export-field content /tmp/seedpass-matrix-content.txt")
await pilot.pause()
app._run_palette_command("open 2")
app._run_palette_command("copy password confirm")
app._run_palette_command("export-field password /tmp/seedpass-matrix-password.txt confirm")
app.action_reveal_selected()
await pilot.pause()
assert "pw-2-" in str(app.query_one("#secret-detail").render())
@@ -643,6 +643,16 @@ async def test_tui2_textual_pagination_and_search_flow() -> None:
await pilot.pause()
assert "Page: 2/3" in _filters_text(app)
app.action_toggle_density()
await pilot.pause()
assert "Density: comfortable" in _status_text(app)
assert "Density: comfortable" in _filters_text(app)
app._run_palette_command("density compact")
await pilot.pause()
assert "Density set to compact" in _status_text(app)
assert "Density: compact" in _filters_text(app)
app.query_one("#search", Input).value = "Entry 44"
app._load_entries(query="Entry 44", reset_page=True)
await pilot.pause()
@@ -978,6 +988,99 @@ async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> Non
assert "Nostr #2 QR" in _widget_text(app, "#secret-detail")
@pytest.mark.anyio
async def test_tui2_textual_copy_command_for_core_and_advanced_fields(tmp_path) -> None:
service = FakeEntryService(
[
{
"id": 1,
"kind": "password",
"label": "Login 1",
"length": 16,
"username": "alice",
"url": "https://example.com",
},
{
"id": 2,
"kind": "ssh",
"label": "SSH 2",
"private_key": "SSH_PRIVATE_2",
"public_key": "ssh-ed25519 AAAA-2",
},
{"id": 3, "kind": "nostr", "label": "Nostr 3", "npub": "npub3", "nsec": "nsec3"},
{"id": 4, "kind": "key_value", "label": "KV 4", "key": "TOKEN", "value": "abc123"},
]
)
app = _build_app(service)
async with app.run_test() as pilot:
await pilot.pause()
app._run_palette_command("open 1")
app._run_palette_command("copy password")
await pilot.pause()
assert "copy password is sensitive" in _status_text(app)
app._run_palette_command("copy password confirm")
await pilot.pause()
assert "Copied password to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "pw-1-16"
app._run_palette_command("copy username")
await pilot.pause()
assert "Copied username to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "alice"
app._run_palette_command("open 2")
app._run_palette_command("copy private")
await pilot.pause()
assert "copy private_key is sensitive" in _status_text(app)
app._run_palette_command("copy private confirm")
await pilot.pause()
assert "Copied private_key to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "SSH_PRIVATE_2"
app._run_palette_command("open 3")
app._run_palette_command("copy npub")
await pilot.pause()
assert "Copied npub to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "npub3"
app._run_palette_command("copy nsec confirm")
await pilot.pause()
assert "Copied nsec to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "nsec3"
app._run_palette_command("open 4")
app._run_palette_command("copy value confirm")
await pilot.pause()
assert "Copied value to clipboard" in _status_text(app)
assert service.clipboard_values[-1] == "abc123"
app._run_palette_command("export-field value value.txt confirm")
await pilot.pause()
assert "Exported value to" in _status_text(app)
assert (Path.cwd() / "value.txt").exists()
assert (Path.cwd() / "value.txt").read_text(encoding="utf-8") == "abc123"
(Path.cwd() / "value.txt").unlink(missing_ok=True)
out_file = tmp_path / "nostr.nsec"
app._run_palette_command("open 3")
app._run_palette_command(f"export-field nsec {out_file}")
await pilot.pause()
assert "export-field nsec is sensitive" in _status_text(app)
app._run_palette_command(f"export-field nsec {out_file} confirm")
await pilot.pause()
assert "Exported nsec to" in _status_text(app)
assert out_file.read_text(encoding="utf-8") == "nsec3"
app._run_palette_command("open 4")
app._run_palette_command("copy bogus")
await pilot.pause()
assert "copy field unsupported for key_value" in _status_text(app)
@pytest.mark.anyio
async def test_tui2_textual_retry_after_search_failure() -> None:
service = FakeEntryService(
@@ -1792,6 +1895,30 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
await pilot.pause()
assert "Usage: unlock <password>" in _status_text(app)
app._run_palette_command("copy")
await pilot.pause()
assert "Usage: copy <field> (optional: confirm)" in _status_text(app)
app._run_palette_command("copy password nope")
await pilot.pause()
assert "Usage: copy <field> (optional: confirm)" in _status_text(app)
app._run_palette_command("export-field")
await pilot.pause()
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
app._run_palette_command("export-field password /tmp/x nope")
await pilot.pause()
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
app._run_palette_command("density")
await pilot.pause()
assert "Usage: density <compact|comfortable>" in _status_text(app)
app._run_palette_command("density noisy")
await pilot.pause()
assert "density must be one of: compact, comfortable" in _status_text(app)
@pytest.mark.anyio
async def test_tui2_textual_palette_session_status_lock_and_unlock() -> None: