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
coding_agent/tui/app.py
ADDED
|
@@ -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
|
+
]
|