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/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
|
+
)
|