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/rendering.py ADDED
@@ -0,0 +1,770 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import re
5
+ from bisect import bisect_right
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from io import StringIO
9
+ from typing import Any, TypeGuard
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from prompt_toolkit import ANSI
13
+ from prompt_toolkit.formatted_text import (
14
+ FormattedText,
15
+ StyleAndTextTuples,
16
+ to_formatted_text,
17
+ )
18
+ from prompt_toolkit.formatted_text.utils import split_lines
19
+ from rich.console import Console, Group
20
+ from rich.markdown import Markdown
21
+ from rich.padding import Padding
22
+ from rich.rule import Rule
23
+ from rich.syntax import Syntax
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+
27
+ from agent.cli.previews import (
28
+ CommandExecutionPreview,
29
+ EXPAND_HINT,
30
+ REVIEW_HINT,
31
+ ToolPreview,
32
+ added_line_label,
33
+ aggregate_file_mutations,
34
+ build_review_mutation,
35
+ build_tool_preview,
36
+ file_tool_preview,
37
+ normalize_file_mutation,
38
+ tool_group,
39
+ )
40
+ from agent.cli.state import CliState, MessageBlock, ToolBlock, TurnSummaryBlock
41
+ from agent.config import DEFAULT_UI_TIMEZONE
42
+
43
+ # Non-file output folding stays in the renderer.
44
+ EXECUTE_TAIL_LINES = 4
45
+
46
+ EXPLORE_PREVIEW_LIMIT = 5
47
+ EXPLORE_FAILURE_PREVIEW_LIMIT = 3
48
+
49
+ # PreviewLine style -> Rich style mapping for line-oriented mutation bodies.
50
+ _PREVIEW_LINE_STYLES: dict[str, str] = {
51
+ "plain": "",
52
+ "dim": "dim",
53
+ "hunk": "bold cyan",
54
+ "add": "white on #245c38",
55
+ "delete": "white on #4a2028",
56
+ "error": "red",
57
+ "url": "bright_cyan",
58
+ }
59
+
60
+
61
+ def render_transcript(
62
+ state: CliState,
63
+ width: int,
64
+ timezone: ZoneInfo | None = None,
65
+ ) -> str:
66
+ return "\n".join(
67
+ _capture(unit.build(), width) for unit in transcript_units(state, timezone or ZoneInfo(DEFAULT_UI_TIMEZONE), width)
68
+ )
69
+
70
+
71
+ # --- Incremental transcript rendering -------------------------------------
72
+ #
73
+ # The transcript is split into render units: the header, the plan, one message,
74
+ # one tool, or one collapsed explore group. A unit is pushed through Rich and
75
+ # parsed into prompt_toolkit fragments exactly once, then replayed from cache
76
+ # until its fingerprint changes. While streaming, only the active block is
77
+ # dirty, so frozen history never pays for Markdown/Syntax parsing again.
78
+
79
+
80
+ @dataclass
81
+ class RenderedUnit:
82
+ fragments: StyleAndTextTuples
83
+ line_count: int
84
+ _lines: tuple[StyleAndTextTuples, ...] | None = None
85
+
86
+ def get_line(self, index: int) -> StyleAndTextTuples:
87
+ if self._lines is None:
88
+ self._lines = tuple(list(line) for line in split_lines(self.fragments))
89
+ return self._lines[index]
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class TranscriptDocument:
94
+ units: tuple[RenderedUnit, ...]
95
+ starts: tuple[int, ...]
96
+ line_count: int
97
+
98
+ def get_line(self, index: int) -> StyleAndTextTuples:
99
+ if index < 0 or index >= self.line_count:
100
+ raise IndexError(index)
101
+ unit_index = bisect_right(self.starts, index) - 1
102
+ return self.units[unit_index].get_line(index - self.starts[unit_index])
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class TranscriptUnit:
107
+ key: Any
108
+ fingerprint: Any
109
+ build: Callable[[], Any]
110
+ # Keeps the keyed object alive while its render is cached, so a freed block's
111
+ # id() can never be recycled into a stale cache hit.
112
+ owner: Any = None
113
+
114
+
115
+ def _render_unit(renderable: Any, width: int) -> RenderedUnit:
116
+ text = _capture(renderable, width)
117
+ fragments = to_formatted_text(ANSI(text))
118
+ return RenderedUnit(fragments, text.count("\n") + 1)
119
+
120
+
121
+ def transcript_units(state: CliState, timezone: ZoneInfo | None = None, width: int = 80) -> list[TranscriptUnit]:
122
+ """Structural pass over the timeline. Cheap: builds no Rich renderables."""
123
+ timezone = timezone or ZoneInfo(DEFAULT_UI_TIMEZONE)
124
+ units: list[TranscriptUnit] = [
125
+ TranscriptUnit("header", (), lambda: Group(*_header())),
126
+ ]
127
+ if state.todos:
128
+ todos = [dict(item) for item in state.todos]
129
+ units.append(TranscriptUnit(
130
+ "todos",
131
+ (
132
+ tuple((item.get("content"), item.get("status")) for item in todos),
133
+ _spinner_tick() if any(item.get("status") == "in_progress" for item in todos) else None,
134
+ ),
135
+ lambda: _todos(todos),
136
+ ))
137
+ index = 0
138
+ blocks = state.blocks
139
+ while index < len(blocks):
140
+ block = blocks[index]
141
+ if isinstance(block, MessageBlock):
142
+ units.append(_message_unit(block, state.thinking_collapsed))
143
+ index += 1
144
+ continue
145
+ if isinstance(block, TurnSummaryBlock):
146
+ units.append(TranscriptUnit(
147
+ key=("turn_summary", id(block)),
148
+ fingerprint=timezone.key,
149
+ build=lambda block=block: _turn_summary(block, timezone),
150
+ owner=block,
151
+ ))
152
+ index += 1
153
+ continue
154
+ create_path = _created_file_path(block)
155
+ if create_path is not None:
156
+ group = [block]
157
+ while index + len(group) < len(blocks):
158
+ candidate = blocks[index + len(group)]
159
+ if _created_file_path(candidate) is None:
160
+ break
161
+ group.append(candidate)
162
+ if len(group) > 1:
163
+ units.append(_create_group_unit(group, state.tools_expanded, width))
164
+ index += len(group)
165
+ continue
166
+ if not state.tools_expanded and _is_explore(block):
167
+ group = [block]
168
+ index += 1
169
+ while index < len(blocks):
170
+ next_block = blocks[index]
171
+ if not _is_explore(next_block):
172
+ break
173
+ group.append(next_block)
174
+ index += 1
175
+ units.append(_explore_unit(group, width))
176
+ continue
177
+ edit_path = _mergeable_modify_path(block)
178
+ if edit_path is not None:
179
+ group = [block]
180
+ while index + len(group) < len(blocks):
181
+ candidate = blocks[index + len(group)]
182
+ if _mergeable_modify_path(candidate) != edit_path:
183
+ break
184
+ group.append(candidate)
185
+ if len(group) > 1:
186
+ units.append(_modify_group_unit(group, width))
187
+ index += len(group)
188
+ continue
189
+ units.append(_tool_unit(block, state.tools_expanded, width))
190
+ index += 1
191
+ return units
192
+
193
+
194
+ def _message_unit(block: MessageBlock, thinking_collapsed: bool) -> TranscriptUnit:
195
+ return TranscriptUnit(
196
+ key=("message", id(block)),
197
+ fingerprint=(block.revision, thinking_collapsed),
198
+ build=lambda: Group(*_message(block, thinking_collapsed)),
199
+ owner=block,
200
+ )
201
+
202
+
203
+ def _tool_unit(block: ToolBlock, expanded: bool, width: int) -> TranscriptUnit:
204
+ return TranscriptUnit(
205
+ key=("tool", id(block), expanded),
206
+ fingerprint=(block.revision, expanded, _live_tick(block)),
207
+ build=lambda: _tool(block, expanded, width),
208
+ owner=block,
209
+ )
210
+
211
+
212
+ def _explore_unit(blocks: list[ToolBlock], width: int) -> TranscriptUnit:
213
+ members = tuple(blocks)
214
+ tick = next((item for item in (_live_tick(block) for block in members) if item is not None), None)
215
+ return TranscriptUnit(
216
+ key=("explore", id(members[0])),
217
+ fingerprint=(tuple(item.revision for item in members), tick),
218
+ build=lambda: _explore_group(list(members), width),
219
+ owner=members,
220
+ )
221
+
222
+
223
+ def _mergeable_modify_path(block: Any) -> str | None:
224
+ mutation = normalize_file_mutation(block) if isinstance(block, ToolBlock) else None
225
+ return mutation.path if mutation and mutation.operation == "modify" and mutation.diff else None
226
+
227
+
228
+ def _created_file_path(block: Any) -> str | None:
229
+ mutation = normalize_file_mutation(block) if isinstance(block, ToolBlock) else None
230
+ return mutation.path if mutation and mutation.operation == "create" else None
231
+
232
+
233
+ def _create_group_unit(blocks: list[ToolBlock], expanded: bool, width: int) -> TranscriptUnit:
234
+ members = tuple(blocks)
235
+ return TranscriptUnit(
236
+ key=("create_group", id(members[0]), expanded),
237
+ fingerprint=tuple(block.revision for block in members) + (tuple(id(block) for block in members), expanded),
238
+ build=lambda: _create_group(list(members), expanded, width),
239
+ owner=members,
240
+ )
241
+
242
+
243
+ def _create_group(blocks: list[ToolBlock], expanded: bool, width: int) -> Any:
244
+ mutations = [item for block in blocks if (item := normalize_file_mutation(block)) is not None]
245
+ mutation = aggregate_file_mutations(mutations, expanded=expanded)
246
+ if len(mutation.paths) == 1:
247
+ return _tool(blocks[-1], expanded, width)
248
+ title = Text.assemble(("● ", "green"), (f"Create {len(mutation.paths)} files", "bold"))
249
+ rows = [Text(line.text, style=_PREVIEW_LINE_STYLES[line.style]) for line in mutation.compact_lines]
250
+ for row in (title, *rows):
251
+ row.truncate(max(1, width - 1), overflow="ellipsis")
252
+ return Group(Text(""), title, *rows)
253
+
254
+
255
+ def _modify_group_unit(blocks: list[ToolBlock], width: int) -> TranscriptUnit:
256
+ members = tuple(blocks)
257
+ return TranscriptUnit(
258
+ key=("modify_group", id(members[0])),
259
+ fingerprint=tuple(block.revision for block in members) + (tuple(id(block) for block in members),),
260
+ build=lambda: _modify_group(list(members), width),
261
+ owner=members,
262
+ )
263
+
264
+
265
+ def _modify_group(blocks: list[ToolBlock], width: int) -> Any:
266
+ mutations = [item for block in blocks if (item := normalize_file_mutation(block)) is not None]
267
+ mutation = aggregate_file_mutations(mutations)
268
+ preview = file_tool_preview(mutation)
269
+ return render_tool_preview(blocks[-1], preview, expanded=False, width=width)
270
+
271
+
272
+ def _live_tick(block: ToolBlock) -> int | None:
273
+ """Spinner frame for in-flight tools, None once they are frozen."""
274
+ return _spinner_tick() if block.status == "running" else None
275
+
276
+
277
+ def _same_owner(cached: Any, current: Any) -> bool:
278
+ """Identity comparison; explore groups own a tuple rebuilt on every pass."""
279
+ if isinstance(cached, tuple) or isinstance(current, tuple):
280
+ return (
281
+ isinstance(cached, tuple)
282
+ and isinstance(current, tuple)
283
+ and len(cached) == len(current)
284
+ and all(left is right for left, right in zip(cached, current))
285
+ )
286
+ return cached is current
287
+
288
+
289
+ class TranscriptRenderer:
290
+ """Per-unit render cache: frozen history is replayed, only changes re-render."""
291
+
292
+ def __init__(self, timezone: ZoneInfo | None = None) -> None:
293
+ self._cache: dict[Any, tuple[Any, Any, RenderedUnit]] = {}
294
+ self._documents: dict[bool, tuple[Any, tuple[Any, ...], TranscriptDocument]] = {}
295
+ self._width = 0
296
+ self._timezone = timezone or ZoneInfo(DEFAULT_UI_TIMEZONE)
297
+
298
+ def render_document(self, state: CliState, width: int) -> TranscriptDocument:
299
+ if width != self._width:
300
+ self.clear()
301
+ self._width = width
302
+ units = transcript_units(state, self._timezone, width)
303
+ live_ids = {id(block) for block in state.blocks}
304
+ live = {"header", "todos"}
305
+ live.update(key for key in self._cache if isinstance(key, tuple) and len(key) > 1 and key[1] in live_ids)
306
+ for key in [key for key in self._cache if key not in live]:
307
+ del self._cache[key]
308
+ signature = tuple((unit.key, unit.fingerprint) for unit in units)
309
+ mode = state.tools_expanded
310
+ document_entry = self._documents.get(mode)
311
+ if document_entry is not None and document_entry[0] == signature:
312
+ return document_entry[2]
313
+ rendered_units: list[RenderedUnit] = []
314
+ starts: list[int] = []
315
+ line_count = 0
316
+ for unit in units:
317
+ entry = self._cache.get(unit.key)
318
+ if (
319
+ entry is None
320
+ or not _same_owner(entry[0], unit.owner)
321
+ or entry[1] != unit.fingerprint
322
+ ):
323
+ rendered = _render_unit(unit.build(), width)
324
+ self._cache[unit.key] = (unit.owner, unit.fingerprint, rendered)
325
+ else:
326
+ rendered = entry[2]
327
+ starts.append(line_count)
328
+ line_count += rendered.line_count
329
+ rendered_units.append(rendered)
330
+ document = TranscriptDocument(tuple(rendered_units), tuple(starts), line_count)
331
+ self._documents[mode] = (signature, tuple(unit.owner for unit in units), document)
332
+ return document
333
+
334
+ def render(self, state: CliState, width: int) -> tuple[FormattedText, int]:
335
+ document = self.render_document(state, width)
336
+ fragments: StyleAndTextTuples = []
337
+ for index in range(document.line_count):
338
+ if index:
339
+ fragments.append(("", "\n"))
340
+ fragments.extend(document.get_line(index))
341
+ return FormattedText(fragments), document.line_count
342
+
343
+ def clear(self) -> None:
344
+ self._cache.clear()
345
+ self._documents.clear()
346
+
347
+
348
+ def render_interaction(controller: Any, width: int) -> str:
349
+ if controller is None:
350
+ return ""
351
+ return _capture(controller.render(), width)
352
+
353
+
354
+ def render_review(calls: list[tuple[str, dict[str, Any]]], width: int) -> str:
355
+ parts: list[Any] = [
356
+ Text("Review", style="bold bright_cyan"),
357
+ Text("Esc or Ctrl+R close · this is not an approval", style="dim"),
358
+ ]
359
+ for name, args in calls:
360
+ mutation = build_review_mutation(name, args)
361
+ if mutation is None or mutation.diff is None or not mutation.diff.full_diff:
362
+ continue
363
+ diff = mutation.diff
364
+ parts.append(Text(""))
365
+ if mutation.operation == "overwrite":
366
+ parts.append(Text(f"write {diff.path}", style="bold"))
367
+ parts.append(Text(added_line_label(diff.added or 0), style="green"))
368
+ parts.append(Group(*(
369
+ row
370
+ for number, line in enumerate(diff.lines, 1)
371
+ for row in _diff_row(f"{number:>4} {line.text}", "add", width)
372
+ )))
373
+ elif mutation.operation == "delete":
374
+ parts.append(Text(f"delete {diff.path}", style="dim"))
375
+ else:
376
+ parts.append(Group(*_numbered_diff(diff.full_diff, width)))
377
+ if len(parts) == 2:
378
+ parts.append(Text("Nothing to review.", style="dim"))
379
+ return _capture(Group(*parts), width)
380
+
381
+
382
+ def _diff_row(content: str, kind: str, width: int) -> list[Text]:
383
+ """Fill every visual row of a changed line, including wrapped rows."""
384
+ style = _PREVIEW_LINE_STYLES[kind]
385
+ available = max(1, width - 1) # Spare terminal column avoids auto-wrap.
386
+ console = Console(width=max(20, width))
387
+ rows = list(Text(content, style=style).wrap(console, available, overflow="fold"))
388
+ if kind in {"add", "delete"}:
389
+ for row in rows:
390
+ row.pad_right(max(0, available - row.cell_len))
391
+ return rows
392
+
393
+
394
+ def _numbered_diff(raw: str, width: int) -> list[Text]:
395
+ """Render source line numbers from unified hunk coordinates."""
396
+ result: list[Text] = []
397
+ old_number = new_number = 0
398
+ for line in raw.splitlines():
399
+ if line.startswith("@@"):
400
+ match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
401
+ if match:
402
+ old_number, new_number = map(int, match.groups())
403
+ result.extend(_diff_row(line, "hunk", width))
404
+ elif line.startswith(("---", "+++")):
405
+ result.extend(_diff_row(line, "dim", width))
406
+ elif line.startswith("-"):
407
+ result.extend(_diff_row(f"{old_number:>4} {line}", "delete", width))
408
+ old_number += 1
409
+ elif line.startswith("+"):
410
+ result.extend(_diff_row(f"{new_number:>4} {line}", "add", width))
411
+ new_number += 1
412
+ elif line.startswith(" "):
413
+ result.extend(_diff_row(f"{new_number:>4} {line}", "dim", width))
414
+ old_number += 1
415
+ new_number += 1
416
+ else:
417
+ result.extend(_diff_row(line, "dim", width))
418
+ return result
419
+
420
+
421
+ def _message(block: MessageBlock, thinking_collapsed: bool) -> list[Any]:
422
+ items: list[Any] = []
423
+ if block.kind == "user":
424
+ style = "white on #404040" if block.pending else "white on #303030"
425
+ label = f"⟳ {block.content}" if block.pending else block.content
426
+ message = Table.grid(expand=True, padding=0)
427
+ message.add_column(width=2, no_wrap=True)
428
+ message.add_column(ratio=1)
429
+ message.add_row(Text("› "), Markdown(label))
430
+ content: list[Any] = [message]
431
+ for index, ref in enumerate(block.attachments, 1):
432
+ content.append(Text(
433
+ f"▣ Image #{index} {ref.filename} · {_format_bytes(ref.size)}",
434
+ style="bright_cyan",
435
+ ))
436
+ items.append(Padding(Group(*content), (1, 1, 1, 1), style=style))
437
+ elif block.kind == "assistant":
438
+ if block.thinking:
439
+ value = "Thinking…" if thinking_collapsed else block.thinking
440
+ items.append(Padding(Markdown(value), (1, 1, 0, 1), style="italic #888888"))
441
+ if block.content:
442
+ items.append(Padding(Markdown(block.content), (1, 1, 0, 1)))
443
+ elif block.kind == "error":
444
+ items.append(Padding(Text(f"Error: {block.content}", style="red"), (1, 1, 0, 1)))
445
+ else:
446
+ items.append(Padding(Text(block.content, style="yellow"), (1, 1, 0, 1)))
447
+ return items
448
+
449
+
450
+ def _turn_summary(block: TurnSummaryBlock, timezone: ZoneInfo) -> Any:
451
+ elapsed = max(0, int(block.elapsed_seconds))
452
+ hours, remaining = divmod(elapsed, 3600)
453
+ minutes, seconds = divmod(remaining, 60)
454
+ if hours:
455
+ duration = f"{hours}h {minutes}m {seconds}s"
456
+ elif minutes:
457
+ duration = f"{minutes}m {seconds}s"
458
+ else:
459
+ duration = f"{seconds}s"
460
+ finished = block.finished_at.astimezone(timezone).strftime("%H:%M")
461
+ return Padding(Text(f"Worked for {duration} · {finished}", style="dim"), (0, 1, 1, 1))
462
+
463
+
464
+ def _header() -> list[Any]:
465
+ title = Text.assemble(("DeepAgent", "bold bright_cyan"), (" terminal coding agent", "dim"))
466
+ hints = Text(
467
+ "Esc interrupt · F2 pending · Ctrl+O tools · Ctrl+R review · / commands",
468
+ style="dim",
469
+ )
470
+ items: list[Any] = [title, hints]
471
+ items.extend([Rule(style="#303030"), Text("")])
472
+ return items
473
+
474
+
475
+ def _todos(todos: list[dict[str, str]]) -> Any:
476
+ lines: list[Text] = [Text("Plan", style="bold bright_cyan")]
477
+ for item in todos:
478
+ status = item.get("status")
479
+ marker_status = {
480
+ "in_progress": "running", "pending": "waiting", "blocked": "waiting",
481
+ "failed": "error", "completed": "completed",
482
+ }.get(status or "", "waiting")
483
+ symbol, style = _tool_status_marker(marker_status)
484
+ lines.append(Text.assemble((f"{symbol} ", style), (str(item.get("content") or ""), style)))
485
+ return Padding(Group(*lines), (0, 1, 1, 1))
486
+
487
+
488
+ def _is_explore(block: Any) -> TypeGuard[ToolBlock]:
489
+ return isinstance(block, ToolBlock) and tool_group(block.name) == "explore"
490
+
491
+
492
+ def _preview_label(preview: ToolPreview) -> str:
493
+ return preview.summary or f"{preview.verb} {preview.target}".strip() or preview.kind
494
+
495
+
496
+ def _explore_group(blocks: list[ToolBlock], width: int = 80) -> Any:
497
+ previews = [build_tool_preview(block) for block in blocks]
498
+ running = any(block.status == "running" for block in blocks)
499
+ waiting = any(block.status == "waiting" for block in blocks)
500
+ interrupted = sum(block.status == "interrupted" for block in blocks)
501
+ suffix = " — waiting for input" if waiting and not running else ""
502
+ failed = [
503
+ (block, preview)
504
+ for block, preview in zip(blocks, previews)
505
+ if block.is_error
506
+ ]
507
+ marker_status = "error" if failed or interrupted else "running" if running else "waiting" if waiting else "completed"
508
+ symbol, color = _tool_status_marker(marker_status)
509
+ count = len(blocks)
510
+ title = Text.assemble(
511
+ (f" {symbol} ", color),
512
+ (f"Explored {count} {'item' if count == 1 else 'items'}", "bold"),
513
+ (f" · {len(failed)} failed", "dim") if failed else ("", ""),
514
+ (f" · {interrupted} interrupted", "dim") if interrupted else ("", ""),
515
+ (suffix, "dim"),
516
+ )
517
+ title.truncate(max(1, width - 1), overflow="ellipsis")
518
+ rows: list[Text] = []
519
+ hidden = count - EXPLORE_PREVIEW_LIMIT
520
+ if hidden > 0:
521
+ row = Text(f" ├ … {hidden} more", style="dim")
522
+ row.truncate(max(1, width - 1), overflow="ellipsis")
523
+ rows.append(row)
524
+ shown = previews[-EXPLORE_PREVIEW_LIMIT:]
525
+ for index, preview in enumerate(shown):
526
+ branch = "└" if index == len(shown) - 1 else "├"
527
+ row = Text.assemble((f" {branch} ", "dim"), (_preview_label(preview), ""))
528
+ row.truncate(max(1, width - 1), overflow="ellipsis")
529
+ rows.append(row)
530
+ if failed:
531
+ omitted = max(0, len(failed) - EXPLORE_FAILURE_PREVIEW_LIMIT)
532
+ if omitted:
533
+ row = Text(f" ├ … {omitted} more failed", style="dim")
534
+ row.truncate(max(1, width - 1), overflow="ellipsis")
535
+ rows.append(row)
536
+ for block, preview in failed[-EXPLORE_FAILURE_PREVIEW_LIMIT:]:
537
+ reason = preview.detail
538
+ row = Text.assemble(
539
+ (f" ├ Failed (exit {_failure_code(block)}) ", "dim"),
540
+ (_preview_label(preview), ""),
541
+ (f": {reason}" if reason else "", "dim"),
542
+ )
543
+ row.truncate(max(1, width - 1), overflow="ellipsis")
544
+ rows.append(row)
545
+ hint = Text(f" {EXPAND_HINT}", style="dim")
546
+ hint.truncate(max(1, width - 1), overflow="ellipsis")
547
+ rows.append(hint)
548
+ # Avoid full-width Padding here: a terminal auto-wrap can leave stale text
549
+ # over the editor when this summary changes.
550
+ return Group(Text(""), title, *rows)
551
+
552
+
553
+ def _tool(block: ToolBlock, expanded: bool, width: int = 80) -> Any:
554
+ return render_tool_preview(
555
+ block, build_tool_preview(block), expanded=expanded, width=width,
556
+ )
557
+
558
+
559
+ def render_tool_preview(
560
+ block: ToolBlock, preview: ToolPreview, *, expanded: bool, width: int = 80,
561
+ ) -> Any:
562
+ """Generic tool card: status, spinner, colors, folding — no tool semantics."""
563
+ if preview.command_execution is not None:
564
+ return _command_card(block.output, preview.command_execution, expanded, width)
565
+ if preview.kind == "failure":
566
+ return _failure_card(block, preview, expanded, width)
567
+ symbol, color = _tool_status_marker(block.status, block.is_error)
568
+ suffix = (
569
+ " — waiting for input" if block.status == "waiting"
570
+ else " — interrupted (completion unconfirmed)" if block.status == "interrupted"
571
+ else ""
572
+ )
573
+ summary = f"{preview.verb} {preview.target}".strip()
574
+ title = Text.assemble((f"{symbol} ", color), (summary, "bold"))
575
+ mutation = preview.file_mutation
576
+ if mutation is not None and mutation.operation == "modify" and mutation.diff is not None:
577
+ title.append(" (")
578
+ title.append(f"+{mutation.diff.added or 0}", style="green")
579
+ title.append(" ")
580
+ title.append(f"-{mutation.diff.deleted or 0}", style="red")
581
+ title.append(")")
582
+ elif preview.group == "mutation" and preview.detail:
583
+ title.append(f" {preview.detail}", style="bold")
584
+ title.append(suffix, style="dim")
585
+ body: list[Any] = [title]
586
+ if preview.group == "mutation":
587
+ body.extend(_mutation_body(preview, width))
588
+ return Padding(Group(*body), (1, 0, 0, 0), expand=False)
589
+ elif preview.lines:
590
+ body.append(Text(
591
+ "\n".join(f" {line.text}" for line in preview.lines),
592
+ style="dim" if not block.is_error else "red",
593
+ ))
594
+ elif preview.group == "web" and not expanded and preview.detail:
595
+ body.append(Text(f" … {preview.detail} · {EXPAND_HINT}", style="dim"))
596
+ else:
597
+ output = block.output.strip()
598
+ if output:
599
+ lines = output.splitlines()
600
+ limit = 40 if expanded else 8
601
+ if len(lines) > limit:
602
+ skipped = len(lines) - limit
603
+ lines = [f"… {skipped} output lines hidden · {EXPAND_HINT}", *lines[-limit:]]
604
+ body.append(Text(
605
+ "\n".join(f" {line}" for line in lines),
606
+ style="dim" if not block.is_error else "red",
607
+ ))
608
+ return Padding(Group(*body), (1, 1, 0, 1), expand=False)
609
+
610
+
611
+ def _command_card(output: str, preview: CommandExecutionPreview, expanded: bool, width: int) -> Any:
612
+ """Render the current output snapshot; every delta rebuilds this card."""
613
+ status = preview.status
614
+ verb = {
615
+ "running": "execute", "waiting": "execute", "interrupted": "execute",
616
+ "succeeded": "Ran", "failed": f"Failed (exit {preview.exit_code if preview.exit_code is not None else 1})",
617
+ "timeout": "Timed out", "cancelled": "Cancelled", "spawn_error": "Failed to start",
618
+ }[status]
619
+ suffix = (
620
+ " — waiting for input" if status == "waiting"
621
+ else " — interrupted (completion unconfirmed)" if status == "interrupted"
622
+ else ""
623
+ )
624
+ marker_status = {
625
+ "running": "running", "waiting": "waiting", "succeeded": "completed",
626
+ }.get(status, "error")
627
+ symbol, color = _tool_status_marker(marker_status)
628
+ title = Text.assemble(
629
+ (f"{symbol} ", color), (f"{verb} {preview.command}", "bold"), (suffix, "dim"),
630
+ )
631
+ console = Console(width=max(20, width))
632
+ body: list[Any] = []
633
+ body.extend(_wrap_failure_line(title, console, width))
634
+
635
+ display_output = output
636
+ if preview.truncated:
637
+ # The executor embeds a saved-log footer in its returned text. It is
638
+ # still kept in ToolBlock.output, but the card displays one normalized
639
+ # notice with the agent-visible path after the live output.
640
+ display_output = re.sub(
641
+ r"(?:\n\n|^)\[Output truncated(?::|\.)[^\]]*\]",
642
+ "", display_output, count=1, flags=re.DOTALL,
643
+ )
644
+ display_output = display_output.removesuffix("\n\n[output truncated]")
645
+ if status == "failed" and preview.exit_code is not None:
646
+ footer = f"Exit code: {preview.exit_code}"
647
+ display_output = "" if display_output == footer else display_output.removesuffix(f"\n\n{footer}")
648
+ elif status == "cancelled":
649
+ footer = "Cancelled by user."
650
+ display_output = "" if display_output == footer else display_output.removesuffix(f"\n\n{footer}")
651
+ elif status == "timeout":
652
+ display_output = re.sub(
653
+ r"(?:\n\n|^)Error: Command timed out after [0-9.]+ seconds\.$",
654
+ "", display_output,
655
+ )
656
+ lines = display_output.strip().splitlines()
657
+ if not expanded and len(lines) > EXECUTE_TAIL_LINES:
658
+ hidden = len(lines) - EXECUTE_TAIL_LINES
659
+ lines = [f"… {hidden} earlier output lines hidden · {EXPAND_HINT}", *lines[-EXECUTE_TAIL_LINES:]]
660
+ if not lines and status not in {"running", "waiting", "interrupted"}:
661
+ lines = ["(no output)"]
662
+ if lines:
663
+ body.extend(_wrap_failure_line(Text(" └ output", style="dim"), console, width))
664
+ for line in lines:
665
+ body.extend(_wrap_failure_line(Text(f" {line}", style="dim"), console, width))
666
+
667
+ if preview.truncated:
668
+ if preview.log_error:
669
+ notice = f"Full output could not be saved: {preview.log_error}"
670
+ elif preview.log_path:
671
+ notice = f"Output truncated · full output: {preview.log_path}"
672
+ else:
673
+ notice = "Output truncated"
674
+ body.extend(_wrap_failure_line(Text(f" {notice}", style="dim"), console, width))
675
+ return Padding(Group(*body), (1, 0, 0, 0), expand=False)
676
+
677
+
678
+ def _mutation_body(preview: ToolPreview, width: int = 80) -> list[Any]:
679
+ body: list[Any] = []
680
+ if preview.label:
681
+ body.append(Text(f" {preview.label}", style="green"))
682
+ body.append(Text(""))
683
+ console = Console(width=max(20, width))
684
+ for line in preview.lines:
685
+ if not line.text:
686
+ body.append(Text(""))
687
+ elif line.style in {"add", "delete"}:
688
+ body.extend(_diff_row(f" {line.text}", line.style, width))
689
+ else:
690
+ text = Text(f" {line.text}", style=_PREVIEW_LINE_STYLES[line.style])
691
+ body.extend(text.wrap(console, max(1, width - 2), overflow="fold"))
692
+ return body
693
+
694
+
695
+ def _failure_card(block: ToolBlock, preview: ToolPreview, expanded: bool, width: int) -> Any:
696
+ _symbol, color = _tool_status_marker("error")
697
+ title = Text.assemble(
698
+ ("● ", color),
699
+ (f"Failed (exit {_failure_code(block)}) ", "bold"),
700
+ (preview.target, ""),
701
+ )
702
+ console = Console(width=max(20, width))
703
+ body: list[Any] = [Text("")]
704
+ body.extend(_wrap_failure_line(title, console, width))
705
+ if expanded:
706
+ lines = block.output.strip().splitlines()
707
+ else:
708
+ lines = [line.text for line in preview.lines]
709
+ if lines:
710
+ body.extend(_wrap_failure_line(Text(" └ output", style="dim"), console, width))
711
+ for line in lines:
712
+ body.extend(_wrap_failure_line(Text(f" {line}", style="dim"), console, width))
713
+ return Group(*body)
714
+
715
+
716
+ def _failure_code(block: ToolBlock) -> int:
717
+ return block.exit_code if isinstance(block.exit_code, int) and not isinstance(block.exit_code, bool) else 1
718
+
719
+
720
+ def _wrap_failure_line(line: Text, console: Console, width: int) -> list[Text]:
721
+ """Leave a spare terminal column while preserving long commands and output."""
722
+ return list(line.wrap(console, max(1, width - 2), overflow="fold"))
723
+
724
+
725
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
726
+
727
+
728
+ def _tool_status_marker(status: str, is_error: bool = False) -> tuple[str, str]:
729
+ """Shared status icon and color for tool cards and grouped tools."""
730
+ if status == "running":
731
+ return _spinner(), "green"
732
+ if status == "waiting":
733
+ return "●", "yellow"
734
+ if status in {"error", "interrupted"} or is_error:
735
+ return "●", "#888888"
736
+ return "●", "green"
737
+
738
+
739
+ def _spinner_tick() -> int:
740
+ return int(time.monotonic() * 10) % len(_SPINNER_FRAMES)
741
+
742
+
743
+ def _spinner() -> str:
744
+ return _SPINNER_FRAMES[_spinner_tick()]
745
+
746
+
747
+ def _format_bytes(size: int) -> str:
748
+ if size >= 1024 * 1024:
749
+ return f"{size / (1024 * 1024):.1f} MiB"
750
+ if size >= 1024:
751
+ return f"{size / 1024:.0f} KiB"
752
+ return f"{size} B"
753
+
754
+
755
+ def _capture(renderable: Any, width: int) -> str:
756
+ stream = StringIO()
757
+ # No soft_wrap: Rich must word-wrap at exactly `width` so one emitted line is
758
+ # one screen row. ScrollbarMargin divides displayed rows by logical lines, so
759
+ # letting the viewport re-wrap would inflate the thumb and skew its position.
760
+ console = Console(
761
+ file=stream,
762
+ force_terminal=True,
763
+ color_system="truecolor",
764
+ # The TUI uses these ANSI styles for semantic diff rows and statuses;
765
+ # an inherited NO_COLOR from the shell must not erase them.
766
+ no_color=False,
767
+ width=max(20, width),
768
+ )
769
+ console.print(renderable)
770
+ return stream.getvalue().rstrip("\n")