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,491 @@
1
+ """Enter and leave voice mode without disturbing an in-flight turn.
2
+
3
+ Listening runs off the UI thread. A 0.5-second timer alternates the existing
4
+ composer border color without hiding or resizing the input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from coding_agent.addons.voice.credentials import UnavailableVoice
13
+ from coding_agent.config.credentials import OFFLINE_HINT
14
+ from coding_agent.tui.composer.input import Composer, PromptInput, QueuedTurn
15
+ from coding_agent.tui.composer.voice import (
16
+ GROK_VOICE_MODEL,
17
+ GrokVoiceTranscriber,
18
+ MicrophoneCapture,
19
+ VOICE_BLINK_INTERVAL_S,
20
+ VoiceCapture,
21
+ VoiceTranscriber,
22
+ )
23
+
24
+
25
+ @dataclass(eq=False)
26
+ class VoiceSession:
27
+ """One listening session. Its identity tells a stale worker it lost."""
28
+
29
+ capture: VoiceCapture
30
+
31
+
32
+ def toggle_voice(app: Any) -> None:
33
+ """Start voice mode, or stop listening and submit what was heard."""
34
+ if getattr(app, "_voice_active", False):
35
+ commit_voice(app)
36
+ return
37
+ if getattr(app, "_agent", None) is None and not _uses_clip_path():
38
+ app.add_notice(OFFLINE_HINT, "error")
39
+ return
40
+ start_voice(app)
41
+
42
+
43
+ def start_voice(
44
+ app: Any,
45
+ *,
46
+ capture: VoiceCapture | None = None,
47
+ transcriber: VoiceTranscriber | None = None,
48
+ stream: Any = None,
49
+ ) -> None:
50
+ """Listen on the realtime socket until a 3 s pause or Enter.
51
+
52
+ Partial transcripts are painted as they arrive. ``capture`` and
53
+ ``transcriber`` remain for tests of the older utterance recorder; a live
54
+ session does not record a clip and then upload it.
55
+ """
56
+ app._voice_active = True
57
+ app._voice_parts = []
58
+ app._voice_text = ""
59
+ app._voice_finish = False
60
+ try:
61
+ _set_voice_border(app, True)
62
+ app.add_notice(
63
+ "Voice mode · listening. A 3 second pause or Enter runs it. Esc cancels.",
64
+ )
65
+ # An explicit or patched recorder is the local clip path (tests). A
66
+ # live session passes neither and listens on the realtime socket.
67
+ if capture is not None or transcriber is not None or _uses_clip_path():
68
+ _start_clip_session(app, capture, transcriber)
69
+ return
70
+ error = ensure_voice_addon(
71
+ app,
72
+ stream=stream,
73
+ on_transcript=lambda text: _on_live_transcript(app, text),
74
+ on_commit=lambda: _on_live_commit(app),
75
+ )
76
+ if error:
77
+ _abort_voice(app, error)
78
+ except Exception as exc:
79
+ _abort_voice(app, f"Voice mode failed · {exc}")
80
+
81
+
82
+ def _uses_clip_path() -> bool:
83
+ """True when a test replaced the recorder, here or on the voice module.
84
+
85
+ ``/voice`` calls this function. A patch of the names above is invisible
86
+ to it unless the lookup happens at call time.
87
+ """
88
+ import coding_agent.tui.composer.voice as voice
89
+
90
+ return (
91
+ voice.MicrophoneCapture is not _CLIP_CAPTURE
92
+ or voice.GrokVoiceTranscriber is not _CLIP_TRANSCRIBER
93
+ )
94
+
95
+
96
+ _CLIP_CAPTURE = MicrophoneCapture
97
+ _CLIP_TRANSCRIBER = GrokVoiceTranscriber
98
+
99
+
100
+ def _on_live_transcript(app: Any, text: str) -> None:
101
+ """Paint a transcript delta. Called from the socket thread.
102
+
103
+ The final transcript can land after the commit decision. Keep it, but do
104
+ not repaint the composer once the turn has taken the text.
105
+ """
106
+ app._voice_text = text
107
+ if not getattr(app, "_voice_active", False) or getattr(app, "_voice_finish", False):
108
+ return
109
+ _post(app, _show_voice_transcript, app, text)
110
+
111
+
112
+ def _post(app: Any, callback: Any, *args: Any) -> None:
113
+ """Run on the UI thread. A callback already there runs directly."""
114
+ import threading
115
+
116
+ if getattr(app, "_thread_id", None) == threading.get_ident():
117
+ callback(*args)
118
+ return
119
+ app.call_from_thread(callback, *args)
120
+
121
+
122
+ def _on_live_commit(app: Any) -> None:
123
+ """The utterance ended (3 s pause or Enter). Submit the latest transcript."""
124
+ if getattr(app, "_voice_finish", False) or not getattr(app, "_voice_active", False):
125
+ return
126
+ app._voice_finish = True
127
+ _post(app, _finish_live, app)
128
+
129
+
130
+ def _finish_live(app: Any) -> None:
131
+ """Submit on the UI thread, reading the transcript after pending deltas."""
132
+ if not getattr(app, "_voice_active", False):
133
+ return
134
+ text = str(getattr(app, "_voice_text", "") or "").strip()
135
+ _finish_voice(app, text, "")
136
+
137
+
138
+ def _start_clip_session(
139
+ app: Any, capture: VoiceCapture | None, transcriber: VoiceTranscriber | None,
140
+ ) -> None:
141
+ """Test path: one local recording, then the scripted transcript."""
142
+ import coding_agent.tui.composer.voice as voice
143
+
144
+ app._voice_capture = capture if capture is not None else voice.MicrophoneCapture()
145
+ session = VoiceSession(app._voice_capture)
146
+ app._voice_session = session
147
+ app._voice_transcriber = transcriber if transcriber is not None else voice.GrokVoiceTranscriber()
148
+ app.run_worker(
149
+ lambda: _listen(app, app._voice_capture, app._voice_transcriber, session),
150
+ exclusive=True, group="voice", thread=True,
151
+ )
152
+
153
+
154
+ def _abort_voice(app: Any, error: str) -> None:
155
+ app._voice_active = False
156
+ app._voice_session = None
157
+ try:
158
+ close_voice_addon(app)
159
+ except Exception:
160
+ pass
161
+ _restore_composer(app)
162
+ app.add_notice(error, "error")
163
+
164
+
165
+ def commit_voice(app: Any) -> None:
166
+ """Enter or Ctrl+A ends the utterance now instead of waiting out the pause."""
167
+ if not getattr(app, "_voice_active", False) or getattr(app, "_voice_finish", False):
168
+ return
169
+ if getattr(app, "_voice_session", None) is not None:
170
+ stop_voice(app, reason="done")
171
+ return
172
+ addon = getattr(app, "_voice_addon", None)
173
+ commit = getattr(getattr(addon, "stream", None), "commit", None)
174
+ if callable(commit):
175
+ # Explicit Enter submits locally immediately; the transport callback
176
+ # may arrive later, after its listen socket has already closed.
177
+ from coding_agent.tui.composer.input import PromptInput
178
+
179
+ transcript = str(getattr(app, "_voice_text", "") or "").strip()
180
+ try:
181
+ transcript = app.query_one("#prompt", PromptInput).text.strip() or transcript
182
+ except Exception:
183
+ pass
184
+ app._voice_finish = True
185
+ _finish_voice(app, transcript, "")
186
+ commit()
187
+ return
188
+ app._voice_finish = True
189
+ _finish_voice(app, str(getattr(app, "_voice_text", "") or "").strip(), "")
190
+
191
+
192
+ def stop_voice(app: Any, *, reason: str) -> None:
193
+ """Leave voice mode. ``done`` submits the transcript; anything else discards it."""
194
+ session = getattr(app, "_voice_session", None)
195
+ if reason == "done" and getattr(app, "_voice_active", False):
196
+ # Submit the utterances already transcribed. The worker may be blocked
197
+ # in another listen(); it must not append that clip after this.
198
+ app._voice_finish = True
199
+ parts = list(getattr(app, "_voice_parts", []) or [])
200
+ text = " ".join(parts).strip() or str(getattr(app, "_voice_text", "") or "").strip()
201
+ _finish_voice(app, text, "", session)
202
+ return
203
+ app._voice_active = False
204
+ app._voice_session = None
205
+ app._voice_parts = []
206
+ app._voice_finish = False
207
+ if session is not None:
208
+ # Unblocks the worker's listen() and releases the microphone now,
209
+ # not when the utterance cap runs out.
210
+ session.capture.close()
211
+ try:
212
+ close_voice_addon(app)
213
+ except Exception as exc:
214
+ app.add_notice(f"Voice mode failed · {exc}", "error")
215
+ finally:
216
+ _restore_composer(app)
217
+ if reason == "toggled":
218
+ app.add_notice("Voice mode off.")
219
+ del session
220
+
221
+
222
+ _DONE_WORDS = {"done", "done."}
223
+
224
+
225
+ def _is_done_phrase(text: str) -> bool:
226
+ """True when the utterance is only the word that ends voice mode."""
227
+ return text.strip().lower() in _DONE_WORDS
228
+
229
+
230
+ def _listen(
231
+ app: Any, capture: VoiceCapture, transcriber: VoiceTranscriber, session: VoiceSession,
232
+ ) -> None:
233
+ """Keep capturing until the user says done or presses Ctrl+A.
234
+
235
+ Textual runs a ``thread=True`` worker as a plain function. Widget updates
236
+ go through ``call_from_thread`` so the composer styles are only
237
+ touched on the app thread. Each ``listen`` returns one utterance; the
238
+ loop continues so voice mode does not submit on the first pause.
239
+ """
240
+ parts: list[str] = []
241
+ while getattr(app, "_voice_active", False) and getattr(app, "_voice_session", None) is session:
242
+ try:
243
+ audio = capture.listen()
244
+ if audio is None:
245
+ if app._voice_session is session:
246
+ app.call_from_thread(_finish_voice, app, " ".join(parts).strip(), "", session)
247
+ return
248
+ text = transcriber.transcribe(audio, model=GROK_VOICE_MODEL).strip()
249
+ except UnavailableVoice as exc:
250
+ app.call_from_thread(_finish_voice, app, "", str(exc), session)
251
+ return
252
+ except Exception as exc: # noqa: BLE001 - surface any capture/API failure as a notice
253
+ app.call_from_thread(_finish_voice, app, "", f"Voice mode failed · {exc}", session)
254
+ return
255
+ if not getattr(app, "_voice_active", False) or getattr(app, "_voice_session", None) is not session:
256
+ return
257
+ # Ctrl+A already submitted the remembered parts. Drop this clip.
258
+ if getattr(app, "_voice_finish", False):
259
+ return
260
+ if text and not _is_done_phrase(text):
261
+ parts.append(text)
262
+ app.call_from_thread(_remember_voice_part, app, text, session)
263
+ if _is_done_phrase(text):
264
+ app.call_from_thread(_finish_voice, app, " ".join(parts).strip(), "", session)
265
+ return
266
+
267
+
268
+ def _remember_voice_part(app: Any, text: str, session: VoiceSession) -> None:
269
+ """Keep an utterance for Ctrl+A without starting the agent yet."""
270
+ if not getattr(app, "_voice_active", False) or getattr(app, "_voice_session", None) is not session:
271
+ return
272
+ if getattr(app, "_voice_finish", False):
273
+ return
274
+ parts = getattr(app, "_voice_parts", None)
275
+ if parts is None:
276
+ parts = []
277
+ app._voice_parts = parts
278
+ parts.append(text)
279
+ _show_voice_transcript(app, " ".join(parts))
280
+
281
+
282
+ def _finish_voice(app: Any, text: str, error: str, session: VoiceSession | None = None) -> None:
283
+ if not getattr(app, "_voice_active", False):
284
+ return
285
+ if session is not None and getattr(app, "_voice_session", None) is not session:
286
+ return
287
+ # Keep the border blinking through execution and the spoken summary.
288
+ app._voice_active = False
289
+ app._voice_session = None
290
+ if session is not None:
291
+ # Ctrl+A may land while the worker is still inside listen().
292
+ session.capture.close()
293
+ if error:
294
+ _restore_composer(app)
295
+ app.add_notice(error, "error")
296
+ return
297
+ if not text:
298
+ _restore_composer(app)
299
+ app.add_notice("Voice mode heard nothing.", "warning")
300
+ return
301
+ try:
302
+ submit_voice_turn(app, text)
303
+ except Exception as exc:
304
+ try:
305
+ close_voice_addon(app)
306
+ except Exception:
307
+ pass
308
+ _restore_composer(app)
309
+ app.add_notice(f"Voice mode failed · {exc}", "error")
310
+
311
+
312
+ def _show_voice_transcript(app: Any, text: str) -> None:
313
+ """Put the words heard so far into the composer. The turn has not started."""
314
+ try:
315
+ prompt = app.query_one("#prompt", PromptInput)
316
+ except Exception:
317
+ return
318
+ prompt.disabled = False
319
+ prompt.value = text
320
+ prompt.cursor_position = len(text)
321
+
322
+
323
+ def _restore_composer(app: Any) -> None:
324
+ """Clear voice styling and restore input focus after cancellation/error."""
325
+ _set_voice_border(app, False)
326
+ prompt = app.query_one("#prompt", PromptInput)
327
+ prompt.disabled = False
328
+ prompt.focus()
329
+ app._set_status("")
330
+
331
+
332
+ def submit_voice_turn(app: Any, text: str) -> None:
333
+ """Hand a transcript to the composer path and arm the voice hooks."""
334
+ if app._agent is None:
335
+ _restore_composer(app)
336
+ app.add_notice(OFFLINE_HINT, "error")
337
+ return
338
+ existing_addon = getattr(app, "_voice_addon", None)
339
+ if existing_addon is not None and getattr(app._agent, "harness", None) is None:
340
+ error = ""
341
+ else:
342
+ error = ensure_voice_addon(app)
343
+ if error:
344
+ _restore_composer(app)
345
+ app.add_notice(error, "error")
346
+ return
347
+ # A live voice turn reuses the existing addon/stream; only arm it for the
348
+ # run. Do not reopen the stream (which closes test/host transports).
349
+ addon = getattr(app, "_voice_addon", None)
350
+ if addon is not None:
351
+ addon.arm()
352
+ # The transcript was shown while listening. Clear it before the turn
353
+ # mounts its own copy in the conversation.
354
+ _show_voice_transcript(app, "")
355
+ turn = QueuedTurn(text, text, (), (), voice=True)
356
+ # A turn already running keeps this one. It shows in the queue and starts
357
+ # when the current run finishes, the same as a typed message would.
358
+ if app._busy:
359
+ app.queue_turn(turn)
360
+ _restore_composer(app)
361
+ return
362
+ app._start_turn(turn)
363
+
364
+
365
+ def ensure_voice_addon(
366
+ app: Any, *, stream: Any = None, on_transcript: Any = None, on_commit: Any = None,
367
+ ) -> str:
368
+ """Reuse the voice add-on and open its realtime socket for this listen."""
369
+ from coding_agent.addons.voice import VoiceAddon
370
+
371
+ agent = app._agent
372
+ harness = getattr(agent, "harness", None)
373
+ if harness is None:
374
+ if stream is None:
375
+ return ""
376
+ if on_transcript is not None:
377
+ stream._on_transcript = on_transcript
378
+ if on_commit is not None:
379
+ stream._on_commit = on_commit
380
+ # Allow an explicitly injected stream for host integrations and tests
381
+ # that provide their own voice transport without a harness.
382
+ existing = VoiceAddon(
383
+ stream=stream,
384
+ on_transcript=on_transcript,
385
+ on_commit=on_commit,
386
+ )
387
+ app._voice_addon = existing
388
+ error = existing.open()
389
+ if not error:
390
+ existing.arm()
391
+ return ""
392
+ app._voice_addon = None
393
+ existing.close()
394
+ return error
395
+ existing = None
396
+ try:
397
+ existing = next((addon for addon in harness.addons if isinstance(addon, VoiceAddon)), None)
398
+ if existing is None:
399
+ def _speaking(speaking: bool) -> None:
400
+ if getattr(app, "_thread_id", None) == __import__("threading").get_ident():
401
+ _set_voice_border(app, speaking)
402
+ return
403
+ app.call_from_thread(_set_voice_border, app, speaking)
404
+
405
+ existing = VoiceAddon(
406
+ stream=stream,
407
+ on_speaking=_speaking,
408
+ on_transcript=on_transcript,
409
+ on_commit=on_commit,
410
+ )
411
+ harness.register_addon(existing)
412
+ # The addon outlives one listen. This session's callbacks replace the
413
+ # previous one's, including when the stream was built before them.
414
+ voice_stream = getattr(existing, "stream", None)
415
+ if voice_stream is not None:
416
+ if on_transcript is not None:
417
+ voice_stream._on_transcript = on_transcript
418
+ if on_commit is not None:
419
+ voice_stream._on_commit = on_commit
420
+ app._voice_addon = existing
421
+ # Open before arming: an unsuccessful startup must not enable speech
422
+ # for the next ordinary text turn.
423
+ error = existing.open()
424
+ if not error:
425
+ existing.arm()
426
+ return ""
427
+ except Exception as exc:
428
+ error = f"Voice mode failed · {exc}"
429
+ app._voice_addon = None
430
+ if existing is not None:
431
+ try:
432
+ existing.close()
433
+ except Exception:
434
+ pass # Preserve the startup error; the UI must still be restored.
435
+ return error
436
+
437
+
438
+ def feed_voice(app: Any, text: str) -> None:
439
+ """Hand streamed assistant text to the voice add-on, when one is mounted."""
440
+ addon = getattr(app, "_voice_addon", None)
441
+ feed = getattr(addon, "feed", None)
442
+ if callable(feed):
443
+ feed(text)
444
+
445
+
446
+ def close_voice_addon(app: Any, *, timeout: float = 0.0) -> None:
447
+ """Stop speech and the realtime socket without blocking the caller.
448
+
449
+ ``timeout`` waits that long for the audio threads to exit; only app
450
+ shutdown asks for it.
451
+ """
452
+ addon = getattr(app, "_voice_addon", None)
453
+ app._voice_addon = None
454
+ try:
455
+ if addon is not None:
456
+ addon.close(timeout=timeout)
457
+ finally:
458
+ _set_voice_border(app, False)
459
+
460
+
461
+ def _advance_voice_border(app: Any) -> None:
462
+ """Alternate colors while retaining the same border type and geometry."""
463
+ composer = app.query_one("#composer", Composer)
464
+ app._voice_border_light = not getattr(app, "_voice_border_light", True)
465
+ composer.set_class(app._voice_border_light, "voice-light")
466
+ composer.set_class(not app._voice_border_light, "voice-dark")
467
+
468
+
469
+ def _set_voice_border(app: Any, active: bool) -> None:
470
+ """Start a single blink timer, or return border styling to the theme."""
471
+ try:
472
+ composer = app.query_one("#composer", Composer)
473
+ except Exception:
474
+ return
475
+ timer = getattr(app, "_voice_timer", None)
476
+ if active:
477
+ if timer is None:
478
+ app._voice_border_light = True
479
+ composer.set_class(True, "voice-light")
480
+ composer.set_class(False, "voice-dark")
481
+ app._voice_timer = app.set_interval(
482
+ VOICE_BLINK_INTERVAL_S, lambda: _advance_voice_border(app),
483
+ )
484
+ return
485
+ # Realtime/TTS callbacks must not clear a newer capture's indicator.
486
+ if getattr(app, "_voice_active", False):
487
+ return
488
+ if timer is not None:
489
+ timer.stop()
490
+ app._voice_timer = None
491
+ composer.remove_class("voice-light", "voice-dark")
@@ -0,0 +1,23 @@
1
+ """TUI runtime: run state, events, control plane, and subagents."""
2
+
3
+ from coding_agent.tui.runtime.sink import (
4
+ ControlPlaneEvent,
5
+ HarnessEvent,
6
+ TextualEventSink,
7
+ )
8
+ from coding_agent.tui.runtime.events import EventPresenter, TranscriptView
9
+ from coding_agent.tui.runtime.state import RunMetrics, UiRunState
10
+ from coding_agent.tui.runtime.subagent import SubagentRecord, SubagentScreen, SubagentWidget
11
+
12
+ __all__ = [
13
+ "ControlPlaneEvent",
14
+ "EventPresenter",
15
+ "HarnessEvent",
16
+ "RunMetrics",
17
+ "SubagentRecord",
18
+ "SubagentScreen",
19
+ "SubagentWidget",
20
+ "TextualEventSink",
21
+ "TranscriptView",
22
+ "UiRunState",
23
+ ]