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/inspector_ui.py ADDED
@@ -0,0 +1,448 @@
1
+ """Temporary alternate-screen tool browser, separate from the inline editor."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ from functools import lru_cache
8
+
9
+ from prompt_toolkit.application import Application, get_app
10
+ from prompt_toolkit.document import Document
11
+ from prompt_toolkit.filters import Always, has_focus
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 DynamicContainer, HSplit, Layout, VSplit
15
+ from prompt_toolkit.layout.dimension import Dimension
16
+ from prompt_toolkit.widgets import Label, TextArea
17
+ from rich.markdown import Markdown
18
+ from rich.syntax import Syntax
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+ from rich.theme import Theme
22
+
23
+ from pcode.clipboard import copy as copy_to_clipboard
24
+ from pcode.delta import Delta
25
+ from pcode.edit_transcript import EditTranscript
26
+ from pcode.edits import patch_text
27
+ from pcode.frame import Frame
28
+ from pcode.inspection import InspectedCall, ToolArchive
29
+ from pcode.popup_ui import (
30
+ RichPane,
31
+ bind_list_paging,
32
+ list_pane_height,
33
+ popup_container,
34
+ popup_mouse,
35
+ popup_style,
36
+ steer_list_from_query,
37
+ )
38
+ from pcode.prefix_keys import PrefixKeys
39
+
40
+ STATE_STYLES = {"failed": "bold red", "succeeded": "bold green", "running": "bold yellow"}
41
+
42
+ # Argument keys whose values are code rather than prose, and the lexer for each.
43
+ CODE_KEYS = {"command": "bash", "content": None, "new_text": None, "old_text": None}
44
+
45
+
46
+ def parse_json(text: str) -> object:
47
+ """The decoded payload when it is JSON, else None; captured payloads are often not."""
48
+ stripped = text.strip()
49
+ if not stripped.startswith(("{", "[")):
50
+ return None
51
+ try:
52
+ return json.loads(stripped)
53
+ except ValueError:
54
+ return None
55
+
56
+
57
+ def code_block(text: str, lexer: str | None, code_theme: str) -> Syntax:
58
+ return Syntax(text, lexer or "text", theme=code_theme, word_wrap=True)
59
+
60
+
61
+ @lru_cache(maxsize=128)
62
+ def format_command(command: str) -> str:
63
+ """Format for display only; never execute or change the copied command."""
64
+ try:
65
+ result = subprocess.run(
66
+ ["shfmt", "-ln", "bash", "-i", "2"],
67
+ input=command,
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=0.25,
71
+ check=False,
72
+ )
73
+ except (OSError, subprocess.TimeoutExpired, UnicodeError):
74
+ pass
75
+ else:
76
+ if result.returncode == 0 and result.stdout.strip():
77
+ # shfmt adds a final newline; don't display an extra empty prompt.
78
+ return "\n".join("$ " + line for line in result.stdout.removesuffix("\n").split("\n"))
79
+ return _fallback_format_command(command)
80
+
81
+
82
+ def _fallback_format_command(command: str) -> str:
83
+ """A shell command with a display-only prompt marker on each logical line.
84
+
85
+ `;` becomes a line break; `&&` and `||` keep the operator, add a backslash
86
+ continuation, and indent the next command so the chain reads as one
87
+ statement. Separators inside quotes or parentheses are left alone, and a
88
+ command the model already spread over lines keeps its layout. Each line
89
+ starts with `$ `, before any indentation. Only the display changes: copying
90
+ still takes the command verbatim. Soft-wrapped rows are not new lines.
91
+ """
92
+ if "\n" in command.strip():
93
+ return "\n".join("$ " + line for line in command.split("\n"))
94
+ lines: list[str] = []
95
+ current: list[str] = []
96
+ indent = ""
97
+ quote: str | None = None
98
+ depth = 0
99
+ i = 0
100
+ while i < len(command):
101
+ char = command[i]
102
+ if quote:
103
+ current.append(char)
104
+ if char == "\\" and quote == '"' and i + 1 < len(command):
105
+ current.append(command[i + 1])
106
+ i += 1
107
+ elif char == quote:
108
+ quote = None
109
+ elif char == "\\" and i + 1 < len(command):
110
+ current.extend(command[i : i + 2])
111
+ i += 1
112
+ elif char in "'\"":
113
+ quote = char
114
+ current.append(char)
115
+ elif char == "(":
116
+ depth += 1
117
+ current.append(char)
118
+ elif char == ")":
119
+ depth = max(depth - 1, 0)
120
+ current.append(char)
121
+ elif depth == 0 and command.startswith(("&&", "||"), i) and "".join(current).strip():
122
+ lines.append(indent + "".join(current).strip() + f" {command[i : i + 2]} \\")
123
+ current = []
124
+ indent = " "
125
+ i += 1
126
+ elif char == ";" and command.startswith(";;", i):
127
+ current.extend(";;")
128
+ i += 1
129
+ elif depth == 0 and char == ";":
130
+ if "".join(current).strip():
131
+ lines.append(indent + "".join(current).strip())
132
+ current = []
133
+ indent = ""
134
+ else:
135
+ current.append(char)
136
+ i += 1
137
+ if "".join(current).strip():
138
+ lines.append(indent + "".join(current).strip())
139
+ return "\n".join("$ " + line for line in (lines or [command]))
140
+
141
+
142
+ def heading(title: str) -> list:
143
+ return [Text(""), Markdown(f"### {title}")]
144
+
145
+
146
+ def arguments_renderables(text: str, code_theme: str) -> list:
147
+ """One row per argument; multi-line or code-like values get their own block."""
148
+ parsed = parse_json(text)
149
+ if not isinstance(parsed, dict) or not parsed:
150
+ return [code_block(text, "json" if parsed is not None else None, code_theme)]
151
+ blocks: list = []
152
+ grid = Table.grid(padding=(0, 2))
153
+ grid.add_column(style="bold", no_wrap=True)
154
+ grid.add_column(overflow="fold")
155
+ later: list[tuple[str, str, str | None]] = []
156
+ for key, value in parsed.items():
157
+ if isinstance(value, str) and (key in CODE_KEYS or "\n" in value):
158
+ later.append((key, value, CODE_KEYS.get(key)))
159
+ else:
160
+ shown = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
161
+ grid.add_row(key, Text(shown))
162
+ if grid.row_count:
163
+ blocks.append(grid)
164
+ for key, value, lexer in later:
165
+ blocks.append(Text(key, style="bold"))
166
+ if key == "command":
167
+ value = format_command(value)
168
+ blocks.append(code_block(value, lexer, code_theme))
169
+ return blocks
170
+
171
+
172
+ def result_renderables(text: str, code_theme: str) -> list:
173
+ """Results get the block treatment arguments already have, never Markdown.
174
+
175
+ JSON is highlighted; anything else is verbatim in a plain block, so command
176
+ output and the command that produced it read as the same kind of thing.
177
+ """
178
+ return [code_block(text, "json" if parse_json(text) is not None else None, code_theme)]
179
+
180
+
181
+ class ToolInspector:
182
+ def __init__(
183
+ self,
184
+ archive: ToolArchive,
185
+ *,
186
+ failed: bool = False,
187
+ edits: bool = False,
188
+ rich_theme: Theme | None = None,
189
+ code_theme: str = "ansi_dark",
190
+ delta: Delta | None = None,
191
+ dedent: bool = True,
192
+ color_system: str | None = "truecolor",
193
+ key_prefix: str | None = None,
194
+ **app_options,
195
+ ) -> None:
196
+ self.archive = archive
197
+ self.dedent = dedent
198
+ self.failed = failed
199
+ self.edits = edits
200
+ self.code_theme = code_theme
201
+ self.delta = delta
202
+ self.tool = "All"
203
+ self.names = ["All", *sorted({call.name for call in archive.calls})]
204
+ self.visible = []
205
+ self.selected = None
206
+ self.notice = ""
207
+ self._refreshing = False
208
+ self.query = TextArea(height=1, prompt="Search tools/commands: ", multiline=False)
209
+ self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
210
+ self.list.window.cursorline = Always()
211
+ self.detail = RichPane(theme=rich_theme, color_system=color_system)
212
+ self.query.buffer.on_text_changed += lambda _: self.refresh()
213
+ self.list.buffer.on_cursor_position_changed += lambda _: self.select()
214
+ keys = KeyBindings()
215
+ self.detail.bind_scrolling(keys)
216
+
217
+ @keys.add("escape", eager=True)
218
+ @keys.add("c-c")
219
+ def close(event):
220
+ event.app.exit()
221
+
222
+ keys.add("tab")(focus_next)
223
+ keys.add("s-tab")(focus_previous)
224
+ steer_list_from_query(keys, self.query, self.list)
225
+ bind_list_paging(keys, self.list, has_focus(self.list) | has_focus(self.query))
226
+
227
+ @keys.add("enter", filter=has_focus(self.query))
228
+ def search_done(event):
229
+ event.app.layout.focus(self.list)
230
+
231
+ self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
232
+ shortcuts.set_help(
233
+ lambda: [
234
+ ("Type", "Search in the search field"),
235
+ ("↑/↓", "Select call / scroll details"),
236
+ ("PgUp/PgDn", "Page"),
237
+ ("Ctrl+U/D", "Half page"),
238
+ ("Enter", "Focus calls (from search field)"),
239
+ ("Tab/Shift+Tab", "Change focus"),
240
+ ("Esc/Ctrl+C", "Close"),
241
+ ]
242
+ )
243
+
244
+ @shortcuts.add("f", "Search")
245
+ def search(event):
246
+ event.app.layout.focus(self.query)
247
+
248
+ @shortcuts.add("x", "Failures only")
249
+ def failures(event):
250
+ self.failed = not self.failed
251
+ self.refresh()
252
+
253
+ @shortcuts.add("e", "Edits only")
254
+ def edits_only(event):
255
+ self.edits = not self.edits
256
+ self.refresh()
257
+
258
+ @shortcuts.add("t", "Tool filter")
259
+ def tool(event):
260
+ self.tool = self.names[(self.names.index(self.tool) + 1) % len(self.names)]
261
+ self.refresh()
262
+
263
+ @shortcuts.add("y", "Copy command")
264
+ def copy_command(event):
265
+ self.copy("command", event.app.output)
266
+
267
+ @shortcuts.add("o", "Copy output")
268
+ def copy_output(event):
269
+ self.copy("output", event.app.output)
270
+
271
+ header = Label(
272
+ lambda: (
273
+ f"Tool inspector · {len(self.visible)}/{len(self.archive.calls)} calls · "
274
+ f"Status: {'Failed' if self.failed else 'All'} · Tool: {self.tool}"
275
+ + (" · Edits only" if self.edits else "")
276
+ + (f" · {self.notice}" if self.notice else "")
277
+ )
278
+ )
279
+ wide = VSplit(
280
+ [
281
+ Frame(self.list, title="Calls", width=Dimension(weight=2)),
282
+ Frame(self.detail, title="Details", width=Dimension(weight=3)),
283
+ ]
284
+ )
285
+ narrow = HSplit(
286
+ [
287
+ Frame(self.list, title="Calls", height=lambda: list_pane_height(len(self.visible))),
288
+ Frame(self.detail, title="Details"),
289
+ ]
290
+ )
291
+ body = DynamicContainer(
292
+ lambda: wide if get_app().output.get_size().columns >= 100 else narrow
293
+ )
294
+ root = HSplit(
295
+ [
296
+ header,
297
+ self.query,
298
+ body,
299
+ Label(shortcuts.summary),
300
+ ]
301
+ )
302
+ self.app = Application(
303
+ # Open in the search line, so typing filters straight away.
304
+ layout=Layout(popup_container(root, shortcuts), focused_element=self.query),
305
+ key_bindings=shortcuts.key_bindings(keys),
306
+ full_screen=True,
307
+ mouse_support=popup_mouse(shortcuts),
308
+ style=popup_style(app_options.pop("style", None)),
309
+ **app_options,
310
+ )
311
+ self.prefetched_width = 0
312
+ self.prefetching: threading.Thread | None = None
313
+ self.app.after_render += self.prefetch
314
+ self.refresh()
315
+
316
+ def prefetch(self, _app=None) -> None:
317
+ """Render every patch through one background delta run, at the Details width.
318
+
319
+ delta lays a diff out for one width, which only a render knows, so this
320
+ waits for one and runs again when a resize or the wide/narrow switch
321
+ changes it. Selecting an edit then reads delta's cache instead of
322
+ starting delta on the event loop.
323
+ """
324
+ info = self.detail.window.render_info
325
+ if self.delta is None or info is None or info.window_width == self.prefetched_width:
326
+ return
327
+ self.prefetched_width = width = info.window_width
328
+ patches = [
329
+ # As EditTranscript shows it, or the prefetched render is never used.
330
+ patch_text(change.patch, dedent=self.dedent)
331
+ for call in self.archive.calls
332
+ for change in call.changes
333
+ if change.patch
334
+ ]
335
+ if patches:
336
+ self.prefetching = threading.Thread(
337
+ target=self.delta.prefetch, args=(patches, width), daemon=True
338
+ )
339
+ self.prefetching.start()
340
+
341
+ def refresh(self) -> None:
342
+ previous = self.selected
343
+ query = self.query.text.casefold()
344
+ self.visible = [
345
+ call
346
+ for call in reversed(self.archive.calls)
347
+ if (not self.failed or call.state == "failed")
348
+ and (not self.edits or call.changes)
349
+ and (self.tool == "All" or call.name == self.tool)
350
+ and query in call.title().casefold()
351
+ ]
352
+ selected = next((i for i, c in enumerate(self.visible) if c is previous), 0)
353
+ lines = [call.title().replace("\n", " ↵ ") for call in self.visible]
354
+ text = "\n".join(lines) or "No matching tool calls."
355
+ position = sum(len(line) + 1 for line in lines[:selected])
356
+ self._refreshing = True
357
+ self.list.buffer.set_document(Document(text, position), bypass_readonly=True)
358
+ self._refreshing = False
359
+ self.select()
360
+
361
+ def select(self) -> None:
362
+ if self._refreshing:
363
+ return
364
+ row = self.list.document.cursor_position_row
365
+ call = self.visible[row] if row < len(self.visible) else None
366
+ if call is self.selected and call is not None:
367
+ return
368
+ self.selected = call
369
+ self.notice = ""
370
+ self.detail.set(self.details(call))
371
+
372
+ def payload(self, call: InspectedCall, what: str) -> tuple[str, str]:
373
+ """The text to copy for "command" or "output", and what to call it.
374
+
375
+ A call without a command copies its whole arguments payload: the point
376
+ is to get the call out of the inspector, not to hold out for a shell.
377
+ """
378
+ if what == "output":
379
+ return "output", call.result.read()
380
+ parsed = parse_json(call.arguments.read())
381
+ if isinstance(parsed, dict) and isinstance(parsed.get("command"), str):
382
+ return "command", parsed["command"]
383
+ return "arguments", call.arguments.read()
384
+
385
+ def copy(self, what: str, output=None) -> None:
386
+ call = self.selected
387
+ if call is None:
388
+ self.notice = "Nothing to copy"
389
+ return
390
+ name, text = self.payload(call, what)
391
+ if not text:
392
+ self.notice = f"No {name} to copy"
393
+ return
394
+ copied, truncated = copy_to_clipboard(text, output)
395
+ limit = " (truncated)" if truncated else ""
396
+ self.notice = f"Copied {name}{limit}" if copied else f"Could not copy {name}"
397
+
398
+ def details(self, call: InspectedCall | None) -> list:
399
+ """Rich renderables for the Details pane: metadata grid, then each payload."""
400
+ if call is None:
401
+ return [Text("No matching tool calls.")]
402
+ title = Text(call.name, style="bold")
403
+ title.append(" · ")
404
+ title.append(call.state, style=STATE_STYLES.get(call.state, "bold"))
405
+ grid = Table.grid(padding=(0, 2))
406
+ grid.add_column(style="dim", no_wrap=True)
407
+ grid.add_column(overflow="fold")
408
+ for label, value in call.metadata(self.archive.calls):
409
+ grid.add_row(label, Text(value))
410
+ arguments = [
411
+ *heading("Arguments"),
412
+ *arguments_renderables(call.arguments.read(), self.code_theme),
413
+ ]
414
+ result = [
415
+ *heading("Returned result / error"),
416
+ *result_renderables(call.result.read(), self.code_theme),
417
+ ]
418
+ if call.changes:
419
+ # What the call did to the files leads; the raw arguments that
420
+ # asked for it stay below for debugging. The pane scrolls, so the
421
+ # scrollback's row cap does not apply.
422
+ changes = [
423
+ EditTranscript(
424
+ change,
425
+ code_theme=self.code_theme,
426
+ max_rows=sys.maxsize,
427
+ delta=self.delta,
428
+ dedent=self.dedent,
429
+ )
430
+ for change in call.changes
431
+ ]
432
+ # Each change heads itself with a rule naming the file.
433
+ payloads = [Text(""), *changes, *result, *arguments]
434
+ else:
435
+ payloads = [*arguments, *result]
436
+ return [
437
+ title,
438
+ grid,
439
+ *payloads,
440
+ Text(""),
441
+ Text(
442
+ "Only captured tool output is shown; tool-side truncation cannot be recovered.",
443
+ style="dim",
444
+ ),
445
+ ]
446
+
447
+ async def run(self) -> None:
448
+ await self.app.run_async()