From f95c34d4335f0857ce1b43961bf2647d2dbe4e20 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 31 Oct 2026 21:37:02 +0900 Subject: [PATCH 1/3] feat: copy a resource's name or a describe view's text with y y copies the open describe pane, or else the selected resource's name (namespace/name when it has one), through Textual's OSC 52 copy_to_clipboard. ResourceInspectController owns it, reading the pane's displayed text and the selected row through InspectSurface and ViewState. The modal describe view binds the same id, so a :keys remap moves both. The copy is the text the view shows, so a masked Secret stays masked; the notice says what was copied, never the text. Part of #519 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/action_policy.py | 1 + src/korvid/ui/app.py | 10 +- src/korvid/ui/app_bindings.py | 3 + src/korvid/ui/app_surfaces.py | 10 ++ src/korvid/ui/read_availability.py | 9 ++ src/korvid/ui/resource_inspect_controller.py | 37 ++++++ src/korvid/ui/widgets/describe_screen.py | 7 ++ tests/ui/test_action_policy.py | 4 +- tests/ui/test_clipboard_copy.py | 123 +++++++++++++++++++ tests/ui/test_resource_inspect_controller.py | 77 ++++++++++++ 10 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 tests/ui/test_clipboard_copy.py diff --git a/src/korvid/ui/action_policy.py b/src/korvid/ui/action_policy.py index ef432a785..fcce08b11 100644 --- a/src/korvid/ui/action_policy.py +++ b/src/korvid/ui/action_policy.py @@ -114,6 +114,7 @@ #: question is the describe pane it owns (issue #388 final review). _INSPECT_REASON_ACTIONS: tuple[str, ...] = ( "describe", + "copy", "hint_details", "log_search_next", "log_search_prev", diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index 5fd6d9b79..a3c621e3b 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -947,13 +947,13 @@ def action_timeline(self) -> None: self._timeline.open() async def action_describe(self) -> None: - """`d` — describe the currently highlighted row. - - Textual resolves `action_*` on the app, so the binding entry point - stays here; the flow belongs to `ResourceInspectController`. - """ + """`d` — describe the highlighted row; the flow is `ResourceInspectController`'s.""" await self._inspect.describe_selected() + def action_copy(self) -> None: + """`y` — copy the describe pane or the selected row's name; likewise.""" + self._inspect.copy() + def on_builtin_command(self, message: BuiltinCommand) -> None: """Dispatch the catalog's typed operation to its feature owner.""" self._commands.route_builtin(message) diff --git a/src/korvid/ui/app_bindings.py b/src/korvid/ui/app_bindings.py index f0a886385..bf935d040 100644 --- a/src/korvid/ui/app_bindings.py +++ b/src/korvid/ui/app_bindings.py @@ -48,6 +48,8 @@ for i in range(1, 10) ], Binding("d", "describe", "Describe", id="describe"), + # The describe view binds the same id, so a `:keys` remap moves both (#519). + Binding("y", "copy", "Copy name or describe text", show=False, id="copy"), Binding("g", "relationships", "Relationships", id="relationships"), Binding("T", "timeline", "Timeline", id="timeline"), Binding("s", "shell", "Shell", id="shell"), @@ -145,6 +147,7 @@ # `/` filters the table but searches inside the log / describe panes. "open_filter": ("Table", "Logs"), "describe": ("Table",), + "copy": ("Table", "Describe"), "relationships": ("Table",), "timeline": ("Table",), "shell": ("Table",), diff --git a/src/korvid/ui/app_surfaces.py b/src/korvid/ui/app_surfaces.py index 1dc2b838d..3242f0593 100644 --- a/src/korvid/ui/app_surfaces.py +++ b/src/korvid/ui/app_surfaces.py @@ -454,6 +454,16 @@ def describe_search(self) -> PaneSearch: return PaneSearch(displayed=False, hits=False) return PaneSearch(displayed=pane.display, hits=pane.has_search_hits) + def describe_text(self) -> str | None: + try: + pane = self._app._describe_pane + except NoMatches: + return None + return pane.body_text if pane.display else None + + def copy_to_clipboard(self, text: str) -> None: + self._app.copy_to_clipboard(text) + class AppTransferScreens(TransferScreens): """Nominal `TransferScreens` adapter over `KorvidApp`'s screen stack. diff --git a/src/korvid/ui/read_availability.py b/src/korvid/ui/read_availability.py index be415a680..50e357efa 100644 --- a/src/korvid/ui/read_availability.py +++ b/src/korvid/ui/read_availability.py @@ -92,6 +92,15 @@ def describe_reason(*, manifest: bool, switching: bool, selected: bool) -> Unava return None +def copy_reason(*, pane: bool, selected: bool) -> UnavailableReason | None: + """Why `y` has nothing to copy, or None. + + `ResourceInspectController.copy` takes the open describe pane first, + then the selected row's name; with neither there is nothing to copy. + """ + return None if pane or selected else NO_SELECTION + + def hint_details_reason(*, row: bool, hinted: bool) -> UnavailableReason | None: """Why `h` has no detail overlay to open, or None. diff --git a/src/korvid/ui/resource_inspect_controller.py b/src/korvid/ui/resource_inspect_controller.py index 66d7591cf..398bef68a 100644 --- a/src/korvid/ui/resource_inspect_controller.py +++ b/src/korvid/ui/resource_inspect_controller.py @@ -3,6 +3,7 @@ `ResourceInspectController` owns what the user's *look at this* keys do: - `d` — describe the selected row, and the hierarchy tree's named describe; +- `y` — copy the open describe pane, or the selected row's name (#519); - the Secret rule both of those share (values render masked in the dedicated viewer, per-key reveal is explicit and audit-logged); - the provider footer the describe screens carry (issue #30); @@ -43,6 +44,7 @@ from korvid.ui.read_availability import ( SEARCH_ACTIONS, PaneSearch, + copy_reason, describe_reason, hint_details_reason, search_reason, @@ -104,6 +106,18 @@ def describe_search(self) -> PaneSearch: `cursor_row_key`, and must never open or move a search. """ + @abstractmethod + def describe_text(self) -> str | None: + """The open describe pane's plain text, exactly as shown; None while closed. + + What `y` copies, so it is the masked text the pane displays, never + the manifest behind it. Tolerates the widget being gone. + """ + + @abstractmethod + def copy_to_clipboard(self, text: str) -> None: + """Hand *text* to the terminal clipboard (an OSC 52 sequence).""" + class InspectShell(Protocol): """The interactive-session entry point the container pick raises.""" @@ -185,6 +199,11 @@ def unavailable_reason(self, action: str) -> UnavailableReason | None: row=row_key is not None, hinted=summary is not None and pod_needs_hint(summary), ) + if action == "copy": + return copy_reason( + pane=self._surface.describe_text() is not None, + selected=self._view.selected_ns_name(notify=False)[1] is not None, + ) if action in SEARCH_ACTIONS: return search_reason( action, @@ -193,6 +212,24 @@ def unavailable_reason(self, action: str) -> UnavailableReason | None: ) return None + def copy(self) -> None: + """`y`: copy the open describe pane, or else the selected row's name. + + A namespaced row is copied as `namespace/name`. The notice says what + was copied, never the text itself. + """ + text = self._surface.describe_text() + if text is not None: + self._surface.copy_to_clipboard(text) + self._ui.notify("Copied the describe pane to the clipboard") + return + namespace, name = self._view.selected_ns_name() + if name is None: + return + self._surface.copy_to_clipboard(f"{namespace}/{name}" if namespace else name) + what = "namespace/name" if namespace else "name" + self._ui.notify(f"Copied the selected resource's {what} to the clipboard") + def _selected_row(self) -> str | None: """The selected row's name, read without the keypress's warning.""" namespace, name = self._view.selected_ns_name(notify=False) diff --git a/src/korvid/ui/widgets/describe_screen.py b/src/korvid/ui/widgets/describe_screen.py index caea552b6..23aea4657 100644 --- a/src/korvid/ui/widgets/describe_screen.py +++ b/src/korvid/ui/widgets/describe_screen.py @@ -226,6 +226,8 @@ class DescribeScreen(ModalScreen[None]): Binding("n", "search_next", "Next hit", show=False), Binding("shift+n", "search_prev", "Prev hit", show=False), Binding("N", "search_prev", "Prev hit", show=False), + # The app's copy id: a `:keys` remap of copy moves this key too (#519). + Binding("y", "copy", "Copy", show=True, id="copy"), ] DEFAULT_CSS = """ @@ -296,6 +298,11 @@ def on_mount(self) -> None: # Inline search (mirrors the log pane's / + n/N semantics) # ------------------------------------------------------------------ + def action_copy(self) -> None: + """Copy the view's text, exactly as shown, without repeating it.""" + self.app.copy_to_clipboard(describe_body_text(self._manifest, self._events)) + self.notify("Copied the describe view to the clipboard") + def action_open_search(self) -> None: """Show the inline search Input and give it focus (``/`` key).""" search_input = self.query_one("#describe-search", Input) diff --git a/tests/ui/test_action_policy.py b/tests/ui/test_action_policy.py index 179e0097b..9186070d8 100644 --- a/tests/ui/test_action_policy.py +++ b/tests/ui/test_action_policy.py @@ -485,6 +485,7 @@ def test_composed_action_reasons_ask_each_owner_about_its_own_actions() -> None: "logs_multi", "log_save", "describe", + "copy", "hint_details", "log_search_next", "log_search_prev", @@ -505,7 +506,8 @@ def test_composed_action_reasons_ask_each_owner_about_its_own_actions() -> None: assert reasons["logs_multi"]() is None assert log_calls == ["logs_multi"] assert reasons["log_search_prev"]() is None - assert inspect_calls == ["log_search_prev"] + assert reasons["copy"]() is None + assert inspect_calls == ["log_search_prev", "copy"] assert reasons["relationships"]() is None assert reasons["sort_by_cpu"]() is None assert reasons["sort_by_age"]() is None diff --git a/tests/ui/test_clipboard_copy.py b/tests/ui/test_clipboard_copy.py new file mode 100644 index 000000000..314df6ba8 --- /dev/null +++ b/tests/ui/test_clipboard_copy.py @@ -0,0 +1,123 @@ +"""Copying a resource's name or a describe view's text with `y` (#519). + +Each test replaces the app's `copy_to_clipboard` with a recorder, so what is +checked is exactly the text the OSC 52 sequence would carry. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from textual.widgets import Input + +from korvid.core.secrets import MASK_PLACEHOLDER +from korvid.ui.app import KorvidApp +from korvid.ui.widgets.describe_screen import DescribePane, DescribeScreen, describe_body_text +from korvid.ui.widgets.resource_table import ResourceTable + +from .test_describe_search import _EVENTS_LIST, _POD_MANIFEST, _open_describe_screen, make_app +from .test_secret_screen import _SECRET_MANIFEST, _b64, make_secret_app +from .waits import until + + +def _record(app: KorvidApp) -> list[str]: + copied: list[str] = [] + app.copy_to_clipboard = copied.append # type: ignore[method-assign,assignment] # record what OSC 52 would carry + return copied + + +def _count(copied: list[str], expected: int) -> Callable[[], bool]: + return lambda: len(copied) == expected + + +def _notes(app: KorvidApp) -> list[str]: + return [n.message for n in app._notifications] + + +async def test_y_copies_the_selected_rows_namespace_and_name() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == ["default/my-pod"] + assert "Copied the selected resource's namespace/name to the clipboard" in _notes(app) + + +async def test_y_in_the_describe_view_copies_what_it_shows() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await _open_describe_screen(pilot, app) + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == [describe_body_text(_POD_MANIFEST, _EVENTS_LIST)] + assert "Copied the describe view to the clipboard" in _notes(app) + + +async def test_y_copies_the_open_describe_pane_before_the_row() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + pane = app.query_one(DescribePane) + pane.show("pods/default/my-pod", dict(_POD_MANIFEST), list(_EVENTS_LIST)) + await until(pilot, lambda: pane.display, label="pane shown") + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == [pane.body_text] + assert "kind: Pod" in copied[0] + + +async def test_a_masked_secret_is_copied_masked(tmp_path: Any) -> None: + from korvid.core.audit import AuditLog + + app = make_secret_app(audit=AuditLog(tmp_path / "audit.jsonl")) + copied = _record(app) + async with app.run_test() as pilot: + manifest = json.loads(json.dumps(_SECRET_MANIFEST)) + for expected, share in ((1, True), (2, False)): + await app._agent_ui._show_describe(share, "secrets/default/db-creds", manifest, []) + await pilot.pause() + await pilot.press("y") + await until(pilot, _count(copied, expected), label="copied") + + assert len(copied) == 2 + for text in copied: + assert MASK_PLACEHOLDER in text + assert "hunter2" not in text + assert _b64("hunter2") not in text + assert "plain-note" not in text + + +async def test_a_remapped_copy_key_works_in_the_describe_view_too() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + app.set_keymap({"copy": "x"}) + await pilot.press("x") + await until(pilot, lambda: len(copied) == 1, label="row copied") + await _open_describe_screen(pilot, app) + + search = app.screen.query_one(Input) + await pilot.press("y", "slash") + # The search opens only once `y` has been handled, and `y` copied nothing. + await until(pilot, lambda: search.display, label="search open") + assert len(copied) == 1 + await pilot.press("escape") + await until(pilot, lambda: not search.display, label="search closed") + await pilot.press("x") + await until(pilot, lambda: len(copied) == 2, label="view copied") + + assert isinstance(app.screen, DescribeScreen) + assert copied == ["default/my-pod", describe_body_text(_POD_MANIFEST, _EVENTS_LIST)] diff --git a/tests/ui/test_resource_inspect_controller.py b/tests/ui/test_resource_inspect_controller.py index 42958e9cf..f9c1885e4 100644 --- a/tests/ui/test_resource_inspect_controller.py +++ b/tests/ui/test_resource_inspect_controller.py @@ -117,6 +117,9 @@ def __init__( self.troubles: list[tuple[tuple[ContainerTrouble, ...], str | None]] = [] self.cleared = 0 self.describe = describe + #: The open describe pane's plain text, or None while it is closed. + self.describe_body: str | None = None + self.copied: list[str] = [] def cursor_row_key(self) -> str | None: return self.row_key @@ -132,6 +135,12 @@ def clear_hint(self) -> None: def describe_search(self) -> PaneSearch: return self.describe + def describe_text(self) -> str | None: + return self.describe_body + + def copy_to_clipboard(self, text: str) -> None: + self.copied.append(text) + class FakeShell: def __init__(self) -> None: @@ -672,3 +681,71 @@ def test_probes_answer_nothing_for_actions_this_controller_does_not_own() -> Non h = Harness() assert h.controller.unavailable_reason("delete_resource") is None assert h.controller.unavailable_reason("shell") is None + + +# --------------------------------------------------------------------------- +# copy (#519) +# --------------------------------------------------------------------------- + + +def test_copy_takes_the_open_describe_pane_whole() -> None: + h = Harness() + h.surface.describe_body = "apiVersion: v1\nkind: Pod\n\nEVENTS\n" + + h.controller.copy() + + assert h.surface.copied == ["apiVersion: v1\nkind: Pod\n\nEVENTS\n"] + assert h.ui.notifications == [("Copied the describe pane to the clipboard", "information")] + + +@pytest.mark.parametrize( + ("selected", "copied", "what"), + [ + (("default", "api-1"), "default/api-1", "namespace/name"), + (("", "node-1"), "node-1", "name"), + ], + ids=["namespaced", "cluster-scoped"], +) +def test_copy_takes_the_selected_rows_name_without_a_pane_open( + selected: tuple[str, str], copied: str, what: str +) -> None: + h = Harness(selected=selected) + + h.controller.copy() + + assert h.surface.copied == [copied] + assert h.ui.notifications == [ + (f"Copied the selected resource's {what} to the clipboard", "information") + ] + + +def test_copy_never_repeats_what_it_copied() -> None: + h = Harness(selected=("payments", "ledger-7c9f")) + + h.controller.copy() + + assert not any("ledger" in message for message, _ in h.ui.notifications) + + +def test_copy_with_nothing_to_copy_copies_nothing() -> None: + h = Harness(selected=(None, None)) + + h.controller.copy() + + assert h.surface.copied == [] + + +def test_copy_probe_needs_an_open_pane_or_a_selected_row() -> None: + h = Harness(selected=(None, None)) + refused = h.controller.unavailable_reason("copy") + + h.surface.describe_body = "kind: Pod\n" + with_pane = h.controller.unavailable_reason("copy") + h.surface.describe_body = None + h.view.selected = ("default", "api-1") + with_row = h.controller.unavailable_reason("copy") + + assert refused is not None + assert refused.code is AvailabilityCode.NO_SELECTION + assert (with_pane, with_row) == (None, None) + assert h.surface.copied == [] From 2f410ae0a664c6fdae5ab54ae6a273355ffb610d Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 31 Oct 2026 21:37:02 +0900 Subject: [PATCH 2/3] docs: describe the copy key, masking and the OSC 52 caveat Part of #519 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 8 + docs/release-notes/unreleased.md | 8 + .../plans/2026-10-31-clipboard-copy.md | 323 ++++++++++++++++++ 3 files changed, 339 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-31-clipboard-copy.md diff --git a/docs/keybindings.md b/docs/keybindings.md index e7f1a3a4b..24bdf13c0 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -24,6 +24,7 @@ Korvid shows only the keys that act on the current view. Press `?` for the compl | `0` | Toggle all namespaces | | `1`–`9` | Jump to a [namespace shortcut](tui.md#namespace-shortcuts): pinned favorites first, then the namespaces you visit, in first-visit order | | `d` | Describe the selected resource | +| `y` | Copy the open describe view or pane, otherwise the selected resource's name (`namespace/name` when it has one), to the clipboard | | `g` | Open operational relationships | | `T` | Open the bounded session timeline | | `l` / `L` | Open selected or merged pod logs | @@ -32,6 +33,13 @@ Korvid shows only the keys that act on the current view. Press `?` for the compl | `Ctrl-A` / `Ctrl-X` | Toggle the Agent / stop its current turn | | `q` | Quit | +`y` copies the text exactly as it is shown, so a masked value stays masked. A +Secret value is copied only by the Secret viewer's own `c`, which records each +copy in the audit log. The copy is an OSC 52 escape sequence, so it can reach +your local clipboard over SSH, but only where the terminal, and any +multiplexer in between, allows OSC 52 clipboard writes: some need it enabled, +and some do not support it. + `Ctrl-P` opens the Action Palette: type what you want to do — `scale`, `logs`, `context` — and korvid ranks the app actions and the built-in `:` commands against it. Canonical spellings match exactly too: an action's own id diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md index 55b3e23b4..cc9af705f 100644 --- a/docs/release-notes/unreleased.md +++ b/docs/release-notes/unreleased.md @@ -52,6 +52,14 @@ tracks the next release scope. without asking. Approval does not rely on them: write proposals still wait for a keystroke in the TUI. See [Tool hints](../mcp.md#tool-hints). +## Copy to the clipboard + +- `y` copies the open describe view or pane, or else the selected resource's + name (`namespace/name` when it has one). The text is copied exactly as shown, + so masked values stay masked. It uses OSC 52, so it depends on the terminal + allowing clipboard writes. The key is remappable in `:keys`. See + [Keybindings](../keybindings.md#move-and-inspect). + ## Current configuration and extension contracts Existing migration links continue in the diff --git a/docs/superpowers/plans/2026-10-31-clipboard-copy.md b/docs/superpowers/plans/2026-10-31-clipboard-copy.md new file mode 100644 index 000000000..0a6142489 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-clipboard-copy.md @@ -0,0 +1,323 @@ +# Copy to the Clipboard (#519) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One key, `y`, copies the open describe view or pane, or else the selected resource's name (`namespace/name` when it has one), through Textual's OSC 52 `copy_to_clipboard` (#519). + +**Architecture:** `ResourceInspectController`, which already owns the read-only "look at this" keys, gains `copy()`. It reads the open describe pane's displayed text and the selected row through `InspectSurface` (now with `describe_text()` and `copy_to_clipboard()`) and `ViewState`. The app binds `y` (id `copy`); the modal `DescribeScreen`, where app bindings do not fire, binds the same id to its own `action_copy`, so one `:keys` remap moves both. + +**Tech Stack:** Python 3.11+, Textual 8, pytest with Textual pilots, mypy `--strict`, ruff, tach. + +## Global Constraints + +- **Masked stays masked:** the copy is the text the view displays (`DescribePane.body_text`, `describe_body_text` of the manifest the screen was given), never the manifest behind it. A Secret reaches the describe views only masked (`mask_secret_manifest` on the agent path; `d` sends Secrets to the Secret viewer), and the Secret viewer's audited `c` stays the only way to copy a Secret value. +- **The notice never repeats the content:** it says what kind of thing was copied. +- **Remappable:** the app binding has id `copy`, and so does the `DescribeScreen` binding, because Textual applies a keymap by id to every node. +- **Size limits:** `src/korvid/ui/app.py` is at 1496 of 1500 lines and `scripts/check_source_size.py` is not edited, so `action_describe`'s docstring is shortened to one line to make room for `action_copy` beside it. +- **Commands:** use `uv run --frozen …`. Behind the corporate mirror, call the project venv's tools directly with `PYTHONPATH=src`. + +## Departures from the issue + +- **The key is `y`.** It was free at the app level and on every main-screen widget, and `y` (yank) is the usual copy key in terminal tools. +- **The describe pane comes first.** With the agent's non-modal describe pane open, `y` copies it, as `/`, `n` and `N` act on that pane first. +- **The app binding is hidden from the footer** (`show=False`), as other secondary keys are; help, the palette and `docs/keybindings.md` list it. The modal `DescribeScreen` shows it in its own footer. + +## File structure + +| File | Change | Responsibility | +| --- | --- | --- | +| `src/korvid/ui/resource_inspect_controller.py` | modify | `InspectSurface.describe_text`/`copy_to_clipboard`, `copy()`, the `copy` probe | +| `src/korvid/ui/read_availability.py` | modify | `copy_reason` | +| `src/korvid/ui/app_surfaces.py` | modify | `AppInspectSurface` implementations | +| `src/korvid/ui/app_bindings.py`, `action_policy.py`, `app.py` | modify | the `y` binding, its help group, its owner, `action_copy` | +| `src/korvid/ui/widgets/describe_screen.py` | modify | the modal's `y` (id `copy`) and `action_copy` | +| `tests/ui/test_resource_inspect_controller.py`, `tests/ui/test_clipboard_copy.py`, `tests/ui/test_action_policy.py` | modify / create | unit, end-to-end and vocabulary tests | +| `docs/keybindings.md`, `docs/release-notes/unreleased.md` | modify | the key, masking and the OSC 52 caveat | + +--- + +### Task 1: The controller + +- [ ] **Step 1: Write the failing tests.** Give `FakeSurface` a settable `describe_body: str | None` and a `copied: list[str]`, implementing the two new surface methods, then append: + +```python +# --------------------------------------------------------------------------- +# copy (#519) +# --------------------------------------------------------------------------- + + +def test_copy_takes_the_open_describe_pane_whole() -> None: + h = Harness() + h.surface.describe_body = "apiVersion: v1\nkind: Pod\n\nEVENTS\n" + + h.controller.copy() + + assert h.surface.copied == ["apiVersion: v1\nkind: Pod\n\nEVENTS\n"] + assert h.ui.notifications == [("Copied the describe pane to the clipboard", "information")] + + +@pytest.mark.parametrize( + ("selected", "copied", "what"), + [ + (("default", "api-1"), "default/api-1", "namespace/name"), + (("", "node-1"), "node-1", "name"), + ], + ids=["namespaced", "cluster-scoped"], +) +def test_copy_takes_the_selected_rows_name_without_a_pane_open( + selected: tuple[str, str], copied: str, what: str +) -> None: + h = Harness(selected=selected) + + h.controller.copy() + + assert h.surface.copied == [copied] + assert h.ui.notifications == [ + (f"Copied the selected resource's {what} to the clipboard", "information") + ] + + +def test_copy_never_repeats_what_it_copied() -> None: + h = Harness(selected=("payments", "ledger-7c9f")) + + h.controller.copy() + + assert not any("ledger" in message for message, _ in h.ui.notifications) + + +def test_copy_with_nothing_to_copy_copies_nothing() -> None: + h = Harness(selected=(None, None)) + + h.controller.copy() + + assert h.surface.copied == [] + + +def test_copy_probe_needs_an_open_pane_or_a_selected_row() -> None: + h = Harness(selected=(None, None)) + refused = h.controller.unavailable_reason("copy") + + h.surface.describe_body = "kind: Pod\n" + with_pane = h.controller.unavailable_reason("copy") + h.surface.describe_body = None + h.view.selected = ("default", "api-1") + with_row = h.controller.unavailable_reason("copy") + + assert refused is not None + assert refused.code is AvailabilityCode.NO_SELECTION + assert (with_pane, with_row) == (None, None) + assert h.surface.copied == [] +``` + +- [ ] **Step 2:** Run `uv run --frozen pytest -p no:tach tests/ui/test_resource_inspect_controller.py -q`. Expected: the new tests fail. +- [ ] **Step 3: Implement.** Add to `InspectSurface`: + +```python + @abstractmethod + def describe_text(self) -> str | None: + """The open describe pane's plain text, exactly as shown; None while closed. + + What `y` copies, so it is the masked text the pane displays, never + the manifest behind it. Tolerates the widget being gone. + """ + + @abstractmethod + def copy_to_clipboard(self, text: str) -> None: + """Hand *text* to the terminal clipboard (an OSC 52 sequence).""" +``` + +Add `copy_reason` to `read_availability.py`: + +```python +def copy_reason(*, pane: bool, selected: bool) -> UnavailableReason | None: + """Why `y` has nothing to copy, or None. + + `ResourceInspectController.copy` takes the open describe pane first, + then the selected row's name; with neither there is nothing to copy. + """ + return None if pane or selected else NO_SELECTION +``` + +Answer `unavailable_reason("copy")` with `copy_reason(pane=self._surface.describe_text() is not None, selected=self._view.selected_ns_name(notify=False)[1] is not None)`, and add: + +```python + def copy(self) -> None: + """`y`: copy the open describe pane, or else the selected row's name. + + A namespaced row is copied as `namespace/name`. The notice says what + was copied, never the text itself. + """ + text = self._surface.describe_text() + if text is not None: + self._surface.copy_to_clipboard(text) + self._ui.notify("Copied the describe pane to the clipboard") + return + namespace, name = self._view.selected_ns_name() + if name is None: + return + self._surface.copy_to_clipboard(f"{namespace}/{name}" if namespace else name) + what = "namespace/name" if namespace else "name" + self._ui.notify(f"Copied the selected resource's {what} to the clipboard") +``` + +Implement the surface in `AppInspectSurface`: + +```python + def describe_text(self) -> str | None: + try: + pane = self._app._describe_pane + except NoMatches: + return None + return pane.body_text if pane.display else None + + def copy_to_clipboard(self, text: str) -> None: + self._app.copy_to_clipboard(text) +``` + +- [ ] **Step 4:** Run the tests again. Expected: all pass. Then commit. + +### Task 2: The key, end to end + +- [ ] **Step 1: Write the failing tests:** create `tests/ui/test_clipboard_copy.py`: + +```python +"""Copying a resource's name or a describe view's text with `y` (#519). + +Each test replaces the app's `copy_to_clipboard` with a recorder, so what is +checked is exactly the text the OSC 52 sequence would carry. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from textual.widgets import Input + +from korvid.core.secrets import MASK_PLACEHOLDER +from korvid.ui.app import KorvidApp +from korvid.ui.widgets.describe_screen import DescribePane, DescribeScreen, describe_body_text +from korvid.ui.widgets.resource_table import ResourceTable + +from .test_describe_search import _EVENTS_LIST, _POD_MANIFEST, _open_describe_screen, make_app +from .test_secret_screen import _SECRET_MANIFEST, _b64, make_secret_app +from .waits import until + + +def _record(app: KorvidApp) -> list[str]: + copied: list[str] = [] + app.copy_to_clipboard = copied.append # type: ignore[method-assign,assignment] # record what OSC 52 would carry + return copied + + +def _count(copied: list[str], expected: int) -> Callable[[], bool]: + return lambda: len(copied) == expected + + +def _notes(app: KorvidApp) -> list[str]: + return [n.message for n in app._notifications] + + +async def test_y_copies_the_selected_rows_namespace_and_name() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == ["default/my-pod"] + assert "Copied the selected resource's namespace/name to the clipboard" in _notes(app) + + +async def test_y_in_the_describe_view_copies_what_it_shows() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await _open_describe_screen(pilot, app) + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == [describe_body_text(_POD_MANIFEST, _EVENTS_LIST)] + assert "Copied the describe view to the clipboard" in _notes(app) + + +async def test_y_copies_the_open_describe_pane_before_the_row() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + pane = app.query_one(DescribePane) + pane.show("pods/default/my-pod", dict(_POD_MANIFEST), list(_EVENTS_LIST)) + await until(pilot, lambda: pane.display, label="pane shown") + + await pilot.press("y") + await until(pilot, lambda: bool(copied), label="copied") + + assert copied == [pane.body_text] + assert "kind: Pod" in copied[0] + + +async def test_a_masked_secret_is_copied_masked(tmp_path: Any) -> None: + from korvid.core.audit import AuditLog + + app = make_secret_app(audit=AuditLog(tmp_path / "audit.jsonl")) + copied = _record(app) + async with app.run_test() as pilot: + manifest = json.loads(json.dumps(_SECRET_MANIFEST)) + for expected, share in ((1, True), (2, False)): + await app._agent_ui._show_describe(share, "secrets/default/db-creds", manifest, []) + await pilot.pause() + await pilot.press("y") + await until(pilot, _count(copied, expected), label="copied") + + assert len(copied) == 2 + for text in copied: + assert MASK_PLACEHOLDER in text + assert "hunter2" not in text + assert _b64("hunter2") not in text + assert "plain-note" not in text + + +async def test_a_remapped_copy_key_works_in_the_describe_view_too() -> None: + app = make_app() + copied = _record(app) + async with app.run_test() as pilot: + await until(pilot, lambda: app.query_one(ResourceTable).row_count > 0, label="pod row") + app.set_keymap({"copy": "x"}) + await pilot.press("x") + await until(pilot, lambda: len(copied) == 1, label="row copied") + await _open_describe_screen(pilot, app) + + search = app.screen.query_one(Input) + await pilot.press("y", "slash") + # The search opens only once `y` has been handled, and `y` copied nothing. + await until(pilot, lambda: search.display, label="search open") + assert len(copied) == 1 + await pilot.press("escape") + await until(pilot, lambda: not search.display, label="search closed") + await pilot.press("x") + await until(pilot, lambda: len(copied) == 2, label="view copied") + + assert isinstance(app.screen, DescribeScreen) + assert copied == ["default/my-pod", describe_body_text(_POD_MANIFEST, _EVENTS_LIST)] +``` + +- [ ] **Step 2: Implement.** In `app_bindings.py`, add `Binding("y", "copy", "Copy name or describe text", show=False, id="copy")` after `d`, and `"copy": ("Table", "Describe")` to `ACTION_HELP_GROUPS`; add `"copy"` to `_INSPECT_REASON_ACTIONS` in `action_policy.py` (and to the expected set in `test_composed_action_reasons_ask_each_owner_about_its_own_actions`). In `app.py`, shorten `action_describe`'s docstring to one line and add `action_copy`, which calls `self._inspect.copy()`. In `DescribeScreen`, add `Binding("y", "copy", "Copy", show=True, id="copy")` and: + +```python + def action_copy(self) -> None: + """Copy the view's text, exactly as shown, without repeating it.""" + self.app.copy_to_clipboard(describe_body_text(self._manifest, self._events)) + self.notify("Copied the describe view to the clipboard") +``` + +- [ ] **Step 3:** Run `uv run --frozen pytest -p no:tach tests/ui -q`. Expected: all pass. Then commit. + +### Task 3: Document it, then run the full gate + +- [ ] `docs/keybindings.md`: a `y` row under "Move and inspect", and a paragraph on masking and the OSC 52 caveat. Add a release-notes entry. +- [ ] Run `make check`. Expected: every step passes. From 4368f4627971ef85cd2b03204a7b4dfb77e9c634 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 31 Oct 2026 21:57:43 +0900 Subject: [PATCH 3/3] fix: keep a remapped copy off the describe view's own keys The describe view binds the app's copy id, so a remap moves its key too, and startup and :keys validation, which only knew the app's bindings, accepted copy on a key the view keeps for itself (quit to f12, copy to q). KeybindingCatalog now derives per-action reservations from the screens that reuse an app binding id (SHARED_BINDING_SCREENS), and plan_keybindings rejects them for that action only, at startup and in the editor's staged assignment. A test fails if a widget screen reuses an app id without being listed. Part of #519 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 2 + .../plans/2026-10-31-clipboard-copy.md | 40 +++++++++++++++++-- src/korvid/core/keybindings.py | 21 +++++++++- src/korvid/core/keymap_edit.py | 4 ++ src/korvid/ui/keybinding_catalog.py | 25 +++++++++++- tests/core/test_keybindings.py | 15 +++++++ tests/ui/test_keybinding_catalog.py | 35 ++++++++++++++++ 7 files changed, 137 insertions(+), 5 deletions(-) diff --git a/docs/keybindings.md b/docs/keybindings.md index 24bdf13c0..944fee240 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -177,6 +177,8 @@ printable keys, including Shift-only letters, punctuation, spaces, and Unicode: those keys must remain available to text inputs and typed approval gates. Use an available Ctrl/Alt combination or function key instead. This restriction does not apply to normal actions or the main-screen-only command-bar shortcut. +Copy (`y`) also works in the Describe view, so it cannot take that view's +own keys: `Esc`, `q`, `/`, `n` and `N`, even when Quit has moved off `q`. The editor cannot open over a dialog or during a context switch. The palette's own key moves under `open_action_palette`, but its fixed diff --git a/docs/superpowers/plans/2026-10-31-clipboard-copy.md b/docs/superpowers/plans/2026-10-31-clipboard-copy.md index 0a6142489..0136e8172 100644 --- a/docs/superpowers/plans/2026-10-31-clipboard-copy.md +++ b/docs/superpowers/plans/2026-10-31-clipboard-copy.md @@ -12,7 +12,7 @@ - **Masked stays masked:** the copy is the text the view displays (`DescribePane.body_text`, `describe_body_text` of the manifest the screen was given), never the manifest behind it. A Secret reaches the describe views only masked (`mask_secret_manifest` on the agent path; `d` sends Secrets to the Secret viewer), and the Secret viewer's audited `c` stays the only way to copy a Secret value. - **The notice never repeats the content:** it says what kind of thing was copied. -- **Remappable:** the app binding has id `copy`, and so does the `DescribeScreen` binding, because Textual applies a keymap by id to every node. +- **Remappable:** the app binding has id `copy`, and so does the `DescribeScreen` binding, because Textual applies a keymap by id to every node. So `copy` must not take `DescribeScreen`'s own fixed keys (`escape`, `q`, `/`, `n`, `N`): `KeybindingCatalog` derives per-action reservations from `SHARED_BINDING_SCREENS`, and `plan_keybindings` (startup and `:keys`) rejects them for that action only. - **Size limits:** `src/korvid/ui/app.py` is at 1496 of 1500 lines and `scripts/check_source_size.py` is not edited, so `action_describe`'s docstring is shortened to one line to make room for `action_copy` beside it. - **Commands:** use `uv run --frozen …`. Behind the corporate mirror, call the project venv's tools directly with `PYTHONPATH=src`. @@ -317,7 +317,41 @@ async def test_a_remapped_copy_key_works_in_the_describe_view_too() -> None: - [ ] **Step 3:** Run `uv run --frozen pytest -p no:tach tests/ui -q`. Expected: all pass. Then commit. -### Task 3: Document it, then run the full gate +### Task 3: Keep a remapped copy off the describe view's own keys -- [ ] `docs/keybindings.md`: a `y` row under "Move and inspect", and a paragraph on masking and the OSC 52 caveat. Add a release-notes entry. +- [ ] **Step 1: Write the failing tests:** `tests/core/test_keybindings.py::test_an_action_may_not_take_a_key_another_screen_of_its_owns` (`plan_keybindings(..., action_reserved_keys=...)` rejects only that action), and in `tests/ui/test_keybinding_catalog.py`, `test_copy_may_not_take_a_fixed_key_of_the_describe_view` (startup plan and editor `assignment`) and `test_every_screen_that_reuses_an_app_binding_id_is_checked` (every widget screen that binds an app id is in `SHARED_BINDING_SCREENS`). +- [ ] **Step 2: Implement.** `plan_keybindings` and `_entry_problem` take `action_reserved_keys: Mapping[str, Mapping[str, str]]`; `KeymapRules` carries it into `plan` and `assignment`; `KeybindingCatalog` derives it: + +```python +SHARED_BINDING_SCREENS: tuple[type[DOMNode], ...] = (DescribeScreen,) + + +def _key_owners(bindings: Iterable[BindingType]) -> dict[str, str]: + return { + canonical_key(key.strip()): binding.action + for raw in bindings + for binding in (as_binding(raw),) + for key in binding.key.split(",") + } +``` + +```python + def _screen_reserved_keys(self, screens: Iterable[type[DOMNode]]) -> dict[str, dict[str, str]]: + """Per shared action, the fixed keys of each screen that binds it too.""" + by_id = {b.id: b.action for b in self._bindings if b.id is not None} + reserved: dict[str, dict[str, str]] = {} + for screen in screens: + own = [as_binding(b) for b in screen.__dict__.get("BINDINGS", [])] + fixed = _key_owners(b for b in own if b.id is None) + for binding in own: + if binding.id in by_id: + reserved.setdefault(by_id[binding.id], {}).update(fixed) + return reserved +``` + +- [ ] **Step 3:** Run `uv run --frozen pytest -p no:tach tests/core/test_keybindings.py tests/core/test_keymap_edit.py tests/ui/test_keybinding_catalog.py -q`. Expected: all pass. Then commit. + +### Task 4: Document it, then run the full gate + +- [ ] `docs/keybindings.md`: a `y` row under "Move and inspect", a paragraph on masking and the OSC 52 caveat, and, under "Remap an app action", the describe view's keys copy cannot take. Add a release-notes entry. - [ ] Run `make check`. Expected: every step passes. diff --git a/src/korvid/core/keybindings.py b/src/korvid/core/keybindings.py index 09fe7894f..f5813c5f5 100644 --- a/src/korvid/core/keybindings.py +++ b/src/korvid/core/keybindings.py @@ -190,6 +190,7 @@ def _entry_problem( priority_actions: Collection[str], reserved: Mapping[str, str], priority_reserved: Mapping[str, str], + action_reserved: Mapping[str, str] | None = None, ) -> str | None: if action in PROTECTED_ACTIONS: return f"'{action}' belongs to the approval dialog and cannot be remapped" @@ -208,6 +209,11 @@ def _entry_problem( return f"priority action '{action}' may not take fixed modal key '{key}' owned by '{priority_owner}'" if marker in reserved: return f"key '{key}' for '{action}' is reserved by '{reserved[marker]}' and cannot be remapped over" + owner = (action_reserved or {}).get(marker) + if owner is not None: + return ( + f"key '{key}' for '{action}' is fixed to '{owner}' on a screen '{action}' also works on" + ) if action in priority_actions and _printable_key(key): return f"priority action '{action}' may not take printable key '{key}' — text input must keep that key" return None @@ -221,6 +227,7 @@ def _validated_overrides( priority_reserved_keys: Mapping[str, str], warnings: list[str], action_contexts: ActionContexts, + action_reserved_keys: Mapping[str, Mapping[str, str]], ) -> dict[str, str]: """First pass: per-entry checks (action known, key usable, no dup key).""" overrides: dict[str, str] = {} @@ -228,8 +235,15 @@ def _validated_overrides( reserved = {canonical_key(key): owner for key, owner in reserved_keys.items()} priority_reserved = {canonical_key(key): owner for key, owner in priority_reserved_keys.items()} for action, raw_key in raw.items(): + fixed = action_reserved_keys.get(action, {}) problem = _entry_problem( - action, raw_key, actions, priority_actions, reserved, priority_reserved + action, + raw_key, + actions, + priority_actions, + reserved, + priority_reserved, + {canonical_key(key): owner for key, owner in fixed.items()}, ) if problem is not None: warnings.append(f"keybindings: {problem}") @@ -297,6 +311,7 @@ def plan_keybindings( priority_reserved_keys: Mapping[str, str] | None = None, *, action_contexts: ActionContexts | None = None, + action_reserved_keys: Mapping[str, Mapping[str, str]] | None = None, ) -> KeymapPlan: """Validate config keybinding overrides against the app's actions. @@ -313,6 +328,9 @@ def plan_keybindings( another priority action must not intercept before the modal. action_contexts: Static resource-view scopes from the dispatch policy. Unlisted actions overlap every view, including split-pane states. + action_reserved_keys: Per action, the fixed keys (key → owning + action) of a screen that binds the action too; that action may + not take them, though other actions may. Returns: A plan whose `overrides` contains only safe, conflict-free entries; @@ -327,6 +345,7 @@ def plan_keybindings( priority_reserved_keys or {}, warnings, action_contexts or {}, + action_reserved_keys or {}, ) _drop_default_collisions(overrides, actions, warnings, action_contexts or {}) return KeymapPlan(overrides, tuple(warnings)) diff --git a/src/korvid/core/keymap_edit.py b/src/korvid/core/keymap_edit.py index bf541f4a8..97cf4db7c 100644 --- a/src/korvid/core/keymap_edit.py +++ b/src/korvid/core/keymap_edit.py @@ -26,6 +26,8 @@ class KeymapRules: reserved_keys: Mapping[str, str] = field(default_factory=dict) priority_reserved_keys: Mapping[str, str] = field(default_factory=dict) action_contexts: ActionContexts = field(default_factory=dict) + #: Per action, a screen's fixed keys that action is also bound beside. + action_reserved_keys: Mapping[str, Mapping[str, str]] = field(default_factory=dict) def plan(self, overrides: Mapping[str, object]) -> KeymapPlan: """Validate a complete proposal using the startup policy.""" @@ -36,6 +38,7 @@ def plan(self, overrides: Mapping[str, object]) -> KeymapPlan: self.reserved_keys, self.priority_reserved_keys, action_contexts=self.action_contexts, + action_reserved_keys=self.action_reserved_keys, ) def assignment(self, action: str, key: str) -> KeymapPlan: @@ -46,6 +49,7 @@ def assignment(self, action: str, key: str) -> KeymapPlan: self.priority_actions, self.reserved_keys, self.priority_reserved_keys, + action_reserved_keys=self.action_reserved_keys, ) def keys(self, action: str, overrides: Mapping[str, str]) -> tuple[str, ...]: diff --git a/src/korvid/ui/keybinding_catalog.py b/src/korvid/ui/keybinding_catalog.py index 4ca2d665f..e0f22b7fe 100644 --- a/src/korvid/ui/keybinding_catalog.py +++ b/src/korvid/ui/keybinding_catalog.py @@ -19,6 +19,12 @@ as_binding, ) from korvid.ui.modal_bindings import modal_bindings +from korvid.ui.widgets.describe_screen import DescribeScreen + +#: Screens that bind an app binding's id too, so a remap moves their key +#: with the app's (Textual applies a keymap by id). Their own fixed keys +#: are then reserved for that action. +SHARED_BINDING_SCREENS: tuple[type[DOMNode], ...] = (DescribeScreen,) def _key_owners(bindings: Iterable[BindingType]) -> dict[str, str]: @@ -34,7 +40,11 @@ class KeybindingCatalog: """One view of declared bindings, shared by startup and the editor.""" def __init__( - self, bindings: Iterable[BindingType], *, modal_keys: Collection[str] = () + self, + bindings: Iterable[BindingType], + *, + modal_keys: Collection[str] = (), + screens: Iterable[type[DOMNode]] = SHARED_BINDING_SCREENS, ) -> None: self._bindings = tuple(as_binding(binding) for binding in bindings) actions: dict[str, tuple[str, ...]] = {} @@ -53,8 +63,21 @@ def __init__( reserved_keys=self._reserved_keys(), priority_reserved_keys=self._priority_keys(modal_keys), action_contexts=action_contexts(), + action_reserved_keys=self._screen_reserved_keys(screens), ) + def _screen_reserved_keys(self, screens: Iterable[type[DOMNode]]) -> dict[str, dict[str, str]]: + """Per shared action, the fixed keys of each screen that binds it too.""" + by_id = {b.id: b.action for b in self._bindings if b.id is not None} + reserved: dict[str, dict[str, str]] = {} + for screen in screens: + own = [as_binding(b) for b in screen.__dict__.get("BINDINGS", [])] + fixed = _key_owners(b for b in own if b.id is None) + for binding in own: + if binding.id in by_id: + reserved.setdefault(by_id[binding.id], {}).update(fixed) + return reserved + def _reserved_keys(self) -> dict[str, str]: fixed: dict[str, str] = {} for widget_type in (App, Screen, DataTable): diff --git a/tests/core/test_keybindings.py b/tests/core/test_keybindings.py index 251d64d7d..47ca8c499 100644 --- a/tests/core/test_keybindings.py +++ b/tests/core/test_keybindings.py @@ -170,3 +170,18 @@ def test_comma_separated_key_values_are_rejected() -> None: assert plan.overrides == {} assert len(plan.warnings) == 1 assert "one key" in plan.warnings[0] + + +def test_an_action_may_not_take_a_key_another_screen_of_its_owns() -> None: + """An action bound on a screen too must not take that screen's fixed keys.""" + actions = {"quit": ("q",), "copy": ("y",), "logs": ("l",)} + reserved = {"copy": {"q": "dismiss", "slash": "open_search"}} + + plan = plan_keybindings( + {"quit": "f12", "copy": "q", "logs": "slash"}, actions, action_reserved_keys=reserved + ) + + assert plan.overrides == {"quit": "f12", "logs": "slash"} + assert plan.warnings == ( + "keybindings: key 'q' for 'copy' is fixed to 'dismiss' on a screen 'copy' also works on", + ) diff --git a/tests/ui/test_keybinding_catalog.py b/tests/ui/test_keybinding_catalog.py index c32ad416a..a8d39f0e4 100644 --- a/tests/ui/test_keybinding_catalog.py +++ b/tests/ui/test_keybinding_catalog.py @@ -161,3 +161,38 @@ def test_emitted_aliases_dispatch_actual_legacy_and_extended_events( bindings = BindingsMap([Binding("l", "logs", "Logs", id="logs")]) bindings.apply_keymap(App._normalize_keymap({"logs": shift_alias_keys(key)})) assert any(alias in bindings.key_to_bindings for alias in events[0].aliases) + + +def test_copy_may_not_take_a_fixed_key_of_the_describe_view() -> None: + catalog = KeybindingCatalog(APP_BINDINGS) + + plan = catalog.rules.plan({"quit": "f12", "copy": "q"}) + staged = catalog.rules.assignment("copy", "n") + + assert plan.overrides == {"quit": "f12"} + assert any("'q' for 'copy'" in warning for warning in plan.warnings) + assert any("'n' for 'copy'" in warning for warning in staged.warnings) + assert catalog.rules.plan({"copy": "x"}).overrides == {"copy": "x"} + + +def test_every_screen_that_reuses_an_app_binding_id_is_checked() -> None: + import importlib + import pkgutil + + from textual.screen import Screen + + import korvid.ui.widgets as widgets + from korvid.ui.keybinding_catalog import SHARED_BINDING_SCREENS + + app_ids = {b.id for b in (a for a in APP_BINDINGS if isinstance(a, Binding)) if b.id} + reusing = set() + for module in pkgutil.iter_modules(widgets.__path__): + loaded = importlib.import_module(f"{widgets.__name__}.{module.name}") + for value in vars(loaded).values(): + if isinstance(value, type) and issubclass(value, Screen): + own = value.__dict__.get("BINDINGS", []) + if any(isinstance(b, Binding) and b.id in app_ids for b in own): + reusing.add(value) + + assert reusing + assert reusing <= set(SHARED_BINDING_SCREENS)