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