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,330 @@
|
|
|
1
|
+
"""Slash-menu and composer-hint flow mixed into CodingAgentApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual import events
|
|
6
|
+
from textual.widgets import Button, OptionList, TextArea
|
|
7
|
+
from textual.worker import WorkerState, get_current_worker
|
|
8
|
+
|
|
9
|
+
from coding_agent.config.credentials import OFFLINE_HINT
|
|
10
|
+
from coding_agent.tui.chrome.footer import DEFAULT_HINT, ComposerOverlay
|
|
11
|
+
from coding_agent.tui.commands import (
|
|
12
|
+
command_matches,
|
|
13
|
+
effort_matches,
|
|
14
|
+
effort_options_for_model,
|
|
15
|
+
model_matches,
|
|
16
|
+
model_supports_effort,
|
|
17
|
+
personality_matches,
|
|
18
|
+
)
|
|
19
|
+
from coding_agent.tui.composer.input import PromptInput
|
|
20
|
+
from coding_agent.tui.composer.input import QueuedTurn
|
|
21
|
+
from coding_agent.tui.composer.slash_menu import SlashMenu
|
|
22
|
+
from coding_agent.tui.screens.file_selector import (
|
|
23
|
+
active_file_mention,
|
|
24
|
+
complete_file_mention,
|
|
25
|
+
)
|
|
26
|
+
from coding_agent.tui.tools.images import build_user_content
|
|
27
|
+
from coding_agent.tui.transcript import UserMessage
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ComposerSurface:
|
|
31
|
+
"""Composer submit, slash-menu navigation, and hint updates."""
|
|
32
|
+
|
|
33
|
+
async def on_prompt_input_submitted(self, event: PromptInput.Submitted) -> None:
|
|
34
|
+
text = event.input.expanded_value(event.input.text).strip()
|
|
35
|
+
pasted_chunks = event.input.take_pasted_chunks()
|
|
36
|
+
event.input.load_text("")
|
|
37
|
+
if self._pending_question_id is not None:
|
|
38
|
+
images = event.input.take_images()
|
|
39
|
+
approval_menu = self.query_one("#approval-menu", SlashMenu)
|
|
40
|
+
if approval_menu.display and approval_menu.selected_value:
|
|
41
|
+
await self._answer_question(approval_menu.selected_value)
|
|
42
|
+
if text:
|
|
43
|
+
user_content = build_user_content(text, images)
|
|
44
|
+
turn = QueuedTurn(user_content, text, pasted_chunks, images)
|
|
45
|
+
if self._busy:
|
|
46
|
+
self.queue_turn(turn)
|
|
47
|
+
else:
|
|
48
|
+
self._start_turn(turn)
|
|
49
|
+
return
|
|
50
|
+
await self._answer_question(self._submitted_question_answer(text))
|
|
51
|
+
return
|
|
52
|
+
if not text:
|
|
53
|
+
event.input.take_images()
|
|
54
|
+
return
|
|
55
|
+
self.query_one("#slash-menu", SlashMenu).set_commands(())
|
|
56
|
+
if text.startswith("/"):
|
|
57
|
+
event.input.take_images()
|
|
58
|
+
await self._run_slash_command(text)
|
|
59
|
+
return
|
|
60
|
+
images = event.input.take_images()
|
|
61
|
+
if self._agent is None:
|
|
62
|
+
self.add_notice(OFFLINE_HINT, "error")
|
|
63
|
+
return
|
|
64
|
+
user_content = build_user_content(text, images)
|
|
65
|
+
if self._busy:
|
|
66
|
+
# Do not disable the editor while the harness is working: a follow-up
|
|
67
|
+
# can be prepared and submitted, then is dispatched FIFO afterward.
|
|
68
|
+
self.queue_turn(QueuedTurn(user_content, text, pasted_chunks, images))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
self._start_turn(QueuedTurn(user_content, text, pasted_chunks, images))
|
|
72
|
+
|
|
73
|
+
def _start_turn(self, turn: QueuedTurn) -> None:
|
|
74
|
+
self._run_generation += 1
|
|
75
|
+
self.sink.reset_cancel()
|
|
76
|
+
self._assistant = None
|
|
77
|
+
self._thinking = None
|
|
78
|
+
self._reasoning = None
|
|
79
|
+
self._process = None
|
|
80
|
+
self._tools = {}
|
|
81
|
+
self._tool_groups = {}
|
|
82
|
+
self._open_tool_group = None
|
|
83
|
+
self._mount_transcript(
|
|
84
|
+
UserMessage(turn.text, pasted_chunks=turn.pasted_chunks, images=turn.images)
|
|
85
|
+
)
|
|
86
|
+
self.set_thinking("Thinking…")
|
|
87
|
+
self._busy = True
|
|
88
|
+
self._set_status("")
|
|
89
|
+
# The harness reads this once, on the run that is about to start.
|
|
90
|
+
agent = getattr(self, "_agent", None)
|
|
91
|
+
harness = getattr(agent, "harness", None)
|
|
92
|
+
if harness is not None:
|
|
93
|
+
harness.reply_aloud = bool(turn.voice)
|
|
94
|
+
# Let the submitted message paint before checkpoint and prompt setup.
|
|
95
|
+
self.call_after_refresh(self.run_agent, turn.content)
|
|
96
|
+
|
|
97
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
98
|
+
if event.button.id not in {"queued-send-now", "queued-edit"} or not self._queued_turns:
|
|
99
|
+
return
|
|
100
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
101
|
+
if event.button.id == "queued-edit":
|
|
102
|
+
prompt.load_text(self.pop_queued_turn().text)
|
|
103
|
+
prompt.submit_on_enter = True
|
|
104
|
+
prompt.disabled = False
|
|
105
|
+
prompt.focus()
|
|
106
|
+
return
|
|
107
|
+
if not self._busy:
|
|
108
|
+
self._start_turn(self.pop_queued_turn())
|
|
109
|
+
return
|
|
110
|
+
# Interrupt the in-flight run and leave the prompt queued. The
|
|
111
|
+
# cancelled worker starts it from ``finally`` only after the previous
|
|
112
|
+
# harness call has actually returned, so two runs cannot overlap.
|
|
113
|
+
self._pending_question_id = None
|
|
114
|
+
self._pending_question_default = ""
|
|
115
|
+
self._pending_question_agent_id = ""
|
|
116
|
+
self._cancel_active_run("queued_send_now")
|
|
117
|
+
|
|
118
|
+
def _composer_menu(self) -> SlashMenu:
|
|
119
|
+
approval_menu = self.query_one("#approval-menu", SlashMenu)
|
|
120
|
+
return (
|
|
121
|
+
approval_menu
|
|
122
|
+
if approval_menu.display
|
|
123
|
+
else self.query_one("#slash-menu", SlashMenu)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _show_file_matches(self, menu: SlashMenu, query: str) -> None:
|
|
127
|
+
"""Filter the last finished walk now; walk again in a worker if stale.
|
|
128
|
+
|
|
129
|
+
The first ``@`` of a session has nothing to filter yet, so the menu
|
|
130
|
+
fills when that walk lands (``_file_index_ready``).
|
|
131
|
+
"""
|
|
132
|
+
cached = self._file_index.cached_matches(query)
|
|
133
|
+
if cached is not None:
|
|
134
|
+
menu.set_files(cached)
|
|
135
|
+
if self._file_index.fresh:
|
|
136
|
+
return
|
|
137
|
+
walking = any(
|
|
138
|
+
worker.group == "file-index"
|
|
139
|
+
and worker.state in {WorkerState.PENDING, WorkerState.RUNNING}
|
|
140
|
+
for worker in self.workers
|
|
141
|
+
)
|
|
142
|
+
if not walking:
|
|
143
|
+
self.run_worker(
|
|
144
|
+
self._walk_workspace, thread=True, group="file-index", exit_on_error=False
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _walk_workspace(self) -> None:
|
|
148
|
+
"""Worker thread: rebuild the ``@`` index, then refresh the menu."""
|
|
149
|
+
self._file_index.rebuild()
|
|
150
|
+
if not get_current_worker().is_cancelled:
|
|
151
|
+
self.call_from_thread(self._file_index_ready)
|
|
152
|
+
|
|
153
|
+
def _file_index_ready(self) -> None:
|
|
154
|
+
if self._pending_question_id is not None:
|
|
155
|
+
return
|
|
156
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
157
|
+
mention = active_file_mention(prompt.text)
|
|
158
|
+
if mention is not None:
|
|
159
|
+
_start, query = mention
|
|
160
|
+
self._show_file_matches(self._composer_menu(), query)
|
|
161
|
+
|
|
162
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
163
|
+
if event.text_area.id != "prompt":
|
|
164
|
+
return
|
|
165
|
+
if self._pending_question_id is not None:
|
|
166
|
+
return
|
|
167
|
+
text = event.text_area.text
|
|
168
|
+
if text and not text.startswith("/") and "@" not in text:
|
|
169
|
+
return
|
|
170
|
+
menu = self._composer_menu()
|
|
171
|
+
mention = active_file_mention(event.text_area.text)
|
|
172
|
+
if mention is not None:
|
|
173
|
+
_start, query = mention
|
|
174
|
+
self._show_file_matches(menu, query)
|
|
175
|
+
elif event.text_area.text.startswith("/model "):
|
|
176
|
+
current = self._agent.harness.model_id if self._agent is not None else ""
|
|
177
|
+
menu.set_models(
|
|
178
|
+
model_matches(
|
|
179
|
+
event.text_area.text.removeprefix("/model "),
|
|
180
|
+
self._model_options,
|
|
181
|
+
),
|
|
182
|
+
current,
|
|
183
|
+
)
|
|
184
|
+
elif event.text_area.text.startswith("/personality "):
|
|
185
|
+
current = getattr(getattr(self, "config", None), "personality", None) or ""
|
|
186
|
+
menu.set_personalities(
|
|
187
|
+
personality_matches(event.text_area.text.removeprefix("/personality ")),
|
|
188
|
+
current,
|
|
189
|
+
)
|
|
190
|
+
elif event.text_area.text.startswith("/effort "):
|
|
191
|
+
current = "default"
|
|
192
|
+
efforts = ()
|
|
193
|
+
if self._agent is not None:
|
|
194
|
+
current = self._agent.harness.reasoning_effort or "default"
|
|
195
|
+
efforts = effort_options_for_model(self._agent.harness.model_id)
|
|
196
|
+
menu.set_efforts(
|
|
197
|
+
effort_matches(
|
|
198
|
+
event.text_area.text.removeprefix("/effort "),
|
|
199
|
+
efforts,
|
|
200
|
+
),
|
|
201
|
+
current,
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
model_id = getattr(getattr(self._agent, "harness", None), "model_id", "")
|
|
205
|
+
try:
|
|
206
|
+
addon_commands = self._agent.user_addons.surface.slash_commands
|
|
207
|
+
except AttributeError: # no agent yet, or a host agent without user addons
|
|
208
|
+
addon_commands = ()
|
|
209
|
+
menu.set_commands(
|
|
210
|
+
command_matches(
|
|
211
|
+
event.text_area.text,
|
|
212
|
+
include_effort=model_supports_effort(model_id),
|
|
213
|
+
extra=addon_commands,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def on_key(self, event: events.Key) -> None:
|
|
218
|
+
"""Navigate, choose, or complete the visible slash menu."""
|
|
219
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
220
|
+
if not prompt.has_focus:
|
|
221
|
+
return
|
|
222
|
+
if event.key in {"ctrl+enter", "control+enter"}:
|
|
223
|
+
prompt.action_submit()
|
|
224
|
+
event.prevent_default()
|
|
225
|
+
event.stop()
|
|
226
|
+
return
|
|
227
|
+
approval_menu = self.query_one("#approval-menu", SlashMenu)
|
|
228
|
+
menu = (
|
|
229
|
+
approval_menu
|
|
230
|
+
if approval_menu.display
|
|
231
|
+
else self.query_one("#slash-menu", SlashMenu)
|
|
232
|
+
)
|
|
233
|
+
if not menu.display:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if event.key in {"up", "down"}:
|
|
237
|
+
menu.move_selection(-1 if event.key == "up" else 1)
|
|
238
|
+
event.prevent_default()
|
|
239
|
+
event.stop()
|
|
240
|
+
return
|
|
241
|
+
if event.key in {"tab", "enter"} and menu.selected_value:
|
|
242
|
+
self._choose_menu_option(menu, submit=event.key == "enter")
|
|
243
|
+
event.prevent_default()
|
|
244
|
+
event.stop()
|
|
245
|
+
elif event.key == "enter" and self._pending_question_id is not None:
|
|
246
|
+
self.call_later(prompt.action_submit)
|
|
247
|
+
event.prevent_default()
|
|
248
|
+
event.stop()
|
|
249
|
+
|
|
250
|
+
def on_option_list_option_selected(
|
|
251
|
+
self, event: OptionList.OptionSelected
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Apply menu choices selected with the pointer."""
|
|
254
|
+
if event.option_list.id not in {"slash-menu", "approval-menu"}:
|
|
255
|
+
return
|
|
256
|
+
menu = event.option_list
|
|
257
|
+
assert isinstance(menu, SlashMenu)
|
|
258
|
+
if menu.select_option_index(event.option_index):
|
|
259
|
+
self._choose_menu_option(menu, submit=True)
|
|
260
|
+
event.stop()
|
|
261
|
+
|
|
262
|
+
def _submitted_question_answer(self, text: str) -> str:
|
|
263
|
+
"""Prefer the visible approval highlight over empty prompt text / default."""
|
|
264
|
+
approval_menu = self.query_one("#approval-menu", SlashMenu)
|
|
265
|
+
if approval_menu.display and approval_menu.selected_value:
|
|
266
|
+
return approval_menu.selected_value
|
|
267
|
+
return text or self._pending_question_default
|
|
268
|
+
|
|
269
|
+
def _choose_menu_option(self, menu: SlashMenu, *, submit: bool) -> None:
|
|
270
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
271
|
+
if self._pending_question_id is not None and submit:
|
|
272
|
+
answer = menu.selected_value
|
|
273
|
+
menu.set_commands(())
|
|
274
|
+
self.call_later(self._answer_question, answer)
|
|
275
|
+
prompt.focus()
|
|
276
|
+
return
|
|
277
|
+
if menu.is_file_selector:
|
|
278
|
+
prompt.value, cursor = complete_file_mention(
|
|
279
|
+
prompt.value,
|
|
280
|
+
menu.selected_value.removeprefix("@"),
|
|
281
|
+
)
|
|
282
|
+
prompt.cursor_position = cursor
|
|
283
|
+
menu.set_commands(())
|
|
284
|
+
else:
|
|
285
|
+
prompt.value = menu.selected_value
|
|
286
|
+
prompt.cursor_position = len(prompt.value)
|
|
287
|
+
if submit:
|
|
288
|
+
menu.set_commands(())
|
|
289
|
+
self.call_later(prompt.action_submit)
|
|
290
|
+
prompt.focus()
|
|
291
|
+
|
|
292
|
+
async def _run_slash_command(self, value: str) -> None:
|
|
293
|
+
command, _, argument = value[1:].partition(" ")
|
|
294
|
+
if command.lower() == "addon":
|
|
295
|
+
if self._busy:
|
|
296
|
+
self.add_notice("/addon is unavailable while a turn is running.", "warning")
|
|
297
|
+
return
|
|
298
|
+
if self._agent is None:
|
|
299
|
+
self.add_notice(OFFLINE_HINT, "error")
|
|
300
|
+
return
|
|
301
|
+
skill_tool = self._agent.harness.tools.get("skill")
|
|
302
|
+
skill_instructions = (
|
|
303
|
+
await skill_tool.execute(sink=None, args={"skill_id": "bundled/addon-authoring"})
|
|
304
|
+
if skill_tool is not None
|
|
305
|
+
else "Bundled addon-authoring skill is unavailable; inspect core_harness.extensions and "
|
|
306
|
+
"the installed addon documentation before proceeding."
|
|
307
|
+
)
|
|
308
|
+
prompt = (
|
|
309
|
+
"Follow these Symphony addon-authoring instructions, then create or improve a "
|
|
310
|
+
"user addon for this request. Use the supported addon API and user addon "
|
|
311
|
+
"directory documented here. Do not modify Symphony's built-in source unless "
|
|
312
|
+
"I explicitly ask.\n\n"
|
|
313
|
+
f"{skill_instructions}"
|
|
314
|
+
)
|
|
315
|
+
if argument.strip():
|
|
316
|
+
prompt += f"\n\nAddon request: {argument.strip()}"
|
|
317
|
+
self._start_turn(QueuedTurn(prompt, prompt, (), ()))
|
|
318
|
+
return
|
|
319
|
+
await self._command_manager.run(value)
|
|
320
|
+
|
|
321
|
+
def add_update(self, text: str, hint: str = "") -> None:
|
|
322
|
+
"""Show a banner above the composer. Only the app has one; composer-less
|
|
323
|
+
views such as the child transcript leave this undefined, so the event
|
|
324
|
+
presenter falls back to a transcript notice."""
|
|
325
|
+
overlay = self.query_one("#composer-overlay", ComposerOverlay)
|
|
326
|
+
overlay.show(text, hint or DEFAULT_HINT)
|
|
327
|
+
|
|
328
|
+
def _update_composer_hint(self) -> None:
|
|
329
|
+
"""Refresh the footer keyboard hint for the current state."""
|
|
330
|
+
self._set_status("")
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Voice capture and xAI transcription for the TUI composer.
|
|
2
|
+
|
|
3
|
+
Credentials, the spoken-summary split, and MP3 playback belong to the voice
|
|
4
|
+
add-on (``coding_agent.addons.voice``); this module imports them from there.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from coding_agent.addons.voice.credentials import (
|
|
13
|
+
XAI_AUDIO_BASE_URL,
|
|
14
|
+
UnavailableVoice,
|
|
15
|
+
grok_voice_credentials,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Speech-to-text model. The realtime alias is not accepted by POST /v1/stt.
|
|
19
|
+
# Transcribe 2.0 is the STT model xAI documents as the default. The chat
|
|
20
|
+
# model is never switched.
|
|
21
|
+
GROK_VOICE_MODEL = "grok-voice-transcribe-2.0"
|
|
22
|
+
GROK_VOICE_FULL_ID = f"grok:{GROK_VOICE_MODEL}"
|
|
23
|
+
|
|
24
|
+
# Alternate the composer border every half second.
|
|
25
|
+
VOICE_BLINK_INTERVAL_S = 0.5
|
|
26
|
+
VOICE_LIGHT_PURPLE = "#D7C4FB"
|
|
27
|
+
VOICE_DARK_PURPLE = "#A371F7"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VoiceTranscriber(Protocol):
|
|
31
|
+
"""Turns one captured utterance into text. Tests inject a fake."""
|
|
32
|
+
|
|
33
|
+
def transcribe(self, audio: bytes, *, model: str) -> str: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VoiceCapture(Protocol):
|
|
37
|
+
"""Records one utterance. ``None`` means the microphone stopped."""
|
|
38
|
+
|
|
39
|
+
def listen(self) -> bytes | None: ...
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
"""Release the microphone; a blocked ``listen`` returns ``None`` soon after."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def transcript_text(body: dict[str, Any]) -> str:
|
|
46
|
+
"""Read a transcript from either the audio API or a chat completion."""
|
|
47
|
+
text = body.get("text")
|
|
48
|
+
if isinstance(text, str) and text.strip():
|
|
49
|
+
return text.strip()
|
|
50
|
+
choices = body.get("choices") or []
|
|
51
|
+
if not choices:
|
|
52
|
+
return ""
|
|
53
|
+
message = choices[0].get("message") or {}
|
|
54
|
+
content = message.get("content") or ""
|
|
55
|
+
return content.strip() if isinstance(content, str) else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GrokVoiceTranscriber:
|
|
59
|
+
"""Transcribe one utterance with grok-voice-transcribe-2.0.
|
|
60
|
+
|
|
61
|
+
The WAV is posted once to ``/stt``. Nothing stays open between utterances.
|
|
62
|
+
Auth is the Grok OAuth session when there is no API key.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def transcribe(self, audio: bytes, *, model: str = GROK_VOICE_MODEL) -> str:
|
|
66
|
+
if not audio:
|
|
67
|
+
return ""
|
|
68
|
+
api_key, extra_headers = grok_voice_credentials()
|
|
69
|
+
try:
|
|
70
|
+
import httpx
|
|
71
|
+
except ImportError as exc: # pragma: no cover - httpx is a core dependency
|
|
72
|
+
raise UnavailableVoice("Voice mode needs httpx.") from exc
|
|
73
|
+
|
|
74
|
+
headers = {"Authorization": f"Bearer {api_key}", **extra_headers}
|
|
75
|
+
# Model fields must precede the file. xAI ignores fields that follow it.
|
|
76
|
+
response = httpx.post(
|
|
77
|
+
f"{XAI_AUDIO_BASE_URL}/stt",
|
|
78
|
+
headers=headers,
|
|
79
|
+
data={"model": model, "language": "en"},
|
|
80
|
+
files={"file": ("utterance.wav", audio, "audio/wav")},
|
|
81
|
+
timeout=30.0,
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
except httpx.HTTPStatusError as exc:
|
|
86
|
+
detail = exc.response.text.strip().splitlines()
|
|
87
|
+
suffix = f" · {detail[0][:180]}" if detail else ""
|
|
88
|
+
raise UnavailableVoice(
|
|
89
|
+
f"Voice transcription failed · {exc.response.status_code}{suffix}"
|
|
90
|
+
) from exc
|
|
91
|
+
return transcript_text(response.json())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_sounddevice() -> Any:
|
|
95
|
+
"""Import PortAudio, installing the voice extra once if it is missing."""
|
|
96
|
+
try:
|
|
97
|
+
import sounddevice as sd
|
|
98
|
+
except ImportError:
|
|
99
|
+
_install_voice_extra()
|
|
100
|
+
try:
|
|
101
|
+
import sounddevice as sd
|
|
102
|
+
except ImportError as exc:
|
|
103
|
+
raise UnavailableVoice(
|
|
104
|
+
"Voice mode could not install the microphone package. "
|
|
105
|
+
"Run: uv sync --package symphony-code --extra voice"
|
|
106
|
+
) from exc
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
raise UnavailableVoice(_NO_PORTAUDIO) from exc
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
# sounddevice imports, then cannot find libportaudio. pip cannot fix that.
|
|
111
|
+
raise UnavailableVoice(_NO_PORTAUDIO) from exc
|
|
112
|
+
return sd
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_NO_PORTAUDIO = (
|
|
116
|
+
"Voice mode needs the PortAudio library. "
|
|
117
|
+
"Install it (brew install portaudio, or apt install libportaudio2)."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _install_voice_extra() -> None:
|
|
122
|
+
"""Install ``sounddevice`` into the interpreter running the TUI."""
|
|
123
|
+
import shutil
|
|
124
|
+
import subprocess
|
|
125
|
+
import sys
|
|
126
|
+
|
|
127
|
+
commands: list[list[str]] = []
|
|
128
|
+
uv = shutil.which("uv")
|
|
129
|
+
if uv:
|
|
130
|
+
commands.append([uv, "pip", "install", "sounddevice>=0.5.1", "--python", sys.executable])
|
|
131
|
+
commands.append([sys.executable, "-m", "pip", "install", "sounddevice>=0.5.1"])
|
|
132
|
+
for command in commands:
|
|
133
|
+
try:
|
|
134
|
+
completed = subprocess.run(
|
|
135
|
+
command,
|
|
136
|
+
check=False,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=120,
|
|
140
|
+
)
|
|
141
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
142
|
+
continue
|
|
143
|
+
if completed.returncode == 0:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# 32 ms per callback (512 frames at 16 kHz): a finer end-of-speech check than
|
|
148
|
+
# the old 50 ms poll, ~31 wakeups a second, and not left to the host's choice.
|
|
149
|
+
CAPTURE_BLOCK_MS = 32
|
|
150
|
+
# int16 RMS. Normal speech sits well above this; a quiet room does not.
|
|
151
|
+
SPEECH_RMS = 500
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def record_wav(
|
|
155
|
+
sd: Any,
|
|
156
|
+
*,
|
|
157
|
+
seconds: float = 8,
|
|
158
|
+
sample_rate: int = 16_000,
|
|
159
|
+
silence_seconds: float = 0.7,
|
|
160
|
+
stop: threading.Event | None = None,
|
|
161
|
+
) -> bytes:
|
|
162
|
+
"""Record mono 16-bit PCM until the speaker pauses, then wrap it in a WAV.
|
|
163
|
+
|
|
164
|
+
Recording ends after ``silence_seconds`` of quiet once speech has started;
|
|
165
|
+
``seconds`` is only the safety cap. The PortAudio callback decides that
|
|
166
|
+
from the frames it has counted and sets an event, so this thread blocks on
|
|
167
|
+
the event instead of waking to poll. Setting ``stop`` ends it early.
|
|
168
|
+
|
|
169
|
+
``RawInputStream`` keeps the samples in a ``bytearray``. ``sd.rec`` would
|
|
170
|
+
allocate a NumPy array and force that dependency on every user.
|
|
171
|
+
"""
|
|
172
|
+
import io
|
|
173
|
+
import warnings
|
|
174
|
+
import wave
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with warnings.catch_warnings():
|
|
178
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
179
|
+
from audioop import rms # Removed in Python 3.13.
|
|
180
|
+
except ImportError:
|
|
181
|
+
rms = _pcm16_rms
|
|
182
|
+
|
|
183
|
+
ended = stop if stop is not None else threading.Event()
|
|
184
|
+
captured = bytearray()
|
|
185
|
+
heard_speech = False
|
|
186
|
+
quiet_frames = 0
|
|
187
|
+
silence_frames = int(silence_seconds * sample_rate)
|
|
188
|
+
|
|
189
|
+
def _on_audio(indata: Any, frames: int, time_info: Any, status: Any) -> None:
|
|
190
|
+
nonlocal heard_speech, quiet_frames
|
|
191
|
+
del time_info, status
|
|
192
|
+
if ended.is_set():
|
|
193
|
+
return
|
|
194
|
+
# PortAudio reuses ``indata``; this extend is the only copy.
|
|
195
|
+
captured.extend(indata)
|
|
196
|
+
if rms(indata, 2) >= SPEECH_RMS:
|
|
197
|
+
heard_speech = True
|
|
198
|
+
quiet_frames = 0
|
|
199
|
+
else:
|
|
200
|
+
quiet_frames += frames
|
|
201
|
+
if heard_speech and quiet_frames >= silence_frames:
|
|
202
|
+
ended.set()
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
stream = sd.RawInputStream(
|
|
206
|
+
samplerate=sample_rate,
|
|
207
|
+
channels=1,
|
|
208
|
+
dtype="int16",
|
|
209
|
+
callback=_on_audio,
|
|
210
|
+
blocksize=sample_rate * CAPTURE_BLOCK_MS // 1000,
|
|
211
|
+
)
|
|
212
|
+
except Exception as exc: # PortAudio errors are not ImportError
|
|
213
|
+
raise UnavailableVoice(f"Microphone unavailable · {exc}") from exc
|
|
214
|
+
with stream:
|
|
215
|
+
ended.wait(seconds)
|
|
216
|
+
buffer = io.BytesIO()
|
|
217
|
+
with wave.open(buffer, "wb") as handle:
|
|
218
|
+
handle.setnchannels(1)
|
|
219
|
+
handle.setsampwidth(2)
|
|
220
|
+
handle.setframerate(sample_rate)
|
|
221
|
+
handle.writeframes(captured)
|
|
222
|
+
return buffer.getvalue()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _pcm16_rms(pcm: Any, width: int) -> int:
|
|
226
|
+
"""``audioop.rms`` for mono int16, for Pythons that no longer ship audioop."""
|
|
227
|
+
del width
|
|
228
|
+
samples = memoryview(pcm).cast("B").cast("h")
|
|
229
|
+
if not samples:
|
|
230
|
+
return 0
|
|
231
|
+
return int((sum(sample * sample for sample in samples) / len(samples)) ** 0.5)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class MicrophoneCapture:
|
|
235
|
+
"""Record utterances from the default input device until ``close``.
|
|
236
|
+
|
|
237
|
+
Imported lazily so the TUI still starts on a machine without an audio
|
|
238
|
+
extra installed. The first listen installs ``sounddevice`` into this
|
|
239
|
+
interpreter; if that fails, the user gets the install command instead of
|
|
240
|
+
a crash. Recording itself does not need NumPy.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self) -> None:
|
|
244
|
+
self._lock = threading.Lock()
|
|
245
|
+
self._closed = False
|
|
246
|
+
self._recording: threading.Event | None = None
|
|
247
|
+
|
|
248
|
+
def listen(self) -> bytes | None:
|
|
249
|
+
sd = _load_sounddevice()
|
|
250
|
+
stop = threading.Event()
|
|
251
|
+
with self._lock:
|
|
252
|
+
if self._closed:
|
|
253
|
+
return None
|
|
254
|
+
self._recording = stop
|
|
255
|
+
try:
|
|
256
|
+
audio = record_wav(sd, stop=stop)
|
|
257
|
+
finally:
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._recording = None
|
|
260
|
+
return None if self._closed else audio
|
|
261
|
+
|
|
262
|
+
def close(self) -> None:
|
|
263
|
+
"""Idempotent and non-blocking, so the UI thread may call it."""
|
|
264
|
+
with self._lock:
|
|
265
|
+
self._closed = True
|
|
266
|
+
if self._recording is not None:
|
|
267
|
+
self._recording.set()
|