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,456 @@
1
+ """A conversation-first Textual interface for the Symphony coding agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from textual import events
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Static
14
+
15
+ from coding_agent.agent import CodingAgent, build_agent
16
+ from coding_agent.config import ensure_spawn_settings
17
+ from coding_agent.config.credentials import OFFLINE_HINT, load_provider_env
18
+ from coding_agent.addons.persistence.active import register_active, release_active
19
+ from coding_agent.tui.chrome import ComposerOverlay, TopBar
20
+ from coding_agent.tui.commands import CommandManager, model_options
21
+ from coding_agent.tui.composer import (
22
+ Composer,
23
+ PromptInput,
24
+ QueuedPrompt,
25
+ QueuedTurn,
26
+ SlashMenu,
27
+ )
28
+ from coding_agent.tui.composer.surface import ComposerSurface
29
+ from coding_agent.tui.runtime import (
30
+ EventPresenter,
31
+ SubagentRecord,
32
+ TextualEventSink,
33
+ UiRunState,
34
+ )
35
+ from coding_agent.tui.runtime.subagent import SubagentSurface
36
+ from coding_agent.tui.runtime.turn import TurnSurface
37
+ from coding_agent.tui.screens.ask import QuestionSurface
38
+ from coding_agent.tui.screens.file_selector import WorkspaceFileIndex
39
+ from coding_agent.tui.screens.onboard import OnboardApp
40
+ from coding_agent.tui.theme import APP_CSS, SYMPHONY_RICH_THEME
41
+ from coding_agent.tui.theme.load import ThemeConfigError, apply_theme, load_theme, theme_signature
42
+ from coding_agent.tui.tools import ToolCallSummary, ToolCallWidget
43
+ from coding_agent.tui.tools.images import dropped_image_paths
44
+ from coding_agent.tui.transcript import (
45
+ AssistantMessage,
46
+ ReasoningWidget,
47
+ RunProcess,
48
+ ThinkingStatus,
49
+ TranscriptScroll,
50
+ TranscriptSurface,
51
+ Welcome,
52
+ )
53
+ from core_ai import has_configured_provider
54
+ from core_harness import Answer, Interrupt, Op
55
+
56
+
57
+ def _paste_might_be_images(text: str) -> bool:
58
+ """True when a paste is short enough to be a file drop naming an image.
59
+
60
+ No filesystem access. Ordinary text, including large pastes, fails here
61
+ on length or on a missing image suffix.
62
+ """
63
+ if not text or len(text) > 4096:
64
+ return False
65
+ lowered = text.lower()
66
+ return any(suffix in lowered for suffix in _IMAGE_DROP_SUFFIXES)
67
+
68
+
69
+ _IMAGE_DROP_SUFFIXES = (
70
+ ".png",
71
+ ".jpg",
72
+ ".jpeg",
73
+ ".gif",
74
+ ".webp",
75
+ ".bmp",
76
+ ".tif",
77
+ ".tiff",
78
+ )
79
+
80
+
81
+ class CodingAgentApp(
82
+ TranscriptSurface,
83
+ SubagentSurface,
84
+ QuestionSurface,
85
+ ComposerSurface,
86
+ TurnSurface,
87
+ App[None],
88
+ ):
89
+ """Full-screen chat transcript backed by core_harness events."""
90
+
91
+ CSS = APP_CSS
92
+ # Keep terminal mouse drag selection enabled for transcript content.
93
+ ALLOW_SELECT = True
94
+
95
+ BINDINGS = [
96
+ Binding("ctrl+g", "subagents", "Subagents", show=False),
97
+ Binding("ctrl+b", "dashboard", "Agents", show=False),
98
+ Binding("ctrl+d", "quit", "Quit", show=False),
99
+ Binding("ctrl+q", "quit", "Quit", show=False),
100
+ Binding("ctrl+l", "clear_transcript", "Clear", show=False),
101
+ Binding("ctrl+y", "copy_selection", "Copy selection", show=False),
102
+ Binding("ctrl+a", "voice", "Voice", show=False, priority=True),
103
+ Binding("escape", "cancel_voice", "Leave voice", show=False, priority=True),
104
+ Binding("escape", "cancel_run", "Cancel", show=True, priority=True),
105
+ Binding("ctrl+x", "cancel_run", "Cancel", show=False, priority=True),
106
+ ]
107
+
108
+ TITLE = "Symphony"
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ workspace: str | Path = ".",
114
+ model_id: Optional[str] = None,
115
+ session_id: Optional[str] = None,
116
+ enable_learning: Optional[bool] = None,
117
+ enable_jev: Optional[bool] = None,
118
+ unattended: bool = False,
119
+ ) -> None:
120
+ super().__init__()
121
+ self.workspace = Path(workspace).resolve()
122
+ self.model_id = model_id
123
+ self.session_id = session_id
124
+ self._resumed = session_id is not None
125
+ self.enable_learning = enable_learning
126
+ self.enable_jev = enable_jev
127
+ self.jev_rule = ""
128
+ overrides: dict[str, Any] = {}
129
+ if enable_learning is not None:
130
+ overrides["learning"] = {"enabled": enable_learning}
131
+ if enable_jev is not None:
132
+ overrides["evaluation"] = {"enabled": enable_jev}
133
+ if unattended:
134
+ overrides["unattended"] = True
135
+ self.config = ensure_spawn_settings(
136
+ self.workspace, overrides=overrides or None
137
+ )
138
+ self.sink = TextualEventSink(workspace=self.workspace)
139
+ self._agent: Optional[CodingAgent] = None
140
+ self._busy = False
141
+ self._reload_queued = False
142
+ # Prompts submitted while a turn is running are dispatched FIFO.
143
+ self._queued_turns: list[QueuedTurn] = []
144
+ self._init_runtime_state()
145
+
146
+ def queue_turn(self, turn: QueuedTurn) -> None:
147
+ """Add a follow-up prompt and refresh the queue control."""
148
+ self._queued_turns.append(turn)
149
+ self.query_one("#queued-prompt-row", QueuedPrompt).refresh_queue(self._queued_turns)
150
+
151
+ def pop_queued_turn(self) -> QueuedTurn:
152
+ """Remove and return the oldest queued prompt."""
153
+ turn = self._queued_turns.pop(0)
154
+ self.query_one("#queued-prompt-row", QueuedPrompt).refresh_queue(self._queued_turns)
155
+ return turn
156
+
157
+ def _init_runtime_state(self) -> None:
158
+ self._ui_state = UiRunState()
159
+ self._presenter: Optional[EventPresenter] = None
160
+ self._assistant: Optional[AssistantMessage] = None
161
+ self._thinking: Optional[ThinkingStatus] = None
162
+ self._reasoning: Optional[ReasoningWidget] = None
163
+ self._process: Optional[RunProcess] = None
164
+ self._tools: dict[str, ToolCallWidget] = {}
165
+ self._tool_groups: dict[str, ToolCallSummary] = {}
166
+ self._open_tool_group: Optional[ToolCallSummary] = None
167
+ self._subagents: dict[str, SubagentRecord] = {}
168
+ self._file_index = WorkspaceFileIndex(self.workspace)
169
+ self._pending_question_id: str | None = None
170
+ self._pending_question_agent_id = ""
171
+ self._pending_question_default = ""
172
+ self._model_options = model_options()
173
+ self._command_manager = CommandManager(self)
174
+ self._stream_flush_timer = None
175
+ self._run_generation = 0
176
+ self._topbar_model: Optional[str] = None
177
+ self._theme_signature = theme_signature()
178
+ self._theme_timer = None
179
+ self._voice_active = False
180
+ self._voice_timer = None
181
+ self._voice_addon = None
182
+ self._voice_session = None
183
+
184
+ async def on_event(self, event: events.Event) -> None:
185
+ # Textual delivers paste only to the focused widget, so a drop on the
186
+ # transcript never reaches the composer. Claim image drops here, before
187
+ # that forward, and leave every other event on the normal path.
188
+ if (
189
+ isinstance(event, events.Paste)
190
+ and not event.is_forwarded
191
+ and self._attach_dropped_images(event.text)
192
+ ):
193
+ return
194
+ await super().on_event(event)
195
+
196
+ def _attach_dropped_images(self, text: str) -> bool:
197
+ """Attach a dropped image path when focus is not the composer.
198
+
199
+ Returns True only when this paste was consumed. Non-image pastes,
200
+ modal screens, and a focused composer return False with no filesystem
201
+ work beyond the existing path check for a short image-like paste.
202
+ """
203
+ if len(self.screen_stack) != 1 or not _paste_might_be_images(text):
204
+ return False
205
+ paths = dropped_image_paths(text)
206
+ if not paths:
207
+ return False
208
+ try:
209
+ prompt = self.query_one("#prompt", PromptInput)
210
+ except Exception:
211
+ return False
212
+ if prompt.has_focus:
213
+ return False
214
+ for path in paths:
215
+ prompt._attach_image(path)
216
+ return True
217
+
218
+ def compose(self) -> ComposeResult:
219
+ yield TopBar(id="topbar")
220
+ with TranscriptScroll(id="transcript"):
221
+ yield Welcome(self.workspace)
222
+ yield SlashMenu(id="slash-menu")
223
+ yield ComposerOverlay()
224
+ yield Composer(id="composer")
225
+ # Keep approval choices in their own full-width surface.
226
+ yield SlashMenu(id="approval-menu")
227
+ yield Static(id="status")
228
+
229
+ def on_mount(self) -> None:
230
+ self.console.push_theme(SYMPHONY_RICH_THEME, inherit=True)
231
+ self.sink.bind(self)
232
+ self._presenter = EventPresenter(
233
+ state=self._ui_state,
234
+ view=self,
235
+ set_status=self._set_status,
236
+ workspace=str(self.workspace),
237
+ schedule_flush=self._schedule_stream_flush,
238
+ )
239
+ topbar = self.query_one("#topbar", TopBar)
240
+ topbar.set_context(self.workspace, self.model_id or os.getenv("OPENAI_MODEL", ""))
241
+ self._update_composer_hint()
242
+
243
+ try:
244
+ self._agent = build_agent(
245
+ workspace=self.workspace,
246
+ sink=self.sink,
247
+ model_id=self.model_id,
248
+ session_id=self.session_id,
249
+ enable_learning=self.enable_learning,
250
+ enable_jev=self.enable_jev,
251
+ config=self.config,
252
+ client="tui",
253
+ )
254
+ from coding_agent.protocols.stdio.commands import load_state
255
+
256
+ load_state(self._agent)
257
+ except Exception as exc:
258
+ self._agent = None
259
+ self._set_status("")
260
+ if "explicitly start a fresh session" in str(exc):
261
+ self.add_notice(str(exc), "error")
262
+ elif has_configured_provider():
263
+ self.add_notice(f"Offline · {exc}. {OFFLINE_HINT}", "error")
264
+ else:
265
+ self.add_notice(OFFLINE_HINT, "error")
266
+ self.query_one("#prompt", PromptInput).focus()
267
+ return
268
+
269
+ self._ui_state.model_id = self._agent.harness.model_id
270
+ self._model_options = model_options(self._agent.registry.namespaces())
271
+ self._ui_state.metrics.context_limit = self._agent.harness.state.context_limit(
272
+ self._ui_state.model_id
273
+ )
274
+ self._ui_state.phase = "idle"
275
+ self._ui_state.detail = "ready"
276
+ self.session_id = self._agent.session_id
277
+ persistence = getattr(self._agent, "persistence", None)
278
+ if persistence is not None:
279
+ persistence.client = "tui"
280
+ self._register_active_session()
281
+ topbar.set_context(self.workspace, self._ui_state.model_id)
282
+ self._presenter.refresh_chrome()
283
+ self._theme_timer = self.set_interval(0.5, self._reload_theme_if_changed)
284
+ if self.session_id and self._resumed:
285
+ self.load_session_history()
286
+ self.query_one("#prompt", PromptInput).focus()
287
+
288
+ def show_model_picker(self) -> None:
289
+ """Open the same model list as ``/model``, from a click on the model name."""
290
+ from coding_agent.tui.commands.manager import show_model_picker
291
+
292
+ if self._agent is None:
293
+ self.add_notice(OFFLINE_HINT, "error")
294
+ return
295
+ show_model_picker(self)
296
+
297
+ def _reload_theme_if_changed(self) -> None:
298
+ """Repaint when ~/.symphony/theme.toml (or the packaged fallback) changes.
299
+
300
+ A half-written or invalid file is ignored so a save in progress does
301
+ not blank the running session. The previous theme stays up.
302
+ """
303
+ signature = theme_signature()
304
+ if signature is None or signature == self._theme_signature:
305
+ return
306
+ try:
307
+ document = load_theme()
308
+ except (OSError, ThemeConfigError):
309
+ return
310
+ apply_theme(document)
311
+ self._theme_signature = signature
312
+ # reparse() rereads whatever is already stored. Replace that source
313
+ # first, or an edit to the CSS body never reaches the widgets.
314
+ self.stylesheet.source.clear()
315
+ self.stylesheet.add_source(document.app_css, is_default_css=True)
316
+ self.stylesheet.reparse()
317
+ self.stylesheet.update(self.screen)
318
+ self.refresh(repaint=True, layout=True)
319
+ self._set_status("")
320
+
321
+ def submit_op(self, op: Op) -> bool:
322
+ """Send one operation to the agent's harness. True when it landed."""
323
+ submit = getattr(getattr(self._agent, "harness", None), "submit", None)
324
+ if callable(submit):
325
+ return bool(submit(op))
326
+ # No agent yet: a question can still be waiting on the TUI's own port.
327
+ if isinstance(op, Answer):
328
+ return self.sink.interaction.answer(op.request_id, op.value)
329
+ return False
330
+
331
+ def _cancel_active_run(self, reason: str) -> None:
332
+ """Stop the agent turn. ``reason`` is reported on ``run_cancelled``."""
333
+ self.sink.request_cancel(reason)
334
+ if self.submit_op(Interrupt(reason)):
335
+ return
336
+ # The worker is outside ``harness.run`` (a slash command, or between
337
+ # turns). Group must match ``run_agent``.
338
+ self.workers.cancel_group(self, "run_agent")
339
+ for worker in list(self.workers):
340
+ if worker.name == "run_agent" and not worker.is_finished:
341
+ worker.cancel()
342
+
343
+ def action_cancel_run(self) -> None:
344
+ if isinstance(self.screen, ModalScreen):
345
+ close = getattr(self.screen, "action_close_modal", None)
346
+ if callable(close):
347
+ close()
348
+ else:
349
+ self.screen.dismiss(None)
350
+ return
351
+ menu = self.query_one("#slash-menu", SlashMenu)
352
+ approval_menu = self.query_one("#approval-menu", SlashMenu)
353
+ if not self._busy:
354
+ menu.set_commands(())
355
+ approval_menu.set_commands(())
356
+ return
357
+ already = self.sink.cancelled
358
+ self._pending_question_id = None
359
+ self._pending_question_default = ""
360
+ menu.set_commands(())
361
+ approval_menu.set_commands(())
362
+ self._cancel_active_run("user_cancel")
363
+ if not already:
364
+ from coding_agent.tui.chrome.footer import update_banner
365
+
366
+ banner = update_banner("cancelling")
367
+ if banner is not None:
368
+ self.add_update(*banner)
369
+ prompt = self.query_one("#prompt", PromptInput)
370
+ prompt.submit_on_enter = True
371
+ prompt.disabled = False
372
+ self._update_composer_hint()
373
+ prompt.focus()
374
+
375
+ def action_quit(self) -> None:
376
+ if self._busy:
377
+ self._cancel_active_run("quit")
378
+ if self._agent is not None and self._agent.learning_loop is not None:
379
+ self._agent.learning_loop.cancel()
380
+ self.exit()
381
+
382
+ def action_voice(self) -> None:
383
+ """Start voice mode, or finish it. Ctrl+A and ``/voice`` both land here."""
384
+ from coding_agent.tui.composer.voice_mode import toggle_voice
385
+
386
+ toggle_voice(self)
387
+
388
+ def action_cancel_voice(self) -> None:
389
+ """Escape leaves voice mode. When voice is off, cancel the run instead."""
390
+ if not getattr(self, "_voice_active", False):
391
+ self.action_cancel_run()
392
+ return
393
+ from coding_agent.tui.composer.voice_mode import stop_voice
394
+
395
+ stop_voice(self, reason="toggled")
396
+
397
+ def action_dashboard(self) -> None:
398
+ from coding_agent.tui.screens.dashboard import AgentDashboard
399
+
400
+ self.push_screen(AgentDashboard())
401
+
402
+ def _register_active_session(self) -> None:
403
+ agent = self._agent
404
+ if agent is None or not agent.session_id:
405
+ return
406
+ register_active(
407
+ agent.session_id,
408
+ workspace=self.workspace,
409
+ kind="tui",
410
+ model_id=getattr(agent.harness, "model_id", "") or "",
411
+ )
412
+
413
+ async def on_unmount(self) -> None:
414
+ from coding_agent.tui.composer.voice_mode import close_voice_addon
415
+
416
+ if self._voice_session is not None:
417
+ # A listen() still running in a thread worker would hold the mic.
418
+ self._voice_session.capture.close()
419
+ # Exiting: give the audio threads a bounded moment to release the
420
+ # devices instead of leaving them to interpreter shutdown.
421
+ close_voice_addon(self, timeout=2.0)
422
+ release_active()
423
+ harness = getattr(self._agent, "harness", None)
424
+ shutdown_children = getattr(harness, "shutdown_children", None)
425
+ if callable(shutdown_children):
426
+ await shutdown_children()
427
+ if self._busy:
428
+ self._cancel_active_run("quit")
429
+ shutdown = getattr(self._agent, "shutdown_learning", None)
430
+ if callable(shutdown):
431
+ await shutdown()
432
+
433
+
434
+ def run_tui(
435
+ *,
436
+ workspace: str | Path = ".",
437
+ model_id: Optional[str] = None,
438
+ session_id: Optional[str] = None,
439
+ enable_learning: Optional[bool] = None,
440
+ enable_jev: Optional[bool] = None,
441
+ unattended: bool = False,
442
+ ) -> None:
443
+ """Load environment configuration and launch the terminal UI."""
444
+ workspace = Path(workspace).resolve()
445
+ load_provider_env(workspace)
446
+ if not has_configured_provider():
447
+ OnboardApp(workspace).run()
448
+ load_provider_env(workspace)
449
+ CodingAgentApp(
450
+ workspace=workspace,
451
+ model_id=model_id,
452
+ session_id=session_id,
453
+ enable_learning=enable_learning,
454
+ enable_jev=enable_jev,
455
+ unattended=unattended,
456
+ ).run()
@@ -0,0 +1,33 @@
1
+ """App chrome: the top bar and footer that frame the transcript and composer."""
2
+
3
+ from coding_agent.tui.chrome.footer import (
4
+ ComposerOverlay,
5
+ context_percent,
6
+ footer_hint,
7
+ footer_segments,
8
+ phase_label,
9
+ render_footer,
10
+ )
11
+ from coding_agent.tui.chrome.topbar import (
12
+ TopBar,
13
+ display_workspace_path,
14
+ read_git_branch,
15
+ read_git_dirty,
16
+ short_model_name,
17
+ topbar_text,
18
+ )
19
+
20
+ __all__ = [
21
+ "ComposerOverlay",
22
+ "TopBar",
23
+ "context_percent",
24
+ "display_workspace_path",
25
+ "footer_hint",
26
+ "footer_segments",
27
+ "phase_label",
28
+ "read_git_branch",
29
+ "read_git_dirty",
30
+ "render_footer",
31
+ "short_model_name",
32
+ "topbar_text",
33
+ ]