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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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)