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/file_refs.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Workspace file references: type `@` in the prompt to insert a relative path.
|
|
2
|
+
|
|
3
|
+
Completing `@ui.py` inserts `./src/pcode/ui.py`, the same workspace-relative
|
|
4
|
+
form the file tools accept, so the model can read or search it without guessing
|
|
5
|
+
where the file lives. The path is all pcode sends; whether the file is worth
|
|
6
|
+
reading is the model's call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from time import monotonic
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
18
|
+
from prompt_toolkit.document import Document
|
|
19
|
+
from prompt_toolkit.lexers import Lexer
|
|
20
|
+
|
|
21
|
+
# Directories that never hold source worth referencing, used only when the
|
|
22
|
+
# workspace is not a Git checkout (Git supplies its own ignore rules).
|
|
23
|
+
# Ripgrep skips the hidden ones on its own; the rest it would happily list.
|
|
24
|
+
IGNORED_DIRECTORIES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
".git",
|
|
27
|
+
".hg",
|
|
28
|
+
".svn",
|
|
29
|
+
".mypy_cache",
|
|
30
|
+
".pytest_cache",
|
|
31
|
+
".ruff_cache",
|
|
32
|
+
".tox",
|
|
33
|
+
".venv",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
"build",
|
|
36
|
+
"dist",
|
|
37
|
+
"node_modules",
|
|
38
|
+
"venv",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
MAX_FILES = 20000
|
|
42
|
+
# Paths read from a listing before the menu's own cap applies; see _ripgrep_files.
|
|
43
|
+
LISTING_CEILING = MAX_FILES * 10
|
|
44
|
+
MAX_COMPLETIONS = 50
|
|
45
|
+
CACHE_SECONDS = 10.0
|
|
46
|
+
|
|
47
|
+
# A reference is a whitespace-delimited token: an unfinished `@fragment`, a
|
|
48
|
+
# `./` or `../` path, or a quoted path whose name contains spaces.
|
|
49
|
+
REFERENCE_PATTERN = re.compile(r'(?<!\S)(?:"\.{1,2}/[^"\n]+"|@\S*|\.{1,2}/\S+)')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _git_files(root: Path) -> list[str] | None:
|
|
53
|
+
"""Tracked and untracked files below root, or None outside a Git checkout."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.SubprocessError):
|
|
62
|
+
return None
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
return None
|
|
65
|
+
# A file staged for deletion is still cached; listing it would complete to a
|
|
66
|
+
# path that no longer exists, so drop duplicates and keep insertion order.
|
|
67
|
+
return list(dict.fromkeys(result.stdout.splitlines()))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ripgrep_files(root: Path) -> list[str] | None:
|
|
71
|
+
"""Files below root via ripgrep, or None when `rg` cannot be run.
|
|
72
|
+
|
|
73
|
+
Used where there is no Git listing to borrow. Ripgrep walks in parallel and
|
|
74
|
+
skips hidden entries itself, which beats `os.walk` several times over on a
|
|
75
|
+
large tree; completion runs on the UI event loop, so that time is a visible
|
|
76
|
+
stall between keystrokes.
|
|
77
|
+
|
|
78
|
+
Ripgrep's default `.gitignore` handling is left alone: outside a checkout it
|
|
79
|
+
reads those files only under `--no-require-git`, and tool-managed trees such
|
|
80
|
+
as a uv cache or a virtualenv carry a `.gitignore` of `*` that would leave
|
|
81
|
+
the menu empty. `.ignore` files still apply, as they do everywhere.
|
|
82
|
+
|
|
83
|
+
Output is read as it arrives and the process is stopped at LISTING_CEILING,
|
|
84
|
+
which bounds an unreasonable tree without truncating a merely large one:
|
|
85
|
+
ripgrep's parallel walk emits in a different order each run, so a cut below
|
|
86
|
+
the caller's own cap would reshuffle the menu between refreshes.
|
|
87
|
+
"""
|
|
88
|
+
excluded = ",".join(sorted(IGNORED_DIRECTORIES))
|
|
89
|
+
arguments = ["rg", "--files", "--glob", f"!{{{excluded}}}"]
|
|
90
|
+
paths: list[str] = []
|
|
91
|
+
try:
|
|
92
|
+
with subprocess.Popen(
|
|
93
|
+
arguments, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
|
|
94
|
+
) as process:
|
|
95
|
+
for line in process.stdout:
|
|
96
|
+
paths.append(line.rstrip("\n"))
|
|
97
|
+
if len(paths) >= LISTING_CEILING:
|
|
98
|
+
process.terminate()
|
|
99
|
+
break
|
|
100
|
+
# A stopped run is killed mid-write, so only judge a complete one.
|
|
101
|
+
if process.wait() not in (0, 1) and len(paths) < LISTING_CEILING:
|
|
102
|
+
return None
|
|
103
|
+
except (OSError, subprocess.SubprocessError):
|
|
104
|
+
return None
|
|
105
|
+
# Ripgrep's parallel walk emits in thread completion order; sorting restores
|
|
106
|
+
# the stable menu `os.walk` gave, and costs far less than `--sort path`,
|
|
107
|
+
# which would make ripgrep search serially.
|
|
108
|
+
return sorted(paths)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _walked_files(root: Path) -> list[str]:
|
|
112
|
+
"""Files below root in pure Python, for a machine without ripgrep."""
|
|
113
|
+
paths: list[str] = []
|
|
114
|
+
for directory, subdirectories, names in os.walk(root):
|
|
115
|
+
subdirectories[:] = sorted(
|
|
116
|
+
name
|
|
117
|
+
for name in subdirectories
|
|
118
|
+
if name not in IGNORED_DIRECTORIES and not name.startswith(".")
|
|
119
|
+
)
|
|
120
|
+
base = Path(directory)
|
|
121
|
+
for name in sorted(names):
|
|
122
|
+
if name.startswith("."):
|
|
123
|
+
continue
|
|
124
|
+
paths.append(str((base / name).relative_to(root)))
|
|
125
|
+
if len(paths) >= MAX_FILES:
|
|
126
|
+
return paths
|
|
127
|
+
return paths
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def human_size(size: int) -> str:
|
|
131
|
+
if size < 1024:
|
|
132
|
+
return f"{size} B"
|
|
133
|
+
if size < 1024 * 1024:
|
|
134
|
+
return f"{size / 1024:.1f} KB"
|
|
135
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class WorkspaceFiles:
|
|
140
|
+
"""Workspace-relative file paths, refreshed at most every CACHE_SECONDS.
|
|
141
|
+
|
|
142
|
+
Completion runs on every keystroke, so the listing is cached rather than
|
|
143
|
+
re-read; new files show up within the refresh window.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
root: Path
|
|
147
|
+
cache_seconds: float = CACHE_SECONDS
|
|
148
|
+
_paths: list[str] = field(default_factory=list)
|
|
149
|
+
_loaded_at: float | None = None
|
|
150
|
+
|
|
151
|
+
def paths(self) -> list[str]:
|
|
152
|
+
now = monotonic()
|
|
153
|
+
if self._loaded_at is None or now - self._loaded_at >= self.cache_seconds:
|
|
154
|
+
# An empty listing falls through as a missing one: a workspace that
|
|
155
|
+
# Git or ripgrep ignores wholesale (a gitignored directory, say) is
|
|
156
|
+
# still worth walking rather than offering nothing.
|
|
157
|
+
listed = _git_files(self.root) or _ripgrep_files(self.root)
|
|
158
|
+
self._paths = (listed or _walked_files(self.root))[:MAX_FILES]
|
|
159
|
+
self._loaded_at = now
|
|
160
|
+
return self._paths
|
|
161
|
+
|
|
162
|
+
def matches(self, fragment: str, limit: int = MAX_COMPLETIONS) -> list[str]:
|
|
163
|
+
"""Paths containing fragment, closest match first.
|
|
164
|
+
|
|
165
|
+
A basename hit outranks a directory hit, and an earlier hit outranks a
|
|
166
|
+
later one, so `@ui.py` offers `src/pcode/ui.py` before `tests/test_ui.py`.
|
|
167
|
+
"""
|
|
168
|
+
needle = fragment.lstrip("@").removeprefix("./").lower()
|
|
169
|
+
if not needle:
|
|
170
|
+
# Shallowest first: the interesting top-level files, not build output.
|
|
171
|
+
return sorted(self.paths(), key=lambda path: (path.count("/"), path))[:limit]
|
|
172
|
+
ranked = []
|
|
173
|
+
for path in self.paths():
|
|
174
|
+
position = path.lower().find(needle)
|
|
175
|
+
if position < 0:
|
|
176
|
+
continue
|
|
177
|
+
name_position = os.path.basename(path).lower().find(needle)
|
|
178
|
+
ranked.append(((0, name_position) if name_position >= 0 else (1, position), path))
|
|
179
|
+
ranked.sort(key=lambda item: (item[0], len(item[1]), item[1]))
|
|
180
|
+
return [path for _, path in ranked[:limit]]
|
|
181
|
+
|
|
182
|
+
def describe(self, path: str) -> str:
|
|
183
|
+
"""The file's size, so a reference's cost to read is visible before picking."""
|
|
184
|
+
try:
|
|
185
|
+
return human_size((self.root / path).stat().st_size)
|
|
186
|
+
except OSError:
|
|
187
|
+
return "file"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def reference_fragment(text: str) -> str | None:
|
|
191
|
+
"""The path fragment of a trailing `@` reference, or None when absent.
|
|
192
|
+
|
|
193
|
+
`@` only starts a reference at a word boundary, so emails and decorators in
|
|
194
|
+
pasted text never open the menu.
|
|
195
|
+
"""
|
|
196
|
+
token = text.rsplit(maxsplit=1)[-1] if text.split() else ""
|
|
197
|
+
if not text or text[-1].isspace() or not token.startswith("@"):
|
|
198
|
+
return None
|
|
199
|
+
return token[1:]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ReferenceLexer(Lexer):
|
|
203
|
+
"""Style file references in the editor so they read as paths, not prose.
|
|
204
|
+
|
|
205
|
+
``extra`` adds further ``(pattern, style)`` tokens, such as paste markers.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, extra: list[tuple[re.Pattern[str], str]] = ()) -> None:
|
|
209
|
+
self.patterns = [(REFERENCE_PATTERN, "class:reference"), *extra]
|
|
210
|
+
|
|
211
|
+
def lex_document(self, document: Document):
|
|
212
|
+
def line_fragments(number: int):
|
|
213
|
+
line = document.lines[number]
|
|
214
|
+
matches = sorted(
|
|
215
|
+
(match.start(), match.end(), style)
|
|
216
|
+
for pattern, style in self.patterns
|
|
217
|
+
for match in pattern.finditer(line)
|
|
218
|
+
)
|
|
219
|
+
fragments = []
|
|
220
|
+
position = 0
|
|
221
|
+
for start, end, style in matches:
|
|
222
|
+
if start < position:
|
|
223
|
+
continue
|
|
224
|
+
fragments.append(("", line[position:start]))
|
|
225
|
+
fragments.append((style, line[start:end]))
|
|
226
|
+
position = end
|
|
227
|
+
fragments.append(("", line[position:]))
|
|
228
|
+
return fragments
|
|
229
|
+
|
|
230
|
+
return line_fragments
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class FileReferenceCompleter(Completer):
|
|
234
|
+
"""Replace a trailing `@fragment` with a `./` workspace-relative path.
|
|
235
|
+
|
|
236
|
+
``workspace`` may be a callable, read on every completion: a hosted session
|
|
237
|
+
learns its worktree from the host only after the prompt exists, and /switch
|
|
238
|
+
can move the terminal to a session in another checkout.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self, workspace: Path | Callable[[], Path] | None = None) -> None:
|
|
242
|
+
self._workspace = workspace if callable(workspace) else (lambda: workspace)
|
|
243
|
+
self.files = WorkspaceFiles(self._root())
|
|
244
|
+
|
|
245
|
+
def _root(self) -> Path:
|
|
246
|
+
return (self._workspace() or Path.cwd()).resolve()
|
|
247
|
+
|
|
248
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent):
|
|
249
|
+
fragment = reference_fragment(document.text_before_cursor)
|
|
250
|
+
if fragment is None:
|
|
251
|
+
return
|
|
252
|
+
if (root := self._root()) != self.files.root:
|
|
253
|
+
self.files = WorkspaceFiles(root)
|
|
254
|
+
for path in self.files.matches(fragment):
|
|
255
|
+
# The `@` is a trigger, not part of the reference: it is replaced so
|
|
256
|
+
# the model receives a path its file tools accept verbatim. The
|
|
257
|
+
# leading './' is what marks it as a path; only a name containing
|
|
258
|
+
# whitespace needs quotes to keep its end unambiguous in prose.
|
|
259
|
+
reference = f"./{path}"
|
|
260
|
+
yield Completion(
|
|
261
|
+
f'"{reference}" ' if any(char.isspace() for char in reference) else f"{reference} ",
|
|
262
|
+
start_position=-(len(fragment) + 1),
|
|
263
|
+
display=path,
|
|
264
|
+
display_meta=self.files.describe(path),
|
|
265
|
+
)
|
pcode/filesystem.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Mutation evidence layered on Harness's filesystem operations.
|
|
2
|
+
|
|
3
|
+
Harness announces each write and edit through `_request` while it holds the
|
|
4
|
+
file's lock and before it writes, so the before snapshot is taken there, inside
|
|
5
|
+
the operation rather than from a later reread. The after side is what the write
|
|
6
|
+
put there: the written content, or the same replacements applied to the snapshot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import posixpath
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from pydantic_ai import CapabilityEvent
|
|
14
|
+
from pydantic_ai_harness.filesystem._toolset import (
|
|
15
|
+
FileSystemToolset,
|
|
16
|
+
_apply_replacements,
|
|
17
|
+
_is_binary,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from pcode.edits import MAX_SOURCE, completed_change, sensitive_path
|
|
21
|
+
from pcode.runtime import EditCompleted
|
|
22
|
+
from pcode.workspace_filesystem import WorkspaceFileSystem
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(kw_only=True)
|
|
26
|
+
class FileChangeEvent(CapabilityEvent, namespace="pcode_files", name="change"):
|
|
27
|
+
change: EditCompleted
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _Snapshot:
|
|
32
|
+
"""What `_request` saw of one write or edit, for the event sent after it."""
|
|
33
|
+
|
|
34
|
+
display_path: str = ""
|
|
35
|
+
before: str | None = None
|
|
36
|
+
existed: bool = True
|
|
37
|
+
omitted: str = ""
|
|
38
|
+
taken: bool = field(default=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# One per tool call: concurrent calls run in separate tasks with their own copy.
|
|
42
|
+
_snapshot: ContextVar[_Snapshot | None] = ContextVar("pcode_file_snapshot", default=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DisplayFileSystem(WorkspaceFileSystem):
|
|
46
|
+
def _toolset_type(self):
|
|
47
|
+
return DisplayFileSystemToolset
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DisplayFileSystemToolset(FileSystemToolset):
|
|
51
|
+
async def _emit_change(self, ctx, path, before, after, **kwargs):
|
|
52
|
+
change = completed_change(path, before, after, call_id=ctx.tool_call_id or "", **kwargs)
|
|
53
|
+
await ctx.emit(FileChangeEvent(change=change))
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _display_path(scope, path: str, resolved: str) -> str:
|
|
57
|
+
"""Workspace-relative inside the workspace, absolute outside it; never a sensitive name."""
|
|
58
|
+
if sensitive_path(path):
|
|
59
|
+
return path
|
|
60
|
+
relative = posixpath.relpath(resolved, scope.cwd)
|
|
61
|
+
return resolved if relative == ".." or relative.startswith("../") else relative
|
|
62
|
+
|
|
63
|
+
async def _request(self, scope, ctx, change, *, path, resolved):
|
|
64
|
+
snapshot = _snapshot.get()
|
|
65
|
+
if ctx is not None and snapshot is not None:
|
|
66
|
+
await self._take(snapshot, scope, path, resolved)
|
|
67
|
+
return await super()._request(scope, ctx, change, path=path, resolved=resolved)
|
|
68
|
+
|
|
69
|
+
async def _take(self, snapshot: _Snapshot, scope, path: str, resolved: str) -> None:
|
|
70
|
+
snapshot.taken = True
|
|
71
|
+
# Harness writes through the path as written; name the file it reaches,
|
|
72
|
+
# so an alias to a sensitive file is hidden as that file would be.
|
|
73
|
+
try:
|
|
74
|
+
real = await scope.workspace.realpath(resolved)
|
|
75
|
+
except OSError:
|
|
76
|
+
real = resolved
|
|
77
|
+
snapshot.display_path = (
|
|
78
|
+
path if sensitive_path(path) else self._display_path(scope, path, real)
|
|
79
|
+
)
|
|
80
|
+
if sensitive_path(snapshot.display_path):
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
raw = await scope.workspace.read_bytes(real)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
snapshot.before, snapshot.existed = "", False
|
|
86
|
+
return
|
|
87
|
+
except PermissionError:
|
|
88
|
+
# Display evidence must not require permissions the write itself
|
|
89
|
+
# doesn't need; leave the diff out instead.
|
|
90
|
+
snapshot.omitted = "Before snapshot unavailable"
|
|
91
|
+
return
|
|
92
|
+
if len(raw) > MAX_SOURCE * 4:
|
|
93
|
+
snapshot.omitted = "File exceeds preview size limit"
|
|
94
|
+
elif _is_binary(raw):
|
|
95
|
+
snapshot.omitted = "Binary content"
|
|
96
|
+
else:
|
|
97
|
+
try:
|
|
98
|
+
snapshot.before = raw.decode("utf-8")
|
|
99
|
+
except UnicodeDecodeError:
|
|
100
|
+
snapshot.omitted = "Binary or non-UTF-8 content"
|
|
101
|
+
|
|
102
|
+
async def _recorded(self, ctx, operation, after) -> str:
|
|
103
|
+
"""Run `operation`, then report the change it made, if it made one."""
|
|
104
|
+
snapshot = _Snapshot()
|
|
105
|
+
token = _snapshot.set(snapshot)
|
|
106
|
+
try:
|
|
107
|
+
result = await operation()
|
|
108
|
+
finally:
|
|
109
|
+
_snapshot.reset(token)
|
|
110
|
+
succeeded = isinstance(result, str) and result.startswith(("Wrote ", "Edited "))
|
|
111
|
+
if ctx is not None and snapshot.taken and succeeded:
|
|
112
|
+
await self._emit_change(
|
|
113
|
+
ctx,
|
|
114
|
+
snapshot.display_path,
|
|
115
|
+
snapshot.before,
|
|
116
|
+
after(snapshot.before) if snapshot.before is not None else "",
|
|
117
|
+
existed=snapshot.existed,
|
|
118
|
+
omitted=snapshot.omitted,
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
async def _write_file(self, scope, ctx, path, content, *, expected_hash=None):
|
|
123
|
+
return await self._recorded(
|
|
124
|
+
ctx,
|
|
125
|
+
lambda: super(DisplayFileSystemToolset, self)._write_file(
|
|
126
|
+
scope, ctx, path, content, expected_hash=expected_hash
|
|
127
|
+
),
|
|
128
|
+
lambda before: content,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _edit_file(self, scope, ctx, path, replacements, *, expected_hash=None):
|
|
132
|
+
return await self._recorded(
|
|
133
|
+
ctx,
|
|
134
|
+
lambda: super(DisplayFileSystemToolset, self)._edit_file(
|
|
135
|
+
scope, ctx, path, replacements, expected_hash=expected_hash
|
|
136
|
+
),
|
|
137
|
+
lambda before: _apply_replacements(before, replacements, path),
|
|
138
|
+
)
|
pcode/frame.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""pcode's framed boxes: a heading on the top rule and an optional footer below.
|
|
2
|
+
|
|
3
|
+
prompt_toolkit's own ``Frame`` centers its title between bars
|
|
4
|
+
(``┌──| Title |──┐``) and has no footer. These read ``┌─ Title ────┐``, the
|
|
5
|
+
way the task panel above the prompt draws its heading, and can carry a short
|
|
6
|
+
footer such as the keys that close an overlay: ``└──── Esc cancel ─┘``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit.filters import Condition, has_completions
|
|
10
|
+
from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text
|
|
11
|
+
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
|
|
14
|
+
from prompt_toolkit.layout import (
|
|
15
|
+
AnyContainer,
|
|
16
|
+
ConditionalContainer,
|
|
17
|
+
DynamicContainer,
|
|
18
|
+
HSplit,
|
|
19
|
+
VSplit,
|
|
20
|
+
Window,
|
|
21
|
+
)
|
|
22
|
+
from prompt_toolkit.layout.dimension import AnyDimension
|
|
23
|
+
from prompt_toolkit.utils import get_cwidth
|
|
24
|
+
from prompt_toolkit.widgets import Box, Label, Shadow
|
|
25
|
+
from prompt_toolkit.widgets.base import Border
|
|
26
|
+
|
|
27
|
+
# Columns a heading or footer takes beyond its text: the corners, the rule
|
|
28
|
+
# stub beside the text, and a space either side. See ``Frame``.
|
|
29
|
+
TITLE_CHROME = 5
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def text_width(text: AnyFormattedText) -> int:
|
|
33
|
+
return get_cwidth(fragment_list_to_text(to_formatted_text(text)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Frame:
|
|
37
|
+
"""A box around ``body``, drop-in for prompt_toolkit's ``Frame``.
|
|
38
|
+
|
|
39
|
+
``title`` and ``footer`` may be callables, read each render; an empty one
|
|
40
|
+
leaves a plain rule.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
body: AnyContainer,
|
|
46
|
+
title: AnyFormattedText = "",
|
|
47
|
+
style: str = "",
|
|
48
|
+
width: AnyDimension = None,
|
|
49
|
+
height: AnyDimension = None,
|
|
50
|
+
key_bindings: KeyBindings | None = None,
|
|
51
|
+
modal: bool = False,
|
|
52
|
+
footer: AnyFormattedText = "",
|
|
53
|
+
) -> None:
|
|
54
|
+
self.title = title
|
|
55
|
+
self.footer = footer
|
|
56
|
+
self.body = body
|
|
57
|
+
|
|
58
|
+
def border(char: str = Border.HORIZONTAL, width: int | None = None) -> Window:
|
|
59
|
+
return Window(char=char, width=width, height=1, style="class:frame.border")
|
|
60
|
+
|
|
61
|
+
def text(get, style: str) -> Label:
|
|
62
|
+
# Reads the attribute each render, so a caller may reassign it.
|
|
63
|
+
return Label(
|
|
64
|
+
lambda: [("", " "), *to_formatted_text(get()), ("", " ")],
|
|
65
|
+
style=style,
|
|
66
|
+
dont_extend_width=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def shown(get) -> Condition:
|
|
70
|
+
return Condition(lambda: bool(fragment_list_to_text(to_formatted_text(get()))))
|
|
71
|
+
|
|
72
|
+
top = ConditionalContainer(
|
|
73
|
+
VSplit(
|
|
74
|
+
[
|
|
75
|
+
border(Border.TOP_LEFT, 1),
|
|
76
|
+
border(width=1),
|
|
77
|
+
text(lambda: self.title, "class:frame.label"),
|
|
78
|
+
border(),
|
|
79
|
+
border(Border.TOP_RIGHT, 1),
|
|
80
|
+
],
|
|
81
|
+
height=1,
|
|
82
|
+
),
|
|
83
|
+
filter=shown(lambda: self.title),
|
|
84
|
+
alternative_content=VSplit(
|
|
85
|
+
[border(Border.TOP_LEFT, 1), border(), border(Border.TOP_RIGHT, 1)], height=1
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
bottom = ConditionalContainer(
|
|
89
|
+
VSplit(
|
|
90
|
+
[
|
|
91
|
+
border(Border.BOTTOM_LEFT, 1),
|
|
92
|
+
border(),
|
|
93
|
+
text(lambda: self.footer, "class:frame.footer"),
|
|
94
|
+
border(width=1),
|
|
95
|
+
border(Border.BOTTOM_RIGHT, 1),
|
|
96
|
+
],
|
|
97
|
+
height=1,
|
|
98
|
+
),
|
|
99
|
+
filter=shown(lambda: self.footer),
|
|
100
|
+
alternative_content=VSplit(
|
|
101
|
+
[border(Border.BOTTOM_LEFT, 1), border(), border(Border.BOTTOM_RIGHT, 1)],
|
|
102
|
+
height=1,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
middle = VSplit(
|
|
106
|
+
[
|
|
107
|
+
Window(width=1, char=Border.VERTICAL, style="class:frame.border"),
|
|
108
|
+
DynamicContainer(lambda: self.body),
|
|
109
|
+
Window(width=1, char=Border.VERTICAL, style="class:frame.border"),
|
|
110
|
+
],
|
|
111
|
+
padding=0,
|
|
112
|
+
)
|
|
113
|
+
self.container = HSplit(
|
|
114
|
+
[top, middle, bottom],
|
|
115
|
+
width=width,
|
|
116
|
+
height=height,
|
|
117
|
+
style="class:frame " + style,
|
|
118
|
+
key_bindings=key_bindings,
|
|
119
|
+
modal=modal,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __pt_container__(self) -> AnyContainer:
|
|
123
|
+
return self.container
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Dialog:
|
|
127
|
+
"""A modal framed box over a background, as pcode's popups use it.
|
|
128
|
+
|
|
129
|
+
The subset of prompt_toolkit's ``Dialog`` used here (no buttons, always on
|
|
130
|
+
a background), drawn with this module's ``Frame``.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, body: AnyContainer, title: AnyFormattedText = "") -> None:
|
|
134
|
+
self.body = body
|
|
135
|
+
self.title = title
|
|
136
|
+
keys = KeyBindings()
|
|
137
|
+
keys.add("tab", filter=~has_completions)(focus_next)
|
|
138
|
+
keys.add("s-tab", filter=~has_completions)(focus_previous)
|
|
139
|
+
frame = Frame(
|
|
140
|
+
DynamicContainer(lambda: self.body),
|
|
141
|
+
title=lambda: self.title,
|
|
142
|
+
style="class:dialog.body",
|
|
143
|
+
key_bindings=keys,
|
|
144
|
+
modal=True,
|
|
145
|
+
)
|
|
146
|
+
self.container = Box(body=Shadow(body=frame), style="class:dialog")
|
|
147
|
+
|
|
148
|
+
def __pt_container__(self) -> AnyContainer:
|
|
149
|
+
return self.container
|