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,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
|
+
]
|