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