pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/prefix_keys.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Shortcut keys behind one configurable prefix, with a which-key hint.
|
|
2
|
+
|
|
3
|
+
The ``key_prefix`` setting defaults to ``ctrl``. With a leader, a shortcut is
|
|
4
|
+
the leader and then its letter. The leader lists what it can do until an action
|
|
5
|
+
is selected or the menu is dismissed.
|
|
6
|
+
|
|
7
|
+
A shortcut works wherever focus is. A chord outranks the editing keys of any
|
|
8
|
+
search line or editor, and an open menu owns keyboard input: every binding a
|
|
9
|
+
surface passes through ``key_bindings`` is off until dismissal, so the letter
|
|
10
|
+
reaches the shortcut instead of being typed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit.application import get_app
|
|
17
|
+
from prompt_toolkit.filters import Condition, Filter, FilterOrBool, to_filter, vi_navigation_mode
|
|
18
|
+
from prompt_toolkit.key_binding import (
|
|
19
|
+
ConditionalKeyBindings,
|
|
20
|
+
KeyBindings,
|
|
21
|
+
KeyBindingsBase,
|
|
22
|
+
merge_key_bindings,
|
|
23
|
+
)
|
|
24
|
+
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
|
25
|
+
from prompt_toolkit.key_binding.vi_state import InputMode
|
|
26
|
+
from prompt_toolkit.keys import Keys
|
|
27
|
+
|
|
28
|
+
from pcode.preferences import (
|
|
29
|
+
RESERVED_CHORDS,
|
|
30
|
+
SETTINGS,
|
|
31
|
+
load_preferences,
|
|
32
|
+
parse_key_prefix,
|
|
33
|
+
parse_vi_key_prefix,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Input that is not a keystroke keeps its own handler while a leader waits:
|
|
37
|
+
# a swallowed cursor-position report would stall the renderer, and a paste
|
|
38
|
+
# or a click is not an answer to "which shortcut?".
|
|
39
|
+
_PASSTHROUGH = frozenset(
|
|
40
|
+
{
|
|
41
|
+
Keys.CPRResponse,
|
|
42
|
+
Keys.Vt100MouseEvent,
|
|
43
|
+
Keys.WindowsMouseEvent,
|
|
44
|
+
Keys.BracketedPaste,
|
|
45
|
+
Keys.ScrollUp,
|
|
46
|
+
Keys.ScrollDown,
|
|
47
|
+
Keys.SIGINT,
|
|
48
|
+
Keys.Ignore,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
# A shortcut's key must also make a Ctrl chord a terminal can send, so the
|
|
52
|
+
# same letter works under either prefix. Ctrl+Shift+N arrives as Ctrl+N, so
|
|
53
|
+
# capitals are out.
|
|
54
|
+
_CHORDABLE = frozenset("abcdefghijklmnopqrstuvwxyz@]\\^_")
|
|
55
|
+
_NAMES = {"@": "Space"}
|
|
56
|
+
# Opens the read-only help view everywhere; shown as Ctrl+/ (^/ when compact).
|
|
57
|
+
HELP_KEY = "c-_"
|
|
58
|
+
HELP_LABEL = "Ctrl+/"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_ctrl_chord(key: str) -> bool:
|
|
62
|
+
return key in _CHORDABLE and f"c-{key}" not in RESERVED_CHORDS
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_action_key(key: str) -> None:
|
|
66
|
+
if len(key) != 1 or not key.isprintable() or key.isspace():
|
|
67
|
+
raise ValueError("A binding key must be one printable non-whitespace character.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def key_label(key: str) -> str:
|
|
71
|
+
"""How a prompt_toolkit key name reads to a person: c-p is Ctrl+P, f2 is F2."""
|
|
72
|
+
if len(key) == 1:
|
|
73
|
+
return "Space" if key == " " else key
|
|
74
|
+
if key.startswith("c-"):
|
|
75
|
+
rest = key[2:]
|
|
76
|
+
return f"Ctrl+{_NAMES.get(rest, rest.upper())}"
|
|
77
|
+
return key.upper()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compact_label(label: str) -> str:
|
|
81
|
+
"""A shortcut label short enough for an inline hint: Ctrl+S reads ^S."""
|
|
82
|
+
return label.replace("Ctrl+", "^")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def configured_prefix() -> str:
|
|
86
|
+
return load_preferences().get("key_prefix", SETTINGS["key_prefix"].default)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _leader_label(leader: tuple[str, ...]) -> str:
|
|
90
|
+
return " ".join(key_label(key) for key in leader)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _shortcut_label(leader: tuple[str, ...], key: str) -> str:
|
|
94
|
+
return f"{_leader_label(leader)} {key}" if leader else key_label(f"c-{key}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def shortcut_label(key: str, prefix: str | None = None) -> str:
|
|
98
|
+
"""How to press shortcut ``key`` under ``prefix``, by default the saved one.
|
|
99
|
+
|
|
100
|
+
For text written once at launch, alongside the prompt reading the same
|
|
101
|
+
setting; a running prompt's own ``PrefixKeys.label`` is exact after that.
|
|
102
|
+
"""
|
|
103
|
+
return _shortcut_label(parse_key_prefix(configured_prefix() if prefix is None else prefix), key)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Choice:
|
|
108
|
+
key: str
|
|
109
|
+
label: str
|
|
110
|
+
handler: Callable[[KeyPressEvent], None]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Shortcut:
|
|
115
|
+
key: str
|
|
116
|
+
# A callable for a toggle whose name follows its state, read per render.
|
|
117
|
+
title: str | Callable[[], str]
|
|
118
|
+
handler: Callable[[KeyPressEvent], None]
|
|
119
|
+
# Whether the shortcut applies right now; it is hidden and inert otherwise.
|
|
120
|
+
filter: Filter
|
|
121
|
+
# Shortcuts registered back to back under one group share a help row.
|
|
122
|
+
group: str | None = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def label(self) -> str:
|
|
126
|
+
return self.title() if callable(self.title) else self.title
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PrefixKeys:
|
|
130
|
+
"""The shortcuts of one surface (the prompt, or a popup) and their prefix.
|
|
131
|
+
|
|
132
|
+
Register each with ``add``, then build the surface's key bindings with
|
|
133
|
+
``key_bindings`` so a waiting leader can switch the rest off. ``prefix``
|
|
134
|
+
defaults to the saved ``key_prefix``, read once, here.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, prefix: str | None = None, *, vi_prefix: str = "off") -> None:
|
|
138
|
+
self.leader = parse_key_prefix(configured_prefix() if prefix is None else prefix)
|
|
139
|
+
# Only the main prompt opts into this alias; popup inputs keep their keys.
|
|
140
|
+
self.vi_leader = parse_vi_key_prefix(vi_prefix)
|
|
141
|
+
self.vi_leader_enabled = vi_navigation_mode & Condition(
|
|
142
|
+
lambda: (
|
|
143
|
+
get_app().vi_state.input_mode == InputMode.NAVIGATION
|
|
144
|
+
and not get_app().quoted_insert
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
# Remember which leader opened the menu, including its help/choices,
|
|
148
|
+
# so repeating a vi alias can close it without hijacking other menus.
|
|
149
|
+
self._pending_leader: tuple[str, ...] = ()
|
|
150
|
+
self.pending = False
|
|
151
|
+
self.browsing = False
|
|
152
|
+
self.help_offset = 0
|
|
153
|
+
self._help_title = "Keybindings"
|
|
154
|
+
self.choice_title = ""
|
|
155
|
+
self.choices: tuple[Choice, ...] = ()
|
|
156
|
+
self.help_provider: Callable[[], list[tuple[str, str]]] = lambda: []
|
|
157
|
+
self.message = ""
|
|
158
|
+
self.shortcuts: list[Shortcut] = []
|
|
159
|
+
self._actions: dict[str, Shortcut] = {}
|
|
160
|
+
self._bound_keys: set[str] = set()
|
|
161
|
+
self.bindings = KeyBindings()
|
|
162
|
+
self.waiting: Filter = Condition(self._waiting)
|
|
163
|
+
if self.leader or self.vi_leader:
|
|
164
|
+
self._bind_leader()
|
|
165
|
+
self._bind_help()
|
|
166
|
+
self._bind_choices()
|
|
167
|
+
|
|
168
|
+
def _waiting(self) -> bool:
|
|
169
|
+
if not self.visible:
|
|
170
|
+
return False
|
|
171
|
+
typed = get_app().key_processor.key_buffer
|
|
172
|
+
passthrough = (
|
|
173
|
+
{Keys.CPRResponse, Keys.Ignore} if self.browsing or self.choices else _PASSTHROUGH
|
|
174
|
+
)
|
|
175
|
+
return not typed or typed[-1].key not in passthrough
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def visible(self) -> bool:
|
|
179
|
+
return self.pending or self.browsing or bool(self.choices)
|
|
180
|
+
|
|
181
|
+
def dismiss(self) -> None:
|
|
182
|
+
self._pending_leader = ()
|
|
183
|
+
self.pending = self.browsing = False
|
|
184
|
+
self.choices = ()
|
|
185
|
+
self.choice_title = ""
|
|
186
|
+
self.message = ""
|
|
187
|
+
self.help_offset = 0
|
|
188
|
+
|
|
189
|
+
def set_help(
|
|
190
|
+
self, provider: Callable[[], list[tuple[str, str]]], *, title: str = "Keybindings"
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Describe ordinary keys for the focused control without moving focus."""
|
|
193
|
+
self.help_provider = provider
|
|
194
|
+
self._help_title = title
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def help_title(self) -> str:
|
|
198
|
+
return self.choice_title if self.choices else self._help_title
|
|
199
|
+
|
|
200
|
+
def choose(self, title: str, choices: list[Choice]) -> None:
|
|
201
|
+
"""Show explicit single-key choices in the existing help overlay.
|
|
202
|
+
|
|
203
|
+
Handlers receive the selection event, with the original focus and draft.
|
|
204
|
+
"""
|
|
205
|
+
if not choices or any(len(choice.key) != 1 for choice in choices):
|
|
206
|
+
raise ValueError("Choices need single-character keys")
|
|
207
|
+
if len({choice.key for choice in choices}) != len(choices):
|
|
208
|
+
raise ValueError("Choice keys must be unique")
|
|
209
|
+
self.dismiss()
|
|
210
|
+
self.choice_title = title
|
|
211
|
+
self.choices = tuple(choices)
|
|
212
|
+
get_app().invalidate()
|
|
213
|
+
|
|
214
|
+
def _bind_choices(self) -> None:
|
|
215
|
+
choosing = Condition(lambda: bool(self.choices))
|
|
216
|
+
|
|
217
|
+
@self.bindings.add("escape", filter=choosing, eager=True)
|
|
218
|
+
@self.bindings.add("c-c", filter=choosing, eager=True)
|
|
219
|
+
def cancel(event: KeyPressEvent) -> None:
|
|
220
|
+
self.dismiss()
|
|
221
|
+
event.app.invalidate()
|
|
222
|
+
|
|
223
|
+
@self.bindings.add(Keys.Any, filter=choosing, eager=True)
|
|
224
|
+
def select(event: KeyPressEvent) -> None:
|
|
225
|
+
for choice in self.choices:
|
|
226
|
+
if event.key_sequence[-1].key == choice.key:
|
|
227
|
+
self.dismiss()
|
|
228
|
+
choice.handler(event)
|
|
229
|
+
event.app.invalidate()
|
|
230
|
+
return
|
|
231
|
+
self.message = f"No choice for {event.data!r}"
|
|
232
|
+
event.app.invalidate()
|
|
233
|
+
|
|
234
|
+
# Default eager bindings beat Any (quoted insert and history search).
|
|
235
|
+
# Claim them explicitly, along with non-keyboard input that could alter
|
|
236
|
+
# the underlying draft or focus. A waiting leader still passes paste
|
|
237
|
+
# and mouse events through as before; browse and choices are read-only.
|
|
238
|
+
protected = Condition(lambda: self.browsing or bool(self.choices))
|
|
239
|
+
|
|
240
|
+
def ignore(event: KeyPressEvent) -> None:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
for key in ("c-q", "c-r", "c-s"):
|
|
244
|
+
self.bindings.add(key, filter=Condition(lambda: self.visible), eager=True)(ignore)
|
|
245
|
+
|
|
246
|
+
for key in (
|
|
247
|
+
Keys.BracketedPaste,
|
|
248
|
+
Keys.Vt100MouseEvent,
|
|
249
|
+
Keys.WindowsMouseEvent,
|
|
250
|
+
Keys.ScrollUp,
|
|
251
|
+
Keys.ScrollDown,
|
|
252
|
+
):
|
|
253
|
+
self.bindings.add(key, filter=protected, eager=True)(ignore)
|
|
254
|
+
|
|
255
|
+
def _bind_help(self) -> None:
|
|
256
|
+
# Terminals send Ctrl+/ as Ctrl+_, which no leader may use.
|
|
257
|
+
@self.bindings.add(HELP_KEY, eager=True)
|
|
258
|
+
def help_keys(event: KeyPressEvent) -> None:
|
|
259
|
+
browsing = not self.browsing
|
|
260
|
+
leader = self._pending_leader
|
|
261
|
+
self.dismiss()
|
|
262
|
+
self.browsing = browsing
|
|
263
|
+
if browsing:
|
|
264
|
+
self._pending_leader = leader
|
|
265
|
+
event.app.invalidate()
|
|
266
|
+
|
|
267
|
+
browse = Condition(lambda: self.browsing)
|
|
268
|
+
scrollable = Condition(lambda: self.visible)
|
|
269
|
+
|
|
270
|
+
@self.bindings.add("escape", filter=browse, eager=True)
|
|
271
|
+
@self.bindings.add("c-c", filter=browse, eager=True)
|
|
272
|
+
def close(event: KeyPressEvent) -> None:
|
|
273
|
+
self.dismiss()
|
|
274
|
+
event.app.invalidate()
|
|
275
|
+
|
|
276
|
+
@self.bindings.add(Keys.Any, filter=browse, eager=True)
|
|
277
|
+
@self.bindings.add(Keys.BracketedPaste, filter=browse, eager=True)
|
|
278
|
+
def ignore(event: KeyPressEvent) -> None:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
@self.bindings.add("down", filter=scrollable, eager=True)
|
|
282
|
+
@self.bindings.add("pagedown", filter=scrollable, eager=True)
|
|
283
|
+
def down(event: KeyPressEvent) -> None:
|
|
284
|
+
self.help_offset = min(max(0, len(self.hint_rows()) - 1), self.help_offset + 1)
|
|
285
|
+
event.app.invalidate()
|
|
286
|
+
|
|
287
|
+
@self.bindings.add("up", filter=scrollable, eager=True)
|
|
288
|
+
@self.bindings.add("pageup", filter=scrollable, eager=True)
|
|
289
|
+
def up(event: KeyPressEvent) -> None:
|
|
290
|
+
self.help_offset = max(0, self.help_offset - 1)
|
|
291
|
+
event.app.invalidate()
|
|
292
|
+
|
|
293
|
+
def _bind_leader(self) -> None:
|
|
294
|
+
keys = self.bindings
|
|
295
|
+
|
|
296
|
+
# Eager so the leader wins over whatever the key did before. Pressed
|
|
297
|
+
# again while waiting, it cancels.
|
|
298
|
+
def bind(leader: tuple[str, ...], filter: FilterOrBool = True) -> None:
|
|
299
|
+
@keys.add(*leader, filter=filter, eager=True)
|
|
300
|
+
def lead(event: KeyPressEvent) -> None:
|
|
301
|
+
pending = not self.visible
|
|
302
|
+
self.dismiss()
|
|
303
|
+
self.pending = pending
|
|
304
|
+
if pending:
|
|
305
|
+
self._pending_leader = leader
|
|
306
|
+
event.app.invalidate()
|
|
307
|
+
|
|
308
|
+
if self.leader:
|
|
309
|
+
bind(self.leader)
|
|
310
|
+
if self.vi_leader:
|
|
311
|
+
# Do not steal choices or actions from a menu opened by another
|
|
312
|
+
# prefix, even when the alias itself is an action letter.
|
|
313
|
+
bind(
|
|
314
|
+
self.vi_leader,
|
|
315
|
+
self.vi_leader_enabled
|
|
316
|
+
& Condition(lambda: not self.visible or self._pending_leader == self.vi_leader),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Esc cancels; unknown keys keep the menu open with an error instead
|
|
320
|
+
# of typing into the draft or invoking an underlying list command.
|
|
321
|
+
@keys.add("escape", filter=self.waiting, eager=True)
|
|
322
|
+
@keys.add("c-c", filter=self.waiting, eager=True)
|
|
323
|
+
def cancel(event: KeyPressEvent) -> None:
|
|
324
|
+
self.dismiss()
|
|
325
|
+
event.app.invalidate()
|
|
326
|
+
|
|
327
|
+
@keys.add(Keys.Any, filter=self.waiting, eager=True)
|
|
328
|
+
def unknown(event: KeyPressEvent) -> None:
|
|
329
|
+
if self.pending:
|
|
330
|
+
self.message = f"No binding for {event.data!r}"
|
|
331
|
+
event.app.invalidate()
|
|
332
|
+
|
|
333
|
+
def add(
|
|
334
|
+
self,
|
|
335
|
+
key: str,
|
|
336
|
+
label: str | Callable[[], str],
|
|
337
|
+
*,
|
|
338
|
+
filter: FilterOrBool = True,
|
|
339
|
+
group: str | None = None,
|
|
340
|
+
):
|
|
341
|
+
"""Register a shortcut: ``key`` after the leader, or Ctrl+``key``.
|
|
342
|
+
|
|
343
|
+
``label`` describes the action clearly in the help overlay;
|
|
344
|
+
a callable is read each time, for a toggle that says what it does next.
|
|
345
|
+
Consecutive shortcuts with the same ``group`` list as one row under
|
|
346
|
+
that name, such as ``n / p Thinking effort up / down``.
|
|
347
|
+
"""
|
|
348
|
+
if not has_ctrl_chord(key):
|
|
349
|
+
raise ValueError(f"{key!r} cannot be a shortcut: it has no free Ctrl chord")
|
|
350
|
+
if any(shortcut.key == key for shortcut in self.shortcuts):
|
|
351
|
+
raise ValueError(f"{key!r} is already a shortcut here")
|
|
352
|
+
condition = to_filter(filter)
|
|
353
|
+
|
|
354
|
+
def decorator(handler: Callable[[KeyPressEvent], None]):
|
|
355
|
+
self.set_shortcut(Shortcut(key, label, handler, condition, group))
|
|
356
|
+
return handler
|
|
357
|
+
|
|
358
|
+
return decorator
|
|
359
|
+
|
|
360
|
+
def set_shortcut(self, shortcut: Shortcut) -> None:
|
|
361
|
+
"""Replace an action in place, or add one, without replacing the keymap."""
|
|
362
|
+
key = shortcut.key
|
|
363
|
+
validate_action_key(key)
|
|
364
|
+
old = self._actions.get(key)
|
|
365
|
+
if old is None:
|
|
366
|
+
self.shortcuts.append(shortcut)
|
|
367
|
+
else:
|
|
368
|
+
self.shortcuts[self.shortcuts.index(old)] = shortcut
|
|
369
|
+
self._actions[key] = shortcut
|
|
370
|
+
if key in self._bound_keys:
|
|
371
|
+
return
|
|
372
|
+
self._bound_keys.add(key)
|
|
373
|
+
enabled = Condition(lambda: key in self._actions and self._actions[key].filter())
|
|
374
|
+
|
|
375
|
+
def run(event: KeyPressEvent) -> None:
|
|
376
|
+
action = self._actions[key]
|
|
377
|
+
leader = self._pending_leader
|
|
378
|
+
self.dismiss()
|
|
379
|
+
event.app.invalidate()
|
|
380
|
+
action.handler(event)
|
|
381
|
+
# Preserve the originating leader when an action opens a chooser.
|
|
382
|
+
if self.visible and not self._pending_leader:
|
|
383
|
+
self._pending_leader = leader
|
|
384
|
+
|
|
385
|
+
if not self.leader and has_ctrl_chord(key):
|
|
386
|
+
self.bindings.add(f"c-{key}", filter=enabled & ~self.waiting, eager=True)(run)
|
|
387
|
+
if self.leader or self.vi_leader:
|
|
388
|
+
self.bindings.add(
|
|
389
|
+
key,
|
|
390
|
+
filter=Condition(lambda: self.pending and self._pending_leader != (key,))
|
|
391
|
+
& self.waiting
|
|
392
|
+
& enabled,
|
|
393
|
+
eager=True,
|
|
394
|
+
)(run)
|
|
395
|
+
|
|
396
|
+
def remove(self, key: str) -> None:
|
|
397
|
+
"""Disable a registered action; its bindings become inactive immediately."""
|
|
398
|
+
old = self._actions.pop(key, None)
|
|
399
|
+
if old is not None:
|
|
400
|
+
self.shortcuts.remove(old)
|
|
401
|
+
|
|
402
|
+
def gate(self, bindings: KeyBindingsBase) -> KeyBindingsBase:
|
|
403
|
+
"""``bindings``, switched off while a shortcut, help, or choice menu is open.
|
|
404
|
+
|
|
405
|
+
Use it for bindings that sit on a container, such as a docked editor's
|
|
406
|
+
Enter and Esc, which would otherwise outrank the shortcuts.
|
|
407
|
+
"""
|
|
408
|
+
return ConditionalKeyBindings(bindings, ~self.waiting)
|
|
409
|
+
|
|
410
|
+
def key_bindings(self, bindings: KeyBindingsBase) -> KeyBindingsBase:
|
|
411
|
+
"""A surface's own bindings, gated, followed by its shortcuts."""
|
|
412
|
+
return merge_key_bindings([self.gate(bindings), self.bindings])
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def leader_label(self) -> str:
|
|
416
|
+
return _leader_label(self.leader)
|
|
417
|
+
|
|
418
|
+
def label(self, key: str) -> str:
|
|
419
|
+
"""How to press shortcut ``key``: Ctrl+Y, or Ctrl+P y after a leader."""
|
|
420
|
+
if not self.leader and not has_ctrl_chord(key):
|
|
421
|
+
return (
|
|
422
|
+
_shortcut_label(self.vi_leader, key)
|
|
423
|
+
if self.vi_leader
|
|
424
|
+
else f"leader {key} (needs a prefix)"
|
|
425
|
+
)
|
|
426
|
+
return _shortcut_label(self.leader, key)
|
|
427
|
+
|
|
428
|
+
def available(self) -> list[Shortcut]:
|
|
429
|
+
return [
|
|
430
|
+
shortcut
|
|
431
|
+
for shortcut in self.shortcuts
|
|
432
|
+
if shortcut.filter()
|
|
433
|
+
and (self.leader or self.vi_leader or has_ctrl_chord(shortcut.key))
|
|
434
|
+
and not (self.pending and self._pending_leader == (shortcut.key,))
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
def summary(self) -> str:
|
|
438
|
+
"""The single help affordance, never a list of individual bindings."""
|
|
439
|
+
leader = self.leader
|
|
440
|
+
if self.pending:
|
|
441
|
+
leader = self._pending_leader or leader
|
|
442
|
+
elif self.vi_leader and self.vi_leader_enabled():
|
|
443
|
+
leader = self.vi_leader
|
|
444
|
+
key = _leader_label(leader) if leader else HELP_LABEL
|
|
445
|
+
return f"{compact_label(key)} …" if self.pending else f"{key} Keys"
|
|
446
|
+
|
|
447
|
+
def hint_rows(self) -> list[tuple[str, str]]:
|
|
448
|
+
"""The which-key list: (key, what it does). ``hint_footer`` says how to leave."""
|
|
449
|
+
if self.choices:
|
|
450
|
+
return [(choice.key, choice.label) for choice in self.choices]
|
|
451
|
+
actions = [
|
|
452
|
+
# A group with one member showing reads as that shortcut alone.
|
|
453
|
+
(
|
|
454
|
+
self._keys_label([shortcut.key for shortcut in run]),
|
|
455
|
+
run[0].group if len(run) > 1 else run[0].label,
|
|
456
|
+
)
|
|
457
|
+
for run in self._grouped(self.available())
|
|
458
|
+
]
|
|
459
|
+
return self.help_provider() + actions if self.browsing or self._vi_pending else actions
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def _vi_pending(self) -> bool:
|
|
463
|
+
"""The vi alias is waiting: its menu doubles as the full help view."""
|
|
464
|
+
return bool(self.vi_leader) and self.pending and self._pending_leader == self.vi_leader
|
|
465
|
+
|
|
466
|
+
def hint_footer(self) -> list[tuple[str, str]]:
|
|
467
|
+
"""How to back out of the open overlay, for its bottom border."""
|
|
468
|
+
if self.browsing:
|
|
469
|
+
return [(f"Esc / {HELP_LABEL}", "close")]
|
|
470
|
+
if self.choices or self._vi_pending:
|
|
471
|
+
return [("Esc", "cancel")]
|
|
472
|
+
return [("Esc", "cancel"), (HELP_LABEL, "all keys")]
|
|
473
|
+
|
|
474
|
+
@staticmethod
|
|
475
|
+
def _grouped(shortcuts: list[Shortcut]) -> list[list[Shortcut]]:
|
|
476
|
+
runs: list[list[Shortcut]] = []
|
|
477
|
+
for shortcut in shortcuts:
|
|
478
|
+
if runs and shortcut.group is not None and runs[-1][-1].group == shortcut.group:
|
|
479
|
+
runs[-1].append(shortcut)
|
|
480
|
+
else:
|
|
481
|
+
runs.append([shortcut])
|
|
482
|
+
return runs
|
|
483
|
+
|
|
484
|
+
def _keys_label(self, keys: list[str]) -> str:
|
|
485
|
+
"""``n / p`` while the leader waits, else ``Ctrl+B n / p`` or ``Ctrl+N / Ctrl+P``."""
|
|
486
|
+
if self.pending:
|
|
487
|
+
return " / ".join(keys)
|
|
488
|
+
if self.leader:
|
|
489
|
+
return f"{self.leader_label} {' / '.join(keys)}"
|
|
490
|
+
return " / ".join(self.label(key) for key in keys)
|