deep-agent-cli 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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/cli/previews.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""Tool preview IR: tool display semantics live here, rendering stays generic.
|
|
2
|
+
|
|
3
|
+
ToolBlock → tool-specific Preview Builder → ToolPreview → Generic Renderer.
|
|
4
|
+
|
|
5
|
+
A ToolPreview describes *what* a tool card shows: verb, target operand,
|
|
6
|
+
content lines, hint action. The renderer owns *how* it looks: spinner, status
|
|
7
|
+
colors, terminal width, folding limits. ToolPreview is a UI-derived object and
|
|
8
|
+
is never persisted; sessions keep the raw ToolMessage artifact instead.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from difflib import unified_diff
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
from typing import Any, Callable, Literal
|
|
19
|
+
|
|
20
|
+
from agent.cli.state import ToolBlock
|
|
21
|
+
from agent.file_mutation import FileMutationOperation
|
|
22
|
+
|
|
23
|
+
PreviewStyle = Literal["plain", "dim", "add", "delete", "error", "url"]
|
|
24
|
+
PreviewGroup = Literal["explore", "mutation", "command", "web", "other"]
|
|
25
|
+
PreviewAction = Literal["none", "expand", "review"]
|
|
26
|
+
CommandExecutionStatus = Literal[
|
|
27
|
+
"running", "succeeded", "failed", "timeout", "cancelled",
|
|
28
|
+
"spawn_error", "interrupted", "waiting",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Hint vocabulary selected by ToolPreview.action: review -> Ctrl+R,
|
|
32
|
+
# expand -> Ctrl+O. Shared by builders and the renderer so no branch
|
|
33
|
+
# hand-writes its own wording.
|
|
34
|
+
EXPAND_HINT = "Ctrl+O to expand"
|
|
35
|
+
REVIEW_HINT = "Ctrl+R to review"
|
|
36
|
+
|
|
37
|
+
# Maximum changed lines (- and +) shown in the edit compact preview.
|
|
38
|
+
# Separators, blanks and the hidden hint never count against it.
|
|
39
|
+
EDIT_PREVIEW_CHANGED_LINES = 8
|
|
40
|
+
WRITE_PREVIEW_LINES = 6
|
|
41
|
+
CREATE_PREVIEW_LIMIT = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PreviewLine:
|
|
46
|
+
text: str
|
|
47
|
+
style: PreviewStyle = "plain"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ToolPreview:
|
|
52
|
+
kind: str
|
|
53
|
+
verb: str
|
|
54
|
+
target: str = ""
|
|
55
|
+
# Title suffix (edit: "(+2 -2)").
|
|
56
|
+
detail: str = ""
|
|
57
|
+
# Highlighted count line above the content (write: "+302 lines").
|
|
58
|
+
label: str = ""
|
|
59
|
+
# One-line label for compact lists (explore group: 'Read file.py').
|
|
60
|
+
summary: str = ""
|
|
61
|
+
lines: tuple[PreviewLine, ...] = ()
|
|
62
|
+
group: PreviewGroup = "other"
|
|
63
|
+
action: PreviewAction = "none"
|
|
64
|
+
syntax: str | None = None
|
|
65
|
+
file_mutation: FileMutationPreview | None = None
|
|
66
|
+
command_execution: CommandExecutionPreview | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class CommandExecutionPreview:
|
|
71
|
+
"""Command result semantics; live output remains in ToolBlock.output."""
|
|
72
|
+
|
|
73
|
+
command: str
|
|
74
|
+
status: CommandExecutionStatus
|
|
75
|
+
exit_code: int | None = None
|
|
76
|
+
termination_reason: str | None = None
|
|
77
|
+
truncated: bool = False
|
|
78
|
+
log_path: str | None = None
|
|
79
|
+
log_error: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class DiffPreview:
|
|
84
|
+
"""Mutation domain model shared by the compact card and the full review."""
|
|
85
|
+
|
|
86
|
+
path: str
|
|
87
|
+
added: int | None = None
|
|
88
|
+
deleted: int | None = None
|
|
89
|
+
lines: tuple[PreviewLine, ...] = ()
|
|
90
|
+
full_diff: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class FileMutationPreview:
|
|
95
|
+
"""A file change independent of the tool that produced it."""
|
|
96
|
+
|
|
97
|
+
path: str
|
|
98
|
+
operation: FileMutationOperation
|
|
99
|
+
diff: DiffPreview | None = None
|
|
100
|
+
compact_lines: tuple[PreviewLine, ...] = ()
|
|
101
|
+
label: str = ""
|
|
102
|
+
paths: tuple[str, ...] = ()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _ChangedLine:
|
|
107
|
+
"""One real -/+ line of a unified diff, with its hunk and diff order."""
|
|
108
|
+
|
|
109
|
+
text: str
|
|
110
|
+
kind: Literal["add", "delete"]
|
|
111
|
+
hunk: int
|
|
112
|
+
order: int
|
|
113
|
+
number: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
PreviewBuilder = Callable[[ToolBlock], ToolPreview]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _display_name(name: str) -> str:
|
|
120
|
+
return {"read_file": "read", "write_file": "write", "edit_file": "edit"}.get(name, name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _operand(args: dict[str, Any]) -> str:
|
|
124
|
+
"""file_path/path label (with offset range), else preferred scalar args."""
|
|
125
|
+
path = args.get("file_path") or args.get("path")
|
|
126
|
+
if path:
|
|
127
|
+
label = str(PurePosixPath(str(path)))
|
|
128
|
+
offset = args.get("offset")
|
|
129
|
+
limit = args.get("limit")
|
|
130
|
+
if offset is not None or limit is not None:
|
|
131
|
+
start = int(offset or 1)
|
|
132
|
+
label += f":{start}-{start + int(limit) - 1}" if limit else f":{start}"
|
|
133
|
+
return label
|
|
134
|
+
values: list[str] = []
|
|
135
|
+
for key in ("query", "pattern", "to", "subject"):
|
|
136
|
+
value = args.get(key)
|
|
137
|
+
if isinstance(value, (str, int, float, bool)) and str(value):
|
|
138
|
+
values.append(shlex.quote(str(value))[:80])
|
|
139
|
+
if len(values) == 2:
|
|
140
|
+
break
|
|
141
|
+
return " ".join(values)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mutation_path(name: str, args: dict[str, Any]) -> str:
|
|
145
|
+
path = args.get("file_path") or args.get("path")
|
|
146
|
+
if not path:
|
|
147
|
+
return "file"
|
|
148
|
+
label = str(PurePosixPath(str(path)))
|
|
149
|
+
if name == "write_file" and label.startswith("/workspace/"):
|
|
150
|
+
label = label.lstrip("/")
|
|
151
|
+
return label
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _diff_name(side: str, path: str) -> str:
|
|
155
|
+
if path.startswith("/"):
|
|
156
|
+
return f"{side}{path}"
|
|
157
|
+
return f"{side}/{path}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def added_line_label(count: int) -> str:
|
|
161
|
+
return f"+{count} {'line' if count == 1 else 'lines'}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _changed_lines(raw: str) -> list[_ChangedLine]:
|
|
165
|
+
"""Real -/+ lines with hunk index and diff order; context is dropped."""
|
|
166
|
+
changed: list[_ChangedLine] = []
|
|
167
|
+
hunk = -1
|
|
168
|
+
seen_hunk = False
|
|
169
|
+
old_number = new_number = 0
|
|
170
|
+
for line in raw.splitlines():
|
|
171
|
+
if line.startswith("@@"):
|
|
172
|
+
seen_hunk = True
|
|
173
|
+
hunk += 1
|
|
174
|
+
match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
|
|
175
|
+
if match:
|
|
176
|
+
old_number, new_number = map(int, match.groups())
|
|
177
|
+
continue
|
|
178
|
+
if not seen_hunk:
|
|
179
|
+
continue # --- / +++ header lines before the first hunk
|
|
180
|
+
if line.startswith("+"):
|
|
181
|
+
changed.append(_ChangedLine(line, "add", hunk, len(changed), new_number))
|
|
182
|
+
new_number += 1
|
|
183
|
+
elif line.startswith("-"):
|
|
184
|
+
changed.append(_ChangedLine(line, "delete", hunk, len(changed), old_number))
|
|
185
|
+
old_number += 1
|
|
186
|
+
elif line.startswith(" "):
|
|
187
|
+
old_number += 1
|
|
188
|
+
new_number += 1
|
|
189
|
+
return changed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _select_changed_lines(changed: list[_ChangedLine]) -> list[_ChangedLine]:
|
|
193
|
+
"""Pick the compact-preview lines: first N, then a +/- floor, then re-sort.
|
|
194
|
+
|
|
195
|
+
When both sides exist, the preview must show at least one - and one +;
|
|
196
|
+
the replacement line keeps its original diff position after sorting.
|
|
197
|
+
"""
|
|
198
|
+
if len(changed) <= EDIT_PREVIEW_CHANGED_LINES:
|
|
199
|
+
return list(changed)
|
|
200
|
+
selected = changed[:EDIT_PREVIEW_CHANGED_LINES]
|
|
201
|
+
if len({line.kind for line in changed}) < 2:
|
|
202
|
+
return selected
|
|
203
|
+
if "add" not in {line.kind for line in selected}:
|
|
204
|
+
replacement = next(line for line in changed if line.kind == "add")
|
|
205
|
+
for index in range(len(selected) - 1, -1, -1):
|
|
206
|
+
if selected[index].kind == "delete":
|
|
207
|
+
selected[index] = replacement
|
|
208
|
+
break
|
|
209
|
+
elif "delete" not in {line.kind for line in selected}:
|
|
210
|
+
replacement = next(line for line in changed if line.kind == "delete")
|
|
211
|
+
for index in range(len(selected) - 1, -1, -1):
|
|
212
|
+
if selected[index].kind == "add":
|
|
213
|
+
selected[index] = replacement
|
|
214
|
+
break
|
|
215
|
+
selected.sort(key=lambda line: line.order)
|
|
216
|
+
return selected
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _format_changed_text(line: str) -> str:
|
|
220
|
+
prefix, content = line[:1], line[1:]
|
|
221
|
+
return f"{prefix} {content}" if content else prefix
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _edit_display_lines(changed: list[_ChangedLine]) -> tuple[PreviewLine, ...]:
|
|
225
|
+
"""Compact edit body: changed lines only, ⋮ at skipped spans, hidden count.
|
|
226
|
+
|
|
227
|
+
A ⋮ marks that the preview jumped over hidden changed lines or crossed a
|
|
228
|
+
hunk boundary; it never counts against the budget or the hidden total.
|
|
229
|
+
"""
|
|
230
|
+
selected = _select_changed_lines(changed)
|
|
231
|
+
if not selected:
|
|
232
|
+
return ()
|
|
233
|
+
display: list[PreviewLine] = [PreviewLine("", "plain")]
|
|
234
|
+
previous: _ChangedLine | None = None
|
|
235
|
+
for line in selected:
|
|
236
|
+
if previous is not None and (line.order > previous.order + 1 or line.hunk != previous.hunk):
|
|
237
|
+
display.append(PreviewLine(" ⋮", "dim"))
|
|
238
|
+
display.append(PreviewLine(
|
|
239
|
+
f"{line.number:>4} {_format_changed_text(line.text)}",
|
|
240
|
+
"add" if line.kind == "add" else "delete",
|
|
241
|
+
))
|
|
242
|
+
previous = line
|
|
243
|
+
hidden = len(changed) - len(selected)
|
|
244
|
+
if hidden > 0:
|
|
245
|
+
display.append(PreviewLine("", "plain"))
|
|
246
|
+
display.append(PreviewLine(f"… {hidden} changed lines hidden · {REVIEW_HINT}", "dim"))
|
|
247
|
+
return tuple(display)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_diff_preview(name: str, args: dict[str, Any]) -> DiffPreview | None:
|
|
251
|
+
"""Parse mutation arguments into the shared DiffPreview domain model."""
|
|
252
|
+
if name == "write_file":
|
|
253
|
+
content = args.get("content")
|
|
254
|
+
if not isinstance(content, str):
|
|
255
|
+
return None
|
|
256
|
+
raw = "\n".join(unified_diff(
|
|
257
|
+
[],
|
|
258
|
+
content.splitlines(),
|
|
259
|
+
fromfile="/dev/null",
|
|
260
|
+
tofile=_diff_name("b", str(args.get("file_path") or args.get("path") or "file")),
|
|
261
|
+
lineterm="",
|
|
262
|
+
))
|
|
263
|
+
if not raw:
|
|
264
|
+
return None
|
|
265
|
+
content_lines = [f"+{line}" for line in content.splitlines()]
|
|
266
|
+
return DiffPreview(
|
|
267
|
+
path=_mutation_path(name, args),
|
|
268
|
+
added=len(content_lines),
|
|
269
|
+
lines=tuple(PreviewLine(line, "add") for line in content_lines),
|
|
270
|
+
full_diff=raw,
|
|
271
|
+
)
|
|
272
|
+
if name == "edit_file":
|
|
273
|
+
# Presence checks, not truthiness: an explicit "" is a legal edit side.
|
|
274
|
+
old = args["old_string"] if "old_string" in args else args.get("old_text")
|
|
275
|
+
new = args["new_string"] if "new_string" in args else args.get("new_text")
|
|
276
|
+
if not isinstance(old, str) or not isinstance(new, str):
|
|
277
|
+
return None
|
|
278
|
+
path = str(args.get("file_path") or args.get("path") or "file")
|
|
279
|
+
raw = "\n".join(unified_diff(
|
|
280
|
+
old.splitlines(),
|
|
281
|
+
new.splitlines(),
|
|
282
|
+
fromfile=_diff_name("a", path),
|
|
283
|
+
tofile=_diff_name("b", path),
|
|
284
|
+
lineterm="",
|
|
285
|
+
))
|
|
286
|
+
if not raw:
|
|
287
|
+
return None
|
|
288
|
+
changed = _changed_lines(raw)
|
|
289
|
+
added = sum(1 for line in changed if line.kind == "add")
|
|
290
|
+
deleted = sum(1 for line in changed if line.kind == "delete")
|
|
291
|
+
lines = tuple(
|
|
292
|
+
PreviewLine(line.text, "add" if line.kind == "add" else "delete")
|
|
293
|
+
for line in changed
|
|
294
|
+
)
|
|
295
|
+
return DiffPreview(
|
|
296
|
+
path=_mutation_path(name, args),
|
|
297
|
+
added=added,
|
|
298
|
+
deleted=deleted,
|
|
299
|
+
lines=lines,
|
|
300
|
+
full_diff=raw,
|
|
301
|
+
)
|
|
302
|
+
if name == "delete":
|
|
303
|
+
return DiffPreview(
|
|
304
|
+
path=_mutation_path(name, args),
|
|
305
|
+
full_diff=f"delete {_mutation_path(name, args)}",
|
|
306
|
+
)
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _write_compact_lines(diff: DiffPreview | None) -> tuple[PreviewLine, ...]:
|
|
311
|
+
if diff is None:
|
|
312
|
+
return ()
|
|
313
|
+
lines = [
|
|
314
|
+
PreviewLine(f"{number:>4} {line.text}", line.style)
|
|
315
|
+
for number, line in enumerate(diff.lines[:WRITE_PREVIEW_LINES], 1)
|
|
316
|
+
]
|
|
317
|
+
hidden = len(diff.lines) - len(lines)
|
|
318
|
+
if hidden:
|
|
319
|
+
lines.append(PreviewLine(f"… {hidden} lines hidden · {REVIEW_HINT}", "dim"))
|
|
320
|
+
return tuple(lines)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def normalize_file_mutation(block: ToolBlock) -> FileMutationPreview | None:
|
|
324
|
+
"""Map a successful tool result to file semantics; never infer a missing write operation."""
|
|
325
|
+
if block.status != "completed" or block.is_error:
|
|
326
|
+
return None
|
|
327
|
+
if block.name == "edit_file":
|
|
328
|
+
diff = build_diff_preview(block.name, block.arguments)
|
|
329
|
+
return FileMutationPreview(
|
|
330
|
+
path=diff.path if diff else _operand(block.arguments),
|
|
331
|
+
operation="modify", diff=diff,
|
|
332
|
+
compact_lines=_edit_display_lines(_changed_lines(diff.full_diff)) if diff else (),
|
|
333
|
+
)
|
|
334
|
+
if block.name == "write_file":
|
|
335
|
+
operation = block.artifact.get("operation") if isinstance(block.artifact, dict) else None
|
|
336
|
+
if operation not in {"create", "overwrite"}:
|
|
337
|
+
return None
|
|
338
|
+
diff = build_diff_preview(block.name, block.arguments) if operation == "overwrite" else None
|
|
339
|
+
return FileMutationPreview(
|
|
340
|
+
path=_operand(block.arguments), operation=operation, diff=diff,
|
|
341
|
+
compact_lines=_write_compact_lines(diff) if operation == "overwrite" else (),
|
|
342
|
+
label=added_line_label(diff.added or 0) if operation == "overwrite" and diff else "",
|
|
343
|
+
)
|
|
344
|
+
if block.name == "delete":
|
|
345
|
+
return FileMutationPreview(path=_operand(block.arguments), operation="delete")
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def build_review_mutation(name: str, args: dict[str, Any]) -> FileMutationPreview | None:
|
|
350
|
+
"""Build proposed diff content when a review has arguments but no tool result."""
|
|
351
|
+
diff = build_diff_preview(name, args)
|
|
352
|
+
if diff is None:
|
|
353
|
+
return None
|
|
354
|
+
operation: FileMutationOperation = {
|
|
355
|
+
"edit_file": "modify", "write_file": "overwrite", "delete": "delete",
|
|
356
|
+
}[name]
|
|
357
|
+
return FileMutationPreview(path=diff.path, operation=operation, diff=diff)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def file_tool_preview(mutation: FileMutationPreview) -> ToolPreview:
|
|
361
|
+
verb = {"create": "Create", "modify": "Edited", "overwrite": "Wrote", "delete": "Deleted"}[mutation.operation]
|
|
362
|
+
detail = ""
|
|
363
|
+
if mutation.operation == "modify" and mutation.diff:
|
|
364
|
+
detail = f"(+{mutation.diff.added or 0} -{mutation.diff.deleted or 0})"
|
|
365
|
+
return ToolPreview(
|
|
366
|
+
kind="file_mutation", verb=verb, target=mutation.path, detail=detail,
|
|
367
|
+
label=mutation.label, lines=mutation.compact_lines,
|
|
368
|
+
group="mutation", action="review", file_mutation=mutation,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def aggregate_file_mutations(
|
|
373
|
+
mutations: list[FileMutationPreview], *, expanded: bool = False,
|
|
374
|
+
) -> FileMutationPreview:
|
|
375
|
+
"""Select compact rows for a consecutive semantic mutation group."""
|
|
376
|
+
first = mutations[0]
|
|
377
|
+
if first.operation == "create":
|
|
378
|
+
paths = tuple(dict.fromkeys(mutation.path for mutation in mutations))
|
|
379
|
+
hidden = 0 if expanded else max(0, len(paths) - CREATE_PREVIEW_LIMIT)
|
|
380
|
+
rows: list[PreviewLine] = []
|
|
381
|
+
if hidden:
|
|
382
|
+
rows.append(PreviewLine(f" ├ … {hidden} more", "dim"))
|
|
383
|
+
shown = paths if expanded else paths[-CREATE_PREVIEW_LIMIT:]
|
|
384
|
+
for index, path in enumerate(shown):
|
|
385
|
+
branch = "└" if index == len(shown) - 1 else "├"
|
|
386
|
+
rows.append(PreviewLine(f" {branch} {path}"))
|
|
387
|
+
if not expanded:
|
|
388
|
+
rows.append(PreviewLine(f" {EXPAND_HINT}", "dim"))
|
|
389
|
+
return FileMutationPreview(
|
|
390
|
+
path=first.path, operation="create", compact_lines=tuple(rows), paths=paths,
|
|
391
|
+
)
|
|
392
|
+
if first.operation != "modify":
|
|
393
|
+
raise ValueError("Only create and modify mutations can be grouped")
|
|
394
|
+
changes = [
|
|
395
|
+
(index, line)
|
|
396
|
+
for index, mutation in enumerate(mutations)
|
|
397
|
+
for line in mutation.compact_lines
|
|
398
|
+
if line.style in {"add", "delete"}
|
|
399
|
+
]
|
|
400
|
+
selected = list(enumerate(changes[:EDIT_PREVIEW_CHANGED_LINES]))
|
|
401
|
+
if len(changes) > EDIT_PREVIEW_CHANGED_LINES:
|
|
402
|
+
kinds = {line.style for _, line in changes}
|
|
403
|
+
shown = {line.style for _, (_, line) in selected}
|
|
404
|
+
if len(kinds) == 2 and len(shown) == 1:
|
|
405
|
+
replacement = next((order, item) for order, item in enumerate(changes) if item[1].style not in shown)
|
|
406
|
+
selected[-1] = replacement
|
|
407
|
+
selected.sort(key=lambda item: item[0])
|
|
408
|
+
rows = [PreviewLine("")]
|
|
409
|
+
previous_order = previous_edit = None
|
|
410
|
+
for order, (edit_index, line) in selected:
|
|
411
|
+
if previous_order is not None and (order != previous_order + 1 or edit_index != previous_edit):
|
|
412
|
+
rows.append(PreviewLine(" ⋮", "dim"))
|
|
413
|
+
rows.append(line)
|
|
414
|
+
previous_order, previous_edit = order, edit_index
|
|
415
|
+
added = sum(mutation.diff.added or 0 for mutation in mutations if mutation.diff)
|
|
416
|
+
deleted = sum(mutation.diff.deleted or 0 for mutation in mutations if mutation.diff)
|
|
417
|
+
hidden = added + deleted - len(selected)
|
|
418
|
+
if hidden:
|
|
419
|
+
rows.extend((PreviewLine(""), PreviewLine(f"… {hidden} changed lines hidden · {REVIEW_HINT}", "dim")))
|
|
420
|
+
return FileMutationPreview(
|
|
421
|
+
path=first.path, operation="modify",
|
|
422
|
+
diff=DiffPreview(path=first.path, added=added, deleted=deleted),
|
|
423
|
+
compact_lines=tuple(rows), paths=tuple(mutation.path for mutation in mutations),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def build_read_preview(block: ToolBlock) -> ToolPreview:
|
|
428
|
+
target = _operand(block.arguments)
|
|
429
|
+
return ToolPreview(
|
|
430
|
+
kind="read", verb="read", target=target, summary=f"Read {target}",
|
|
431
|
+
group="explore", action="expand",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def build_grep_preview(block: ToolBlock) -> ToolPreview:
|
|
436
|
+
pattern = block.arguments.get("pattern") or block.arguments.get("query")
|
|
437
|
+
label = json.dumps(str(pattern), ensure_ascii=False) if pattern else ""
|
|
438
|
+
return ToolPreview(
|
|
439
|
+
kind="grep", verb="grep", target=_operand(block.arguments),
|
|
440
|
+
summary=f"Search {label}" if pattern else "Search",
|
|
441
|
+
group="explore", action="expand",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def build_glob_preview(block: ToolBlock) -> ToolPreview:
|
|
446
|
+
pattern = block.arguments.get("pattern") or block.arguments.get("query")
|
|
447
|
+
label = json.dumps(str(pattern), ensure_ascii=False) if pattern else ""
|
|
448
|
+
return ToolPreview(
|
|
449
|
+
kind="glob", verb="glob", target=_operand(block.arguments),
|
|
450
|
+
summary=f"Glob {label}" if pattern else "Glob",
|
|
451
|
+
group="explore", action="expand",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def build_ls_preview(block: ToolBlock) -> ToolPreview:
|
|
456
|
+
path = block.arguments.get("path") or block.arguments.get("file_path")
|
|
457
|
+
return ToolPreview(
|
|
458
|
+
kind="ls", verb="ls", target=_operand(block.arguments),
|
|
459
|
+
summary=f"List {PurePosixPath(str(path))}" if path else "List",
|
|
460
|
+
group="explore", action="expand",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def build_write_preview(block: ToolBlock) -> ToolPreview:
|
|
465
|
+
mutation = normalize_file_mutation(block)
|
|
466
|
+
if mutation is not None:
|
|
467
|
+
return file_tool_preview(mutation)
|
|
468
|
+
diff = build_diff_preview("write_file", block.arguments)
|
|
469
|
+
if diff is None:
|
|
470
|
+
return ToolPreview(
|
|
471
|
+
kind="write", verb="write", target=_operand(block.arguments),
|
|
472
|
+
group="mutation",
|
|
473
|
+
)
|
|
474
|
+
return ToolPreview(
|
|
475
|
+
kind="write", verb="write", target=diff.path,
|
|
476
|
+
label=added_line_label(diff.added or 0),
|
|
477
|
+
lines=_write_compact_lines(diff), group="mutation", action="review",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_edit_preview(block: ToolBlock) -> ToolPreview:
|
|
482
|
+
mutation = normalize_file_mutation(block)
|
|
483
|
+
if mutation is not None:
|
|
484
|
+
return file_tool_preview(mutation)
|
|
485
|
+
diff = build_diff_preview("edit_file", block.arguments)
|
|
486
|
+
verb = "edit"
|
|
487
|
+
if diff is None:
|
|
488
|
+
return ToolPreview(
|
|
489
|
+
kind="edit", verb=verb, target=_operand(block.arguments),
|
|
490
|
+
group="mutation",
|
|
491
|
+
)
|
|
492
|
+
if block.status == "completed" and diff.added is not None and diff.deleted is not None:
|
|
493
|
+
detail = f"(+{diff.added} -{diff.deleted})"
|
|
494
|
+
else:
|
|
495
|
+
detail = ""
|
|
496
|
+
return ToolPreview(
|
|
497
|
+
kind="edit", verb=verb, target=diff.path, detail=detail,
|
|
498
|
+
lines=_edit_display_lines(_changed_lines(diff.full_diff)),
|
|
499
|
+
group="mutation", action="review",
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_delete_preview(block: ToolBlock) -> ToolPreview:
|
|
504
|
+
mutation = normalize_file_mutation(block)
|
|
505
|
+
if mutation is not None:
|
|
506
|
+
return file_tool_preview(mutation)
|
|
507
|
+
diff = build_diff_preview("delete", block.arguments)
|
|
508
|
+
target = diff.path if diff is not None else _operand(block.arguments)
|
|
509
|
+
return ToolPreview(
|
|
510
|
+
kind="delete", verb="delete", target=target,
|
|
511
|
+
group="mutation", action="review",
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def normalize_command_execution(block: ToolBlock) -> CommandExecutionPreview:
|
|
516
|
+
"""Interpret execute metadata once, without reading output text."""
|
|
517
|
+
artifact = block.artifact if isinstance(block.artifact, dict) else {}
|
|
518
|
+
raw_code = artifact.get("exit_code")
|
|
519
|
+
exit_code = block.exit_code
|
|
520
|
+
if exit_code is None and isinstance(raw_code, int) and not isinstance(raw_code, bool):
|
|
521
|
+
exit_code = raw_code
|
|
522
|
+
reason = artifact.get("termination_reason")
|
|
523
|
+
reason = reason if isinstance(reason, str) else None
|
|
524
|
+
if block.status == "waiting":
|
|
525
|
+
status: CommandExecutionStatus = "waiting"
|
|
526
|
+
elif block.status == "interrupted":
|
|
527
|
+
status = "interrupted"
|
|
528
|
+
elif block.status == "running":
|
|
529
|
+
status = "running"
|
|
530
|
+
elif reason in ("cancelled", "timeout", "spawn_error"):
|
|
531
|
+
status = reason
|
|
532
|
+
elif exit_code not in (None, 0) or block.is_error or block.status == "error":
|
|
533
|
+
status = "failed"
|
|
534
|
+
else:
|
|
535
|
+
status = "succeeded"
|
|
536
|
+
agent_path = artifact.get("agent_log_path")
|
|
537
|
+
host_path = artifact.get("host_log_path")
|
|
538
|
+
log_path = agent_path or host_path
|
|
539
|
+
log_error = artifact.get("log_error")
|
|
540
|
+
return CommandExecutionPreview(
|
|
541
|
+
command=str(block.arguments.get("command") or "…"),
|
|
542
|
+
status=status,
|
|
543
|
+
exit_code=exit_code,
|
|
544
|
+
termination_reason=reason,
|
|
545
|
+
truncated=artifact.get("truncated") is True,
|
|
546
|
+
log_path=str(log_path) if log_path else None,
|
|
547
|
+
log_error=str(log_error) if log_error else None,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def build_execute_preview(block: ToolBlock) -> ToolPreview:
|
|
552
|
+
execution = normalize_command_execution(block)
|
|
553
|
+
return ToolPreview(
|
|
554
|
+
kind="execute", verb="execute", target=execution.command,
|
|
555
|
+
summary=f"execute {execution.command}",
|
|
556
|
+
group="command", action="expand", command_execution=execution,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _format_seconds(value: Any) -> str:
|
|
561
|
+
if value is None or isinstance(value, bool):
|
|
562
|
+
return ""
|
|
563
|
+
if isinstance(value, (int, float)):
|
|
564
|
+
return f"{value:g}s"
|
|
565
|
+
text = str(value).strip()
|
|
566
|
+
return f"{text}s" if text else ""
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _legacy_search_stats(output: str) -> str:
|
|
570
|
+
"""Fallback for old sessions / third-party tools without an artifact."""
|
|
571
|
+
if not output.startswith("Web search results for:"):
|
|
572
|
+
return ""
|
|
573
|
+
count_line = next((
|
|
574
|
+
line for line in output.splitlines()
|
|
575
|
+
if line.endswith(" results") and line.split(" ", 1)[0].isdigit()
|
|
576
|
+
), "")
|
|
577
|
+
return count_line
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build_web_search_preview(block: ToolBlock) -> ToolPreview:
|
|
581
|
+
query = str(block.arguments.get("query") or "…").replace("\n", " ")
|
|
582
|
+
detail = ""
|
|
583
|
+
artifact = block.artifact if isinstance(block.artifact, dict) else None
|
|
584
|
+
if block.status == "completed":
|
|
585
|
+
if artifact is not None:
|
|
586
|
+
stats: list[str] = []
|
|
587
|
+
results = artifact.get("results")
|
|
588
|
+
if isinstance(results, list):
|
|
589
|
+
stats.append(f"{len(results)} results")
|
|
590
|
+
seconds = _format_seconds(artifact.get("response_time"))
|
|
591
|
+
if seconds:
|
|
592
|
+
stats.append(seconds)
|
|
593
|
+
detail = " · ".join(stats)
|
|
594
|
+
else:
|
|
595
|
+
detail = _legacy_search_stats(block.output)
|
|
596
|
+
return ToolPreview(
|
|
597
|
+
kind="web_search", verb="web_search", target=f'"{query[:80]}"',
|
|
598
|
+
detail=detail, group="web", action="expand",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def build_human_input_preview(block: ToolBlock) -> ToolPreview:
|
|
603
|
+
question = str(block.arguments.get("question") or "input required").replace("\n", " ")
|
|
604
|
+
return ToolPreview(
|
|
605
|
+
kind="human_input", verb="ask", target=question[:100],
|
|
606
|
+
summary=f"ask {question[:100]}", action="expand",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def build_generic_preview(block: ToolBlock) -> ToolPreview:
|
|
611
|
+
display = _display_name(block.name)
|
|
612
|
+
target = _operand(block.arguments)
|
|
613
|
+
return ToolPreview(
|
|
614
|
+
kind=block.name, verb=display, target=target,
|
|
615
|
+
summary=f"{display} {target}".strip() or display,
|
|
616
|
+
action="expand",
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
_PREVIEW_BUILDERS: dict[str, PreviewBuilder] = {
|
|
621
|
+
"ls": build_ls_preview,
|
|
622
|
+
"read_file": build_read_preview,
|
|
623
|
+
"grep": build_grep_preview,
|
|
624
|
+
"glob": build_glob_preview,
|
|
625
|
+
|
|
626
|
+
"write_file": build_write_preview,
|
|
627
|
+
"edit_file": build_edit_preview,
|
|
628
|
+
"delete": build_delete_preview,
|
|
629
|
+
|
|
630
|
+
"execute": build_execute_preview,
|
|
631
|
+
|
|
632
|
+
"web_search": build_web_search_preview,
|
|
633
|
+
|
|
634
|
+
"request_human_input": build_human_input_preview,
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
_TOOL_GROUPS: dict[str, PreviewGroup] = {
|
|
638
|
+
"ls": "explore",
|
|
639
|
+
"read_file": "explore",
|
|
640
|
+
"grep": "explore",
|
|
641
|
+
"glob": "explore",
|
|
642
|
+
"write_file": "mutation",
|
|
643
|
+
"edit_file": "mutation",
|
|
644
|
+
"delete": "mutation",
|
|
645
|
+
"execute": "command",
|
|
646
|
+
"web_search": "web",
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def tool_group(name: str) -> PreviewGroup:
|
|
651
|
+
"""Classification lookup for cheap structural passes (no Rich involved)."""
|
|
652
|
+
return _TOOL_GROUPS.get(name, "other")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def is_mutation_tool(name: str) -> bool:
|
|
656
|
+
return tool_group(name) == "mutation"
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _failure_summary(output: str) -> str:
|
|
660
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
661
|
+
if not lines:
|
|
662
|
+
return ""
|
|
663
|
+
if lines[0].startswith("参数校验失败"):
|
|
664
|
+
return next((line.removeprefix("- ") for line in lines if line.startswith("- ")), lines[0])
|
|
665
|
+
return next((line for line in lines if not line.startswith("Exit code:")), "")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _failure_lines(block: ToolBlock, group: PreviewGroup) -> list[str]:
|
|
669
|
+
lines = block.output.strip().splitlines()
|
|
670
|
+
if lines and lines[0].startswith("参数校验失败"):
|
|
671
|
+
details = [line.strip().removeprefix("- ") for line in lines if line.startswith("- ")]
|
|
672
|
+
shown = details[:4] or lines[:1]
|
|
673
|
+
hidden = len(lines) - len(shown)
|
|
674
|
+
else:
|
|
675
|
+
content = [line for line in lines if line.strip() and not line.strip().startswith("Exit code:")]
|
|
676
|
+
limit = 6 if group == "command" else 4
|
|
677
|
+
shown = content[-limit:] if group == "command" else content[:limit]
|
|
678
|
+
hidden = len(content) - len(shown)
|
|
679
|
+
if hidden > 0:
|
|
680
|
+
shown.append(f"… {hidden} output lines hidden · {EXPAND_HINT}")
|
|
681
|
+
return shown
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def build_failure_preview(block: ToolBlock) -> ToolPreview:
|
|
685
|
+
"""Uniform preview for failed tools: real exit code + compact error output."""
|
|
686
|
+
builder = _PREVIEW_BUILDERS.get(block.name, build_generic_preview)
|
|
687
|
+
base = builder(block)
|
|
688
|
+
group = tool_group(block.name)
|
|
689
|
+
summary = base.summary or f"{base.verb} {base.target}".strip() or base.kind
|
|
690
|
+
return ToolPreview(
|
|
691
|
+
kind="failure",
|
|
692
|
+
verb="Failed",
|
|
693
|
+
target=summary,
|
|
694
|
+
detail=_failure_summary(block.output),
|
|
695
|
+
summary=base.summary,
|
|
696
|
+
lines=tuple(PreviewLine(line, "dim") for line in _failure_lines(block, group)),
|
|
697
|
+
group=group,
|
|
698
|
+
action="expand",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def build_tool_preview(block: ToolBlock) -> ToolPreview:
|
|
703
|
+
if block.name == "execute":
|
|
704
|
+
return build_execute_preview(block)
|
|
705
|
+
if block.is_error:
|
|
706
|
+
return build_failure_preview(block)
|
|
707
|
+
builder = _PREVIEW_BUILDERS.get(block.name)
|
|
708
|
+
if builder is None:
|
|
709
|
+
return build_generic_preview(block)
|
|
710
|
+
return builder(block)
|