pcode 0.1.0__py3-none-any.whl

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