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,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)}"')
|