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,345 @@
|
|
|
1
|
+
"""Transcript mounting mixed into CodingAgentApp.
|
|
2
|
+
|
|
3
|
+
Every transcript entry is mounted once and then updated in place. Groupable
|
|
4
|
+
tool calls become rows of one ``ToolCallSummary`` per consecutive stretch (the
|
|
5
|
+
render-time grouping fx does in ``tool_group_projection.zig``), so a status
|
|
6
|
+
change only re-renders that group's text. Following the newest output is
|
|
7
|
+
Textual's scroll anchor on ``#transcript``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from textual import events
|
|
15
|
+
from textual.containers import VerticalScroll
|
|
16
|
+
from textual.geometry import Offset
|
|
17
|
+
from textual.widget import Widget
|
|
18
|
+
|
|
19
|
+
from coding_agent.tui.transcript.messages import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
Notice,
|
|
22
|
+
RunSummary,
|
|
23
|
+
SelectableStatic,
|
|
24
|
+
Welcome,
|
|
25
|
+
)
|
|
26
|
+
from coding_agent.tui.transcript.process import (
|
|
27
|
+
ReasoningWidget,
|
|
28
|
+
RunProcess,
|
|
29
|
+
ThinkingStatus,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
# tools.* imports transcript.messages, so the runtime imports stay local.
|
|
34
|
+
from coding_agent.tui.tools.calls import ToolCallWidget
|
|
35
|
+
from coding_agent.tui.tools.snapshots import ToolCallSnapshot, ToolCallSummary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TranscriptScroll(VerticalScroll):
|
|
39
|
+
"""The transcript viewport: anchored to its newest content.
|
|
40
|
+
|
|
41
|
+
Textual keeps an anchored widget at the bottom through every layout,
|
|
42
|
+
releases the anchor when the reader scrolls up, and restores it when they
|
|
43
|
+
scroll back to the end.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
self.anchor()
|
|
48
|
+
|
|
49
|
+
def _wheel_leaves_tail(self, event: events.MouseScrollUp | events.MouseScrollDown) -> bool:
|
|
50
|
+
"""A plain wheel tick is the reader leaving the tail, not the stream.
|
|
51
|
+
|
|
52
|
+
Pointer scrolling does not release Textual's anchor (keyboard does).
|
|
53
|
+
Worse, a follow animation sets ``scroll_target_y`` at the end before
|
|
54
|
+
``scroll_y`` arrives. A wheel tick then computes its next target from
|
|
55
|
+
that end, so the page never moves and the next layout snaps back.
|
|
56
|
+
Stop the animation and release before the tick is applied.
|
|
57
|
+
"""
|
|
58
|
+
if event.ctrl or event.shift:
|
|
59
|
+
return False
|
|
60
|
+
self.app.animator.force_stop_animation(self, "scroll_y")
|
|
61
|
+
self.app.animator.force_stop_animation(self, "scroll_x")
|
|
62
|
+
if self.is_anchored:
|
|
63
|
+
self.release_anchor()
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
# Textual dispatches a handler from every class in the MRO, so Widget's own
|
|
67
|
+
# wheel handler applies the tick after these. Calling super() scrolled twice.
|
|
68
|
+
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
|
69
|
+
self._wheel_leaves_tail(event)
|
|
70
|
+
|
|
71
|
+
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
72
|
+
self._wheel_leaves_tail(event)
|
|
73
|
+
|
|
74
|
+
def anchor(self, anchor: bool = True) -> None:
|
|
75
|
+
"""Follow new content with a short scroll instead of an instant jump.
|
|
76
|
+
|
|
77
|
+
A reader who already scrolled away owns the viewport. Re-anchoring
|
|
78
|
+
them (a stream paint, a composer layout) is what makes the wheel
|
|
79
|
+
look dead.
|
|
80
|
+
"""
|
|
81
|
+
if anchor and self._anchored and self._anchor_released:
|
|
82
|
+
return
|
|
83
|
+
self._anchored = anchor
|
|
84
|
+
if anchor:
|
|
85
|
+
self.scroll_end(animate=True, duration=0.35, easing="out_cubic")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def scroll_offset(self) -> Offset:
|
|
89
|
+
"""Keep short transcripts top-aligned while anchored.
|
|
90
|
+
|
|
91
|
+
While anchored, Textual parks ``scroll_y`` at ``content - viewport``,
|
|
92
|
+
which is negative until the transcript fills the screen and would push
|
|
93
|
+
the first rows down to the composer.
|
|
94
|
+
"""
|
|
95
|
+
offset = super().scroll_offset
|
|
96
|
+
return Offset(offset.x, max(0, offset.y))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TranscriptSurface:
|
|
100
|
+
"""TranscriptView implementation mixed into CodingAgentApp."""
|
|
101
|
+
|
|
102
|
+
def _mount_transcript(self, widget: Widget) -> None:
|
|
103
|
+
transcript = self.query_one("#transcript", VerticalScroll)
|
|
104
|
+
welcome = self.query(".welcome")
|
|
105
|
+
if welcome:
|
|
106
|
+
welcome.first().remove()
|
|
107
|
+
transcript.mount(widget)
|
|
108
|
+
|
|
109
|
+
def finalize_transcript_history(self) -> None:
|
|
110
|
+
"""Freeze completed message renders after restoring history."""
|
|
111
|
+
for message in self.query(".message"):
|
|
112
|
+
if isinstance(message, SelectableStatic):
|
|
113
|
+
message.freeze_render()
|
|
114
|
+
|
|
115
|
+
def set_assistant(self, text: str, *, new: bool = False) -> None:
|
|
116
|
+
if new or self._assistant is None:
|
|
117
|
+
if self._thinking is not None:
|
|
118
|
+
self._thinking.set_visible(False)
|
|
119
|
+
self._assistant = AssistantMessage(text, streaming=True)
|
|
120
|
+
# Keep assistant text in the run timeline so tools and text stay in
|
|
121
|
+
# stream order: a later non-tool widget can close the open stretch.
|
|
122
|
+
if self._process is not None and not self._process.completed:
|
|
123
|
+
self._mount_process_item(self._assistant)
|
|
124
|
+
else:
|
|
125
|
+
self._mount_transcript(self._assistant)
|
|
126
|
+
else:
|
|
127
|
+
self._assistant.set_content(text, streaming=True)
|
|
128
|
+
|
|
129
|
+
def finish_assistant(self) -> None:
|
|
130
|
+
if self._assistant is not None:
|
|
131
|
+
self._assistant.finish_stream()
|
|
132
|
+
|
|
133
|
+
def invalidate_workspace_caches(self) -> None:
|
|
134
|
+
index = getattr(self, "_file_index", None)
|
|
135
|
+
if index is not None:
|
|
136
|
+
index.invalidate()
|
|
137
|
+
|
|
138
|
+
def set_thinking(self, text: str) -> None:
|
|
139
|
+
if self._thinking is None:
|
|
140
|
+
self._thinking = ThinkingStatus(text)
|
|
141
|
+
self._process = RunProcess(self._thinking)
|
|
142
|
+
self._mount_transcript(self._process)
|
|
143
|
+
else:
|
|
144
|
+
self._thinking.set_text(text)
|
|
145
|
+
|
|
146
|
+
def set_working(self, detail: str = "") -> None:
|
|
147
|
+
if self._thinking is None:
|
|
148
|
+
self.set_thinking("Working")
|
|
149
|
+
assert self._thinking is not None
|
|
150
|
+
self._thinking.set_visible(True)
|
|
151
|
+
self._thinking.set_working(detail)
|
|
152
|
+
|
|
153
|
+
def set_churning(self, turn: int = 0) -> None:
|
|
154
|
+
if self._thinking is None:
|
|
155
|
+
self.set_thinking("Churning")
|
|
156
|
+
assert self._thinking is not None
|
|
157
|
+
self._thinking.set_visible(True)
|
|
158
|
+
self._thinking.set_churning()
|
|
159
|
+
if self._process is not None:
|
|
160
|
+
self._process.place_thinking_last()
|
|
161
|
+
|
|
162
|
+
def _mount_process_item(self, widget: Widget) -> None:
|
|
163
|
+
"""Append a run timeline entry; anything but a tool row ends the open group."""
|
|
164
|
+
if self._process is None:
|
|
165
|
+
self.set_thinking("Thinking…")
|
|
166
|
+
assert self._process is not None
|
|
167
|
+
self._close_tool_group()
|
|
168
|
+
self._process.add_item(widget)
|
|
169
|
+
|
|
170
|
+
def _close_tool_group(self) -> None:
|
|
171
|
+
if self._open_tool_group is not None:
|
|
172
|
+
self._open_tool_group.close()
|
|
173
|
+
self._open_tool_group = None
|
|
174
|
+
|
|
175
|
+
def set_reasoning(self, text: str, *, new: bool = False) -> None:
|
|
176
|
+
if new or self._reasoning is None:
|
|
177
|
+
if self._thinking is not None:
|
|
178
|
+
self._thinking.set_visible(False)
|
|
179
|
+
self._reasoning = ReasoningWidget(text)
|
|
180
|
+
self._mount_process_item(self._reasoning)
|
|
181
|
+
else:
|
|
182
|
+
self._reasoning.set_content(text)
|
|
183
|
+
|
|
184
|
+
def finish_reasoning(self) -> None:
|
|
185
|
+
if self._reasoning is None:
|
|
186
|
+
return
|
|
187
|
+
self._reasoning.complete()
|
|
188
|
+
self._reasoning = None
|
|
189
|
+
|
|
190
|
+
def add_tool(self, call_id: str, name: str) -> None:
|
|
191
|
+
self._show_tool(self._new_tool(call_id, name))
|
|
192
|
+
|
|
193
|
+
def _new_tool(self, call_id: str, name: str) -> ToolCallWidget:
|
|
194
|
+
from coding_agent.tui.tools.calls import make_tool_widget
|
|
195
|
+
|
|
196
|
+
if self._thinking is not None:
|
|
197
|
+
self._thinking.set_visible(False)
|
|
198
|
+
tool = make_tool_widget(call_id, name, workspace=self.workspace)
|
|
199
|
+
self._tools[call_id] = tool
|
|
200
|
+
return tool
|
|
201
|
+
|
|
202
|
+
def _show_tool(self, tool: ToolCallWidget) -> None:
|
|
203
|
+
"""Mount an interactive card once, or place/update a row in its group."""
|
|
204
|
+
if tool.keep_in_transcript and not (
|
|
205
|
+
self._process is not None and self._process.completed and tool.status in {"done", "failed"}
|
|
206
|
+
):
|
|
207
|
+
if not tool.is_attached:
|
|
208
|
+
self._mount_process_item(tool)
|
|
209
|
+
return
|
|
210
|
+
snapshot = tool.snapshot()
|
|
211
|
+
if self._process is not None and self._process.completed:
|
|
212
|
+
self._process.absorb_late_call(snapshot)
|
|
213
|
+
self._tool_groups[tool.call_id] = self._process.completed_summary or self._tool_groups.get(tool.call_id)
|
|
214
|
+
return
|
|
215
|
+
group = self._tool_groups.get(tool.call_id)
|
|
216
|
+
if group is not None and not (
|
|
217
|
+
group is self._open_tool_group and group.rejects_last(snapshot)
|
|
218
|
+
):
|
|
219
|
+
group.update_call(snapshot)
|
|
220
|
+
return
|
|
221
|
+
if group is not None:
|
|
222
|
+
# Activity arrived with the arguments and no longer matches the
|
|
223
|
+
# stretch this call joined while it was still preparing.
|
|
224
|
+
group.remove_call(tool.call_id)
|
|
225
|
+
group = self._tool_group_for(snapshot)
|
|
226
|
+
group.add_call(snapshot)
|
|
227
|
+
self._tool_groups[tool.call_id] = group
|
|
228
|
+
|
|
229
|
+
def _tool_group_for(self, snapshot: ToolCallSnapshot) -> ToolCallSummary:
|
|
230
|
+
from coding_agent.tui.tools.snapshots import ToolCallSummary
|
|
231
|
+
|
|
232
|
+
group = self._open_tool_group
|
|
233
|
+
if group is not None and group.accepts(snapshot):
|
|
234
|
+
return group
|
|
235
|
+
group = ToolCallSummary(expanded=True)
|
|
236
|
+
self._mount_process_item(group)
|
|
237
|
+
self._open_tool_group = group
|
|
238
|
+
return group
|
|
239
|
+
|
|
240
|
+
def update_tool(
|
|
241
|
+
self,
|
|
242
|
+
call_id: str,
|
|
243
|
+
*,
|
|
244
|
+
tool_name: str = "tool",
|
|
245
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
246
|
+
raw_arguments: str = "",
|
|
247
|
+
status: str = "preparing",
|
|
248
|
+
result: Any = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
tool = self._tools.get(call_id)
|
|
251
|
+
if tool is None:
|
|
252
|
+
if call_id in self._tool_groups:
|
|
253
|
+
return # a finished group row is final; its model is released
|
|
254
|
+
tool = self._new_tool(call_id, tool_name)
|
|
255
|
+
if status == "running":
|
|
256
|
+
tool.set_running(arguments)
|
|
257
|
+
elif status in {"done", "failed"}:
|
|
258
|
+
tool.set_result(result)
|
|
259
|
+
if status == "failed":
|
|
260
|
+
tool.status = "failed"
|
|
261
|
+
tool.refresh_content()
|
|
262
|
+
if tool.tool_name in {"write_file", "patch", "bash"}:
|
|
263
|
+
self.invalidate_workspace_caches()
|
|
264
|
+
else:
|
|
265
|
+
tool.set_arguments(arguments, raw_arguments)
|
|
266
|
+
self._show_tool(tool)
|
|
267
|
+
if not tool.keep_in_transcript and status in {"done", "failed"}:
|
|
268
|
+
# The group row holds the finished call's snapshot. Dropping the
|
|
269
|
+
# unmounted model keeps a session from retaining a widget tree
|
|
270
|
+
# per finished call (and the GC work that comes with it).
|
|
271
|
+
del self._tools[call_id]
|
|
272
|
+
|
|
273
|
+
def append_tool_output(self, call_id: str, chunk: str) -> None:
|
|
274
|
+
"""Feed a streamed output chunk to its Bash card, in place."""
|
|
275
|
+
from coding_agent.tui.tools.calls import BashToolWidget
|
|
276
|
+
|
|
277
|
+
tool = self._tools.get(call_id)
|
|
278
|
+
if isinstance(tool, BashToolWidget):
|
|
279
|
+
tool.append_output(chunk)
|
|
280
|
+
|
|
281
|
+
def add_notice(self, text: str, tone: str = "info") -> None:
|
|
282
|
+
notice = Notice(text, tone)
|
|
283
|
+
if self._busy and self._process is not None:
|
|
284
|
+
self._mount_process_item(notice)
|
|
285
|
+
else:
|
|
286
|
+
self._mount_transcript(notice)
|
|
287
|
+
|
|
288
|
+
def add_run_summary(
|
|
289
|
+
self,
|
|
290
|
+
summary: str,
|
|
291
|
+
*,
|
|
292
|
+
label: str = "summary so far",
|
|
293
|
+
event_type: str = "run_summary",
|
|
294
|
+
) -> None:
|
|
295
|
+
widget = RunSummary(summary, label=label, event_type=event_type)
|
|
296
|
+
if self._busy and self._process is not None:
|
|
297
|
+
self._mount_process_item(widget)
|
|
298
|
+
else:
|
|
299
|
+
self._mount_transcript(widget)
|
|
300
|
+
|
|
301
|
+
def finish_process(
|
|
302
|
+
self, title: str, *, collapse: bool = True, verb: str = "", duration: str = ""
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Collect successful run work and retain the final reply and metrics."""
|
|
305
|
+
self._close_tool_group()
|
|
306
|
+
if self._process is not None:
|
|
307
|
+
self._process.complete(title, collapse=collapse, verb=verb, duration=duration)
|
|
308
|
+
|
|
309
|
+
def mount_transcript(self, widget: Widget) -> None:
|
|
310
|
+
"""Public adapter used by the persisted-history loader."""
|
|
311
|
+
self._mount_transcript(widget)
|
|
312
|
+
|
|
313
|
+
def mount_transcript_batch(self, widgets: list[Widget]) -> None:
|
|
314
|
+
"""Mount restored history in one layout pass instead of one per row."""
|
|
315
|
+
if not widgets:
|
|
316
|
+
return
|
|
317
|
+
transcript = self.query_one("#transcript", VerticalScroll)
|
|
318
|
+
for welcome in self.query(".welcome"):
|
|
319
|
+
welcome.remove()
|
|
320
|
+
transcript.mount(*widgets)
|
|
321
|
+
|
|
322
|
+
def action_copy_selection(self) -> None:
|
|
323
|
+
"""Copy the currently selected rendered transcript text to the clipboard."""
|
|
324
|
+
selected = self.screen.get_selected_text()
|
|
325
|
+
if selected:
|
|
326
|
+
self.copy_to_clipboard(selected)
|
|
327
|
+
|
|
328
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
329
|
+
"""Copy the drag selection, then clear its highlight."""
|
|
330
|
+
if self.screen.get_selected_text():
|
|
331
|
+
self.action_copy_selection()
|
|
332
|
+
self.notify("Copied to clipboard!")
|
|
333
|
+
self.screen.clear_selection()
|
|
334
|
+
|
|
335
|
+
def action_clear_transcript(self) -> None:
|
|
336
|
+
transcript = self.query_one("#transcript", VerticalScroll)
|
|
337
|
+
transcript.remove_children()
|
|
338
|
+
transcript.mount(Welcome(self.workspace))
|
|
339
|
+
self._assistant = None
|
|
340
|
+
self._thinking = None
|
|
341
|
+
self._reasoning = None
|
|
342
|
+
self._process = None
|
|
343
|
+
self._tools.clear()
|
|
344
|
+
self._tool_groups.clear()
|
|
345
|
+
self._open_tool_group = None
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Markdown for thinking text: muted block rendering and one-line previews.
|
|
2
|
+
|
|
3
|
+
Thinking is rendered into plain Rich ``Text`` (wrapped in a ``Group`` and
|
|
4
|
+
grids for lists) rather than Rich's ``Markdown`` renderable or Textual's
|
|
5
|
+
``Markdown`` widget. ``Text`` carries only emphasis (bold, italic, strike,
|
|
6
|
+
underline) and inherits the widget's CSS colour, so the muted thinking tone
|
|
7
|
+
comes from theme.toml instead of Rich's ``markdown.*`` palette. It also costs
|
|
8
|
+
one markdown-it parse per paint, with no child widgets to mount.
|
|
9
|
+
|
|
10
|
+
Thinking streams token by token, so ``render_thinking(..., streaming=True)``
|
|
11
|
+
first closes a dangling ``**``/``*``/backtick in the last paragraph: a
|
|
12
|
+
half-written ``**Tracing the`` renders bold instead of showing raw asterisks,
|
|
13
|
+
and the real closing marker replaces the synthetic one on the next chunk.
|
|
14
|
+
|
|
15
|
+
Streaming renders freeze every top-level block except the unfinished tail.
|
|
16
|
+
Later chunks reuse that frozen renderable and parse only the tail, matching
|
|
17
|
+
the stable-checkpoint approach used by Grok's streaming Markdown renderer.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
|
|
25
|
+
from markdown_it import MarkdownIt
|
|
26
|
+
from markdown_it.tree import SyntaxTreeNode
|
|
27
|
+
from rich.console import Group, RenderableType
|
|
28
|
+
from rich.padding import Padding
|
|
29
|
+
from rich.style import Style
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
PARSER = MarkdownIt("commonmark").enable("strikethrough")
|
|
34
|
+
FENCE_LINE = re.compile(r"^[ \t]{0,3}(?:```|~~~)", re.MULTILINE)
|
|
35
|
+
BULLETS = ("•", "◦", "▪")
|
|
36
|
+
QUOTE_BAR = "│"
|
|
37
|
+
RULE = "───"
|
|
38
|
+
CODE_BLOCK_INDENT = 2
|
|
39
|
+
PLAIN = Style()
|
|
40
|
+
BOLD = Style(bold=True)
|
|
41
|
+
ITALIC = Style(italic=True)
|
|
42
|
+
STRIKE = Style(strike=True)
|
|
43
|
+
LINK = Style(underline=True)
|
|
44
|
+
HEADING = Style(bold=True)
|
|
45
|
+
TOP_HEADING = Style(bold=True, underline=True)
|
|
46
|
+
BLOCK_SEPARATOR = " · "
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def close_dangling_markers(source: str) -> str:
|
|
50
|
+
"""Close or drop emphasis/code markers left open by a partial stream.
|
|
51
|
+
|
|
52
|
+
Only the last paragraph is scanned: earlier paragraphs are complete, and
|
|
53
|
+
an open code fence is left alone because markdown-it already renders an
|
|
54
|
+
unclosed fence up to the end of the text.
|
|
55
|
+
"""
|
|
56
|
+
if len(FENCE_LINE.findall(source)) % 2:
|
|
57
|
+
return source
|
|
58
|
+
length = len(source)
|
|
59
|
+
index = source.rfind("\n\n") + 1
|
|
60
|
+
opened: list[tuple[str, int]] = []
|
|
61
|
+
code_marker = ""
|
|
62
|
+
code_at = -1
|
|
63
|
+
while index < length:
|
|
64
|
+
character = source[index]
|
|
65
|
+
if character == "\\":
|
|
66
|
+
index += 2
|
|
67
|
+
continue
|
|
68
|
+
if character in "`*":
|
|
69
|
+
run = len(source[index:]) - len(source[index:].lstrip(character))
|
|
70
|
+
if character == "`":
|
|
71
|
+
if code_at < 0:
|
|
72
|
+
code_marker, code_at = source[index : index + run], index
|
|
73
|
+
elif run == len(code_marker):
|
|
74
|
+
code_marker, code_at = "", -1
|
|
75
|
+
elif code_at < 0:
|
|
76
|
+
before = source[index - 1] if index else " "
|
|
77
|
+
# A run at the very end is a marker still being typed: treat
|
|
78
|
+
# it as an opener so the empty emphasis is dropped below.
|
|
79
|
+
after = source[index + run] if index + run < length else character
|
|
80
|
+
for marker in ("**",) * (run // 2) + ("*",) * (run % 2):
|
|
81
|
+
if opened and opened[-1][0] == marker and not before.isspace():
|
|
82
|
+
opened.pop()
|
|
83
|
+
elif not after.isspace():
|
|
84
|
+
opened.append((marker, index))
|
|
85
|
+
index += run
|
|
86
|
+
continue
|
|
87
|
+
index += 1
|
|
88
|
+
if code_at >= 0:
|
|
89
|
+
opened.append((code_marker, code_at))
|
|
90
|
+
closed = source
|
|
91
|
+
for marker, position in reversed(opened):
|
|
92
|
+
if closed[position + len(marker) :].strip():
|
|
93
|
+
closed = closed.rstrip() + marker
|
|
94
|
+
else:
|
|
95
|
+
closed = closed[:position].rstrip()
|
|
96
|
+
return closed
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class StreamingThinkingRenderer:
|
|
100
|
+
"""Freeze stable top-level blocks and reparse only the unfinished tail."""
|
|
101
|
+
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
self._source = ""
|
|
104
|
+
self._style: Style | None = None
|
|
105
|
+
self._frozen = Group()
|
|
106
|
+
self._frozen_end = 0
|
|
107
|
+
self._scan_start = 0
|
|
108
|
+
self._group: Group | None = None
|
|
109
|
+
self._group_tail = ""
|
|
110
|
+
|
|
111
|
+
def render(self, source: str, *, code_style: Style) -> RenderableType:
|
|
112
|
+
if not source.startswith(self._source[: self._frozen_end]) or code_style != self._style:
|
|
113
|
+
self._source = ""
|
|
114
|
+
self._style = code_style
|
|
115
|
+
self._frozen = Group()
|
|
116
|
+
self._frozen_end = 0
|
|
117
|
+
self._scan_start = 0
|
|
118
|
+
self._group = None
|
|
119
|
+
self._group_tail = ""
|
|
120
|
+
self._source = source
|
|
121
|
+
text = close_dangling_markers(source)
|
|
122
|
+
frozen_end, self._scan_start = _stable_prefix_end(source, text, self._scan_start)
|
|
123
|
+
if frozen_end < self._frozen_end:
|
|
124
|
+
self._frozen = Group()
|
|
125
|
+
self._frozen_end = 0
|
|
126
|
+
if frozen_end > self._frozen_end:
|
|
127
|
+
added = SyntaxTreeNode(PARSER.parse(source[self._frozen_end:frozen_end])).children
|
|
128
|
+
rendered = render_blocks(added, code_style, depth=0, loose=True)
|
|
129
|
+
if self._frozen.renderables and rendered:
|
|
130
|
+
self._frozen.renderables.append(Text())
|
|
131
|
+
self._frozen.renderables.extend(rendered)
|
|
132
|
+
self._frozen_end = frozen_end
|
|
133
|
+
tail = text[self._frozen_end :]
|
|
134
|
+
if self._group is not None and tail == self._group_tail:
|
|
135
|
+
return self._group
|
|
136
|
+
tail_blocks = SyntaxTreeNode(PARSER.parse(tail)).children if tail.strip() else []
|
|
137
|
+
rendered_tail = render_blocks(tail_blocks, code_style, depth=0, loose=True)
|
|
138
|
+
blocks = list(self._frozen.renderables)
|
|
139
|
+
if blocks and rendered_tail:
|
|
140
|
+
blocks.append(Text())
|
|
141
|
+
self._group_tail = tail
|
|
142
|
+
self._group = Group(*blocks, *rendered_tail) if blocks or rendered_tail else Group(Text(" "))
|
|
143
|
+
return self._group
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_thinking(source: str, *, code_style: Style, streaming: bool) -> RenderableType:
|
|
147
|
+
"""Render thinking markdown as emphasis-only Text blocks in the widget's colour."""
|
|
148
|
+
text = close_dangling_markers(source) if streaming else source
|
|
149
|
+
root = SyntaxTreeNode(PARSER.parse(text))
|
|
150
|
+
rendered = render_blocks(root.children, code_style, depth=0, loose=True)
|
|
151
|
+
return Group(*rendered) if rendered else Text(" ")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _stable_prefix_end(source: str, display_text: str, scan_start: int) -> tuple[int, int]:
|
|
155
|
+
"""Return the frozen byte end and where the next tail scan can resume.
|
|
156
|
+
|
|
157
|
+
Nested blocks are not checkpoints because their container can continue.
|
|
158
|
+
A display-only closing marker means the final paragraph is unfinished.
|
|
159
|
+
``scan_start`` advances with the stream, so each update scans only the
|
|
160
|
+
newly closed tail rather than the whole thought.
|
|
161
|
+
"""
|
|
162
|
+
if display_text != source or len(FENCE_LINE.findall(source)) % 2:
|
|
163
|
+
return 0, 0
|
|
164
|
+
start = _checkpoint_scan_start(source, scan_start)
|
|
165
|
+
if start == 0:
|
|
166
|
+
return 0, 0
|
|
167
|
+
tokens = PARSER.parse(source[start:])
|
|
168
|
+
closed = [token for token in tokens if token.level == 0 and token.map and token.nesting == 0]
|
|
169
|
+
if len(closed) < 2:
|
|
170
|
+
return start, start
|
|
171
|
+
keep = len(closed) - 1 if _tail_can_extend(closed[-1]) else len(closed)
|
|
172
|
+
if keep < 1:
|
|
173
|
+
return start, start
|
|
174
|
+
lines = source[start:].splitlines(keepends=True)
|
|
175
|
+
frozen = start + sum(len(line) for line in lines[: closed[keep - 1].map[1]])
|
|
176
|
+
# An open container must stay inside the next scan. Advancing past it made
|
|
177
|
+
# every following block unfreezable.
|
|
178
|
+
return frozen, start if keep < len(closed) else frozen
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _tail_can_extend(token: object) -> bool:
|
|
182
|
+
"""A following paragraph can continue these top-level containers."""
|
|
183
|
+
return getattr(token, "type", "") in {
|
|
184
|
+
"bullet_list_close",
|
|
185
|
+
"ordered_list_close",
|
|
186
|
+
"blockquote_close",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _checkpoint_scan_start(source: str, scan_start: int) -> int:
|
|
191
|
+
"""Return the newest paragraph boundary followed by another closed block."""
|
|
192
|
+
cursor = scan_start
|
|
193
|
+
start = scan_start
|
|
194
|
+
while True:
|
|
195
|
+
found = source.find("\n\n", cursor)
|
|
196
|
+
if found < 0:
|
|
197
|
+
return start
|
|
198
|
+
nxt = found + 2
|
|
199
|
+
if "\n\n" in source[nxt:]:
|
|
200
|
+
start = nxt
|
|
201
|
+
cursor = nxt
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def render_blocks(
|
|
205
|
+
nodes: list[SyntaxTreeNode], code_style: Style, *, depth: int, loose: bool
|
|
206
|
+
) -> list[RenderableType]:
|
|
207
|
+
"""Render sibling blocks, with a blank row between them when ``loose``."""
|
|
208
|
+
rendered: list[RenderableType] = []
|
|
209
|
+
for node in nodes:
|
|
210
|
+
if rendered and loose:
|
|
211
|
+
rendered.append(Text())
|
|
212
|
+
rendered.append(render_block(node, code_style, depth=depth))
|
|
213
|
+
return rendered
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_block(node: SyntaxTreeNode, code_style: Style, *, depth: int) -> RenderableType:
|
|
217
|
+
match node.type:
|
|
218
|
+
case "paragraph":
|
|
219
|
+
return render_inline(node.children, code_style, PLAIN)
|
|
220
|
+
case "heading":
|
|
221
|
+
style = TOP_HEADING if node.tag == "h1" else HEADING
|
|
222
|
+
return render_inline(node.children, code_style, style)
|
|
223
|
+
case "bullet_list" | "ordered_list":
|
|
224
|
+
return render_list(node, code_style, depth=depth)
|
|
225
|
+
case "blockquote":
|
|
226
|
+
grid = Table.grid(expand=True, padding=0)
|
|
227
|
+
grid.add_column(width=2, no_wrap=True)
|
|
228
|
+
grid.add_column(ratio=1)
|
|
229
|
+
body = render_blocks(node.children, code_style, depth=depth, loose=True)
|
|
230
|
+
grid.add_row(Text(QUOTE_BAR), Group(*body))
|
|
231
|
+
return grid
|
|
232
|
+
case "fence" | "code_block":
|
|
233
|
+
code = Text(node.content.rstrip("\n"), style=code_style)
|
|
234
|
+
return Padding(code, (0, 0, 0, CODE_BLOCK_INDENT))
|
|
235
|
+
case "hr":
|
|
236
|
+
return Text(RULE)
|
|
237
|
+
case _:
|
|
238
|
+
return Text(node.content.rstrip("\n"))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def render_list(node: SyntaxTreeNode, code_style: Style, *, depth: int) -> Table:
|
|
242
|
+
"""A list as a grid so wrapped item text hangs under the item, not the marker."""
|
|
243
|
+
if node.type == "ordered_list":
|
|
244
|
+
start = int(node.attrs.get("start", 1))
|
|
245
|
+
markers = [f"{start + offset}." for offset in range(len(node.children))]
|
|
246
|
+
else:
|
|
247
|
+
markers = [BULLETS[depth % len(BULLETS)]] * len(node.children)
|
|
248
|
+
grid = Table.grid(expand=True, padding=0)
|
|
249
|
+
grid.add_column(width=max(len(marker) for marker in markers) + 1, no_wrap=True)
|
|
250
|
+
grid.add_column(ratio=1)
|
|
251
|
+
for marker, item in zip(markers, node.children):
|
|
252
|
+
loose = any(not child.hidden for child in item.children if child.type == "paragraph")
|
|
253
|
+
body = render_blocks(item.children, code_style, depth=depth + 1, loose=loose)
|
|
254
|
+
grid.add_row(Text(marker), Group(*body))
|
|
255
|
+
return grid
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render_inline(nodes: list[SyntaxTreeNode], code_style: Style, style: Style) -> Text:
|
|
259
|
+
"""Render a block's inline children (markdown-it wraps them in one ``inline`` node)."""
|
|
260
|
+
text = Text(style=style)
|
|
261
|
+
for node in nodes:
|
|
262
|
+
append_inline(text, node, code_style, PLAIN)
|
|
263
|
+
return text
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def append_inline(text: Text, node: SyntaxTreeNode, code_style: Style, style: Style) -> None:
|
|
267
|
+
match node.type:
|
|
268
|
+
case "inline":
|
|
269
|
+
for child in node.children:
|
|
270
|
+
append_inline(text, child, code_style, style)
|
|
271
|
+
case "strong" | "em" | "s" | "link" | "image":
|
|
272
|
+
emphasis = {"strong": BOLD, "em": ITALIC, "s": STRIKE}.get(node.type, LINK)
|
|
273
|
+
href = str((node.attrs or {}).get("href") or "")
|
|
274
|
+
# Rich only opens a link when the span carries one. Styling the
|
|
275
|
+
# text as a link without the target is why clicks did nothing.
|
|
276
|
+
if node.type == "link" and href.startswith(("http://", "https://")):
|
|
277
|
+
emphasis = emphasis + Style(link=href)
|
|
278
|
+
for child in node.children:
|
|
279
|
+
append_inline(text, child, code_style, style + emphasis)
|
|
280
|
+
case "code_inline":
|
|
281
|
+
text.append(node.content, style=style + code_style)
|
|
282
|
+
case "softbreak" | "hardbreak":
|
|
283
|
+
text.append("\n")
|
|
284
|
+
case _:
|
|
285
|
+
text.append(node.content, style=style)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def thinking_plain_text(source: str) -> str:
|
|
289
|
+
"""Thinking markdown as one line of plain text, every marker stripped.
|
|
290
|
+
|
|
291
|
+
Blocks (headings, paragraphs, list items, code) are joined with `` · `` so
|
|
292
|
+
a bold lead-in such as ``**Checking the caller**`` stays apart from the
|
|
293
|
+
prose after it.
|
|
294
|
+
"""
|
|
295
|
+
blocks = (" ".join(block.split()) for block in plain_blocks(SyntaxTreeNode(PARSER.parse(source))))
|
|
296
|
+
return BLOCK_SEPARATOR.join(block for block in blocks if block)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def plain_blocks(node: SyntaxTreeNode) -> Iterator[str]:
|
|
300
|
+
if node.type == "inline":
|
|
301
|
+
yield "".join(plain_inline(child) for child in node.children)
|
|
302
|
+
elif node.children:
|
|
303
|
+
for child in node.children:
|
|
304
|
+
yield from plain_blocks(child)
|
|
305
|
+
else:
|
|
306
|
+
yield node.content
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def plain_inline(node: SyntaxTreeNode) -> str:
|
|
310
|
+
if node.type in {"softbreak", "hardbreak"}:
|
|
311
|
+
return " "
|
|
312
|
+
if node.children:
|
|
313
|
+
return "".join(plain_inline(child) for child in node.children)
|
|
314
|
+
return node.content
|