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.
Files changed (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. 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