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,408 @@
1
+ """Transcript message widgets and shared text helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any, Mapping, Sequence
10
+
11
+ from rich.align import Align
12
+ from rich.console import Group
13
+ from rich.segment import Segment
14
+ from rich.style import Style
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+ from textual.selection import Selection
18
+ from textual.strip import Strip
19
+ from textual.widgets import Static
20
+
21
+ from coding_agent.tui.screens.modal import ContentModal
22
+ from coding_agent.tui.theme import SYMPHONY_COLORS, themed_markdown
23
+ from coding_agent.tui.tools.images import IMAGE_MARKER_RE, ImageAttachment, ImageModal
24
+
25
+ _MERMAID_FENCE = re.compile(r"```mermaid\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
26
+
27
+ # Accent glyph that opens every user prompt in the transcript (mock: purple `>`).
28
+ USER_PROMPT_GLYPH = ">"
29
+ USER_PROMPT_GUTTER = 3
30
+
31
+
32
+ def return_composer_focus(widget: Any) -> None:
33
+ """Give the prompt its caret back after a transcript click.
34
+
35
+ Tool and thought headers are focusable so Enter and Space can toggle them.
36
+ A pointer click focuses that header, and Textual hides the ``TextArea``
37
+ caret until ``#prompt`` is focused again. Keyboard focus is left alone.
38
+ """
39
+ try:
40
+ prompt = widget.app.query_one("#prompt")
41
+ except Exception:
42
+ return
43
+ if getattr(prompt, "disabled", False) or not getattr(prompt, "display", True):
44
+ return
45
+ prompt.focus()
46
+
47
+
48
+ class SelectableStatic(Static):
49
+ """Selectable Rich content using Textual's dirty-region render cache."""
50
+
51
+ ALLOW_SELECT = True
52
+
53
+ def _invalidate_render_cache(self, *, layout: bool = False) -> None:
54
+ self.refresh(layout=layout)
55
+
56
+ def freeze_render(self) -> None:
57
+ """Compatibility hook for transcript completion; rendering remains lazy.
58
+
59
+ Textual caches rendered strips until content, size, or styles change.
60
+ Its _render_content() populates that cache in place and returns None;
61
+ eagerly calling it here would re-render even clean, completed messages.
62
+ """
63
+
64
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
65
+ # Extract from the exact rendered strips so Rich renderables (Markdown,
66
+ # Groups, Tables, and other non-Text visuals) are selectable too.
67
+ if self._dirty_regions:
68
+ self._render_content()
69
+ lines = [line.text.rstrip() for line in self._render_cache.lines]
70
+ return selection.extract("\n".join(lines)), "\n"
71
+
72
+ def render_line(self, y: int) -> Strip:
73
+ """Attach Textual's selection offsets to every rendered cell."""
74
+ line = super().render_line(y)
75
+ selection = self.text_selection
76
+ if selection is not None and (span := selection.get_span(y)) is not None:
77
+ # RichVisual ignores Textual's selection render options. Paint the
78
+ # selected characters ourselves, without modifying cached strips.
79
+ start, end = span
80
+ if end == -1:
81
+ end = len(line.text)
82
+ highlight = Style(bgcolor="#3a3a3a")
83
+ segments = []
84
+ offset = 0
85
+ for segment in line:
86
+ text, style, control = segment
87
+ left = max(0, min(len(text), start - offset))
88
+ right = max(left, min(len(text), end - offset))
89
+ if left:
90
+ segments.append(Segment(text[:left], style, control))
91
+ if right > left:
92
+ segments.append(Segment(text[left:right], (style or Style()) + highlight, control))
93
+ if right < len(text):
94
+ segments.append(Segment(text[right:], style, control))
95
+ offset += len(text)
96
+ line = Strip(segments, line.cell_length)
97
+ return line.apply_offsets(0, y)
98
+
99
+
100
+ def compact_json(value: Mapping[str, Any]) -> str:
101
+ """Serialize a mapping compactly for single-line UI summaries."""
102
+ if not value:
103
+ return ""
104
+ return json.dumps(value, ensure_ascii=False, separators=(", ", ": "))
105
+
106
+
107
+ def clip_text(value: Any, limit: int = 420) -> str:
108
+ """Trim text to a display limit while preserving a visual ellipsis."""
109
+ text = str(value or "").strip()
110
+ if len(text) <= limit:
111
+ return text
112
+ return f"{text[:limit].rstrip()}…"
113
+
114
+
115
+ def preview_text(value: Any, limit: int = 180) -> str:
116
+ """Trim an event value for a short notice message."""
117
+ text = str(value)
118
+ return text if len(text) <= limit else f"{text[:limit]}…"
119
+
120
+ class Welcome(Static):
121
+ def __init__(self, workspace: Path) -> None:
122
+ config = workspace / ".symphony" / "dashboard.toml"
123
+ if not config.is_file():
124
+ config = workspace / "dashboard.toml"
125
+ settings: dict[str, Any] = {}
126
+ try:
127
+ with config.open("rb") as stream:
128
+ settings = tomllib.load(stream)
129
+ except (OSError, tomllib.TOMLDecodeError):
130
+ pass
131
+
132
+ art = settings.get("art", [
133
+ " ███████╗██╗ ██╗███╗ ███╗██████╗ ██╗ ██╗ ██████╗ ███╗ ██╗██╗ ██╗",
134
+ " ██╔════╝╚██╗ ██╔╝████╗ ████║██╔══██╗██║ ██║██╔═══██╗████╗ ██║╚██╗ ██╔╝",
135
+ " ███████╗ ╚████╔╝ ██╔████╔██║██████╔╝███████║██║ ██║██╔██╗ ██║ ╚████╔╝ ",
136
+ " ╚════██║ ╚██╔╝ ██║╚██╔╝██║██╔═══╝ ██╔══██║██║ ██║██║╚██╗██║ ╚██╔╝ ",
137
+ " ███████║ ██║ ██║ ╚═╝ ██║██║ ██║ ██║╚██████╔╝██║ ╚████║ ██║ ",
138
+ " ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ",
139
+ ])
140
+ if not isinstance(art, list) or not all(isinstance(line, str) for line in art):
141
+ art = []
142
+ rainbow = ("#ff5f5f", "#ffaf5f", "#ffff5f", "#5fff5f", "#5fffff", "#5fafff", "#af5fff")
143
+ logo_width = max((len(line) for line in art), default=1)
144
+ lines = []
145
+ for line in art:
146
+ colored_line = Text()
147
+ for index, character in enumerate(line):
148
+ if character != " ":
149
+ color_index = round(index * (len(rainbow) - 1) / max(logo_width - 1, 1))
150
+ colored_line.append(character, style=rainbow[color_index])
151
+ else:
152
+ colored_line.append(character)
153
+ lines.append(colored_line)
154
+ lines.append(Text(str(workspace), style=SYMPHONY_COLORS["muted_dim"]))
155
+ body = Align.center(Group(*lines), vertical="middle")
156
+ super().__init__(body, classes="welcome")
157
+
158
+ class UserMessage(SelectableStatic):
159
+ """A user prompt with long pasted chunks and images hidden behind compact links."""
160
+
161
+ COMPACT_PASTE_AFTER = 100
162
+
163
+ def __init__(
164
+ self,
165
+ content: str,
166
+ *,
167
+ pasted_chunks: tuple[str, ...] = (),
168
+ images: Sequence[ImageAttachment] = (),
169
+ ) -> None:
170
+ self.message_text = content
171
+ self._hidden_content: dict[str, str] = {}
172
+ self._images = {image.number: image for image in images}
173
+ super().__init__(
174
+ self._with_prompt_glyph(self._compact_content(content, pasted_chunks)),
175
+ classes="message user-message",
176
+ )
177
+
178
+ def archive_text(self) -> str:
179
+ return f"USER\n{self.message_text}"
180
+
181
+ @staticmethod
182
+ def _with_prompt_glyph(content: Text) -> Table:
183
+ """Lay the prompt text beside an accent `>` so wrapped lines stay aligned."""
184
+ row = Table.grid(expand=True, padding=0)
185
+ row.add_column(width=USER_PROMPT_GUTTER, no_wrap=True)
186
+ row.add_column(ratio=1)
187
+ row.add_row(
188
+ Text(USER_PROMPT_GLYPH, style="bold " + SYMPHONY_COLORS["accent"]),
189
+ content,
190
+ )
191
+ return row
192
+
193
+ def _compact_content(self, content: str, pasted_chunks: tuple[str, ...]) -> Text:
194
+ self._hidden_content.clear()
195
+ replacements: list[tuple[int, int, Text]] = []
196
+ occupied: list[tuple[int, int]] = []
197
+
198
+ for chunk in pasted_chunks:
199
+ candidates = (chunk, chunk.strip(), chunk.lstrip(), chunk.rstrip())
200
+ displayed_chunk = next(
201
+ (candidate for candidate in candidates if candidate in content),
202
+ "",
203
+ )
204
+ if (
205
+ len(displayed_chunk) <= self.COMPACT_PASTE_AFTER
206
+ and "\n" not in displayed_chunk
207
+ and "\r" not in displayed_chunk
208
+ ):
209
+ continue
210
+ start = content.find(displayed_chunk)
211
+ while start >= 0 and _overlaps(start, start + len(displayed_chunk), occupied):
212
+ start = content.find(displayed_chunk, start + 1)
213
+ if start < 0:
214
+ continue
215
+ end = start + len(displayed_chunk)
216
+ key = str(len(self._hidden_content))
217
+ self._hidden_content[key] = displayed_chunk
218
+ occupied.append((start, end))
219
+ replacements.append(
220
+ (
221
+ start,
222
+ end,
223
+ Text(
224
+ f"[{len(displayed_chunk):,} chars]",
225
+ style=Style(
226
+ color="#87b5b1",
227
+ bold=True,
228
+ underline=True,
229
+ meta={"@click": f"open_content('{key}')"},
230
+ ),
231
+ ),
232
+ )
233
+ )
234
+
235
+ for match in IMAGE_MARKER_RE.finditer(content):
236
+ number = match.group(1)
237
+ if number not in self._images:
238
+ continue
239
+ start, end = match.span()
240
+ if _overlaps(start, end, occupied):
241
+ continue
242
+ occupied.append((start, end))
243
+ replacements.append(
244
+ (
245
+ start,
246
+ end,
247
+ Text(
248
+ match.group(0),
249
+ style=Style(
250
+ color="#87b5b1",
251
+ bold=True,
252
+ underline=True,
253
+ meta={"@click": f"open_image('{number}')"},
254
+ ),
255
+ ),
256
+ )
257
+ )
258
+
259
+ if not replacements:
260
+ return Text(content)
261
+
262
+ display = Text()
263
+ cursor = 0
264
+ for start, end, chip in sorted(replacements, key=lambda item: item[0]):
265
+ display.append(content[cursor:start])
266
+ display.append(chip)
267
+ cursor = end
268
+ display.append(content[cursor:])
269
+ return display
270
+
271
+ def action_open_content(self, key: str) -> None:
272
+ content = self._hidden_content.get(key)
273
+ if content is not None:
274
+ self.app.push_screen(ContentModal(content))
275
+
276
+ def action_open_image(self, number: str) -> None:
277
+ image = self._images.get(number)
278
+ if image is not None:
279
+ self.app.push_screen(ImageModal(image))
280
+
281
+
282
+ class AssistantMessage(SelectableStatic):
283
+ def __init__(
284
+ self, content: str = "", *, streaming: bool = False
285
+ ) -> None:
286
+ self._streaming = False
287
+ self._markdown = None
288
+ super().__init__(classes="message assistant-message")
289
+ self.set_content(content, streaming=streaming)
290
+
291
+ def set_content(self, content: str, *, streaming: bool = False) -> None:
292
+ if (
293
+ not streaming
294
+ and not self._streaming
295
+ and content == getattr(self, "message_text", None)
296
+ and self._markdown is not None
297
+ ):
298
+ return
299
+ self.message_text = content
300
+ self._streaming = streaming
301
+ if streaming:
302
+ self._markdown = None
303
+ body: object = Text(content or " ")
304
+ else:
305
+ prose, diagrams = render_mermaid(content or " ")
306
+ self._markdown = themed_markdown(prose)
307
+ body = Group(self._markdown, *diagrams) if diagrams else self._markdown
308
+ self._invalidate_render_cache(layout=True)
309
+ self.update(Group(body))
310
+
311
+ def on_click(self, event: Any) -> None:
312
+ """Open the http(s) link under the pointer. Other clicks select text."""
313
+ href = _link_at(self.message_text, event)
314
+ if href is None:
315
+ return
316
+ import webbrowser
317
+
318
+ webbrowser.open(href)
319
+ event.stop()
320
+
321
+ def finish_stream(self) -> None:
322
+ if self._streaming:
323
+ self.set_content(self.message_text)
324
+ self.freeze_render()
325
+
326
+ def archive_text(self) -> str:
327
+ return self.message_text
328
+
329
+ _LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)|(https?://[^\s)]+)")
330
+
331
+
332
+ def _link_at(content: str, event: Any) -> str | None:
333
+ """Return the http(s) target on the clicked line, if the pointer is on it."""
334
+ line_index = getattr(event, "y", None)
335
+ lines = (content or "").splitlines()
336
+ if not isinstance(line_index, int) or not 0 <= line_index < len(lines):
337
+ # A click without a row still opens the only link in a short message.
338
+ found = [match.group(2) or match.group(3) for match in _LINK.finditer(content or "")]
339
+ return found[0] if len(found) == 1 else None
340
+ line = lines[line_index]
341
+ column = int(getattr(event, "x", 0) or 0)
342
+ for match in _LINK.finditer(line):
343
+ if match.start() <= column <= match.end():
344
+ return match.group(2) or match.group(3)
345
+ return None
346
+
347
+
348
+ def render_mermaid(content: str) -> tuple[str, tuple[Text, ...]]:
349
+ """Replace ```mermaid fences with termaid diagrams. The rest stays markdown."""
350
+ diagrams: list[Text] = []
351
+
352
+ def draw(match: re.Match[str]) -> str:
353
+ source = match.group(1).strip()
354
+ if not source:
355
+ return match.group(0)
356
+ try:
357
+ from termaid import render_rich
358
+
359
+ diagrams.append(render_rich(source))
360
+ except Exception as exc: # noqa: BLE001
361
+ diagrams.append(Text(f"Could not render diagram ({exc}).", style="#c67b82"))
362
+ return ""
363
+
364
+ return _MERMAID_FENCE.sub(draw, content).strip() or " ", tuple(diagrams)
365
+
366
+
367
+ def _overlaps(start: int, end: int, occupied: Sequence[tuple[int, int]]) -> bool:
368
+ return any(
369
+ start < occupied_end and end > occupied_start
370
+ for occupied_start, occupied_end in occupied
371
+ )
372
+
373
+
374
+ class Notice(Static):
375
+ COLORS = {
376
+ "info": "#707070",
377
+ "warning": "#d7a84b",
378
+ "error": "#e06c75",
379
+ "success": "#70a879",
380
+ }
381
+
382
+ def __init__(self, text: str, tone: str = "info") -> None:
383
+ self.message_text = text
384
+ color = self.COLORS.get(tone, self.COLORS["info"])
385
+ super().__init__(Text(f" {text}", style=color), classes=f"notice {tone}")
386
+
387
+ def archive_text(self) -> str:
388
+ return self.message_text
389
+
390
+
391
+ class RunSummary(Static):
392
+ """User-message-like purple block for a summary emitted during a run."""
393
+
394
+ def __init__(
395
+ self,
396
+ summary: str,
397
+ *,
398
+ label: str = "Summary",
399
+ event_type: str = "run_summary",
400
+ ) -> None:
401
+ self.summary = summary
402
+ self.label = label
403
+ heading = Text(f"◆ {label} · {event_type}", style="bold #c5a9e6")
404
+ body = Text(summary, style="#c2b5cf")
405
+ super().__init__(Group(heading, body), classes="message run-summary")
406
+
407
+ def archive_text(self) -> str:
408
+ return f"{self.label}\n{self.summary}"