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