Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -169,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
Expand Down
8 changes: 8 additions & 0 deletions docs/release-notes/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
357 changes: 357 additions & 0 deletions docs/superpowers/plans/2026-10-31-clipboard-copy.md

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion src/korvid/core/keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -221,15 +227,23 @@ 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] = {}
used_keys: dict[str, list[str]] = {}
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}")
Expand Down Expand Up @@ -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.

Expand All @@ -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;
Expand All @@ -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))
4 changes: 4 additions & 0 deletions src/korvid/core/keymap_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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, ...]:
Expand Down
1 change: 1 addition & 0 deletions src/korvid/ui/action_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/korvid/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/korvid/ui/app_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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",),
Expand Down
10 changes: 10 additions & 0 deletions src/korvid/ui/app_surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion src/korvid/ui/keybinding_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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, ...]] = {}
Expand All @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions src/korvid/ui/read_availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 37 additions & 0 deletions src/korvid/ui/resource_inspect_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -43,6 +44,7 @@
from korvid.ui.read_availability import (
SEARCH_ACTIONS,
PaneSearch,
copy_reason,
describe_reason,
hint_details_reason,
search_reason,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/korvid/ui/widgets/describe_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
hellices marked this conversation as resolved.
Resolved
]

DEFAULT_CSS = """
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/core/test_keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
4 changes: 3 additions & 1 deletion tests/ui/test_action_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
Loading
Loading