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