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/delta.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Diffs rendered by delta (https://dandavison.github.io/delta/) when it is installed.
|
|
2
|
+
|
|
3
|
+
delta never reads the user's git config (`--no-gitconfig`, which also ignores
|
|
4
|
+
`--config`) or its own `DELTA_*`/`BAT_*` environment: `delta_args` is the one
|
|
5
|
+
place to customize it, so pcode's diffs look the same whatever `git diff` is set
|
|
6
|
+
up to do. pcode sets width, dark or light, no pager or headers, layout, and
|
|
7
|
+
syntax colors on deletions. It leaves out defaults the user's own arguments
|
|
8
|
+
already pass: delta rejects a flag given twice. Anything that goes wrong (delta
|
|
9
|
+
missing, unknown arguments, a timeout) returns None, and the caller falls back
|
|
10
|
+
to its own Rich rendering.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from rich.cells import chop_cells
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
LAYOUTS = ("auto", "unified", "side-by-side")
|
|
26
|
+
WIDTH_FLAGS = {"-w", "--width"}
|
|
27
|
+
SIDE_BY_SIDE_FLAGS = {"-s", "--side-by-side"}
|
|
28
|
+
LINE_NUMBER_FLAGS = {"-n", "--line-numbers"}
|
|
29
|
+
# `auto` switches to side-by-side at this width: each half then keeps about 90
|
|
30
|
+
# columns, enough for most code lines beside their line numbers.
|
|
31
|
+
SIDE_BY_SIDE_WIDTH = 180
|
|
32
|
+
TIMEOUT = 2.0
|
|
33
|
+
# A batch gets a little longer per patch, up to this.
|
|
34
|
+
MAX_TIMEOUT = 5.0
|
|
35
|
+
# Patches per delta run, so a batch fits within MAX_TIMEOUT.
|
|
36
|
+
BATCH = 60
|
|
37
|
+
# delta extends a background to the edge with "erase to end of line", which
|
|
38
|
+
# neither Rich nor prompt_toolkit keeps; it is replaced with padding instead.
|
|
39
|
+
ERASE_LINE = "\x1b[0K"
|
|
40
|
+
_MARK = "\ue000"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Delta:
|
|
45
|
+
executable: str
|
|
46
|
+
args: tuple[str, ...] = ()
|
|
47
|
+
layout: str = "unified"
|
|
48
|
+
light: bool = False
|
|
49
|
+
|
|
50
|
+
def side_by_side(self, width: int) -> bool:
|
|
51
|
+
if self.given() & SIDE_BY_SIDE_FLAGS:
|
|
52
|
+
return True
|
|
53
|
+
if self.layout == "auto":
|
|
54
|
+
return width >= SIDE_BY_SIDE_WIDTH
|
|
55
|
+
return self.layout == "side-by-side"
|
|
56
|
+
|
|
57
|
+
def given(self) -> set[str]:
|
|
58
|
+
"""The flags the user's own arguments set."""
|
|
59
|
+
return {arg.split("=", 1)[0] for arg in self.args if arg.startswith("-")}
|
|
60
|
+
|
|
61
|
+
def command(self, width: int) -> list[str]:
|
|
62
|
+
given = self.given()
|
|
63
|
+
defaults = [
|
|
64
|
+
({"--paging"}, "--paging=never"),
|
|
65
|
+
(WIDTH_FLAGS, f"--width={width}"),
|
|
66
|
+
# Match additions' syntax colors without changing delta's backgrounds.
|
|
67
|
+
({"--minus-style"}, "--minus-style=syntax auto"),
|
|
68
|
+
({"--minus-emph-style"}, "--minus-emph-style=syntax auto"),
|
|
69
|
+
({"--dark", "--light"}, "--light" if self.light else "--dark"),
|
|
70
|
+
# The block heading already names the file.
|
|
71
|
+
({"--file-style"}, "--file-style=omit"),
|
|
72
|
+
# A boxed `line: function` heading that often holds only a number.
|
|
73
|
+
({"--hunk-header-style"}, "--hunk-header-style=omit"),
|
|
74
|
+
]
|
|
75
|
+
if self.side_by_side(width):
|
|
76
|
+
defaults.append((SIDE_BY_SIDE_FLAGS, "--side-by-side"))
|
|
77
|
+
added = [flag for names, flag in defaults if not names & given]
|
|
78
|
+
isolated = [] if "--no-gitconfig" in given else ["--no-gitconfig"]
|
|
79
|
+
return [self.executable, *isolated, *added, *self.args]
|
|
80
|
+
|
|
81
|
+
def render(self, patch: str, width: int, *, cache: bool = True) -> list[Text] | None:
|
|
82
|
+
"""One styled Rich row per output row, or None if delta failed.
|
|
83
|
+
|
|
84
|
+
Safe to call off the event loop. Without `cache`, the output is not
|
|
85
|
+
kept, so the live preview's many short-lived bodies do not push the
|
|
86
|
+
settled blocks' renderings out.
|
|
87
|
+
"""
|
|
88
|
+
command = tuple(self.command(max(1, width)))
|
|
89
|
+
key = (command, patch)
|
|
90
|
+
with _lock:
|
|
91
|
+
cached = key in _cache
|
|
92
|
+
if cached:
|
|
93
|
+
_cache.move_to_end(key)
|
|
94
|
+
output = _cache[key]
|
|
95
|
+
if not cached:
|
|
96
|
+
output = _run(command, patch)
|
|
97
|
+
if cache:
|
|
98
|
+
_remember(key, output)
|
|
99
|
+
groups = self._groups(command, width, output)
|
|
100
|
+
return None if groups is None else [row for group in groups for row in group]
|
|
101
|
+
|
|
102
|
+
def render_all(self, patches: list[str], width: int) -> list[list[list[Text]] | None]:
|
|
103
|
+
"""Each patch through one delta run, as one group of rows per output line.
|
|
104
|
+
|
|
105
|
+
Unlike `render`, the results come back rather than through the shared
|
|
106
|
+
cache, so a review with more hunks than the cache holds is not evicted
|
|
107
|
+
while it is laid out. A line delta folds keeps its rows together, so a
|
|
108
|
+
caller can match output lines to patch lines. None marks a patch delta
|
|
109
|
+
failed on.
|
|
110
|
+
"""
|
|
111
|
+
command = tuple(self.command(max(1, width)))
|
|
112
|
+
outputs = self._outputs(command, patches)
|
|
113
|
+
return [self._groups(command, width, outputs.get(patch)) for patch in patches]
|
|
114
|
+
|
|
115
|
+
def prefetch(self, patches: list[str], width: int) -> None:
|
|
116
|
+
"""Render many patches through one delta process, ready for `render`."""
|
|
117
|
+
self._outputs(tuple(self.command(max(1, width))), patches)
|
|
118
|
+
|
|
119
|
+
def _outputs(self, command: tuple[str, ...], patches: list[str]) -> dict[str, str | None]:
|
|
120
|
+
"""delta's raw output for each patch, running only those not cached.
|
|
121
|
+
|
|
122
|
+
Starting delta costs tens of milliseconds, and a scrollback rebuild
|
|
123
|
+
renders every edit block at once. The patches go in one input,
|
|
124
|
+
separated by a marker line delta passes through untouched, since only
|
|
125
|
+
`+`, `-`, space and backslash lines continue a hunk. A big set goes in
|
|
126
|
+
batches, each within one run's timeout. If a batch's output does not
|
|
127
|
+
split back into one piece per patch, its patches are left unrendered
|
|
128
|
+
(None, and not cached) rather than each started alone: a review can hold
|
|
129
|
+
hundreds. If delta fails outright, every patch is recorded as failed,
|
|
130
|
+
so the blocks fall back to Rich at once instead of each waiting on its
|
|
131
|
+
own failing run.
|
|
132
|
+
"""
|
|
133
|
+
outputs: dict[str, str | None] = {}
|
|
134
|
+
with _lock:
|
|
135
|
+
for patch in dict.fromkeys(patches):
|
|
136
|
+
if (command, patch) in _cache:
|
|
137
|
+
outputs[patch] = _cache[(command, patch)]
|
|
138
|
+
missing = [patch for patch in dict.fromkeys(patches) if patch not in outputs]
|
|
139
|
+
if len(missing) == 1:
|
|
140
|
+
outputs[missing[0]] = _remember((command, missing[0]), _run(command, missing[0]))
|
|
141
|
+
return outputs
|
|
142
|
+
for start in range(0, len(missing), BATCH):
|
|
143
|
+
batch = missing[start : start + BATCH]
|
|
144
|
+
marker = f"pcode-delta-{secrets.token_hex(8)}"
|
|
145
|
+
output = _run(command, f"\n{marker}\n".join(batch), len(batch))
|
|
146
|
+
pieces = None if output is None else output.split(f"{marker}\n")
|
|
147
|
+
if pieces is not None and len(pieces) != len(batch):
|
|
148
|
+
outputs.update(dict.fromkeys(batch))
|
|
149
|
+
continue
|
|
150
|
+
for patch, piece in zip(batch, pieces or [None] * len(batch), strict=True):
|
|
151
|
+
outputs[patch] = _remember(
|
|
152
|
+
(command, patch), piece if piece and piece.strip() else None
|
|
153
|
+
)
|
|
154
|
+
return outputs
|
|
155
|
+
|
|
156
|
+
def _groups(
|
|
157
|
+
self, command: tuple[str, ...], width: int, output: str | None
|
|
158
|
+
) -> list[list[Text]] | None:
|
|
159
|
+
if output is None:
|
|
160
|
+
return None
|
|
161
|
+
lines = output.splitlines()
|
|
162
|
+
# With the file section omitted, delta still opens with its blank line.
|
|
163
|
+
# Only that one: a hunk's first context line can be blank too.
|
|
164
|
+
if lines and not lines[0].strip():
|
|
165
|
+
lines.pop(0)
|
|
166
|
+
# Padding to pcode's width would wrap every row of a wider user width.
|
|
167
|
+
pad = 0 if self.given() & WIDTH_FLAGS else width
|
|
168
|
+
gutter = bool(set(command) & LINE_NUMBER_FLAGS)
|
|
169
|
+
return [_rows(line, pad, gutter) for line in lines]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def from_preferences(preferences: dict[str, str], *, light: bool = False) -> Delta | None:
|
|
173
|
+
"""The configured delta, or None to render diffs with Rich."""
|
|
174
|
+
if preferences.get("diff_renderer", "delta") != "delta":
|
|
175
|
+
return None
|
|
176
|
+
executable = find_delta()
|
|
177
|
+
if executable is None:
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
args = tuple(shlex.split(preferences.get("delta_args", "")))
|
|
181
|
+
except ValueError:
|
|
182
|
+
args = ()
|
|
183
|
+
layout = preferences.get("diff_layout", "unified")
|
|
184
|
+
return Delta(executable, args, layout if layout in LAYOUTS else "unified", light)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def find_delta() -> str | None:
|
|
188
|
+
return shutil.which("delta")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# delta's output by (command, patch): a rebuild at the same width and palette
|
|
192
|
+
# reuses it rather than starting delta again.
|
|
193
|
+
_cache: OrderedDict[tuple[tuple[str, ...], str], str | None] = OrderedDict()
|
|
194
|
+
CACHE_SIZE = 256
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# The live edit preview renders from a worker thread.
|
|
198
|
+
_lock = threading.Lock()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _remember(key: tuple[tuple[str, ...], str], output: str | None) -> str | None:
|
|
202
|
+
with _lock:
|
|
203
|
+
_cache[key] = output
|
|
204
|
+
_cache.move_to_end(key)
|
|
205
|
+
while len(_cache) > CACHE_SIZE:
|
|
206
|
+
_cache.popitem(last=False)
|
|
207
|
+
return output
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def preview_patch(path: str, body: str) -> str:
|
|
211
|
+
"""A unified diff of a streaming edit preview's `-`/`+` lines.
|
|
212
|
+
|
|
213
|
+
The preview has no line numbers, so the hunk is numbered from 1; its
|
|
214
|
+
header is never drawn. The path's extension picks the
|
|
215
|
+
syntax highlighting. A line the preview's tail clip cut short of its
|
|
216
|
+
prefix is dropped.
|
|
217
|
+
"""
|
|
218
|
+
lines = [line for line in body.split("\n") if line[:1] in ("-", "+")]
|
|
219
|
+
removed = sum(line.startswith("-") for line in lines)
|
|
220
|
+
header = [f"--- a/{path}", f"+++ b/{path}", f"@@ -1,{removed} +1,{len(lines) - removed} @@"]
|
|
221
|
+
return "\n".join(header + lines)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _run(command: tuple[str, ...], patch: str, count: int = 1) -> str | None:
|
|
225
|
+
try:
|
|
226
|
+
done = subprocess.run(
|
|
227
|
+
command,
|
|
228
|
+
input=patch + "\n",
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
encoding="utf-8",
|
|
232
|
+
errors="replace",
|
|
233
|
+
timeout=min(TIMEOUT + 0.05 * count, MAX_TIMEOUT),
|
|
234
|
+
# A user's --paging=always would start a pager holding the pipes.
|
|
235
|
+
env=_environment(),
|
|
236
|
+
# No controlling terminal: delta must never query or write the
|
|
237
|
+
# terminal pcode is drawing on.
|
|
238
|
+
start_new_session=True,
|
|
239
|
+
)
|
|
240
|
+
except (OSError, subprocess.SubprocessError):
|
|
241
|
+
return None
|
|
242
|
+
if done.returncode != 0 or not done.stdout.strip():
|
|
243
|
+
return None
|
|
244
|
+
return done.stdout
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _environment() -> dict[str, str]:
|
|
248
|
+
# DELTA_FEATURES, BAT_THEME and the like configure delta behind the
|
|
249
|
+
# user's arguments. A pager could hold the pipes open, so it is `cat`.
|
|
250
|
+
env = {
|
|
251
|
+
key: value for key, value in os.environ.items() if not key.startswith(("DELTA_", "BAT_"))
|
|
252
|
+
}
|
|
253
|
+
return {**env, "DELTA_PAGER": "cat"}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _rows(raw: str, width: int, gutter: bool) -> list[Text]:
|
|
257
|
+
"""Parse one ANSI line into rows of at most `width` cells (0 for any width).
|
|
258
|
+
|
|
259
|
+
The unified layout never wraps, and a long row left to the terminal would
|
|
260
|
+
restart under the line numbers, so it is folded here, each continuation
|
|
261
|
+
indented past the `gutter` when `--line-numbers` draws one. Where delta erased to the
|
|
262
|
+
end of the line, every row is padded in that background.
|
|
263
|
+
"""
|
|
264
|
+
if ERASE_LINE not in raw:
|
|
265
|
+
text, style = Text.from_ansi(raw), None
|
|
266
|
+
else:
|
|
267
|
+
text = Text.from_ansi(raw.split(ERASE_LINE, 1)[0] + _MARK)
|
|
268
|
+
style = next(
|
|
269
|
+
(span.style for span in reversed(text.spans) if span.start <= len(text) - 1 < span.end),
|
|
270
|
+
"",
|
|
271
|
+
)
|
|
272
|
+
text.right_crop(1)
|
|
273
|
+
rows = _fold(text, width, gutter) if width else [text]
|
|
274
|
+
if style is not None:
|
|
275
|
+
for row in rows:
|
|
276
|
+
if (pad := width - row.cell_len) > 0:
|
|
277
|
+
row.append(" " * pad, style)
|
|
278
|
+
return rows
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _fold(text: Text, width: int, gutter: bool) -> list[Text]:
|
|
282
|
+
if text.cell_len <= width:
|
|
283
|
+
return [text]
|
|
284
|
+
# The line-number gutter ends at its first bar, like ` 24 ⋮ 25 │`.
|
|
285
|
+
bar = text.plain.find("│") if gutter else -1
|
|
286
|
+
indent = bar + 1 if 0 < bar < width // 2 else 0
|
|
287
|
+
prefix = Text(" " * (indent - 1)) + text[bar:indent] if indent else Text()
|
|
288
|
+
# chop_cells measures whole grapheme clusters, so an emoji never splits.
|
|
289
|
+
first = chop_cells(text.plain, width)[0]
|
|
290
|
+
start = len(first)
|
|
291
|
+
rows = [text[:start]]
|
|
292
|
+
for piece in chop_cells(text.plain[start:], width - indent):
|
|
293
|
+
rows.append(prefix + text[start : start + len(piece)])
|
|
294
|
+
start += len(piece)
|
|
295
|
+
return rows
|
pcode/diagnostics.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Useful provider errors without HTTP headers or credential values."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sysconfig
|
|
6
|
+
import traceback
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
RESTART = "Restart pcode to load it; /resume picks this conversation back up."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def stale_install() -> str | None:
|
|
15
|
+
"""Why this process's code may no longer match what is on disk.
|
|
16
|
+
|
|
17
|
+
pcode imports much of itself lazily, and the global install is editable, so
|
|
18
|
+
a long-lived session that outlives a merge mixes old modules with new ones
|
|
19
|
+
(a new module reading a setting the old one never defined), and one that
|
|
20
|
+
outlives a reinstall on another Python finds its site-packages deleted.
|
|
21
|
+
Both surface as unrelated-looking errors far from the cause.
|
|
22
|
+
"""
|
|
23
|
+
import pcode
|
|
24
|
+
|
|
25
|
+
if not Path(sysconfig.get_paths()["purelib"]).is_dir():
|
|
26
|
+
return f"pcode was reinstalled since this session started. {RESTART}"
|
|
27
|
+
try:
|
|
28
|
+
changed = any(
|
|
29
|
+
path.stat().st_mtime > pcode.STARTED
|
|
30
|
+
for path in Path(pcode.__file__).parent.rglob("*.py")
|
|
31
|
+
)
|
|
32
|
+
except OSError:
|
|
33
|
+
changed = True # A file vanished mid-scan: the source is changing.
|
|
34
|
+
return f"pcode's source changed since this session started. {RESTART}" if changed else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_TOKEN = re.compile(
|
|
38
|
+
r"\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,}|"
|
|
39
|
+
r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b"
|
|
40
|
+
)
|
|
41
|
+
_ASSIGNMENT = re.compile(
|
|
42
|
+
r"(?i)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|"
|
|
43
|
+
r"password|secret|authorization)[\"']?\s*[:=]\s*[\"']?)([^\s\"',;}]+)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def redact(text: str) -> str:
|
|
48
|
+
for name, value in os.environ.items():
|
|
49
|
+
if len(value) >= 8 and re.search(r"TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY", name):
|
|
50
|
+
text = text.replace(value, "[redacted]")
|
|
51
|
+
text = re.sub(r"(?i)\bBearer\s+[^\s\"',;}]+", "Bearer [redacted]", text)
|
|
52
|
+
text = re.sub(r"(?i)(https?://)[^/\s@]+@", r"\1[redacted]@", text)
|
|
53
|
+
text = _TOKEN.sub("[redacted]", text)
|
|
54
|
+
text = _ASSIGNMENT.sub(r"\1[redacted]", text)
|
|
55
|
+
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Enough for a deep cause chain through the agent graph, small enough that a
|
|
59
|
+
# failing loop cannot fill a session directory.
|
|
60
|
+
REPORT_CHARS = 32_000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def error_report(error: BaseException) -> str:
|
|
64
|
+
"""The frames `error_details` deliberately omits, redacted and bounded.
|
|
65
|
+
|
|
66
|
+
Frames and exception messages only: locals are never captured, so a request
|
|
67
|
+
object or credential held in a stack frame cannot reach the file. Keep the
|
|
68
|
+
end when truncating -- the innermost frames are where the failure is.
|
|
69
|
+
"""
|
|
70
|
+
text = redact("".join(traceback.format_exception(error)))
|
|
71
|
+
if len(text) <= REPORT_CHARS:
|
|
72
|
+
return text
|
|
73
|
+
return "[earlier frames omitted]\n" + text[-REPORT_CHARS:]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error_details(error: BaseException) -> dict:
|
|
77
|
+
"""Keep bounded, redacted causes without traceback locals or request objects."""
|
|
78
|
+
return _error_details(error, set())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _error_details(error: BaseException, seen: set[int]) -> dict:
|
|
82
|
+
if id(error) in seen or len(seen) >= 16:
|
|
83
|
+
return {"type": type(error).__name__, "truncated": True}
|
|
84
|
+
seen.add(id(error))
|
|
85
|
+
if isinstance(error, BaseExceptionGroup) and error.exceptions:
|
|
86
|
+
return _error_details(error.exceptions[0], seen)
|
|
87
|
+
result = {"type": type(error).__name__}
|
|
88
|
+
status = getattr(error, "status_code", None)
|
|
89
|
+
if isinstance(status, int):
|
|
90
|
+
result["status"] = status
|
|
91
|
+
body = getattr(error, "body", None)
|
|
92
|
+
if isinstance(body, dict):
|
|
93
|
+
detail = body.get("error", body)
|
|
94
|
+
if isinstance(detail, dict):
|
|
95
|
+
for key in ("message", "code", "param", "type"):
|
|
96
|
+
value = detail.get(key)
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
result[f"provider_{key}"] = redact(value)[:4000]
|
|
99
|
+
if "provider_message" not in result:
|
|
100
|
+
result["message"] = redact(str(error))[:4000]
|
|
101
|
+
if error.__cause__ is not None:
|
|
102
|
+
result["cause"] = _error_details(error.__cause__, seen)
|
|
103
|
+
elif error.__context__ is not None and not error.__suppress_context__:
|
|
104
|
+
result["context"] = _error_details(error.__context__, seen)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
TRANSIENT_TRANSPORT = frozenset(
|
|
109
|
+
{
|
|
110
|
+
"RemoteProtocolError",
|
|
111
|
+
"APIConnectionError",
|
|
112
|
+
"ConnectError",
|
|
113
|
+
"ConnectTimeout",
|
|
114
|
+
"ReadError",
|
|
115
|
+
"ReadTimeout",
|
|
116
|
+
"WriteError",
|
|
117
|
+
"WriteTimeout",
|
|
118
|
+
"APITimeoutError",
|
|
119
|
+
"IncompleteRead",
|
|
120
|
+
# A `claude:` CLI process ended mid-request; the retry starts another.
|
|
121
|
+
"ClaudeProcessError",
|
|
122
|
+
# A `claude:` request got no HTTP response; the retry forks the transcript.
|
|
123
|
+
"ClaudeConnectionError",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def transport_types(error: BaseException) -> set[str]:
|
|
129
|
+
"""Name every exception in the bounded cause chain, never its text."""
|
|
130
|
+
names, detail = set(), error_details(error)
|
|
131
|
+
while detail:
|
|
132
|
+
names.add(detail["type"])
|
|
133
|
+
detail = detail.get("cause", detail.get("context", {}))
|
|
134
|
+
return names
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def transient(error: BaseException) -> bool:
|
|
138
|
+
"""Whether the provider dropped the connection rather than answering.
|
|
139
|
+
|
|
140
|
+
A status code means the provider did answer: rate limits and server errors
|
|
141
|
+
need their own handling, so they are deliberately not transient here.
|
|
142
|
+
"""
|
|
143
|
+
detail = error_details(error)
|
|
144
|
+
while detail:
|
|
145
|
+
if "status" in detail:
|
|
146
|
+
return False
|
|
147
|
+
detail = detail.get("cause", detail.get("context", {}))
|
|
148
|
+
return bool(transport_types(error) & TRANSIENT_TRANSPORT)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def provider_context(model) -> dict[str, str]:
|
|
152
|
+
"""Best-effort configured route, never requests, headers, or proxy credentials.
|
|
153
|
+
|
|
154
|
+
This names the parent model, not necessarily a failing delegated request.
|
|
155
|
+
Unresolved model strings must not trigger credential loading during failure.
|
|
156
|
+
"""
|
|
157
|
+
result = {}
|
|
158
|
+
try:
|
|
159
|
+
if isinstance(model, str):
|
|
160
|
+
result["model"] = redact(model)
|
|
161
|
+
return result
|
|
162
|
+
result["model"] = redact(model.model_name)
|
|
163
|
+
provider = model.provider
|
|
164
|
+
if provider is not None:
|
|
165
|
+
result["provider"] = redact(provider.name)
|
|
166
|
+
url = urlsplit(str(provider.client.base_url))
|
|
167
|
+
if url.scheme in {"http", "https"} and url.hostname:
|
|
168
|
+
host = url.hostname
|
|
169
|
+
if ":" in host:
|
|
170
|
+
host = f"[{host}]"
|
|
171
|
+
if url.port is not None:
|
|
172
|
+
host += f":{url.port}"
|
|
173
|
+
# Drop userinfo, query, and fragment even for unfamiliar secrets.
|
|
174
|
+
result["base_url"] = redact(urlunsplit((url.scheme, host, url.path, "", "")))
|
|
175
|
+
except Exception:
|
|
176
|
+
# Diagnostics must never replace the original failure.
|
|
177
|
+
pass
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def quota_message(error: BaseException) -> str | None:
|
|
182
|
+
"""Classify bounded provider causes; never echo arbitrary response text."""
|
|
183
|
+
detail = error_details(error)
|
|
184
|
+
rate_limited = False
|
|
185
|
+
http_status = None
|
|
186
|
+
while detail:
|
|
187
|
+
if http_status is None:
|
|
188
|
+
http_status = detail.get("status")
|
|
189
|
+
text = " ".join(
|
|
190
|
+
str(detail.get(key, ""))
|
|
191
|
+
for key in ("provider_code", "provider_type", "provider_message", "message")
|
|
192
|
+
).lower()
|
|
193
|
+
if any(
|
|
194
|
+
marker in text
|
|
195
|
+
for marker in (
|
|
196
|
+
"insufficient_quota",
|
|
197
|
+
"no credits remaining",
|
|
198
|
+
"credit balance is too low",
|
|
199
|
+
"billing_hard_limit_reached",
|
|
200
|
+
"exceeded your current quota",
|
|
201
|
+
)
|
|
202
|
+
):
|
|
203
|
+
return (
|
|
204
|
+
"Provider quota or credits exhausted. Check usage and billing for the "
|
|
205
|
+
"selected provider account; a new login may not help. "
|
|
206
|
+
"See the saved session diagnostics."
|
|
207
|
+
)
|
|
208
|
+
rate_limited |= detail.get("status") == 429 or (
|
|
209
|
+
"status" not in detail
|
|
210
|
+
and any(marker in text for marker in ("rate_limit_error", "rate_limit_exceeded"))
|
|
211
|
+
)
|
|
212
|
+
detail = detail.get("cause", detail.get("context", {}))
|
|
213
|
+
if rate_limited:
|
|
214
|
+
return (
|
|
215
|
+
f"Provider rate limit reached{f' (HTTP {http_status})' if http_status else ''}. "
|
|
216
|
+
"Wait before retrying and check "
|
|
217
|
+
"the selected account's usage limits. See the saved session diagnostics."
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def versions() -> dict[str, str]:
|
|
223
|
+
return {
|
|
224
|
+
package: version(package)
|
|
225
|
+
for package in ("pcode", "pydantic-ai-slim", "pydantic-ai-harness")
|
|
226
|
+
}
|
pcode/edit_preview.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Transient, line-buffered projections of tool arguments; never execute them."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from time import monotonic
|
|
6
|
+
|
|
7
|
+
from pydantic_ai.messages import (
|
|
8
|
+
FunctionToolCallEvent,
|
|
9
|
+
FunctionToolResultEvent,
|
|
10
|
+
PartDeltaEvent,
|
|
11
|
+
PartEndEvent,
|
|
12
|
+
PartStartEvent,
|
|
13
|
+
ToolCallPart,
|
|
14
|
+
ToolCallPartDelta,
|
|
15
|
+
)
|
|
16
|
+
from pydantic_core import from_json
|
|
17
|
+
|
|
18
|
+
from pcode.edits import MAX_SOURCE, edit_text, sensitive_path
|
|
19
|
+
from pcode.runtime import EditPreview
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StreamingEditPreview:
|
|
23
|
+
def __init__(self, root: Path | None = None):
|
|
24
|
+
self.root = (root or Path.cwd()).resolve()
|
|
25
|
+
self.parts = {}
|
|
26
|
+
self.shown = {}
|
|
27
|
+
self.updated = {}
|
|
28
|
+
self.blocked = set()
|
|
29
|
+
|
|
30
|
+
def update(self, event):
|
|
31
|
+
clear = []
|
|
32
|
+
if isinstance(event, PartStartEvent):
|
|
33
|
+
if not isinstance(event.part, ToolCallPart):
|
|
34
|
+
return clear
|
|
35
|
+
index = event.index
|
|
36
|
+
if index in self.shown:
|
|
37
|
+
clear.append(EditPreview(f"edit-preview:{index}"))
|
|
38
|
+
self.shown.pop(index, None)
|
|
39
|
+
self.blocked.discard(index)
|
|
40
|
+
self.updated.pop(index, None)
|
|
41
|
+
self.parts[index] = deepcopy(event.part)
|
|
42
|
+
elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, ToolCallPartDelta):
|
|
43
|
+
index = event.index
|
|
44
|
+
if index not in self.parts or index in self.blocked:
|
|
45
|
+
return clear
|
|
46
|
+
self.parts[index] = event.delta.apply(self.parts[index])
|
|
47
|
+
elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
|
|
48
|
+
index = event.index
|
|
49
|
+
if index in self.blocked:
|
|
50
|
+
return clear
|
|
51
|
+
self.parts[index] = deepcopy(event.part)
|
|
52
|
+
elif isinstance(event, (FunctionToolCallEvent, FunctionToolResultEvent)):
|
|
53
|
+
call_id = event.part.tool_call_id
|
|
54
|
+
for index, part in list(self.parts.items()):
|
|
55
|
+
if part.tool_call_id == call_id:
|
|
56
|
+
self.parts.pop(index)
|
|
57
|
+
self.updated.pop(index, None)
|
|
58
|
+
if self.shown.pop(index, None) is not None:
|
|
59
|
+
clear.append(EditPreview(f"edit-preview:{index}"))
|
|
60
|
+
return clear
|
|
61
|
+
else:
|
|
62
|
+
return clear
|
|
63
|
+
|
|
64
|
+
part = self.parts[index]
|
|
65
|
+
if part.tool_name not in ("edit_file", "write_file", "run_code"):
|
|
66
|
+
return clear
|
|
67
|
+
if isinstance(part.args, str) and len(part.args) > MAX_SOURCE * 2:
|
|
68
|
+
self.blocked.add(index)
|
|
69
|
+
self.parts.pop(index)
|
|
70
|
+
if self.shown.pop(index, None) is not None:
|
|
71
|
+
clear.append(EditPreview(f"edit-preview:{index}"))
|
|
72
|
+
return clear
|
|
73
|
+
now = monotonic()
|
|
74
|
+
if isinstance(event, PartDeltaEvent) and now - self.updated.get(index, 0) < 0.05:
|
|
75
|
+
return clear
|
|
76
|
+
self.updated[index] = now
|
|
77
|
+
try:
|
|
78
|
+
if isinstance(part.args, str):
|
|
79
|
+
complete = from_json(part.args, allow_partial=True)
|
|
80
|
+
partial = from_json(part.args, allow_partial="trailing-strings")
|
|
81
|
+
else:
|
|
82
|
+
complete = partial = part.args
|
|
83
|
+
except ValueError:
|
|
84
|
+
return clear
|
|
85
|
+
if not isinstance(complete, dict) or not isinstance(partial, dict):
|
|
86
|
+
return clear
|
|
87
|
+
if part.tool_name == "run_code":
|
|
88
|
+
return clear + self._code_preview(index, partial, complete)
|
|
89
|
+
path = complete.get("path")
|
|
90
|
+
# Never expose content until the complete path is known and checked.
|
|
91
|
+
permitted = isinstance(path, str) and not sensitive_path(path)
|
|
92
|
+
if permitted:
|
|
93
|
+
try:
|
|
94
|
+
resolved = (self.root / path).resolve()
|
|
95
|
+
permitted = (
|
|
96
|
+
not Path(path).is_absolute()
|
|
97
|
+
and resolved.is_relative_to(self.root)
|
|
98
|
+
and not sensitive_path(str(resolved.relative_to(self.root)))
|
|
99
|
+
)
|
|
100
|
+
except (OSError, ValueError, RuntimeError):
|
|
101
|
+
permitted = False
|
|
102
|
+
if not permitted:
|
|
103
|
+
if self.shown.pop(index, None) is not None:
|
|
104
|
+
clear.append(EditPreview(f"edit-preview:{index}"))
|
|
105
|
+
return clear
|
|
106
|
+
lines = []
|
|
107
|
+
fields = (
|
|
108
|
+
[("content", "+")]
|
|
109
|
+
if part.tool_name == "write_file"
|
|
110
|
+
else [("old_text", "-"), ("new_text", "+")]
|
|
111
|
+
)
|
|
112
|
+
pairs = [(partial, complete)]
|
|
113
|
+
if part.tool_name == "edit_file" and isinstance(partial.get("replacements"), list):
|
|
114
|
+
finished = complete.get("replacements", [])
|
|
115
|
+
if not isinstance(finished, list):
|
|
116
|
+
finished = []
|
|
117
|
+
pairs = [
|
|
118
|
+
(item, finished[i] if i < len(finished) and isinstance(finished[i], dict) else {})
|
|
119
|
+
for i, item in enumerate(partial["replacements"])
|
|
120
|
+
if isinstance(item, dict)
|
|
121
|
+
]
|
|
122
|
+
for proposed, finished in pairs:
|
|
123
|
+
for field, prefix in fields:
|
|
124
|
+
text = proposed.get(field)
|
|
125
|
+
if not isinstance(text, str) or len(text) > MAX_SOURCE:
|
|
126
|
+
continue
|
|
127
|
+
# Sanitize before clipping; unfinished strings expose only whole lines.
|
|
128
|
+
text = edit_text(text)
|
|
129
|
+
if field not in finished:
|
|
130
|
+
text = text[: text.rfind("\n") + 1]
|
|
131
|
+
lines.extend(prefix + line for line in text.splitlines())
|
|
132
|
+
preview = EditPreview(f"edit-preview:{index}", edit_text(path), "\n".join(lines)[-8192:])
|
|
133
|
+
return clear + self._show(index, preview)
|
|
134
|
+
|
|
135
|
+
def _code_preview(self, index: int, partial: dict, complete: dict) -> list:
|
|
136
|
+
"""Project a sandboxed snippet as it streams, before it is ever executed."""
|
|
137
|
+
code = partial.get("code")
|
|
138
|
+
if not isinstance(code, str) or len(code) > MAX_SOURCE:
|
|
139
|
+
if self.shown.pop(index, None) is not None:
|
|
140
|
+
return [EditPreview(f"edit-preview:{index}")]
|
|
141
|
+
return []
|
|
142
|
+
# Sanitize before clipping; an unfinished string exposes only whole lines.
|
|
143
|
+
text = edit_text(code)
|
|
144
|
+
if "code" not in complete:
|
|
145
|
+
text = text[: text.rfind("\n") + 1]
|
|
146
|
+
if not text:
|
|
147
|
+
# No complete line yet: an empty box would only flash open and shut.
|
|
148
|
+
return []
|
|
149
|
+
return self._show(
|
|
150
|
+
index,
|
|
151
|
+
EditPreview(f"edit-preview:{index}", "run_code", text[-8192:], kind="code"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _show(self, index: int, preview: EditPreview) -> list:
|
|
155
|
+
if preview == self.shown.get(index):
|
|
156
|
+
return []
|
|
157
|
+
self.shown[index] = preview
|
|
158
|
+
return [preview]
|