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,250 @@
1
+ """Image attachments for the coding-agent TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Sequence
11
+ from urllib.parse import unquote, urlparse
12
+
13
+ from rich.text import Text
14
+ from textual.containers import Container
15
+ from textual.widget import Widget
16
+ from textual.widgets import Static
17
+
18
+ from core_ai.content import IMAGE_MIME_BY_SUFFIX, image_part, normalize_content, sniff_image_media_type
19
+ from core_ai.types import Content
20
+ from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton, ModalScroll
21
+ from coding_agent.tui.theme import IMAGE_MODAL_CSS
22
+
23
+
24
+ IMAGE_MARKER_RE = re.compile(r"\[Image (\d+)\]")
25
+ IMAGE_EXTENSIONS = set(IMAGE_MIME_BY_SUFFIX)
26
+ IMAGE_MIME_TYPES = IMAGE_MIME_BY_SUFFIX
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ImageAttachment:
31
+ """One dropped or restored image, referenced in the composer as `[Image N]`."""
32
+
33
+ marker: str
34
+ filename: str
35
+ media_type: str
36
+ data: str
37
+ path: str | None = None
38
+
39
+ @property
40
+ def number(self) -> str:
41
+ match = IMAGE_MARKER_RE.fullmatch(self.marker)
42
+ return match.group(1) if match else self.marker
43
+
44
+ def to_part(self) -> dict[str, str]:
45
+ return image_part(
46
+ media_type=self.media_type,
47
+ data=self.data,
48
+ filename=self.filename,
49
+ )
50
+
51
+ def decoded(self) -> bytes:
52
+ return base64.b64decode(self.data) if self.data else b""
53
+
54
+ @classmethod
55
+ def from_path(cls, path: Path, marker: str) -> "ImageAttachment":
56
+ payload = path.read_bytes()
57
+ suffix = path.suffix.lower()
58
+ return cls(
59
+ marker=marker,
60
+ filename=path.name,
61
+ media_type=sniff_image_media_type(payload, filename=path.name) or IMAGE_MIME_TYPES.get(suffix, "image/png"),
62
+ data=base64.b64encode(payload).decode("ascii"),
63
+ path=str(path),
64
+ )
65
+
66
+ @classmethod
67
+ def from_part(cls, part: dict[str, object], marker: str) -> "ImageAttachment":
68
+ return cls(
69
+ marker=marker,
70
+ filename=str(part.get("filename") or "image"),
71
+ media_type=str(part.get("media_type") or "image/png"),
72
+ data=str(part.get("data") or ""),
73
+ )
74
+
75
+
76
+ def dropped_image_paths(text: str) -> list[Path]:
77
+ """Return image files when the entire paste is one or more dropped paths."""
78
+ stripped = text.strip()
79
+ if not stripped:
80
+ return []
81
+ single = _resolve_image_path(stripped)
82
+ if single is not None:
83
+ return [single]
84
+ lines = [line.strip() for line in stripped.splitlines() if line.strip()]
85
+ if len(lines) > 1:
86
+ paths = [_resolve_image_path(line) for line in lines]
87
+ if all(path is not None for path in paths):
88
+ return [path for path in paths if path is not None]
89
+ return []
90
+ tokens = _split_path_tokens(stripped)
91
+ if not tokens:
92
+ return []
93
+ paths = []
94
+ for token in tokens:
95
+ path = _resolve_image_path(token)
96
+ if path is None:
97
+ return []
98
+ paths.append(path)
99
+ return paths
100
+
101
+
102
+ def build_user_content(text: str, images: Sequence[ImageAttachment]) -> Content:
103
+ """Turn composer text plus `[Image N]` attachments into canonical content."""
104
+ active = [image for image in images if image.marker in text]
105
+ if not active:
106
+ return text
107
+ parts: list[dict[str, object]] = []
108
+ remaining = text
109
+ for image in active:
110
+ before, _marker, remaining = remaining.partition(image.marker)
111
+ if before:
112
+ parts.append({"type": "text", "text": before})
113
+ parts.append(image.to_part())
114
+ if remaining:
115
+ parts.append({"type": "text", "text": remaining})
116
+ return parts
117
+
118
+
119
+ def display_from_content(content: Content) -> tuple[str, tuple[ImageAttachment, ...]]:
120
+ """Rebuild composer-style text and attachments from a stored message body."""
121
+ if isinstance(content, str) or content is None:
122
+ return content or "", ()
123
+ chunks: list[str] = []
124
+ images: list[ImageAttachment] = []
125
+ for part in normalize_content(content):
126
+ if part["type"] == "text":
127
+ text = str(part.get("text") or "")
128
+ if text:
129
+ chunks.append(text)
130
+ continue
131
+ marker = f"[Image {len(images) + 1}]"
132
+ images.append(ImageAttachment.from_part(part, marker))
133
+ if chunks and not chunks[-1].endswith((" ", "\n", "\t")):
134
+ chunks.append(" ")
135
+ chunks.append(marker)
136
+ chunks.append(" ")
137
+ return "".join(chunks).strip(), tuple(images)
138
+
139
+
140
+ def textual_image(payload: bytes, *, widget_id: str | None = None) -> Widget:
141
+ """Render image bytes with textual-image, which picks the terminal's best protocol."""
142
+ from textual_image.widget import Image as TextualImage
143
+
144
+ image = TextualImage(io.BytesIO(payload) if payload else None, id=widget_id)
145
+ image.styles.max_width = 88
146
+ image.styles.max_height = 28
147
+ return image
148
+
149
+
150
+ def _split_path_tokens(text: str) -> list[str]:
151
+ tokens: list[str] = []
152
+ current: list[str] = []
153
+ quote: str | None = None
154
+ index = 0
155
+ while index < len(text):
156
+ char = text[index]
157
+ if quote:
158
+ if char == quote:
159
+ quote = None
160
+ else:
161
+ current.append(char)
162
+ index += 1
163
+ continue
164
+ if char in {'"', "'"}:
165
+ quote = char
166
+ index += 1
167
+ continue
168
+ if char == "\\" and index + 1 < len(text) and text[index + 1] == " ":
169
+ current.append(" ")
170
+ index += 2
171
+ continue
172
+ if char.isspace():
173
+ if current:
174
+ tokens.append("".join(current))
175
+ current = []
176
+ index += 1
177
+ continue
178
+ current.append(char)
179
+ index += 1
180
+ if quote:
181
+ return []
182
+ if current:
183
+ tokens.append("".join(current))
184
+ return tokens
185
+
186
+
187
+ def _resolve_image_path(token: str) -> Path | None:
188
+ raw = token.strip().replace("\\ ", " ")
189
+ if not raw or "\n" in raw or "\r" in raw or len(raw) > 1024:
190
+ return None
191
+ if raw.startswith("file://"):
192
+ raw = unquote(urlparse(raw).path)
193
+ path = Path(raw).expanduser()
194
+ if path.suffix.lower() not in IMAGE_EXTENSIONS:
195
+ return None
196
+ try:
197
+ path = path.resolve()
198
+ if not path.is_file():
199
+ return None
200
+ except OSError:
201
+ return None
202
+ return path
203
+
204
+
205
+ class ImageModal(ModalBase[None]):
206
+ """Terminal image preview behind a compact `[Image 1]` chip."""
207
+
208
+ CSS = IMAGE_MODAL_CSS
209
+
210
+ def __init__(self, attachment: ImageAttachment) -> None:
211
+ super().__init__()
212
+ self.attachment = attachment
213
+
214
+ def compose(self): # type: ignore[no-untyped-def]
215
+ with Container(id="content-pane", classes="modal-pane"):
216
+ yield ModalCloseButton("Esc", id="modal-close")
217
+ yield Static(self._title(), id="image-title")
218
+ with ModalScroll(id="content-body", classes="modal-body"):
219
+ yield textual_image(self.attachment.decoded(), widget_id="image-preview")
220
+ yield Static("↑↓ scroll · Esc close", classes="modal-footer")
221
+
222
+ def _title(self) -> Text:
223
+ title = Text()
224
+ title.append(self.attachment.filename, style="bold #d0d0d0")
225
+ details = self._details()
226
+ if details:
227
+ title.append(f" · {details}", style="#686868")
228
+ return title
229
+
230
+ def _details(self) -> str:
231
+ bits = [self.attachment.media_type]
232
+ payload = self.attachment.decoded()
233
+ size = _image_size(payload)
234
+ if size is not None:
235
+ bits.insert(0, f"{size[0]}×{size[1]}")
236
+ return " · ".join(bits)
237
+
238
+
239
+ def _image_size(payload: bytes) -> tuple[int, int] | None:
240
+ if not payload:
241
+ return None
242
+ try:
243
+ from PIL import Image
244
+ except ImportError:
245
+ return None
246
+ try:
247
+ with Image.open(io.BytesIO(payload)) as image:
248
+ return image.size
249
+ except Exception: # noqa: BLE001
250
+ return None
@@ -0,0 +1,135 @@
1
+ """One-line labels, argument summaries, and result previews for tool cards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Mapping
7
+
8
+ from textual.content import Content
9
+
10
+ from coding_agent.tui.tools.partial_args import string_field
11
+ from coding_agent.tui.transcript.messages import clip_text, compact_json
12
+
13
+ TOOL_LABELS = {
14
+ "bash": ("Bash", "$"),
15
+ "search": ("Search", "⌕"),
16
+ "web_search": ("Web Search", "⌕"),
17
+ "write_file": ("Write", "+"),
18
+ "generate_image": ("Image", "└"),
19
+ "patch": ("Update", "±"),
20
+ "read_file": ("Read", "└"),
21
+ }
22
+
23
+
24
+ def tool_label(tool_name: str) -> tuple[str, str]:
25
+ return TOOL_LABELS.get(tool_name, (tool_name.replace("_", " ").title(), "›"))
26
+
27
+
28
+ def tool_detail(
29
+ tool_name: str,
30
+ arguments: Mapping[str, Any] | None = None,
31
+ raw_arguments: str = "",
32
+ ) -> str:
33
+ args = dict(arguments or {})
34
+ if tool_name == "bash":
35
+ return str(args.get("command") or raw_arguments)
36
+ if tool_name in {"search", "web_search"}:
37
+ return str(args.get("query") or args.get("pattern") or compact_json(args))
38
+ if tool_name == "patch":
39
+ return patch_path(args, raw_arguments)
40
+ if tool_name in {"write_file", "generate_image", "read_file"}:
41
+ path = str(args.get("path") or "")
42
+ if path:
43
+ return path
44
+ return compact_json(args) or raw_arguments
45
+ return compact_json(args) or raw_arguments
46
+
47
+
48
+ def patch_path(arguments: Mapping[str, Any], raw_arguments: str = "", result: str = "") -> str:
49
+ """Recover the target even when a restored patch has only its result."""
50
+ path = str(arguments.get("path") or "").strip()
51
+ if path:
52
+ return path
53
+ raw_path = string_field(raw_arguments, "path")
54
+ if raw_path is not None:
55
+ return raw_path
56
+ for pattern in (
57
+ r"^(?:patched|updated) (.+?)(?: \(|$)",
58
+ r"^noop: .* in (.+?); no change",
59
+ r"^old_str not found in (.+)$",
60
+ r"^error: (?:file not found|not a file|file is not valid UTF-8 text): (.+)$",
61
+ r"^error: failed to (?:read|write) (.+?): ",
62
+ ):
63
+ match = re.search(pattern, result, re.MULTILINE | re.IGNORECASE)
64
+ if match:
65
+ return match.group(1).strip()
66
+ return ""
67
+
68
+
69
+ def tool_header_text(label: str, target: str, status: str) -> Content:
70
+ """Status-colored tool name plus a muted target, without parsing the target as markup."""
71
+ color = {
72
+ "preparing": "#d7a84b",
73
+ "running": "#d7a84b",
74
+ "done": "#72a57a",
75
+ "failed": "#d66b73",
76
+ }.get(status, "#9aa7b2")
77
+ parts: list[tuple[str, str]] = [(label, f"bold {color}")]
78
+ if target:
79
+ parts.append((f" {target}", "#9aa7b2"))
80
+ return Content.assemble(*parts)
81
+
82
+
83
+ def header_target(summary: str) -> str:
84
+ """Show the protocol value beside the verb: path, command, or query."""
85
+ text = " ".join(str(summary or "").split())
86
+ if not text:
87
+ return ""
88
+ head, _, rest = text.partition(" ")
89
+ if "/" in head or head.startswith(("~", ".")):
90
+ leaf = head.replace("\\", "/").rstrip("/").split("/")[-1].strip()
91
+ if leaf:
92
+ return f"{leaf} {rest}".strip() if rest else leaf
93
+ return text
94
+
95
+
96
+ def result_preview(tool_name: str, result: str) -> str:
97
+ if not result:
98
+ return ""
99
+ if tool_name == "bash":
100
+ return clip_text("\n".join(result.splitlines()[-4:]), 360)
101
+ return clip_text(result, 260)
102
+
103
+
104
+ def read_file_detail(arguments: Mapping[str, Any], raw_arguments: str) -> str:
105
+ path = arguments.get("path")
106
+ if not path:
107
+ path = string_field(raw_arguments or "", "path")
108
+ if path is None:
109
+ return raw_arguments
110
+ offset = int(arguments.get("offset") or 1)
111
+ limit = int(arguments.get("limit") or 0)
112
+ if offset == 1 and not limit:
113
+ return str(path)
114
+ end = offset + limit - 1 if limit else "…"
115
+ return f"{path} lines {offset}–{end}"
116
+
117
+
118
+ def read_file_result(result: str) -> str:
119
+ if not result:
120
+ return ""
121
+ if result.startswith("error:"):
122
+ return result
123
+ if result.startswith("Read image ") or "[image:" in result:
124
+ return result.splitlines()[0]
125
+ count = len(result.splitlines())
126
+ size = len(result.encode("utf-8"))
127
+ return f"Read {count} lines ({size:,} bytes)"
128
+
129
+
130
+ def generate_image_result(result: str) -> str:
131
+ if not result:
132
+ return ""
133
+ if result.startswith("error:"):
134
+ return result
135
+ return result.splitlines()[0]
@@ -0,0 +1,137 @@
1
+ """Details card for one finished tool call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Group
10
+ from rich.text import Text
11
+ from textual import events
12
+ from textual.containers import Container, Horizontal
13
+ from textual.widgets import Input, Static
14
+
15
+ from coding_agent.tui.screens.modal import ModalBase, ModalCloseButton, ModalScroll
16
+ from coding_agent.tui.theme import CONTENT_MODAL_CSS
17
+ from coding_agent.tui.transcript.messages import SelectableStatic
18
+
19
+ if TYPE_CHECKING:
20
+ from coding_agent.tui.tools.calls import ToolCallWidget
21
+
22
+ _FILE_TOKEN = re.compile(r"(?<!\S)([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)(?!\S)")
23
+ _CODE_SUFFIXES = {".py", ".js", ".ts", ".json", ".md", ".toml", ".yaml", ".yml", ".sh"}
24
+
25
+
26
+ class ToolCallModal(ModalBase[None]):
27
+ """A snapshot of one finished tool call, opened from its transcript row.
28
+
29
+ The card is rendered once when it opens. Live calls stay in the transcript
30
+ and do not push updates into this modal, so a busy run cannot stall the UI
31
+ by rebuilding a card on every chunk.
32
+ """
33
+
34
+ BINDINGS = [("slash", "focus_search", "Search"), ("w", "toggle_wrap", "Wrap"), ("y", "copy_detail", "Copy")]
35
+
36
+ CSS = CONTENT_MODAL_CSS + """
37
+ ToolCallModal { align: center middle; background: rgba(0, 0, 0, 0.52); }
38
+ #tool-meta, #tool-command, #tool-detail { width: 100%; height: auto; background: #0d0d0d; }
39
+ #tool-detail { padding: 1 2; }
40
+ #tool-search { display: none; width: 100%; height: 1; }
41
+ .jump-chip { width: auto; height: 1; margin-right: 1; color: #87b5b1; }
42
+ """
43
+
44
+ def __init__(self, tool: ToolCallWidget) -> None:
45
+ super().__init__()
46
+ self.tool = tool
47
+ self._wrap = False
48
+
49
+ def compose(self): # type: ignore[no-untyped-def]
50
+ with Container(id="content-pane", classes="modal-pane"):
51
+ yield ModalCloseButton("Esc", id="modal-close")
52
+ yield Static(self.tool.detail_title(), id="content-title", markup=False)
53
+ yield Static(self._meta(), id="tool-meta", markup=False)
54
+ command = self.tool._summary()
55
+ if command:
56
+ yield Static(command, id="tool-command", markup=False)
57
+ chips = self._jump_targets()
58
+ if chips:
59
+ with Horizontal(id="tool-jumps"):
60
+ for path in chips:
61
+ yield Static(path, classes="jump-chip", markup=False)
62
+ yield Input(placeholder="Search", id="tool-search")
63
+ with ModalScroll(id="content-body", classes="modal-body"):
64
+ yield SelectableStatic(self._body(), id="tool-detail", markup=False)
65
+ yield Static("↑↓ scroll · / search · w wrap · y copy · Esc close", classes="modal-footer")
66
+
67
+ def on_mount(self) -> None:
68
+ self.query_one(ModalScroll).focus()
69
+
70
+ def on_input_changed(self, event: Input.Changed) -> None:
71
+ if event.input.id != "tool-search":
72
+ return
73
+ query = event.value.strip().lower()
74
+ body = self.query_one("#tool-detail", Static)
75
+ if not query:
76
+ body.update(self._body())
77
+ return
78
+ lines = self.tool.detail_plain().splitlines()
79
+ matches = [line for line in lines if query in line.lower()]
80
+ body.update(Text("\n".join(matches) or "No matches.", style="#d0d0d0"))
81
+
82
+ def action_focus_search(self) -> None:
83
+ search = self.query_one("#tool-search", Input)
84
+ search.display = True
85
+ search.focus()
86
+
87
+ def action_toggle_wrap(self) -> None:
88
+ self._wrap = not self._wrap
89
+ self.query_one("#tool-detail", Static).update(self._body())
90
+
91
+ def action_copy_detail(self) -> None:
92
+ copy = getattr(self.app, "copy_to_clipboard", None)
93
+ if not callable(copy):
94
+ return
95
+ copy(self.tool.detail_plain())
96
+ notify = getattr(self.app, "notify", None)
97
+ if callable(notify):
98
+ notify("Copied tool details.")
99
+
100
+ def _body(self) -> object:
101
+ rows = self.tool.detail_rows(wrap=self._wrap) or [Text("No details yet.", style="#686868")]
102
+ return Group(*rows)
103
+
104
+ def _meta(self) -> str:
105
+ lines = len(self.tool.detail_plain().splitlines())
106
+ timeout = self.tool.arguments.get("timeout")
107
+ parts = [f"{lines} lines"]
108
+ if timeout:
109
+ parts.insert(0, f"timeout {timeout}s")
110
+ return " · ".join(parts)
111
+
112
+ def _jump_targets(self) -> tuple[str, ...]:
113
+ workspace = getattr(self.tool, "workspace", None)
114
+ if not isinstance(workspace, Path):
115
+ return ()
116
+ candidates = [str(self.tool.arguments.get("path") or "")]
117
+ candidates.extend(_FILE_TOKEN.findall(self.tool._summary()))
118
+ found: list[str] = []
119
+ root = workspace.resolve()
120
+ for candidate in candidates:
121
+ path = Path(candidate)
122
+ if path.suffix.lower() not in _CODE_SUFFIXES or path.is_absolute():
123
+ continue
124
+ if (root / path).is_file() and candidate not in found:
125
+ found.append(candidate)
126
+ if len(found) == 4:
127
+ break
128
+ return tuple(found)
129
+
130
+ def on_click(self, event: events.Click) -> None:
131
+ target = getattr(event.widget, "classes", None)
132
+ if target is None or "jump-chip" not in target:
133
+ return
134
+ path = str(event.widget.render())
135
+ opener = getattr(self.app, "open_file_preview", None)
136
+ if callable(opener):
137
+ opener(path)
@@ -0,0 +1,19 @@
1
+ """Read string values out of tool-call JSON that may still be streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Optional
8
+
9
+
10
+ def string_field(raw: str, key: str) -> Optional[str]:
11
+ """Decoded value of the first complete ``"key": "..."`` string in ``raw``.
12
+
13
+ ``raw`` may be a partial JSON blob, so this does not parse the whole
14
+ document. Returns None when no complete string value for ``key`` exists.
15
+ """
16
+ match = re.search(rf'"{re.escape(key)}"\s*:\s*"((?:\\.|[^"\\])*)"', raw)
17
+ if match is None:
18
+ return None
19
+ return json.loads(f'"{match.group(1)}"')