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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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)