symphony-code 0.0.1__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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Tool-call widgets for the coding-agent TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from difflib import unified_diff
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import Any, Final, Mapping
|
|
10
|
+
|
|
11
|
+
from rich.console import Group
|
|
12
|
+
from rich.style import Style
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual import events
|
|
15
|
+
from textual.containers import Horizontal
|
|
16
|
+
from textual.message import Message
|
|
17
|
+
from textual.widgets import Collapsible, Static
|
|
18
|
+
|
|
19
|
+
from coding_agent.tui.tools.activity import strip_activity_json, take_activity
|
|
20
|
+
from coding_agent.tui.tools.images import ImageAttachment, ImageModal
|
|
21
|
+
from coding_agent.tui.tools.labels import (
|
|
22
|
+
generate_image_result,
|
|
23
|
+
header_target,
|
|
24
|
+
read_file_detail,
|
|
25
|
+
read_file_result,
|
|
26
|
+
result_preview,
|
|
27
|
+
patch_path,
|
|
28
|
+
tool_detail,
|
|
29
|
+
tool_header_text,
|
|
30
|
+
tool_label,
|
|
31
|
+
)
|
|
32
|
+
from coding_agent.tui.tools.modal import ToolCallModal
|
|
33
|
+
from coding_agent.tui.tools.code_view import code_view
|
|
34
|
+
from coding_agent.tui.tools.snapshots import LIVE_STATUSES, ToolCallSnapshot
|
|
35
|
+
from coding_agent.tui.transcript.messages import (
|
|
36
|
+
SelectableStatic,
|
|
37
|
+
clip_text,
|
|
38
|
+
return_composer_focus,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
IMAGE_CHIP = "[Image 1]"
|
|
42
|
+
# Streamed output lines a running Bash card shows under its header; the
|
|
43
|
+
# finished card's expanded body carries the result as before.
|
|
44
|
+
LIVE_OUTPUT_TAIL_LINES: Final = 6
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BashToolHeader(Horizontal, can_focus=True):
|
|
48
|
+
"""Focusable Bash timeline header that toggles its output."""
|
|
49
|
+
|
|
50
|
+
class Toggle(Message):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def _on_click(self, event: events.Click) -> None:
|
|
54
|
+
event.stop()
|
|
55
|
+
self.post_message(self.Toggle())
|
|
56
|
+
self.app.call_after_refresh(return_composer_focus, self)
|
|
57
|
+
|
|
58
|
+
def _on_key(self, event: events.Key) -> None:
|
|
59
|
+
if event.key in {"enter", "space"}:
|
|
60
|
+
event.stop()
|
|
61
|
+
self.post_message(self.Toggle())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ToolCallWidget(Collapsible):
|
|
65
|
+
"""A collapsible tool lifecycle card that updates as arguments/results arrive."""
|
|
66
|
+
|
|
67
|
+
# Cards (image preview, child transcript, Bash output, patch diff) stay
|
|
68
|
+
# mounted as their own transcript entry instead of becoming a row in an
|
|
69
|
+
# Explored group; either way the widget is mounted once and updated.
|
|
70
|
+
keep_in_transcript = False
|
|
71
|
+
|
|
72
|
+
def __init__(self, call_id: str, tool_name: str) -> None:
|
|
73
|
+
self._body = self._make_body()
|
|
74
|
+
self._tool_label = Static(classes="tool-call-label", markup=False)
|
|
75
|
+
self.call_id = call_id
|
|
76
|
+
self.tool_name = tool_name
|
|
77
|
+
self.arguments: dict[str, Any] = {}
|
|
78
|
+
self.raw_arguments = ""
|
|
79
|
+
self.result = ""
|
|
80
|
+
self.status = "preparing"
|
|
81
|
+
self._started_at = monotonic()
|
|
82
|
+
self._duration: float | None = None
|
|
83
|
+
self.activity_verb = ""
|
|
84
|
+
self.activity_reason = ""
|
|
85
|
+
self.activity_group = ""
|
|
86
|
+
self._body_dirty = True
|
|
87
|
+
self._header_values: tuple[str, str, str] | None = None
|
|
88
|
+
self._styled_status: str | None = None
|
|
89
|
+
super().__init__(
|
|
90
|
+
self._body,
|
|
91
|
+
title=tool_label(tool_name)[0],
|
|
92
|
+
collapsed=True,
|
|
93
|
+
collapsed_symbol="",
|
|
94
|
+
expanded_symbol="",
|
|
95
|
+
classes="tool-call",
|
|
96
|
+
)
|
|
97
|
+
self.refresh_content()
|
|
98
|
+
|
|
99
|
+
def _make_body(self) -> Static:
|
|
100
|
+
return SelectableStatic(markup=False)
|
|
101
|
+
|
|
102
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
103
|
+
# Keep CollapsibleTitle in the DOM for keyboard/accessibility compatibility;
|
|
104
|
+
# the timeline header is the visible control shared by every tool.
|
|
105
|
+
yield self._title
|
|
106
|
+
with BashToolHeader(classes="tool-call-header"):
|
|
107
|
+
yield self._tool_label
|
|
108
|
+
with self.Contents():
|
|
109
|
+
yield self._body
|
|
110
|
+
|
|
111
|
+
def on_bash_tool_header_toggle(self, event: BashToolHeader.Toggle) -> None:
|
|
112
|
+
event.stop()
|
|
113
|
+
if self.status in LIVE_STATUSES:
|
|
114
|
+
return
|
|
115
|
+
self.open_detail()
|
|
116
|
+
|
|
117
|
+
def open_detail(self) -> None:
|
|
118
|
+
"""Open a finished call in a card. Live calls stay in the transcript."""
|
|
119
|
+
if self.status in LIVE_STATUSES:
|
|
120
|
+
return
|
|
121
|
+
self.app.push_screen(ToolCallModal(self))
|
|
122
|
+
|
|
123
|
+
def detail_title(self) -> Text:
|
|
124
|
+
label, target, status = self._header_values or (self._tool_title()[0], "", self.status)
|
|
125
|
+
return tool_header_text(label, target, status)
|
|
126
|
+
|
|
127
|
+
def detail_rows(self, *, wrap: bool = False) -> list[Any]:
|
|
128
|
+
rows: list[Any] = []
|
|
129
|
+
request = self._summary()
|
|
130
|
+
if request:
|
|
131
|
+
rows.append(Text(request, style="bold #d0d0d0"))
|
|
132
|
+
if self.result:
|
|
133
|
+
if rows:
|
|
134
|
+
rows.append(Text())
|
|
135
|
+
rows.append(code_view(self.result, lexer="text", wrap=wrap))
|
|
136
|
+
return rows
|
|
137
|
+
|
|
138
|
+
def detail_plain(self) -> str:
|
|
139
|
+
"""Plain command/path and result for the card's copy binding."""
|
|
140
|
+
return "\n\n".join(part for part in (self._summary(), self.result) if part)
|
|
141
|
+
|
|
142
|
+
def _watch_collapsed(self, collapsed: bool) -> None:
|
|
143
|
+
# Collapsible scrolls itself into view after every state change. The
|
|
144
|
+
# transcript already owns tail-following, so that competing scroll
|
|
145
|
+
# produces visible jumps as tools complete.
|
|
146
|
+
self._update_collapsed(collapsed)
|
|
147
|
+
if collapsed:
|
|
148
|
+
self.post_message(self.Collapsed(self))
|
|
149
|
+
else:
|
|
150
|
+
self.post_message(self.Expanded(self))
|
|
151
|
+
self._body.display = not collapsed
|
|
152
|
+
self.refresh_content()
|
|
153
|
+
|
|
154
|
+
def set_arguments(self, arguments: Mapping[str, Any] | None, raw: str = "") -> None:
|
|
155
|
+
incoming = dict(arguments or {})
|
|
156
|
+
if incoming:
|
|
157
|
+
self.arguments = incoming
|
|
158
|
+
elif raw:
|
|
159
|
+
self.arguments = {}
|
|
160
|
+
self._apply_activity(take_activity(self.arguments))
|
|
161
|
+
if raw:
|
|
162
|
+
self.raw_arguments = strip_activity_json(raw)
|
|
163
|
+
self._body_dirty = True
|
|
164
|
+
self.refresh_content()
|
|
165
|
+
|
|
166
|
+
def set_running(self, arguments: Mapping[str, Any] | None) -> None:
|
|
167
|
+
self.status = "running"
|
|
168
|
+
incoming = dict(arguments or {})
|
|
169
|
+
if incoming:
|
|
170
|
+
self.arguments = incoming
|
|
171
|
+
self._apply_activity(take_activity(self.arguments))
|
|
172
|
+
self._body_dirty = True
|
|
173
|
+
self.refresh_content()
|
|
174
|
+
|
|
175
|
+
def _apply_activity(self, activity: Any) -> None:
|
|
176
|
+
self.activity_verb = activity.verb
|
|
177
|
+
self.activity_reason = activity.reason
|
|
178
|
+
self.activity_group = activity.group
|
|
179
|
+
|
|
180
|
+
def set_result(self, result: Any) -> None:
|
|
181
|
+
if self._duration is None:
|
|
182
|
+
self._duration = max(0.0, monotonic() - self._started_at)
|
|
183
|
+
self.status = "failed" if str(result).startswith("error:") else "done"
|
|
184
|
+
self.result = str(result or "")
|
|
185
|
+
self._body_dirty = True
|
|
186
|
+
self.refresh_content()
|
|
187
|
+
|
|
188
|
+
def _tool_title(self) -> tuple[str, str]:
|
|
189
|
+
return tool_label(self.tool_name)
|
|
190
|
+
|
|
191
|
+
def _summary(self) -> str:
|
|
192
|
+
return tool_detail(self.tool_name, self.arguments, self.raw_arguments)
|
|
193
|
+
|
|
194
|
+
def _result_summary(self) -> str:
|
|
195
|
+
return result_preview(self.tool_name, self.result)
|
|
196
|
+
|
|
197
|
+
def _body_rows(self) -> list[Any]:
|
|
198
|
+
rows: list[Any] = []
|
|
199
|
+
summary = clip_text(self._summary(), 300)
|
|
200
|
+
if summary:
|
|
201
|
+
rows.append(Text(summary, style="#a4a4a4"))
|
|
202
|
+
result = self._result_summary()
|
|
203
|
+
if result:
|
|
204
|
+
_label, icon = self._tool_title()
|
|
205
|
+
result_color = "#d66b73" if self.status == "failed" else "#666666"
|
|
206
|
+
rows.append(Text(f"{icon} {result}", style=result_color))
|
|
207
|
+
return rows
|
|
208
|
+
|
|
209
|
+
def _refresh_status_class(self) -> None:
|
|
210
|
+
if self._styled_status == self.status:
|
|
211
|
+
return
|
|
212
|
+
if self._styled_status is not None:
|
|
213
|
+
self.remove_class(f"status-{self._styled_status}")
|
|
214
|
+
self.add_class(f"status-{self.status}")
|
|
215
|
+
self._styled_status = self.status
|
|
216
|
+
|
|
217
|
+
def _refresh_header(self, label: str, summary: str) -> None:
|
|
218
|
+
target = header_target(summary)
|
|
219
|
+
values = (label, target, self.status)
|
|
220
|
+
if values == self._header_values:
|
|
221
|
+
return
|
|
222
|
+
self._tool_label.update(tool_header_text(label, target, self.status), layout=False)
|
|
223
|
+
self._header_values = values
|
|
224
|
+
|
|
225
|
+
def _refresh_body(self) -> None:
|
|
226
|
+
if self.collapsed or not self._body_dirty:
|
|
227
|
+
return
|
|
228
|
+
self._body.update(Group(*self._body_rows()))
|
|
229
|
+
self._body_dirty = False
|
|
230
|
+
|
|
231
|
+
def refresh_content(self) -> None:
|
|
232
|
+
label, _icon = self._tool_title()
|
|
233
|
+
self._refresh_header(label, clip_text(self._summary(), 140))
|
|
234
|
+
self._refresh_status_class()
|
|
235
|
+
self._refresh_body()
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def duration(self) -> float | None:
|
|
239
|
+
return self._duration
|
|
240
|
+
|
|
241
|
+
def snapshot(self) -> ToolCallSnapshot:
|
|
242
|
+
label, _icon = self._tool_title()
|
|
243
|
+
return ToolCallSnapshot(
|
|
244
|
+
call_id=self.call_id,
|
|
245
|
+
tool_name=self.tool_name,
|
|
246
|
+
label=label,
|
|
247
|
+
detail=clip_text(self._summary(), 300),
|
|
248
|
+
status=self.status,
|
|
249
|
+
result=self._result_summary(),
|
|
250
|
+
activity_verb=self.activity_verb,
|
|
251
|
+
activity_reason=self.activity_reason,
|
|
252
|
+
activity_group=self.activity_group,
|
|
253
|
+
duration=self._duration,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ImageChipBody(Static):
|
|
258
|
+
"""Tool-result body that opens an image modal when clicked."""
|
|
259
|
+
|
|
260
|
+
def action_open_image(self) -> None:
|
|
261
|
+
self._open_owner_preview()
|
|
262
|
+
|
|
263
|
+
def on_click(self, event: events.Click) -> None:
|
|
264
|
+
if self._open_owner_preview():
|
|
265
|
+
event.stop()
|
|
266
|
+
|
|
267
|
+
def _open_owner_preview(self) -> bool:
|
|
268
|
+
node = self.parent
|
|
269
|
+
while node is not None:
|
|
270
|
+
if isinstance(node, GenerateImageWidget):
|
|
271
|
+
return node.open_preview()
|
|
272
|
+
node = node.parent
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class GenerateImageWidget(ToolCallWidget):
|
|
277
|
+
"""Path-oriented generate_image card with a clickable `[Image 1]` preview."""
|
|
278
|
+
|
|
279
|
+
keep_in_transcript = True
|
|
280
|
+
|
|
281
|
+
def __init__(self, call_id: str, tool_name: str, *, workspace: Path | None = None) -> None:
|
|
282
|
+
self.workspace = workspace
|
|
283
|
+
super().__init__(call_id, tool_name)
|
|
284
|
+
|
|
285
|
+
def _make_body(self) -> Static:
|
|
286
|
+
return ImageChipBody()
|
|
287
|
+
|
|
288
|
+
def _tool_title(self) -> tuple[str, str]:
|
|
289
|
+
return ("Image", "└")
|
|
290
|
+
|
|
291
|
+
def _summary(self) -> str:
|
|
292
|
+
return str(self.arguments.get("path") or self.raw_arguments)
|
|
293
|
+
|
|
294
|
+
def _result_summary(self) -> str:
|
|
295
|
+
return generate_image_result(self.result)
|
|
296
|
+
|
|
297
|
+
def set_result(self, result: Any) -> None:
|
|
298
|
+
super().set_result(result)
|
|
299
|
+
if self.status == "done":
|
|
300
|
+
self.collapsed = False
|
|
301
|
+
|
|
302
|
+
def _body_rows(self) -> list[Any]:
|
|
303
|
+
rows: list[Any] = []
|
|
304
|
+
summary = clip_text(self._summary(), 300)
|
|
305
|
+
if summary:
|
|
306
|
+
rows.append(Text(summary, style="#a4a4a4"))
|
|
307
|
+
if self.status == "failed":
|
|
308
|
+
if self.result:
|
|
309
|
+
rows.append(Text(f"└ {self.result}", style="#d66b73"))
|
|
310
|
+
return rows
|
|
311
|
+
if self.status == "done":
|
|
312
|
+
chip = Text()
|
|
313
|
+
chip.append(IMAGE_CHIP, style=Style(color="#87b5b1", bold=True, underline=True))
|
|
314
|
+
detail = self._result_summary()
|
|
315
|
+
if detail:
|
|
316
|
+
chip.append(f" {detail}", style="#666666")
|
|
317
|
+
rows.append(chip)
|
|
318
|
+
return rows
|
|
319
|
+
|
|
320
|
+
def open_preview(self) -> bool:
|
|
321
|
+
workspace = self.workspace
|
|
322
|
+
path = str(self.arguments.get("path") or "")
|
|
323
|
+
if workspace is None or not path or self.status == "failed":
|
|
324
|
+
return False
|
|
325
|
+
try:
|
|
326
|
+
root = workspace.resolve()
|
|
327
|
+
candidate = Path(path).expanduser()
|
|
328
|
+
target = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
329
|
+
if not target.is_file():
|
|
330
|
+
return False
|
|
331
|
+
image = ImageAttachment.from_path(target, IMAGE_CHIP)
|
|
332
|
+
except OSError:
|
|
333
|
+
return False
|
|
334
|
+
self.app.push_screen(ImageModal(image))
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class ReadFileWidget(ToolCallWidget):
|
|
339
|
+
"""Compact, path-oriented presentation for the read_file tool."""
|
|
340
|
+
|
|
341
|
+
def _tool_title(self) -> tuple[str, str]:
|
|
342
|
+
return ("Read", "└")
|
|
343
|
+
|
|
344
|
+
def _summary(self) -> str:
|
|
345
|
+
return read_file_detail(self.arguments, self.raw_arguments)
|
|
346
|
+
|
|
347
|
+
def _result_summary(self) -> str:
|
|
348
|
+
return read_file_result(self.result)
|
|
349
|
+
|
|
350
|
+
def detail_rows(self, *, wrap: bool = False) -> list[Any]:
|
|
351
|
+
rows: list[Any] = [Text(self._summary() or "Read", style="bold #d0d0d0")]
|
|
352
|
+
if self.result:
|
|
353
|
+
rows.append(code_view(self.result, lexer=_lexer_for_path(self._summary()), wrap=wrap))
|
|
354
|
+
return rows
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _lexer_for_path(path: str) -> str:
|
|
358
|
+
suffix = Path(path).suffix.lower()
|
|
359
|
+
return {
|
|
360
|
+
".py": "python",
|
|
361
|
+
".js": "javascript",
|
|
362
|
+
".ts": "typescript",
|
|
363
|
+
".json": "json",
|
|
364
|
+
".md": "markdown",
|
|
365
|
+
".toml": "toml",
|
|
366
|
+
".yaml": "yaml",
|
|
367
|
+
".yml": "yaml",
|
|
368
|
+
".sh": "bash",
|
|
369
|
+
}.get(suffix, "")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class BashToolWidget(ToolCallWidget):
|
|
373
|
+
"""Bash card: the command on one header line and, while it runs, the tail
|
|
374
|
+
of its streamed output; once finished, the collapsible command-and-result
|
|
375
|
+
body. Output chunks and status changes only update text in place."""
|
|
376
|
+
|
|
377
|
+
keep_in_transcript = True
|
|
378
|
+
|
|
379
|
+
def __init__(self, call_id: str, tool_name: str) -> None:
|
|
380
|
+
self._bash_label = Static(classes="bash-tool-label", markup=False)
|
|
381
|
+
self._tail = Static(classes="bash-tool-tail", markup=False)
|
|
382
|
+
self._tail.display = False
|
|
383
|
+
# A fixed window: new chunks repaint the tail without a layout pass.
|
|
384
|
+
self._tail.styles.height = LIVE_OUTPUT_TAIL_LINES
|
|
385
|
+
self._tail_lines: deque[str] = deque(maxlen=LIVE_OUTPUT_TAIL_LINES)
|
|
386
|
+
self._partial_line = ""
|
|
387
|
+
super().__init__(call_id, tool_name)
|
|
388
|
+
self.add_class("bash-tool")
|
|
389
|
+
self._body.add_class("bash-tool-body")
|
|
390
|
+
|
|
391
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
392
|
+
with BashToolHeader(classes="bash-tool-header"):
|
|
393
|
+
yield self._bash_label
|
|
394
|
+
yield self._tail
|
|
395
|
+
yield self._body
|
|
396
|
+
|
|
397
|
+
def append_output(self, chunk: str) -> None:
|
|
398
|
+
"""Take one streamed output chunk; only the tail's text changes."""
|
|
399
|
+
lines = (self._partial_line + chunk).split("\n")
|
|
400
|
+
self._partial_line = lines.pop()
|
|
401
|
+
self._tail_lines.extend(lines)
|
|
402
|
+
self._refresh_tail()
|
|
403
|
+
|
|
404
|
+
def _live_tail(self) -> list[str]:
|
|
405
|
+
if self.status not in LIVE_STATUSES:
|
|
406
|
+
return []
|
|
407
|
+
lines = [*self._tail_lines, self._partial_line] if self._partial_line else list(self._tail_lines)
|
|
408
|
+
return lines[-LIVE_OUTPUT_TAIL_LINES:]
|
|
409
|
+
|
|
410
|
+
def _refresh_tail(self) -> None:
|
|
411
|
+
lines = self._live_tail()
|
|
412
|
+
if lines:
|
|
413
|
+
self._tail.update("\n".join(lines), layout=False)
|
|
414
|
+
if bool(lines) != self._tail.display:
|
|
415
|
+
self._tail.display = bool(lines)
|
|
416
|
+
|
|
417
|
+
def detail_rows(self, *, wrap: bool = False) -> list[Any]:
|
|
418
|
+
rows: list[Any] = []
|
|
419
|
+
command = self._summary()
|
|
420
|
+
if command:
|
|
421
|
+
rows.append(code_view(command, lexer="bash", wrap=wrap))
|
|
422
|
+
if self.result:
|
|
423
|
+
if rows:
|
|
424
|
+
rows.append(Text())
|
|
425
|
+
rows.append(code_view(self.result, lexer="text", wrap=wrap))
|
|
426
|
+
return rows
|
|
427
|
+
|
|
428
|
+
def refresh_content(self) -> None:
|
|
429
|
+
target = header_target(clip_text(self._summary(), 180))
|
|
430
|
+
values = ("Bash", target, self.status)
|
|
431
|
+
if values != self._header_values:
|
|
432
|
+
self._bash_label.update(tool_header_text("Bash", target, self.status), layout=False)
|
|
433
|
+
self._header_values = values
|
|
434
|
+
self._refresh_status_class()
|
|
435
|
+
self._refresh_tail()
|
|
436
|
+
self._refresh_body()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class PatchDiffWidget(ToolCallWidget):
|
|
440
|
+
"""Patch card: an Update header with the path and diff stats, expanding to
|
|
441
|
+
the unified diff. Argument and status changes update it in place."""
|
|
442
|
+
|
|
443
|
+
keep_in_transcript = True
|
|
444
|
+
MAX_DIFF_LINES = 80
|
|
445
|
+
|
|
446
|
+
def _tool_title(self) -> tuple[str, str]:
|
|
447
|
+
return ("Update", "±")
|
|
448
|
+
|
|
449
|
+
def _patch_path(self) -> str:
|
|
450
|
+
return patch_path(self.arguments, self.raw_arguments, self.result)
|
|
451
|
+
|
|
452
|
+
def _summary(self) -> str:
|
|
453
|
+
"""Keep live and collected Update rows on the same path-plus-stats line."""
|
|
454
|
+
path = self._patch_path()
|
|
455
|
+
summary = patch_summary(path, self._diff())
|
|
456
|
+
if summary:
|
|
457
|
+
return summary
|
|
458
|
+
return path or header_target(self.raw_arguments)
|
|
459
|
+
|
|
460
|
+
def __init__(self, call_id: str, tool_name: str) -> None:
|
|
461
|
+
self._diff_key: tuple[str, str, str] | None = None
|
|
462
|
+
self._diff_cache: list[str] = []
|
|
463
|
+
super().__init__(call_id, tool_name)
|
|
464
|
+
self.add_class("diff-tool")
|
|
465
|
+
|
|
466
|
+
def _diff(self) -> list[str]:
|
|
467
|
+
old = str(self.arguments.get("old_str") or "")
|
|
468
|
+
new = str(self.arguments.get("new_str") or "")
|
|
469
|
+
path = str(self.arguments.get("path") or "file")
|
|
470
|
+
key = (path, old, new)
|
|
471
|
+
if key == self._diff_key:
|
|
472
|
+
return self._diff_cache
|
|
473
|
+
self._diff_key = key
|
|
474
|
+
self._diff_cache = make_unified_diff(old, new, path) if old or new else []
|
|
475
|
+
return self._diff_cache
|
|
476
|
+
|
|
477
|
+
def detail_rows(self, *, wrap: bool = False) -> list[Any]:
|
|
478
|
+
rows = [code_view("\n".join(self._diff()), lexer="diff", wrap=wrap)]
|
|
479
|
+
if self.result:
|
|
480
|
+
rows.append(Text(self.result, style="#626262"))
|
|
481
|
+
return rows
|
|
482
|
+
|
|
483
|
+
def _diff_rows(self, *, limit: int, clip_result: bool) -> list[Any]:
|
|
484
|
+
diff = self._diff()
|
|
485
|
+
rows: list[Any] = []
|
|
486
|
+
for line in diff[:limit]:
|
|
487
|
+
if line.startswith("@@"):
|
|
488
|
+
rows.append(Text(line, style="#6688a8"))
|
|
489
|
+
elif line.startswith("+"):
|
|
490
|
+
rows.append(Text(line, style="#8fc49a on #203026"))
|
|
491
|
+
elif line.startswith("-"):
|
|
492
|
+
rows.append(Text(line, style="#df8b91 on #352225"))
|
|
493
|
+
else:
|
|
494
|
+
rows.append(Text(line, style="#686868"))
|
|
495
|
+
if len(diff) > limit:
|
|
496
|
+
rows.append(Text(f"… {len(diff) - limit} diff lines hidden", style="#555555"))
|
|
497
|
+
if self.result:
|
|
498
|
+
result_color = "#d66b73" if self.status == "failed" else "#626262"
|
|
499
|
+
result = clip_text(self.result, 260) if clip_result else self.result
|
|
500
|
+
rows.append(Text(f"└ {result}", style=result_color))
|
|
501
|
+
return rows
|
|
502
|
+
|
|
503
|
+
def refresh_content(self) -> None:
|
|
504
|
+
self._refresh_header("Update", self._summary())
|
|
505
|
+
self._refresh_status_class()
|
|
506
|
+
if self.collapsed or not self._body_dirty:
|
|
507
|
+
return
|
|
508
|
+
self._body.update(Group(*self._diff_rows(limit=self.MAX_DIFF_LINES, clip_result=True)))
|
|
509
|
+
self._body_dirty = False
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def make_tool_widget(
|
|
513
|
+
call_id: str, tool_name: str, *, workspace: Path | None = None
|
|
514
|
+
) -> ToolCallWidget:
|
|
515
|
+
"""Build the live card for a tool; ``workspace`` resolves generated-image paths."""
|
|
516
|
+
if tool_name == "spawn_agent":
|
|
517
|
+
from coding_agent.tui.runtime.subagent import SubagentWidget
|
|
518
|
+
|
|
519
|
+
return SubagentWidget(call_id, tool_name)
|
|
520
|
+
if tool_name == "bash":
|
|
521
|
+
return BashToolWidget(call_id, tool_name)
|
|
522
|
+
if tool_name == "read_file":
|
|
523
|
+
return ReadFileWidget(call_id, tool_name)
|
|
524
|
+
if tool_name == "generate_image":
|
|
525
|
+
return GenerateImageWidget(call_id, tool_name, workspace=workspace)
|
|
526
|
+
if tool_name == "patch":
|
|
527
|
+
return PatchDiffWidget(call_id, tool_name)
|
|
528
|
+
return ToolCallWidget(call_id, tool_name)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def make_unified_diff(old: str, new: str, path: str) -> list[str]:
|
|
532
|
+
"""Build display-ready unified diff lines for an exact-text edit."""
|
|
533
|
+
if not old and not new:
|
|
534
|
+
return []
|
|
535
|
+
lines = list(
|
|
536
|
+
unified_diff(
|
|
537
|
+
old.splitlines(),
|
|
538
|
+
new.splitlines(),
|
|
539
|
+
fromfile=f"a/{path}",
|
|
540
|
+
tofile=f"b/{path}",
|
|
541
|
+
lineterm="",
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
return lines[2:] if len(lines) >= 2 else lines
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def diff_stats(diff: list[str]) -> tuple[int, int]:
|
|
548
|
+
"""Count additions and deletions in display-ready diff lines."""
|
|
549
|
+
additions = sum(line.startswith("+") and not line.startswith("+++") for line in diff)
|
|
550
|
+
deletions = sum(line.startswith("-") and not line.startswith("---") for line in diff)
|
|
551
|
+
return int(additions), int(deletions)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def patch_summary(path: str, diff: list[str]) -> str:
|
|
555
|
+
"""Path plus +/− counts for a patch card header."""
|
|
556
|
+
if not diff:
|
|
557
|
+
return path
|
|
558
|
+
additions, deletions = diff_stats(diff)
|
|
559
|
+
stats = f"+{additions} -{deletions}"
|
|
560
|
+
return f"{path} {stats}" if path else stats
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Code-style renderables for finished tool cards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Group
|
|
6
|
+
from rich.syntax import Syntax
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from coding_agent.tui.theme import SYMPHONY_CODE_THEME
|
|
10
|
+
|
|
11
|
+
MAX_CODE_LINES = 2000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def code_view(
|
|
15
|
+
text: str,
|
|
16
|
+
*,
|
|
17
|
+
lexer: str = "text",
|
|
18
|
+
limit: int = MAX_CODE_LINES,
|
|
19
|
+
wrap: bool = False,
|
|
20
|
+
) -> object:
|
|
21
|
+
"""Render text as a numbered code view, truncating oversized results."""
|
|
22
|
+
lines = str(text or "").splitlines()
|
|
23
|
+
if not lines:
|
|
24
|
+
return Text("No details yet.", style="#686868")
|
|
25
|
+
hidden = max(0, len(lines) - limit)
|
|
26
|
+
visible = "\n".join(lines[:limit])
|
|
27
|
+
if hidden == 0:
|
|
28
|
+
body: object = Syntax(
|
|
29
|
+
visible,
|
|
30
|
+
lexer,
|
|
31
|
+
theme=SYMPHONY_CODE_THEME,
|
|
32
|
+
line_numbers=True,
|
|
33
|
+
word_wrap=wrap,
|
|
34
|
+
background_color="#0d0d0d",
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
body = _numbered(visible, wrap=wrap)
|
|
38
|
+
if hidden:
|
|
39
|
+
return Group(body, Text(f"… {hidden} lines hidden", style="#555555"))
|
|
40
|
+
return body
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _numbered(text: str, *, wrap: bool) -> Text:
|
|
44
|
+
lines = text.splitlines() or [""]
|
|
45
|
+
width = max(2, len(str(len(lines))))
|
|
46
|
+
rendered = Text(no_wrap=not wrap, overflow="fold" if wrap else "crop")
|
|
47
|
+
for number, line in enumerate(lines, start=1):
|
|
48
|
+
rendered.append(f"{number:>{width}} │ ", style="#687e8b")
|
|
49
|
+
rendered.append(line, style="#d0d0d0")
|
|
50
|
+
if number != len(lines):
|
|
51
|
+
rendered.append("\n")
|
|
52
|
+
return rendered
|