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/jobs_ui.py ADDED
@@ -0,0 +1,265 @@
1
+ """Popup over the shell jobs this session knows about: what runs, what it printed."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Callable
6
+
7
+ from prompt_toolkit.application import Application, get_app
8
+ from prompt_toolkit.document import Document
9
+ from prompt_toolkit.filters import Always, has_focus
10
+ from prompt_toolkit.key_binding import KeyBindings
11
+ from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
12
+ from prompt_toolkit.layout import DynamicContainer, HSplit, Layout, VSplit
13
+ from prompt_toolkit.layout.dimension import Dimension
14
+ from prompt_toolkit.widgets import Label, TextArea
15
+ from rich.text import Text
16
+ from rich.theme import Theme
17
+
18
+ from pcode.frame import Frame
19
+ from pcode.inspector_ui import code_block, format_command
20
+ from pcode.jobs import Job, JobRegistry, format_duration
21
+ from pcode.popup_ui import (
22
+ RichPane,
23
+ bind_list_paging,
24
+ list_pane_height,
25
+ popup_container,
26
+ popup_mouse,
27
+ popup_style,
28
+ )
29
+ from pcode.prefix_keys import PrefixKeys
30
+ from pcode.shell import preview_text
31
+ from pcode.tool_display import command_text
32
+
33
+ REFRESH_SECONDS = 0.5
34
+
35
+ # More than the model's tail, since this is where a person reads the whole log,
36
+ # but no more than `preview_text` keeps: it clips to its last 128 KiB anyway.
37
+ LOG_BYTES = 128 * 1024
38
+
39
+
40
+ def icon(job: Job) -> str:
41
+ if job.running:
42
+ return "⟳"
43
+ return "✗" if job.stopped or job.exit_code != 0 else "✓"
44
+
45
+
46
+ def ordered(registry: JobRegistry) -> list[Job]:
47
+ """Running jobs first, since those are the ones you open this to act on."""
48
+ return sorted(registry.jobs.values(), key=lambda job: (not job.running, job.started_at))
49
+
50
+
51
+ def row(job: Job, watched: str = "") -> str:
52
+ line = f"{icon(job)} {job.id} · {job.outcome()} · {format_duration(job.elapsed)}"
53
+ line += f" · {command_text(job.label())}"
54
+ if job.id == watched:
55
+ line += " · watching"
56
+ return line
57
+
58
+
59
+ def log_size(job: Job) -> int:
60
+ try:
61
+ return job.output_path.stat().st_size
62
+ except OSError:
63
+ return -1
64
+
65
+
66
+ def details(registry: JobRegistry, job: Job | None, *, code_theme: str, watched: str = "") -> list:
67
+ """The job's purpose, command, and state, then the tail of what it printed.
68
+
69
+ Nothing here moves with the clock, so an idle job never re-renders its log.
70
+ """
71
+ if job is None:
72
+ return [Text("Jobs appear here once the model runs a shell command.", style="dim")]
73
+ blocks: list = []
74
+ if job.purpose:
75
+ blocks.append(Text(command_text(job.purpose), style="bold"))
76
+ blocks.append(code_block(format_command(command_text(job.command)), "bash", code_theme))
77
+ started = time.strftime("%H:%M:%S", time.localtime(job.started_at))
78
+ state = f"started {started}"
79
+ if not job.running:
80
+ state = f"{job.outcome()} · {state} · took {format_duration(job.elapsed)}"
81
+ notes = [state]
82
+ if job.background:
83
+ notes.append("background")
84
+ if job.adopted:
85
+ notes.append("adopted from an earlier pcode")
86
+ if job.id == watched:
87
+ notes.append("watching in the preview")
88
+ blocks += [Text(" " + " · ".join(notes), style="dim"), Text("")]
89
+ if not job.output_path.exists():
90
+ return [*blocks, Text("The log has been removed.", style="dim")]
91
+ output, truncated = registry.read_output(job, max_bytes=LOG_BYTES)
92
+ output = preview_text(output, final=True)
93
+ if truncated:
94
+ blocks.append(Text(f"… earlier output is in {job.output_path}", style="dim"))
95
+ blocks.append(Text(output) if output else Text("(no output yet)", style="dim"))
96
+ # A one-line last block, so anchoring to it scrolls to the very end.
97
+ return [*blocks, Text("")]
98
+
99
+
100
+ class JobBrowser:
101
+ """List jobs, follow the selected one's log, and stop or watch it."""
102
+
103
+ def __init__(
104
+ self,
105
+ registry: JobRegistry,
106
+ *,
107
+ stop: Callable[[Job], None],
108
+ watch: Callable[[Job | None], None],
109
+ watched: Callable[[], str],
110
+ rich_theme: Theme | None = None,
111
+ code_theme: str = "ansi_dark",
112
+ color_system: str | None = "truecolor",
113
+ key_prefix: str | None = None,
114
+ **app_options,
115
+ ) -> None:
116
+ self.registry = registry
117
+ self.stop = stop
118
+ self.watch = watch
119
+ self.watched = watched
120
+ self.code_theme = code_theme
121
+ self.items = ordered(registry)
122
+ self.selected = self.items[0].id if self.items else None
123
+ self.notice = ""
124
+ self._rendered: tuple | None = None
125
+ self._refreshing = False
126
+ self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
127
+ self.list.window.cursorline = Always()
128
+ self.detail = RichPane(theme=rich_theme, color_system=color_system)
129
+ self.list.buffer.on_cursor_position_changed += lambda _: self.select()
130
+ keys = KeyBindings()
131
+ self.detail.bind_scrolling(keys)
132
+ bind_list_paging(keys, self.list, has_focus(self.list))
133
+
134
+ @keys.add("escape", eager=True)
135
+ @keys.add("c-c")
136
+ @keys.add("enter")
137
+ def close(event):
138
+ event.app.exit(result=None)
139
+
140
+ self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
141
+ shortcuts.set_help(
142
+ lambda: [
143
+ ("↑/↓", "Select job / scroll output"),
144
+ ("PgUp/PgDn", "Page"),
145
+ ("Ctrl+U/D", "Half page"),
146
+ ("Tab/Shift+Tab", "Change focus"),
147
+ ("Enter/Esc/Ctrl+C", "Close"),
148
+ ]
149
+ )
150
+
151
+ @shortcuts.add("w", "Watch in preview")
152
+ def toggle_watch(event):
153
+ job = self.current()
154
+ if job is not None and job.id == self.watched():
155
+ self.watch(None)
156
+ self.notice = f"stopped watching {job.id}"
157
+ elif job is None or not job.running:
158
+ self.notice = "only a running job can be watched"
159
+ return
160
+ else:
161
+ self.watch(job)
162
+ self.notice = f"watching {job.id} in the preview"
163
+ self.refresh()
164
+
165
+ @shortcuts.add("k", "Stop")
166
+ def stop_selected(event):
167
+ # Same key meaning as in /btw: stop the work, keep the record.
168
+ job = self.current()
169
+ if job is None or not job.running:
170
+ self.notice = "nothing running to stop"
171
+ return
172
+ self.stop(job)
173
+ self.notice = f"stopped {job.id}"
174
+ self.refresh()
175
+
176
+ keys.add("tab")(focus_next)
177
+ keys.add("s-tab")(focus_previous)
178
+
179
+ def header() -> str:
180
+ running = sum(job.running for job in self.items)
181
+ line = f"Jobs · {len(self.items)} this session · {running} running"
182
+ return line + (f" · {self.notice}" if self.notice else "")
183
+
184
+ wide = VSplit(
185
+ [
186
+ Frame(self.list, title="Jobs", width=Dimension(weight=2)),
187
+ Frame(self.detail, title="Output", width=Dimension(weight=3)),
188
+ ]
189
+ )
190
+ narrow = HSplit(
191
+ [
192
+ Frame(self.list, title="Jobs", height=lambda: list_pane_height(len(self.items))),
193
+ Frame(self.detail, title="Output"),
194
+ ]
195
+ )
196
+ body = DynamicContainer(
197
+ lambda: wide if get_app().output.get_size().columns >= 100 else narrow
198
+ )
199
+ root_container = HSplit(
200
+ [
201
+ Label(header),
202
+ body,
203
+ Label(shortcuts.summary),
204
+ ]
205
+ )
206
+ self.app = Application(
207
+ layout=Layout(popup_container(root_container, shortcuts), focused_element=self.list),
208
+ key_bindings=shortcuts.key_bindings(keys),
209
+ full_screen=True,
210
+ mouse_support=popup_mouse(shortcuts),
211
+ style=popup_style(app_options.pop("style", None)),
212
+ **app_options,
213
+ )
214
+ self.refresh()
215
+
216
+ def refresh(self) -> None:
217
+ """Re-read statuses and rebuild the list, keeping the selection."""
218
+ self.registry.refresh()
219
+ self.items = ordered(self.registry)
220
+ watched = self.watched()
221
+ lines = [row(job, watched) for job in self.items] or ["No jobs yet"]
222
+ index = next((i for i, job in enumerate(self.items) if job.id == self.selected), 0)
223
+ position = sum(len(line) + 1 for line in lines[:index])
224
+ self._refreshing = True
225
+ self.list.buffer.set_document(Document("\n".join(lines), position), bypass_readonly=True)
226
+ self._refreshing = False
227
+ self.select()
228
+
229
+ def current(self) -> Job | None:
230
+ return next((job for job in self.items if job.id == self.selected), None)
231
+
232
+ def select(self) -> None:
233
+ if self._refreshing:
234
+ return
235
+ index = self.list.document.cursor_position_row
236
+ if index < len(self.items):
237
+ self.selected = self.items[index].id
238
+ job = self.current()
239
+ watched = self.watched()
240
+ state = (job.id, job.outcome(), log_size(job), job.id == watched) if job else (None,)
241
+ if state == self._rendered:
242
+ return
243
+ switched = self._rendered is None or state[0] != self._rendered[0]
244
+ self._rendered = state
245
+ blocks = details(self.registry, job, code_theme=self.code_theme, watched=watched)
246
+ if switched:
247
+ # Open on the newest output: that is what a log is read for. The
248
+ # anchor is the log's last block, and the window clamps a scroll
249
+ # past the bottom, so this lands on its final line.
250
+ self.detail.set(blocks, anchor=len(blocks) - 1)
251
+ else:
252
+ self.detail.follow(blocks)
253
+
254
+ async def run(self) -> None:
255
+ async def follow():
256
+ while True:
257
+ await asyncio.sleep(REFRESH_SECONDS)
258
+ # Rows carry elapsed time, so the list moves even when no log does.
259
+ self.refresh()
260
+ self.app.invalidate()
261
+
262
+ def start():
263
+ self.app.create_background_task(follow())
264
+
265
+ await self.app.run_async(pre_run=start)
pcode/keymap.py ADDED
@@ -0,0 +1,215 @@
1
+ """User-owned prompt bindings, distinct from editable project preferences."""
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ from filelock import FileLock
9
+ from prompt_toolkit.completion import CompleteEvent, Completion
10
+ from prompt_toolkit.document import Document
11
+ from prompt_toolkit.filters import Always
12
+
13
+ from pcode.commands import CommandRegistry, SlashCompleter
14
+ from pcode.preferences import config_dir, write_json
15
+ from pcode.prefix_keys import PrefixKeys, Shortcut, validate_action_key
16
+
17
+ # These names keep native actions addressable even after their keys are replaced.
18
+ DEFAULT_ACTIONS = {
19
+ "s": "@send-mode",
20
+ "l": "@model",
21
+ "n": "@effort-up",
22
+ "p": "@effort-down",
23
+ "o": "@tasks",
24
+ "t": "@thinking",
25
+ "g": "@commands",
26
+ "^": "@previous-session",
27
+ "y": "@copy",
28
+ }
29
+ USAGE = "/bind [list | actions | KEY [TARGET] | reset [KEY]]; /unbind KEY disables a key"
30
+
31
+
32
+ def bindings_path() -> Path:
33
+ return config_dir() / "bindings.json"
34
+
35
+
36
+ def validate_target(target: str) -> None:
37
+ if not target or any(not char.isprintable() for char in target):
38
+ raise ValueError("A binding target must be a single-line slash command or named @action.")
39
+ if target.startswith("@"):
40
+ if target not in DEFAULT_ACTIONS.values():
41
+ raise ValueError(f"Unknown built-in action: {target}. Use /bind actions.")
42
+ elif not target.startswith("/") or target.split(maxsplit=1)[0] == "/":
43
+ raise ValueError("A binding target must start with / (command) or @ (built-in action).")
44
+
45
+
46
+ def read_bindings() -> dict[str, str | None]:
47
+ path = bindings_path()
48
+ try:
49
+ data = json.loads(path.read_text())
50
+ except FileNotFoundError:
51
+ return {}
52
+ except ValueError:
53
+ raise ValueError(
54
+ f"Invalid bindings JSON: {path}. Repair it before editing bindings."
55
+ ) from None
56
+ if not isinstance(data, dict):
57
+ raise ValueError(f"Bindings must be a JSON object: {path}")
58
+ for key, target in data.items():
59
+ validate_action_key(key)
60
+ if target is not None:
61
+ if not isinstance(target, str):
62
+ raise ValueError(f"Binding {key!r} must be a command, @action, or null.")
63
+ validate_target(target)
64
+ return data
65
+
66
+
67
+ def save_binding(key: str | None, target: str | None = None, *, reset: bool = False) -> dict:
68
+ path = bindings_path()
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ with FileLock(str(path) + ".lock", timeout=5):
71
+ data = read_bindings()
72
+ if reset:
73
+ if key is None:
74
+ data.clear()
75
+ else:
76
+ data.pop(key, None)
77
+ else:
78
+ validate_action_key(key)
79
+ if target is not None:
80
+ validate_target(target)
81
+ data[key] = target
82
+ write_json(path, data)
83
+ return data
84
+
85
+
86
+ class PromptKeymap:
87
+ def __init__(
88
+ self,
89
+ shortcuts: PrefixKeys,
90
+ registry: CommandRegistry,
91
+ execute: Callable[[str], None],
92
+ report: Callable[[str], None],
93
+ ) -> None:
94
+ self.shortcuts = shortcuts
95
+ self.registry = registry
96
+ self.execute = execute
97
+ self.report = report
98
+ self.defaults = {shortcut.key: shortcut for shortcut in shortcuts.shortcuts}
99
+ self.actions = {DEFAULT_ACTIONS[key]: value for key, value in self.defaults.items()}
100
+ self.overrides: dict[str, str | None] = {}
101
+ try:
102
+ self.apply(read_bindings())
103
+ except (OSError, ValueError) as error:
104
+ report(f"Could not load keybindings: {error}")
105
+
106
+ def check_command(self, target: str) -> None:
107
+ validate_target(target)
108
+ if self.registry.resolve(target) is None:
109
+ raise ValueError(f"Command unavailable: {target.split(maxsplit=1)[0]}")
110
+
111
+ def invoke(self, target: str) -> None:
112
+ try:
113
+ # Re-resolve at keypress time: extensions and skills can disappear.
114
+ self.check_command(target)
115
+ self.execute(target)
116
+ except ValueError as error:
117
+ self.report(str(error))
118
+
119
+ def apply(self, overrides: dict[str, str | None]) -> None:
120
+ for key in set(self.defaults) | set(self.overrides) | set(overrides):
121
+ self.shortcuts.remove(key)
122
+ self.overrides = overrides
123
+ for key in dict.fromkeys([*self.defaults, *overrides]):
124
+ target = overrides.get(key, DEFAULT_ACTIONS.get(key))
125
+ if target is None:
126
+ continue
127
+ if target.startswith("@"):
128
+ self.shortcuts.set_shortcut(replace(self.actions[target], key=key))
129
+ else:
130
+ self.shortcuts.set_shortcut(
131
+ Shortcut(key, target, lambda event, text=target: self.invoke(text), Always())
132
+ )
133
+
134
+ def describe(self, key: str) -> str:
135
+ target = self.overrides.get(key, DEFAULT_ACTIONS.get(key))
136
+ source = "custom" if key in self.overrides else "default"
137
+ if target is None:
138
+ return f"{key}: disabled" if key in self.overrides else f"{key}: unbound"
139
+ description = self.actions[target].label if target.startswith("@") else target
140
+ if target.startswith("/") and self.registry.find(target.split(maxsplit=1)[0]) is None:
141
+ description += " (unavailable)"
142
+ return f"{self.shortcuts.label(key)}: {target} — {description} [{source}]"
143
+
144
+ def manage(self, argument: str, *, unbind: bool = False) -> str:
145
+ parts = argument.strip().split(maxsplit=1)
146
+ if unbind:
147
+ if len(parts) != 1:
148
+ raise ValueError("Usage: /unbind KEY")
149
+ key = parts[0]
150
+ validate_action_key(key)
151
+ self.apply(save_binding(key, None))
152
+ return f"Disabled binding {key}. /bind reset {key} restores its default."
153
+ if not parts or parts == ["list"]:
154
+ return "Prompt keybindings:\n" + "\n".join(
155
+ self.describe(key) for key in dict.fromkeys([*self.defaults, *self.overrides])
156
+ )
157
+ if parts == ["actions"]:
158
+ return "Built-in actions:\n" + "\n".join(
159
+ f"{name}: {action.label}" for name, action in self.actions.items()
160
+ )
161
+ if parts[0] == "reset":
162
+ key = parts[1] if len(parts) == 2 else None
163
+ if key is not None:
164
+ validate_action_key(key)
165
+ self.apply(save_binding(key, reset=True))
166
+ return (
167
+ f"Restored default binding for {key}." if key else "Restored all default bindings."
168
+ )
169
+ key = parts[0]
170
+ validate_action_key(key)
171
+ if len(parts) == 1:
172
+ return self.describe(key)
173
+ target = parts[1].strip()
174
+ validate_target(target)
175
+ if target.startswith("/"):
176
+ self.check_command(target)
177
+ self.apply(save_binding(key, target))
178
+ return "Saved " + self.describe(key)
179
+
180
+ def action_label(self, default_key: str) -> str:
181
+ """Tell help/notifications where a native action moved, or that it is unbound."""
182
+ target = DEFAULT_ACTIONS.get(default_key)
183
+ for key in dict.fromkeys([*self.defaults, *self.overrides]):
184
+ if self.overrides.get(key, DEFAULT_ACTIONS.get(key)) == target:
185
+ return self.shortcuts.label(key)
186
+ return "unbound"
187
+
188
+ def complete(self, argument: str):
189
+ parts = argument.split(maxsplit=1)
190
+ keys = list(dict.fromkeys([*self.defaults, *self.overrides]))
191
+ if not any(char.isspace() for char in argument):
192
+ for word in ["list", "actions", "reset", *keys]:
193
+ if word.startswith(argument):
194
+ yield Completion(word, start_position=-len(argument))
195
+ return
196
+ head = parts[0] if parts else ""
197
+ tail = parts[1] if len(parts) > 1 else ""
198
+ if head == "reset":
199
+ for key in keys:
200
+ if key.startswith(tail):
201
+ yield Completion(key, start_position=-len(tail))
202
+ elif len(head) == 1:
203
+ for target, action in self.actions.items():
204
+ if target.startswith(tail):
205
+ yield Completion(target, start_position=-len(tail), display_meta=action.label)
206
+ yield from (
207
+ SlashCompleter(self.registry).get_completions(
208
+ Document(tail or "/"), CompleteEvent()
209
+ )
210
+ if tail
211
+ else (
212
+ Completion(command.name, display_meta=command.description)
213
+ for command in self.registry.commands
214
+ )
215
+ )
pcode/layout_speed.py ADDED
@@ -0,0 +1,103 @@
1
+ """Faster width/height division for prompt_toolkit splits.
2
+
3
+ Every repaint (each spinner frame, each scrollback flush) re-lays out the whole
4
+ bottom block. prompt_toolkit's ``VSplit._divide_widths`` and
5
+ ``HSplit._divide_heights`` grow the children one cell at a time through a
6
+ weighted round-robin generator, re-summing the sizes on every step, so one
7
+ split costs O(width × children) and the eight splits in pcode's layout were
8
+ ~45% of a repaint. Every split here uses the default weight, and for equal
9
+ weights the round-robin is a plain cycle, which can be applied in whole
10
+ rounds. Unequal weights keep the original path.
11
+ """
12
+
13
+ from prompt_toolkit.layout.containers import HSplit, VSplit, WritePosition
14
+ from prompt_toolkit.layout.dimension import Dimension, sum_layout_dimensions
15
+
16
+
17
+ def divide(dimensions: list[Dimension], available: int, *, fill: bool = True) -> list[int] | None:
18
+ """Sizes for ``dimensions`` in ``available`` cells, exactly as prompt_toolkit divides them.
19
+
20
+ ``fill`` False stops at the preferred sizes, as ``_divide_heights`` does once
21
+ the application is done.
22
+ """
23
+ if not dimensions:
24
+ return []
25
+ weights = [d.weight for d in dimensions]
26
+ positive = [w for w in weights if w > 0]
27
+ if not positive or any(w != positive[0] for w in positive):
28
+ return None # Caller falls back to the original algorithm.
29
+ total = sum_layout_dimensions(dimensions)
30
+ if total.min > available:
31
+ return None
32
+ sizes = [d.min for d in dimensions]
33
+ order = [i for i, w in enumerate(weights) if w > 0]
34
+ position = 0 # Where the round-robin cycle continues between the two passes.
35
+
36
+ def grow(limits: list[int], stop: int) -> None:
37
+ nonlocal position
38
+ remaining = stop - sum(sizes)
39
+ while remaining > 0:
40
+ open_items = [i for i in order if sizes[i] < limits[i]]
41
+ if not open_items:
42
+ return # The original would spin forever here; it cannot happen.
43
+ headroom = min(limits[i] - sizes[i] for i in open_items)
44
+ rounds = min(headroom, remaining // len(open_items))
45
+ if rounds:
46
+ for i in open_items:
47
+ sizes[i] += rounds
48
+ remaining -= rounds * len(open_items)
49
+ if remaining == 0:
50
+ # The cycle stopped right after the last open item it
51
+ # visited, which is the last open one counting round
52
+ # from the current position.
53
+ cycle = order[position:] + order[:position]
54
+ last = max(cycle.index(i) for i in open_items)
55
+ position = (position + last + 1) % len(order)
56
+ continue
57
+ # Fewer cells left than open items: hand them out one by one from
58
+ # the current position, skipping saturated items as the cycle does.
59
+ while remaining > 0:
60
+ i = order[position]
61
+ position = (position + 1) % len(order)
62
+ if sizes[i] < limits[i]:
63
+ sizes[i] += 1
64
+ remaining -= 1
65
+
66
+ grow([d.preferred for d in dimensions], min(available, total.preferred))
67
+ if fill:
68
+ grow([d.max for d in dimensions], min(available, total.max))
69
+ return sizes
70
+
71
+
72
+ _original_divide_widths = VSplit._divide_widths
73
+ _original_divide_heights = HSplit._divide_heights
74
+
75
+
76
+ def _divide_widths(self: VSplit, width: int) -> list[int] | None:
77
+ children = self._all_children
78
+ if not children:
79
+ return []
80
+ dimensions = [c.preferred_width(width) for c in children]
81
+ sizes = divide(dimensions, width)
82
+ if sizes is None and sum_layout_dimensions(dimensions).min <= width:
83
+ return _original_divide_widths(self, width)
84
+ return sizes
85
+
86
+
87
+ def _divide_heights(self: HSplit, write_position: WritePosition) -> list[int] | None:
88
+ from prompt_toolkit.application.current import get_app
89
+
90
+ if not self.children:
91
+ return []
92
+ width, height = write_position.width, write_position.height
93
+ dimensions = [c.preferred_height(width, height) for c in self._all_children]
94
+ sizes = divide(dimensions, height, fill=not get_app().is_done)
95
+ if sizes is None and sum_layout_dimensions(dimensions).min <= height:
96
+ return _original_divide_heights(self, write_position)
97
+ return sizes
98
+
99
+
100
+ def install_fast_layout_division() -> None:
101
+ """Replace the per-cell division on every split; idempotent."""
102
+ VSplit._divide_widths = _divide_widths
103
+ HSplit._divide_heights = _divide_heights
pcode/links.py ADDED
@@ -0,0 +1,90 @@
1
+ """Collect URLs from a conversation and open them in the user's browser."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+
10
+ # Markdown [label](url) first so the label survives; then bare URLs. Trailing
11
+ # punctuation that prose attaches to a URL is not part of it.
12
+ MARKDOWN_LINK = re.compile(r"\[([^\]\n]+)\]\((https?://[^\s)\\]+)\)")
13
+ BARE_URL = re.compile(r"https?://[^\s<>\"'`\]\\]+")
14
+ _TRAILING = ".,;:!?'\""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Link:
19
+ url: str
20
+ label: str = ""
21
+ source: str = "" # "user", "assistant", or a tool name
22
+
23
+
24
+ def _strip(url: str) -> str:
25
+ url = url.rstrip(_TRAILING)
26
+ # Balanced parentheses as in Wikipedia URLs stay; an unmatched close goes.
27
+ while url.endswith(")") and url.count("(") < url.count(")"):
28
+ url = url[:-1].rstrip(_TRAILING)
29
+ return url
30
+
31
+
32
+ def remember_link(found: dict[str, Link], link: Link) -> None:
33
+ """Move repeated URLs to their latest position, retaining useful labels."""
34
+ previous = found.pop(link.url, None)
35
+ label = link.label or (previous.label if previous else "")
36
+ found[link.url] = Link(link.url, label, link.source)
37
+
38
+
39
+ def extract_links(text: str, source: str = "") -> list[Link]:
40
+ """Return distinct URLs in last-seen order, oldest first."""
41
+ matches = [
42
+ (match.start(), Link(_strip(match.group(2)), match.group(1).strip(), source))
43
+ for match in MARKDOWN_LINK.finditer(text)
44
+ ]
45
+ # Preserve offsets while hiding Markdown links from the bare-URL pass.
46
+ remainder = MARKDOWN_LINK.sub(lambda match: " " * len(match.group(0)), text)
47
+ matches.extend(
48
+ (match.start(), Link(_strip(match.group(0)), "", source))
49
+ for match in BARE_URL.finditer(remainder)
50
+ )
51
+ found: dict[str, Link] = {}
52
+ for _, link in sorted(matches, key=lambda item: item[0]):
53
+ remember_link(found, link)
54
+ return list(found.values())
55
+
56
+
57
+ def conversation_links(tree, *, include_tools: bool = True) -> list[Link]:
58
+ """Links on the active path, oldest first; filter before deduplicating URLs."""
59
+ found: dict[str, Link] = {}
60
+ for identity in tree.path(tree.active):
61
+ node = tree.nodes[identity]
62
+ # A compaction's summary restates links it already listed; a merged
63
+ # side answer is part of the conversation like any reply.
64
+ if node.kind == "compaction":
65
+ continue
66
+ links = node.links if include_tools else node.message_links
67
+ for link in links.values():
68
+ remember_link(found, link)
69
+ return list(found.values())
70
+
71
+
72
+ def open_link(url: str) -> None:
73
+ """Hand the URL to the desktop without tying it to this terminal.
74
+
75
+ ``webbrowser`` is avoided on purpose: without a display it falls back to
76
+ text browsers such as lynx and takes over the terminal pcode is drawing.
77
+ """
78
+ if sys.platform == "win32":
79
+ os.startfile(url) # type: ignore[attr-defined]
80
+ return
81
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
82
+ if shutil.which(opener) is None:
83
+ raise RuntimeError(f"Cannot open links: `{opener}` is not on PATH.")
84
+ subprocess.Popen(
85
+ [opener, url],
86
+ stdin=subprocess.DEVNULL,
87
+ stdout=subprocess.DEVNULL,
88
+ stderr=subprocess.DEVNULL,
89
+ start_new_session=True,
90
+ )