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