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