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,504 @@
1
+ """Live run-process widgets: thinking, timeline, and reasoning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from dataclasses import dataclass
8
+ from time import monotonic
9
+ from typing import Any
10
+
11
+ from rich.console import RenderableType
12
+ from rich.style import Style
13
+ from rich.text import Text
14
+ from textual import events
15
+ from textual.containers import Container, Horizontal, VerticalScroll
16
+ from textual.message import Message
17
+ from textual.widget import Widget
18
+ from textual.widgets import Collapsible, Static
19
+
20
+ from coding_agent.tui.transcript.messages import SelectableStatic, return_composer_focus
21
+ from coding_agent.tui.transcript.thinking_markdown import StreamingThinkingRenderer, render_thinking
22
+
23
+
24
+ class ThinkingStatus(Static):
25
+ """Muted run/usage metadata displayed directly beneath the user prompt."""
26
+
27
+ # Short, present-participle activity verbs in the style of Claude Code's
28
+ # spinner words. These rotate while a model turn is in flight.
29
+ _CHURNING_VERBS = (
30
+ "Thinking",
31
+ "Cooking",
32
+ "Noodling",
33
+ "Pondering",
34
+ "Wandering",
35
+ "Tinkering",
36
+ "Musing",
37
+ "Orbiting",
38
+ "Whisking",
39
+ "Grooving",
40
+ "Spelunking",
41
+ "Percolating",
42
+ "Ruminating",
43
+ "Concocting",
44
+ "Meandering",
45
+ "Incubating",
46
+ "Harmonizing",
47
+ "Forging",
48
+ "Sketching",
49
+ "Vibing",
50
+ "Stargazing",
51
+ "Brewing",
52
+ "Marinating",
53
+ "Gallivanting",
54
+ "Pontificating",
55
+ "Combobulating",
56
+ "Enchanting",
57
+ "Moonwalking",
58
+ "Reticulating",
59
+ "Wrangling",
60
+ )
61
+
62
+ _WORKING_COLORS = (
63
+ "#6f5930",
64
+ "#94733a",
65
+ "#bd9145",
66
+ "#e2b85f",
67
+ "#f0d58a",
68
+ "#d7a84b",
69
+ "#a77f3d",
70
+ )
71
+
72
+ def __init__(self, text: str = "Thinking…") -> None:
73
+ self._working = False
74
+ self._churning = False
75
+ self._working_detail = ""
76
+ self._gradient_step = 0
77
+ self._churning_started_at = 0.0
78
+ self._churning_verb = "Thinking"
79
+ self._animation_timer: Any = None
80
+ super().__init__(classes="thinking-status")
81
+ self.set_text(text)
82
+
83
+ def on_mount(self) -> None:
84
+ self._animation_timer = self.set_interval(0.12, self._advance_gradient)
85
+ self._sync_animation_timer()
86
+
87
+ def on_unmount(self) -> None:
88
+ if self._animation_timer is not None:
89
+ self._animation_timer.stop()
90
+ self._animation_timer = None
91
+
92
+ def watch_display(self, display: bool) -> None:
93
+ del display
94
+ self._sync_animation_timer()
95
+
96
+ def set_visible(self, visible: bool) -> None:
97
+ """Show or hide the status line and pause the gradient when it is off-screen."""
98
+ self.display = visible
99
+ self._sync_animation_timer()
100
+
101
+ def _sync_animation_timer(self) -> None:
102
+ timer = self._animation_timer
103
+ if timer is None:
104
+ return
105
+ if self._working and self.display:
106
+ timer.resume()
107
+ else:
108
+ timer.pause()
109
+
110
+ def set_text(self, value: str) -> None:
111
+ self._churning = False
112
+ self._working = False
113
+ self._sync_animation_timer()
114
+ self.update(Text(f"✻ {value}", style="#666666"))
115
+
116
+ def set_churning(self) -> None:
117
+ """Show the normal waiting state for a model turn."""
118
+ self._churning = True
119
+ self._working = True
120
+ self._working_detail = ""
121
+ self._churning_started_at = time.monotonic()
122
+ self._churning_verb = random.choice(self._CHURNING_VERBS)
123
+ self._sync_animation_timer()
124
+ self._render_churning()
125
+
126
+ def _render_churning(self) -> None:
127
+ elapsed = time.monotonic() - self._churning_started_at
128
+ self.update(Text(f"{self._churning_verb} {elapsed:.1f}s", style="#858585"))
129
+
130
+ def set_working(self, detail: str = "") -> None:
131
+ """Show a moving color gradient while a model request is retrying."""
132
+ self._churning = False
133
+ self._working = True
134
+ self._working_detail = detail
135
+ self._sync_animation_timer()
136
+ self._render_working()
137
+
138
+ def _advance_gradient(self) -> None:
139
+ if not self._working or not self.display:
140
+ self._sync_animation_timer()
141
+ return
142
+ if self._churning:
143
+ self._render_churning()
144
+ return
145
+ self._gradient_step = (self._gradient_step + 1) % len(self._WORKING_COLORS)
146
+ # Only colors changed; preserve geometry and Textual's scroll fast path.
147
+ self._render_working(layout=False)
148
+
149
+ def _render_working(self, *, layout: bool = True) -> None:
150
+ label = "✻ Working"
151
+ line = Text()
152
+ for index, character in enumerate(label):
153
+ color = self._WORKING_COLORS[
154
+ (index + self._gradient_step) % len(self._WORKING_COLORS)
155
+ ]
156
+ line.append(character, style=f"bold {color}")
157
+ if self._working_detail:
158
+ line.append(f" · {self._working_detail}", style="#666666")
159
+ self.update(line, layout=layout)
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class TimelineEntry:
164
+ """Shared identity and display state for one timeline row."""
165
+
166
+ entry_id: int
167
+ kind: str
168
+ widget: Widget
169
+ expanded: bool = False
170
+
171
+
172
+ class RunProcess(Container):
173
+ """One run's flat timeline of live status, thoughts, and tools."""
174
+
175
+ def __init__(self, thinking: ThinkingStatus) -> None:
176
+ # ``_items`` is the single source of truth for timeline order. Items
177
+ # added before this container is composed are yielded by ``compose``;
178
+ # items added afterwards are mounted directly. ``entries`` mirrors
179
+ # that order with stable ids so later virtualization can render the
180
+ # same state without asking each widget to own it.
181
+ self._next_entry_id = 1
182
+ self.entries: list[TimelineEntry] = []
183
+ self._items: list[Widget] = [thinking]
184
+ self._remember_entry(thinking)
185
+ self._thinking = thinking
186
+ self._completed = False
187
+ self.completed_summary = None
188
+ super().__init__(classes="run-process")
189
+
190
+ def compose(self): # type: ignore[no-untyped-def]
191
+ yield from self._items
192
+
193
+ def on_mount(self) -> None:
194
+ """Mount items queued during composition."""
195
+ pending = [item for item in self._items if item.parent is None]
196
+ if pending:
197
+ self.mount(*pending)
198
+
199
+ def _remember_entry(self, widget: Widget) -> None:
200
+ from coding_agent.tui.tools.snapshots import ToolCallSummary
201
+
202
+ expanded = bool(getattr(widget, "is_expanded", not getattr(widget, "collapsed", True)))
203
+ self.entries.append(
204
+ TimelineEntry(self._next_entry_id, type(widget).__name__, widget, expanded and isinstance(widget, ToolCallSummary))
205
+ )
206
+ self._next_entry_id += 1
207
+
208
+ def add_item(self, widget: Widget) -> None:
209
+ self._items.append(widget)
210
+ self._remember_entry(widget)
211
+ if self.is_mounted:
212
+ self.mount(widget)
213
+
214
+ def place_thinking_last(self) -> None:
215
+ """Keep the live status below the work it is describing, without remounting it."""
216
+ if not self._items or self._items[-1] is self._thinking:
217
+ return
218
+ self._items.remove(self._thinking)
219
+ entry = next(item for item in self.entries if item.widget is self._thinking)
220
+ self.entries.remove(entry)
221
+ previous = self._items[-1]
222
+ self._items.append(self._thinking)
223
+ self.entries.append(entry)
224
+ if self._thinking.is_attached and previous.is_attached:
225
+ self.move_child(self._thinking, after=previous)
226
+
227
+ def timeline_items(self) -> list[Widget]:
228
+ """Timeline order, including items not yet flushed to the DOM."""
229
+ return list(self._items)
230
+
231
+ @property
232
+ def completed(self) -> bool:
233
+ return self._completed
234
+
235
+ def complete(
236
+ self, title: str, *, collapse: bool = False, verb: str = "", duration: str = ""
237
+ ) -> None:
238
+ """Finalize the run, optionally collecting its work before the final reply."""
239
+ if self._completed:
240
+ return
241
+ self._completed = True
242
+ self._thinking.set_visible(False)
243
+ if collapse:
244
+ self.fold_into_summary(verb=verb, duration=duration)
245
+ self.add_item(ProcessComplete(title))
246
+
247
+ def fold_into_summary(self, *, verb: str = "", duration: str = "") -> None:
248
+ """Collect tool/thought snapshots, including finished bash and update cards."""
249
+ from coding_agent.tui.tools.calls import BashToolWidget, PatchDiffWidget, ToolCallWidget
250
+ from coding_agent.tui.tools.snapshots import (
251
+ CompletedRunSummary,
252
+ ThoughtSnapshot,
253
+ ToolCallSummary,
254
+ )
255
+ from coding_agent.tui.transcript.messages import AssistantMessage
256
+
257
+ snapshots = []
258
+ assistants = [item for item in self._items if isinstance(item, AssistantMessage)]
259
+ final_assistant = assistants[-1] if assistants else None
260
+ removed: list[Widget] = []
261
+ for item in self._items:
262
+ if isinstance(item, ReasoningWidget):
263
+ snapshots.append(ThoughtSnapshot(item.title, item.reasoning_text))
264
+ elif isinstance(item, ToolCallSummary):
265
+ snapshots.extend(item.entries)
266
+ elif isinstance(item, ToolCallWidget) and (
267
+ not item.keep_in_transcript
268
+ or isinstance(item, PatchDiffWidget)
269
+ or isinstance(item, BashToolWidget) and not item.arguments.get("background")
270
+ ):
271
+ snapshots.append(item.snapshot())
272
+ elif not (isinstance(item, AssistantMessage) and item is not final_assistant):
273
+ continue
274
+ removed.append(item)
275
+ summary = CompletedRunSummary(snapshots, verb=verb, duration=duration)
276
+ self.completed_summary = summary
277
+ removed_set = set(removed)
278
+ self._items = [item for item in self._items if item not in removed_set]
279
+ self.entries = [entry for entry in self.entries if entry.widget not in removed_set]
280
+ for item in removed:
281
+ if item.is_attached:
282
+ item.remove()
283
+ if not summary.entries:
284
+ return
285
+ if final_assistant is not None:
286
+ index = self._items.index(final_assistant)
287
+ self._items.insert(index, summary)
288
+ self.entries.insert(
289
+ index,
290
+ TimelineEntry(self._next_entry_id, type(summary).__name__, summary, summary.is_expanded),
291
+ )
292
+ self._next_entry_id += 1
293
+ if final_assistant.is_attached:
294
+ self.mount(summary, before=final_assistant)
295
+ else:
296
+ self.add_item(summary)
297
+
298
+ def absorb_late_call(self, snapshot: Any) -> None:
299
+ """Keep a tool result that arrives after folding inside the run summary."""
300
+ summary = self.completed_summary
301
+ if summary is None or snapshot.call_id in summary.call_ids:
302
+ return
303
+ summary.add_call(snapshot)
304
+
305
+ def archive_text(self) -> str:
306
+ from coding_agent.tui.tools.calls import ToolCallWidget
307
+ from coding_agent.tui.tools.snapshots import ToolCallSummary
308
+
309
+ chunks: list[str] = []
310
+ for item in self.timeline_items():
311
+ if isinstance(item, ThinkingStatus):
312
+ continue
313
+ if isinstance(item, ReasoningWidget):
314
+ if item.reasoning_text:
315
+ chunks.append(f"THOUGHT\n{item.reasoning_text}")
316
+ elif isinstance(item, ToolCallWidget):
317
+ chunks.append(item.snapshot().as_text())
318
+ elif isinstance(item, ToolCallSummary):
319
+ chunks.append(item.archive_text())
320
+ else:
321
+ archive = getattr(item, "archive_text", None)
322
+ if callable(archive):
323
+ chunks.append(str(archive()))
324
+ return "\n\n".join(chunk for chunk in chunks if chunk)
325
+
326
+
327
+ class ProcessComplete(Static):
328
+ """Compact completion row for a finished run."""
329
+
330
+ def __init__(self, title: str) -> None:
331
+ super().__init__(
332
+ Text(f"✓ {title}", style="#858585"), classes="process-complete"
333
+ )
334
+
335
+
336
+ class ReasoningHeader(Horizontal, can_focus=True):
337
+ """Focusable Thought header that matches the compact tool-call line."""
338
+
339
+ class Toggle(Message):
340
+ pass
341
+
342
+ def _on_click(self, event: events.Click) -> None:
343
+ event.stop()
344
+ self.post_message(self.Toggle())
345
+ self.app.call_after_refresh(return_composer_focus, self)
346
+
347
+ def _on_key(self, event: events.Key) -> None:
348
+ if event.key in {"enter", "space"}:
349
+ event.stop()
350
+ self.post_message(self.Toggle())
351
+
352
+
353
+ REASONING_CODE = "reasoning-text--code"
354
+
355
+
356
+ class ReasoningBody(SelectableStatic):
357
+ """Thinking text rendered as markdown in the muted thinking tone.
358
+
359
+ Each chunk swaps the source and re-renders this same widget; nothing is
360
+ remounted. While streaming, completed top-level blocks stay frozen and only
361
+ the unfinished tail is parsed again. A completed thought renders once.
362
+ """
363
+
364
+ COMPONENT_CLASSES = {REASONING_CODE}
365
+
366
+ def __init__(self) -> None:
367
+ self._source = ""
368
+ self._streaming = True
369
+ self._renderer = StreamingThinkingRenderer()
370
+ self._rendered: tuple[str, bool, Style, RenderableType] | None = None
371
+ super().__init__(classes="reasoning-text", markup=False)
372
+
373
+ def show_markdown(self, source: str, *, streaming: bool) -> None:
374
+ if (source, streaming) == (self._source, self._streaming):
375
+ return
376
+ self._source = source
377
+ self._streaming = streaming
378
+ self._invalidate_render_cache(layout=True)
379
+
380
+ def render(self) -> RenderableType:
381
+ code_style = self.get_component_rich_style(REASONING_CODE, partial=True, default=Style())
382
+ key = (self._source, self._streaming, code_style)
383
+ if self._rendered is None or self._rendered[:3] != key:
384
+ if self._streaming:
385
+ renderable = self._renderer.render(self._source or " ", code_style=code_style)
386
+ else:
387
+ renderable = render_thinking(self._source or " ", code_style=code_style, streaming=False)
388
+ self._rendered = (*key, renderable)
389
+ return self._rendered[3]
390
+
391
+
392
+ class ReasoningScroll(VerticalScroll):
393
+ """A thought's capped body. The wheel scrolls the transcript, not this box.
394
+
395
+ A nested scroll area under the pointer takes every wheel tick until it
396
+ reaches its own end. A live thought follows its newest text, so it never
397
+ gets there, and the transcript looks frozen while the run streams.
398
+ Declining the pointer scroll lets the event bubble to ``#transcript``.
399
+ """
400
+
401
+ def _scroll_down_for_pointer(self, **_: Any) -> bool:
402
+ return False
403
+
404
+ def _scroll_up_for_pointer(self, **_: Any) -> bool:
405
+ return False
406
+
407
+
408
+ class ReasoningWidget(Collapsible):
409
+ """A live thought whose inner scroll area is anchored to its newest text."""
410
+
411
+ def __init__(self, content: str = "") -> None:
412
+ # Collapsible's constructor can invoke the collapsed watcher before
413
+ # set_content(), so initialize the deferred body state first.
414
+ self._body_source = ""
415
+ self._body_streaming = True
416
+ self._body = ReasoningBody()
417
+ self._scroll = ReasoningScroll(self._body, classes="reasoning-scroll")
418
+ self._label = Static("Thinking…", classes="reasoning-label", markup=False)
419
+ self._status = Static("", classes="reasoning-status", markup=False)
420
+ self._started_at = monotonic()
421
+ self._duration: float | None = None
422
+ super().__init__(
423
+ self._scroll,
424
+ title="Thinking…",
425
+ collapsed=False,
426
+ collapsed_symbol="",
427
+ expanded_symbol="",
428
+ classes="reasoning-block is-live",
429
+ )
430
+ self.set_content(content)
431
+
432
+ def compose(self): # type: ignore[no-untyped-def]
433
+ # Keep CollapsibleTitle in the DOM for keyboard/accessibility compatibility;
434
+ # the visible header matches the compact tool-call line.
435
+ yield self._title
436
+ with ReasoningHeader(classes="reasoning-header"):
437
+ yield self._label
438
+ yield self._status
439
+ with self.Contents():
440
+ yield self._scroll
441
+
442
+ def on_reasoning_header_toggle(self, event: ReasoningHeader.Toggle) -> None:
443
+ event.stop()
444
+ self.collapsed = not self.collapsed
445
+
446
+ def _watch_collapsed(self, collapsed: bool) -> None:
447
+ if not collapsed:
448
+ self._sync_body()
449
+ self._update_collapsed(collapsed)
450
+ if collapsed:
451
+ self.post_message(self.Collapsed(self))
452
+ else:
453
+ self.post_message(self.Expanded(self))
454
+ self._scroll.display = not collapsed
455
+
456
+ def _set_visible_title(self, title: str) -> None:
457
+ self.title = title
458
+ label, _, duration = title.partition(" ")
459
+ self._label.update(label or "Thought")
460
+ self._status.update(duration)
461
+
462
+ def set_content(self, content: str) -> None:
463
+ self.reasoning_text = content
464
+ self._body_source = content
465
+ self._body_streaming = True
466
+ if not self.collapsed:
467
+ self._sync_body()
468
+
469
+ def _sync_body(self) -> None:
470
+ """Apply the latest source only when the thought is visible."""
471
+ self._body.show_markdown(self._body_source, streaming=self._body_streaming)
472
+
473
+ def on_mount(self) -> None:
474
+ # Anchored, the inner area follows new text until the reader scrolls it.
475
+ self._scroll.anchor()
476
+
477
+ @staticmethod
478
+ def _format_duration(seconds: float) -> str:
479
+ if seconds < 10:
480
+ return f"{seconds:.1f}s"
481
+ if seconds < 60:
482
+ return f"{seconds:.0f}s"
483
+ minutes, remainder = divmod(int(seconds), 60)
484
+ return f"{minutes}m {remainder:02d}s"
485
+
486
+ def complete(self) -> None:
487
+ if self.has_class("is-complete"):
488
+ return
489
+ if self.is_mounted:
490
+ self._scroll.anchor(False)
491
+ self._scroll.scroll_home(animate=False, force=True)
492
+ self._duration = max(0.0, monotonic() - self._started_at)
493
+ completed_title = f"Thought {self._format_duration(self._duration)}"
494
+ self._body_source = self.reasoning_text.strip()
495
+ self._body_streaming = False
496
+ # Keep completed reasoning expanded so the provider's streamed content
497
+ # remains visible; the title still identifies the Thought row.
498
+ if self.collapsed:
499
+ self.collapsed = False # The watcher flushes the final body state.
500
+ else:
501
+ self._sync_body()
502
+ self._set_visible_title(completed_title)
503
+ self.remove_class("is-live")
504
+ self.add_class("is-complete")