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,728 @@
1
+ """Map harness events into durable conversation components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from typing import Any, Callable, Mapping, Optional, Protocol, Tuple
9
+
10
+ from coding_agent.addons.evaluation.addon import JevAddon
11
+ from coding_agent.addons.evaluation.state import bound_text
12
+ from coding_agent.addons.persistence.collection import is_collected
13
+ from coding_agent.addons.voice.summary import SpokenSummaryStream, split_spoken_summary
14
+ from coding_agent.tui.runtime.state import UiRunState
15
+ from coding_agent.tui.tools.activity import choose_completion_verb
16
+ from coding_agent.tui.tools.partial_args import string_field
17
+ from coding_agent.tui.transcript.messages import preview_text
18
+
19
+ StatusFn = Callable[[str], None]
20
+ ScheduleFlush = Callable[[Callable[[], None]], None]
21
+ ChromeSnapshot = Tuple[Any, ...]
22
+
23
+ # High-frequency events are reduced immediately but painted on one throttled UI tick.
24
+ _BUFFERED_PAINT_EVENT_TYPES = frozenset(
25
+ {"text_delta", "reasoning_delta", "tool_call_delta", "tool_call_arguments"}
26
+ )
27
+ _TOASTABLE_JEV_ACTIONS = frozenset({"replan", "retry", "review", "gather", "ask"})
28
+ _JEV_TOAST_REASON_LIMIT = 120
29
+
30
+
31
+ def jev_recommendation_update(decision: Any) -> Optional[str]:
32
+ """ComposerOverlay text for a user-facing Jev decision, else None."""
33
+ if decision is None:
34
+ return None
35
+ action = getattr(decision, "action", None)
36
+ if action not in _TOASTABLE_JEV_ACTIONS:
37
+ return None
38
+ reason = bound_text(str(getattr(decision, "reason", "") or ""), _JEV_TOAST_REASON_LIMIT)
39
+ return f"Jev → {action}: {reason}" if reason else f"Jev → {action}"
40
+
41
+
42
+ def _jev_last_decision(view: Any) -> Any:
43
+ """Fail-open lookup of mounted JevAddon.last_decision via the app view."""
44
+ agent = getattr(view, "_agent", None)
45
+ harness = getattr(agent, "harness", None) if agent is not None else None
46
+ if harness is None:
47
+ return None
48
+ addon = harness.find_addon(JevAddon)
49
+ return addon.last_decision if addon is not None else None
50
+
51
+
52
+ def _clean_reasoning(text: str) -> str:
53
+ """Remove provider labels that duplicate the UI's Thought disclosure."""
54
+ return re.sub(
55
+ r"^\s*(?:#{1,6}\s*)?(?:\*{1,2}|_{1,2})?\s*"
56
+ r"reasoning summary\s*(?:\*{1,2}|_{1,2})?\s*\n+",
57
+ "",
58
+ text,
59
+ count=1,
60
+ flags=re.IGNORECASE,
61
+ )
62
+
63
+
64
+ def _compact_tokens(value: int) -> str:
65
+ for divisor, suffix in ((1_000_000, "M"), (1_000, "k")):
66
+ if value >= divisor:
67
+ return f"{value / divisor:.2f}".rstrip("0").rstrip(".") + suffix
68
+ return str(value)
69
+
70
+
71
+ def _tool_arguments_from_protocol(raw: str) -> Optional[dict[str, Any]]:
72
+ """Read plain tool values from streamed protocol JSON, including partial blobs."""
73
+ if not raw:
74
+ return None
75
+ try:
76
+ decoded = json.loads(raw)
77
+ except json.JSONDecodeError:
78
+ decoded = None
79
+ if isinstance(decoded, dict):
80
+ return decoded
81
+ arguments: dict[str, Any] = {}
82
+ for key in ("path", "command", "query", "pattern"):
83
+ value = string_field(raw, key)
84
+ if value is not None:
85
+ arguments[key] = value
86
+ return arguments or None
87
+
88
+
89
+ def _duration(seconds: float) -> str:
90
+ minutes, seconds = divmod(max(0, int(seconds)), 60)
91
+ hours, minutes = divmod(minutes, 60)
92
+ if hours:
93
+ return f"{hours}h {minutes}m {seconds}s"
94
+ if minutes:
95
+ return f"{minutes}m {seconds}s"
96
+ return f"{seconds}s"
97
+
98
+
99
+ def _compaction_notice(payload: Mapping[str, Any]) -> str:
100
+ """Notice text with message counts and, when known, estimated token counts."""
101
+ before = payload.get("message_count_before", "?")
102
+ after = payload.get("message_count_after", "?")
103
+ text = f"Compacted context · {before} → {after} messages"
104
+ tokens_before = payload.get("estimated_tokens_before")
105
+ tokens_after = payload.get("estimated_tokens_after")
106
+ if isinstance(tokens_before, int) and isinstance(tokens_after, int):
107
+ text += f" · ~{tokens_before:,} → ~{tokens_after:,} tokens"
108
+ return text
109
+
110
+
111
+ class TranscriptView(Protocol):
112
+ """Small rendering boundary, deliberately free of Textual types."""
113
+
114
+ def set_assistant(self, text: str, *, new: bool = False) -> None: ...
115
+
116
+ def finish_assistant(self) -> None: ...
117
+
118
+ def set_thinking(self, text: str) -> None: ...
119
+
120
+ def set_working(self, detail: str = "") -> None: ...
121
+
122
+ def set_churning(self, turn: int = 0) -> None: ...
123
+
124
+ def set_reasoning(self, text: str, *, new: bool = False) -> None: ...
125
+
126
+ def finish_reasoning(self) -> None: ...
127
+
128
+ def finish_process(
129
+ self, title: str, *, collapse: bool = True, verb: str = "", duration: str = ""
130
+ ) -> None: ...
131
+
132
+ def add_tool(self, call_id: str, name: str) -> None: ...
133
+
134
+ def update_tool(
135
+ self,
136
+ call_id: str,
137
+ *,
138
+ tool_name: str = "tool",
139
+ arguments: Optional[Mapping[str, Any]] = None,
140
+ raw_arguments: str = "",
141
+ status: str = "preparing",
142
+ result: Any = None,
143
+ ) -> None: ...
144
+
145
+ def append_tool_output(self, call_id: str, chunk: str) -> None: ...
146
+
147
+ def add_notice(self, text: str, tone: str = "info") -> None: ...
148
+
149
+ def add_update(self, text: str, hint: str = "") -> None: ...
150
+
151
+ def add_run_summary(
152
+ self,
153
+ summary: str,
154
+ *,
155
+ label: str = "summary so far",
156
+ event_type: str = "run_summary",
157
+ ) -> None: ...
158
+
159
+
160
+ class EventPresenter:
161
+ """Stateful event reducer that updates a transcript view."""
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ state: UiRunState,
167
+ view: TranscriptView,
168
+ set_status: StatusFn,
169
+ workspace: str = "",
170
+ schedule_flush: Optional[ScheduleFlush] = None,
171
+ ) -> None:
172
+ self.state = state
173
+ self.view = view
174
+ self._set_status = set_status
175
+ self.workspace = workspace
176
+ self._schedule_flush = schedule_flush
177
+ self._assistant_open = False
178
+ self._spoken = SpokenSummaryStream()
179
+ self._tool_names: dict[str, str] = {}
180
+ self._tool_argument_chunks: dict[str, list[str]] = {}
181
+ self._tool_argument_tails: dict[str, str] = {}
182
+ self._reasoning_parts: dict[int, str] = {}
183
+ self._reasoning_active = False
184
+ self._pending_assistant: Optional[tuple[str, bool]] = None
185
+ self._pending_reasoning: Optional[tuple[str, bool]] = None
186
+ self._pending_tool_paints: dict[str, None] = {}
187
+ self._pending_tool_output: dict[str, list[str]] = {}
188
+ self._pending_thinking: Optional[str] = None
189
+ self._flush_scheduled = False
190
+ self._stream_generation = 0
191
+ self._last_chrome: Optional[ChromeSnapshot] = None
192
+ self._started_at: Optional[float] = None
193
+ self._started_ts: Optional[float] = None
194
+ self._elapsed_seconds: Optional[float] = None
195
+ self._model_turns: set[int] = set()
196
+ self._tool_executions: set[str] = set()
197
+ self._usage_estimated = False
198
+
199
+ def _chrome_snapshot(self) -> ChromeSnapshot:
200
+ m = self.state.metrics
201
+ return (
202
+ self.state.phase,
203
+ self.state.detail,
204
+ self.state.status_line(workspace=self.workspace),
205
+ m.prompt_tokens,
206
+ m.completion_tokens,
207
+ m.reasoning_tokens,
208
+ m.total_tokens,
209
+ m.cumulative_tokens,
210
+ m.tokens_used,
211
+ m.context_limit,
212
+ m.context_left,
213
+ m.utilization,
214
+ m.estimated,
215
+ )
216
+
217
+ def _show_banner(self, event_type: str, payload: Optional[Mapping[str, Any]] = None) -> None:
218
+ """Show a composer banner when this event has one. Otherwise stay quiet."""
219
+ from coding_agent.tui.chrome.footer import update_banner
220
+
221
+ built = update_banner(event_type, dict(payload or {}))
222
+ if built is None:
223
+ return
224
+ title, hint = built
225
+ add_update = getattr(self.view, "add_update", None)
226
+ if callable(add_update):
227
+ add_update(title, hint)
228
+
229
+ def refresh_chrome(self) -> None:
230
+ snapshot = self._chrome_snapshot()
231
+ if snapshot == self._last_chrome:
232
+ return
233
+ self._last_chrome = snapshot
234
+ self._set_status(self.state.status_line(workspace=self.workspace))
235
+
236
+ def handle(self, event_type: str, payload: Optional[Mapping[str, Any]] = None) -> None:
237
+ payload = payload or {}
238
+ if event_type in {"collected", "run_completed_meta"} or is_collected(payload):
239
+ # Mid-run work already folded into the completed-run collection.
240
+ # Honour the sticky tag so resume cannot resurrect live cards.
241
+ return
242
+ before = self._chrome_snapshot()
243
+ # Observations must not turn an interleaved stream into per-event paints.
244
+ observational = event_type in {"usage", "context", "tool_execution_output"}
245
+ if event_type not in _BUFFERED_PAINT_EVENT_TYPES and not observational:
246
+ if event_type != "run_started":
247
+ self.flush_stream_paints()
248
+ handler = getattr(self, f"_on_{event_type}", None)
249
+ if handler is None:
250
+ self.view.add_notice(f"{event_type} · {preview_text(payload)}")
251
+ else:
252
+ handler(payload)
253
+ if event_type in _BUFFERED_PAINT_EVENT_TYPES or observational:
254
+ self._request_stream_flush()
255
+ after = self._chrome_snapshot()
256
+ if after != before:
257
+ self.refresh_chrome()
258
+
259
+ def flush_stream_to_log(self) -> None:
260
+ """Paint any buffered stream text, then close the live assistant widget."""
261
+ self.flush_stream_paints()
262
+ self.view.finish_assistant()
263
+ self._assistant_open = False
264
+
265
+ def flush_stream_paints(self) -> None:
266
+ """Apply buffered stream, tool output, and usage widget updates."""
267
+ self._flush_scheduled = False
268
+ # Invalidate callbacks left queued after a structural event drains us.
269
+ self._stream_generation += 1
270
+ pending_thinking = self._pending_thinking
271
+ self._pending_thinking = None
272
+ pending_output = self._pending_tool_output
273
+ self._pending_tool_output = {}
274
+ pending_reasoning = self._pending_reasoning
275
+ pending_assistant = self._pending_assistant
276
+ self._pending_reasoning = None
277
+ self._pending_assistant = None
278
+ pending_tools = tuple(self._pending_tool_paints)
279
+ self._pending_tool_paints.clear()
280
+ if pending_thinking is not None:
281
+ self.view.set_thinking(pending_thinking)
282
+ if pending_reasoning is not None:
283
+ text, new = pending_reasoning
284
+ self.view.set_reasoning(text, new=new)
285
+ if pending_assistant is not None:
286
+ text, new = pending_assistant
287
+ self.view.set_assistant(text, new=new)
288
+ for call_id in pending_tools:
289
+ self._paint_pending_tool(call_id)
290
+ for call_id, chunks in pending_output.items():
291
+ self.view.append_tool_output(call_id, "".join(chunks))
292
+
293
+ def _paint_pending_tool(self, call_id: str) -> None:
294
+ """Mount/update a buffered tool paint only when the real name is known."""
295
+ tool_name = self._tool_names.get(call_id)
296
+ if tool_name is None:
297
+ # Hold rather than flash a nameless "Tool" title.
298
+ self._pending_tool_paints[call_id] = None
299
+ return
300
+ raw = "".join(self._tool_argument_chunks.get(call_id, ()))
301
+ self.view.update_tool(
302
+ call_id,
303
+ tool_name=tool_name,
304
+ arguments=_tool_arguments_from_protocol(raw),
305
+ raw_arguments=raw,
306
+ )
307
+
308
+ def _request_stream_flush(self) -> None:
309
+ """Schedule one paint for all deltas received during this interval."""
310
+ if (
311
+ self._pending_assistant is None
312
+ and self._pending_reasoning is None
313
+ and not self._pending_tool_paints
314
+ and not self._pending_tool_output
315
+ and self._pending_thinking is None
316
+ ):
317
+ return
318
+ if self._schedule_flush is None:
319
+ self.flush_stream_paints()
320
+ return
321
+ if self._flush_scheduled:
322
+ return
323
+ self._flush_scheduled = True
324
+ generation = self._stream_generation
325
+
326
+ def flush_if_current() -> None:
327
+ if generation == self._stream_generation:
328
+ self.flush_stream_paints()
329
+
330
+ self._schedule_flush(flush_if_current)
331
+
332
+ def _buffer_assistant(self, text: str, *, new: bool) -> None:
333
+ if self._pending_assistant is None:
334
+ self._pending_assistant = (text, new)
335
+ return
336
+ _, was_new = self._pending_assistant
337
+ self._pending_assistant = (text, was_new or new)
338
+
339
+ def _buffer_reasoning(self, text: str, *, new: bool) -> None:
340
+ if self._pending_reasoning is None:
341
+ self._pending_reasoning = (text, new)
342
+ return
343
+ _, was_new = self._pending_reasoning
344
+ self._pending_reasoning = (text, was_new or new)
345
+
346
+ def _usage_text(self, prefix: str = "Thinking") -> str:
347
+ m = self.state.metrics
348
+ if not (m.prompt_tokens or m.completion_tokens):
349
+ turn = f" · turn {self.state.turn + 1}" if self.state.turn is not None else ""
350
+ return f"{prefix}{turn}"
351
+ estimate = "~" if m.estimated else ""
352
+ text = (
353
+ f"{prefix} · {estimate}{m.prompt_tokens:,} in / "
354
+ f"{estimate}{m.completion_tokens:,} out"
355
+ )
356
+ if m.reasoning_tokens:
357
+ text += f" · {m.reasoning_tokens:,} reasoning"
358
+ return text
359
+
360
+ # Run lifecycle
361
+ def _on_run_started(self, payload: Mapping[str, Any]) -> None:
362
+ self.state.reset_for_run(model_id=str(payload.get("model_id") or self.state.model_id))
363
+ self._started_at = time.monotonic()
364
+ self._started_ts = payload.get("ts")
365
+ self._elapsed_seconds = None
366
+ self._model_turns.clear()
367
+ self._tool_executions.clear()
368
+ self._usage_estimated = False
369
+ self._assistant_open = False
370
+ self._tool_names.clear()
371
+ self._tool_argument_chunks.clear()
372
+ self._tool_argument_tails.clear()
373
+ self._reasoning_parts.clear()
374
+ self._reasoning_active = False
375
+ self._pending_assistant = None
376
+ self._pending_reasoning = None
377
+ self._pending_tool_paints.clear()
378
+ self._pending_tool_output.clear()
379
+ self._pending_thinking = None
380
+ self._flush_scheduled = False
381
+ self._stream_generation += 1
382
+ self.view.set_thinking("Thinking…")
383
+
384
+ def _on_run_completed(self, payload: Mapping[str, Any]) -> None:
385
+ self._finish_reasoning()
386
+ stored = payload.get("elapsed_seconds")
387
+ if isinstance(stored, (int, float)):
388
+ self._elapsed_seconds = max(0.0, float(stored))
389
+ elif self._started_ts is not None and payload.get("ts") is not None:
390
+ self._elapsed_seconds = max(0.0, payload["ts"] - self._started_ts)
391
+ elif self._started_at is not None:
392
+ self._elapsed_seconds = max(0.0, time.monotonic() - self._started_at)
393
+ if self._elapsed_seconds is not None:
394
+ payload.setdefault("elapsed_seconds", self._elapsed_seconds)
395
+ payload.setdefault("duration", _duration(self._elapsed_seconds))
396
+ payload.setdefault("completion_verb", choose_completion_verb())
397
+ usage = payload.get("usage") or {}
398
+ context = payload.get("context") or {}
399
+ if usage:
400
+ self.state.update_usage(
401
+ {
402
+ **usage,
403
+ "cumulative_tokens": usage.get("total_tokens", 0),
404
+ "estimated": self._usage_estimated,
405
+ }
406
+ )
407
+ if context:
408
+ self.state.update_context(context)
409
+ self.state.phase = "idle"
410
+ self.state.detail = "ready"
411
+ completed = self._completed_text()
412
+ final_output = str(payload.get("output_text") or "")
413
+ if final_output:
414
+ # Install the authoritative final response while the process is
415
+ # still open; finish_assistant() then freezes it as Markdown.
416
+ # A voice run's spoken section is dictated, never shown.
417
+ final_output, _spoken = split_spoken_summary(final_output)
418
+ self.view.set_assistant(final_output)
419
+ self.view.finish_assistant()
420
+ self.view.set_thinking(completed)
421
+ self.view.finish_process(
422
+ completed,
423
+ verb=str(payload.get("completion_verb") or ""),
424
+ duration=str(payload.get("duration") or ""),
425
+ )
426
+ self._assistant_open = False
427
+ toast = jev_recommendation_update(_jev_last_decision(self.view))
428
+ if toast:
429
+ add_update = getattr(self.view, "add_update", None)
430
+ if callable(add_update):
431
+ add_update(toast)
432
+
433
+ def _on_run_summary(self, payload: Mapping[str, Any]) -> None:
434
+ summary = str(payload.get("summary") or "").strip()
435
+ if not summary:
436
+ return
437
+ label = str(payload.get("label") or "summary so far").strip() or "summary so far"
438
+ self.view.add_run_summary(summary, label=label, event_type="run_summary")
439
+
440
+ def _completed_text(self) -> str:
441
+ m = self.state.metrics
442
+ elapsed = _duration(self._elapsed_seconds) if self._elapsed_seconds is not None else "—"
443
+ estimate = "~" if m.estimated else ""
444
+ models = len(self._model_turns)
445
+ tools = len(self._tool_executions)
446
+ return (
447
+ f"{elapsed} (↑{estimate}{_compact_tokens(m.prompt_tokens)} "
448
+ f"↓{estimate}{_compact_tokens(m.completion_tokens)})"
449
+ f" · {models} model call{'s' if models != 1 else ''}"
450
+ f" · {tools} tool call{'s' if tools != 1 else ''}"
451
+ )
452
+
453
+ def _on_run_failed(self, payload: Mapping[str, Any]) -> None:
454
+ self._finish_reasoning()
455
+ self.state.phase = "idle"
456
+ self.state.detail = "failed"
457
+ self.view.set_thinking("Stopped with an error")
458
+ self._show_banner("run_failed", payload)
459
+ self.view.finish_process("Stopped with an error", collapse=False)
460
+
461
+ def _on_run_cancelled(self, payload: Mapping[str, Any]) -> None:
462
+ self._finish_reasoning()
463
+ self.state.phase = "idle"
464
+ self.state.detail = "cancelled"
465
+ self.view.set_thinking("Cancelled")
466
+ self._show_banner("run_cancelled", payload)
467
+ self.view.finish_process("Cancelled", collapse=False)
468
+
469
+ def _on_run_limit_exceeded(self, payload: Mapping[str, Any]) -> None:
470
+ self._finish_reasoning()
471
+ self.state.phase = "idle"
472
+ self.state.detail = "limit exceeded"
473
+ self.view.set_thinking("Stopped at a run limit")
474
+ self._show_banner("run_limit_exceeded", payload)
475
+ self.view.finish_process("Stopped at a run limit", collapse=False)
476
+
477
+ # Turns and streaming
478
+ def _on_turn_started(self, payload: Mapping[str, Any]) -> None:
479
+ turn = int(payload.get("turn") or 0)
480
+ self._model_turns.add(turn)
481
+ self.state.begin_turn(turn)
482
+ self._assistant_open = False
483
+ self.view.set_churning(turn)
484
+
485
+ def _on_turn_completed(self, payload: Mapping[str, Any]) -> None:
486
+ self._finish_reasoning()
487
+ self.state.detail = "running tools" if payload.get("had_tool_calls") else "finishing"
488
+ if not payload.get("had_tool_calls"):
489
+ self.view.finish_assistant()
490
+ self._assistant_open = False
491
+
492
+ def _on_model_retry_scheduled(self, payload: Mapping[str, Any]) -> None:
493
+ retry_after = float(payload.get("retry_after") or 0.0)
494
+ attempt = int(payload.get("attempt") or 1)
495
+ reason = str(payload.get("reason") or "rate_limit")
496
+ if payload.get("resets_stream"):
497
+ self._pending_assistant = None
498
+ self._pending_reasoning = None
499
+ if self._reasoning_active:
500
+ self.view.set_reasoning("")
501
+ self._finish_reasoning()
502
+ self._reasoning_parts.clear()
503
+ self.state.stream_text = ""
504
+ self._spoken = SpokenSummaryStream()
505
+ self.state.reasoning_text = ""
506
+ if self._assistant_open:
507
+ self.view.set_assistant("")
508
+ delay = (
509
+ f"{retry_after:.1f}s"
510
+ if retry_after < 10 and not retry_after.is_integer()
511
+ else f"{retry_after:.0f}s"
512
+ )
513
+ self.state.phase = "thinking"
514
+ labels = {
515
+ "rate_limit": "rate limited",
516
+ "ssl_mac_error": "SSL MAC error",
517
+ "ssl_error": "SSL error",
518
+ "server_error": "server error",
519
+ "stream_error": "stream error",
520
+ "connection_error": "connection error",
521
+ "timeout": "timed out",
522
+ }
523
+ label = labels.get(reason, reason.replace("_", " "))
524
+ self.state.detail = f"{label}; retrying in {delay}"
525
+ working = label[0].upper() + label[1:] if label else label
526
+ self.view.set_working(
527
+ f"{working} · retrying in {delay} · attempt {attempt}"
528
+ )
529
+
530
+ def _on_text_delta(self, payload: Mapping[str, Any]) -> None:
531
+ delta = str(payload.get("delta") or "")
532
+ if not delta:
533
+ return
534
+ self._finish_reasoning()
535
+ is_new = not self._assistant_open
536
+ if is_new:
537
+ self.state.stream_text = ""
538
+ self._spoken = SpokenSummaryStream()
539
+ self.state.append_text(delta)
540
+ self._assistant_open = True
541
+ visible = self._spoken.visible(self.state.stream_text)
542
+ self._buffer_assistant(visible, new=is_new)
543
+ speak = getattr(self.view, "feed_voice", None)
544
+ if callable(speak):
545
+ speak(self.state.stream_text)
546
+
547
+ def _on_reasoning_delta(self, payload: Mapping[str, Any]) -> None:
548
+ delta = str(payload.get("delta") or "")
549
+ if not delta:
550
+ return
551
+ index = int(payload.get("summary_index") or 0)
552
+ is_new = not self._reasoning_active
553
+ self._reasoning_active = True
554
+ cumulative_text = str(payload.get("text") or "")
555
+ if cumulative_text:
556
+ # Some adapters include the complete content accumulated so far;
557
+ # retain that form without appending it twice.
558
+ self._reasoning_parts[index] = _clean_reasoning(cumulative_text)
559
+ else:
560
+ # Delta-only providers (for example Anthropic thinking streams)
561
+ # require incremental accumulation rather than replacement.
562
+ previous = self._reasoning_parts.get(index, "")
563
+ self._reasoning_parts[index] = previous + delta
564
+ text = "\n\n".join(
565
+ self._reasoning_parts[key] for key in sorted(self._reasoning_parts)
566
+ )
567
+ self.state.reasoning_text = text
568
+ self.state.phase = "thinking"
569
+ self.state.detail = "reasoning"
570
+ self._buffer_reasoning(text, new=is_new)
571
+
572
+ def _finish_reasoning(self) -> None:
573
+ pending = self._pending_reasoning
574
+ if pending is not None:
575
+ self._pending_reasoning = None
576
+ text, new = pending
577
+ self.view.set_reasoning(text, new=new)
578
+ if not self._reasoning_active:
579
+ return
580
+ self.view.finish_reasoning()
581
+ self._reasoning_active = False
582
+ self._reasoning_parts.clear()
583
+
584
+ # Tools
585
+ def _on_tool_call_started(self, payload: Mapping[str, Any]) -> None:
586
+ self._finish_reasoning()
587
+ self.view.finish_assistant()
588
+ self._assistant_open = False
589
+ call_id = str(payload.get("tool_call_id") or "tool")
590
+ name = str(payload.get("tool_name") or "tool")
591
+ self._tool_names[call_id] = name
592
+ self._tool_argument_chunks[call_id] = []
593
+ self._tool_argument_tails[call_id] = ""
594
+ self.state.phase = "tool"
595
+ self.state.detail = f"preparing {name}"
596
+ # Do not mount an empty widget yet. The authoritative tool name and
597
+ # complete arguments arrive with tool_execution_started.
598
+
599
+ def _on_tool_call_delta(self, payload: Mapping[str, Any]) -> None:
600
+ call_id = str(payload.get("tool_call_id") or "tool")
601
+ delta = str(payload.get("delta") or "")
602
+ self._tool_argument_chunks.setdefault(call_id, []).append(delta)
603
+ tail = (self._tool_argument_tails.get(call_id, "") + delta)[-80:]
604
+ self._tool_argument_tails[call_id] = tail
605
+ self.state.phase = "tool"
606
+ self.state.tool_args_preview = tail
607
+ self._pending_tool_paints[call_id] = None
608
+
609
+ def _on_tool_call_arguments(self, payload: Mapping[str, Any]) -> None:
610
+ # Responses API completion events carry a full snapshot, sometimes
611
+ # twice. Replace the streamed fragments rather than appending it.
612
+ call_id = str(payload.get("tool_call_id") or "tool")
613
+ raw = payload.get("arguments")
614
+ if not isinstance(raw, str):
615
+ return
616
+ self._tool_argument_chunks[call_id] = [raw]
617
+ self._tool_argument_tails[call_id] = raw[-80:]
618
+ self.state.phase = "tool"
619
+ self.state.tool_args_preview = raw[-80:]
620
+ self._pending_tool_paints[call_id] = None
621
+
622
+ def _on_tool_execution_started(self, payload: Mapping[str, Any]) -> None:
623
+ call_id = str(payload.get("tool_call_id") or "tool")
624
+ self._tool_executions.add(call_id)
625
+ name = str(payload.get("tool_name") or self._tool_names.get(call_id, "tool"))
626
+ # Always stamp and mount with the real name. `_tool_names` means
627
+ # "name known", not "widget already mounted".
628
+ self._tool_names[call_id] = name
629
+ self.state.phase = "tool"
630
+ self.state.detail = f"running {name}"
631
+ self.view.update_tool(
632
+ call_id,
633
+ tool_name=name,
634
+ arguments=payload.get("arguments") or {},
635
+ status="running",
636
+ )
637
+
638
+ def _on_tool_execution_output(self, payload: Mapping[str, Any]) -> None:
639
+ """A streamed output chunk from a running tool (shown by Bash cards)."""
640
+ chunk = str(payload.get("delta") or "")
641
+ if chunk:
642
+ call_id = str(payload.get("tool_call_id") or "tool")
643
+ self._pending_tool_output.setdefault(call_id, []).append(chunk)
644
+
645
+ def _on_tool_execution_completed(self, payload: Mapping[str, Any]) -> None:
646
+ call_id = str(payload.get("tool_call_id") or "tool")
647
+ self.state.phase = "thinking"
648
+ self.state.detail = f"finished {payload.get('tool_name') or 'tool'}"
649
+ status = "failed" if payload.get("status") in {"error", "cancelled", "timeout"} else "done"
650
+ self.view.update_tool(
651
+ call_id,
652
+ tool_name=str(payload.get("tool_name") or self._tool_names.get(call_id, "tool")),
653
+ status=status,
654
+ result=payload.get("result", ""),
655
+ )
656
+ self._tool_argument_chunks.pop(call_id, None)
657
+ self._tool_argument_tails.pop(call_id, None)
658
+
659
+ # Metrics and context
660
+ def _on_usage(self, payload: Mapping[str, Any]) -> None:
661
+ self._usage_estimated |= bool(payload.get("estimated", False))
662
+ self.state.update_usage(payload)
663
+ self._pending_thinking = self._usage_text()
664
+
665
+ def _on_context(self, payload: Mapping[str, Any]) -> None:
666
+ self.state.update_context(payload)
667
+
668
+ def _on_context_warning(self, payload: Mapping[str, Any]) -> None:
669
+ left = payload.get("context_left")
670
+ message = (
671
+ f"Context is running low · {left:,} tokens left"
672
+ if isinstance(left, int)
673
+ else "Context is running low"
674
+ )
675
+ self.view.add_notice(message, "warning")
676
+
677
+ def _on_compaction_started(self, payload: Mapping[str, Any]) -> None:
678
+ self.state.detail = "compacting context"
679
+ # The started event only knows the before-count. Keep the same toast
680
+ # wording while representing the in-progress state rather than claiming
681
+ # compaction has already completed.
682
+ before = payload.get("message_count", "?")
683
+ text = f"Compacted context · {before} → … messages"
684
+ add_update = getattr(self.view, "add_update", None)
685
+ if callable(add_update):
686
+ add_update(text)
687
+ else:
688
+ self.view.add_notice(text, "success")
689
+
690
+ def _on_compaction_completed(self, payload: Mapping[str, Any]) -> None:
691
+ self.state.update_after_compaction(payload)
692
+ if payload.get("manual"):
693
+ self.state.detail = "ready"
694
+ add_update = getattr(self.view, "add_update", None)
695
+ if callable(add_update):
696
+ add_update(_compaction_notice(payload))
697
+ else:
698
+ # Keep lightweight presenter test doubles and older integrations working.
699
+ self.view.add_notice(_compaction_notice(payload), "success")
700
+
701
+ def _on_paused(self, payload: Mapping[str, Any]) -> None:
702
+ self.state.phase = "paused"
703
+ self.state.detail = "paused"
704
+ self.view.set_thinking("Paused")
705
+
706
+ def _on_resumed(self, payload: Mapping[str, Any]) -> None:
707
+ self.state.phase = "thinking"
708
+ self.state.detail = "resumed"
709
+ self.view.set_thinking("Resuming…")
710
+
711
+ def _on_message_injected(self, payload: Mapping[str, Any]) -> None:
712
+ if payload.get("source") == "subagent":
713
+ return # The child's lifecycle card already presents this result.
714
+ role = payload.get("role", "user")
715
+ content = preview_text(payload.get("content", ""))
716
+ self.view.add_notice(f"Injected {role} message · {content}")
717
+
718
+ def _on_waiting_for_children(self, payload: Mapping[str, Any]) -> None:
719
+ count = len(payload.get("child_ids") or [])
720
+ self.state.phase = "waiting"
721
+ self.state.detail = f"waiting for {count} subagent{'s' if count != 1 else ''}"
722
+ self.view.set_working(self.state.detail)
723
+
724
+ def _on_question_asked(self, payload: Mapping[str, Any]) -> None:
725
+ self.state.phase = "paused"
726
+ self.state.detail = "waiting for user"
727
+ question = str(payload.get("question") or "")
728
+ self.view.add_notice(f"Question · {preview_text(question)}", "warning")