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,206 @@
|
|
|
1
|
+
"""Agent-turn worker mixed into CodingAgentApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
from coding_agent.tui.chrome import TopBar, display_workspace_path, footer_hint, render_footer
|
|
12
|
+
from coding_agent.tui.composer.input import PromptInput
|
|
13
|
+
from coding_agent.tui.runtime.sink import HarnessEvent
|
|
14
|
+
from coding_agent.tui.screens.history import load_session_history
|
|
15
|
+
from core_ai.types import Content
|
|
16
|
+
from core_harness import HarnessCancelled, HarnessLimitExceeded, HarnessResult
|
|
17
|
+
|
|
18
|
+
STREAM_PAINT_INTERVAL_S = 1 / 15
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TurnSurface:
|
|
22
|
+
"""Harness event routing and the exclusive agent-turn worker."""
|
|
23
|
+
|
|
24
|
+
def _set_status(self, _value: str) -> None:
|
|
25
|
+
"""Repaint the footer from run state; the header only when the model changes.
|
|
26
|
+
|
|
27
|
+
Usage events refresh this often. Re-reading git and rebuilding the
|
|
28
|
+
top bar on every token made the composer stutter during a live run.
|
|
29
|
+
"""
|
|
30
|
+
hint = footer_hint(question_pending=self._pending_question_id is not None)
|
|
31
|
+
self.query_one("#status", Static).update(
|
|
32
|
+
render_footer(
|
|
33
|
+
self._ui_state,
|
|
34
|
+
hint=hint,
|
|
35
|
+
workspace=display_workspace_path(self.workspace),
|
|
36
|
+
),
|
|
37
|
+
# The footer has fixed width/height; metrics only change its paint.
|
|
38
|
+
layout=False,
|
|
39
|
+
)
|
|
40
|
+
model_id = self._ui_state.model_id or getattr(self, "model_id", "") or ""
|
|
41
|
+
if model_id != getattr(self, "_topbar_model", None):
|
|
42
|
+
self._topbar_model = model_id
|
|
43
|
+
self.query_one("#topbar", TopBar).set_context(self.workspace, model_id)
|
|
44
|
+
|
|
45
|
+
def _schedule_stream_flush(self, callback: Callable[[], None]) -> None:
|
|
46
|
+
"""Coalesce stream paints to keep Markdown/widget work bounded."""
|
|
47
|
+
# A structural event can drain paints before this timer fires. Keep
|
|
48
|
+
# the newest generation's callback rather than stranding its updates.
|
|
49
|
+
self._stream_flush_callback = callback
|
|
50
|
+
if getattr(self, "_stream_flush_timer", None) is not None:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
def _run() -> None:
|
|
54
|
+
self._stream_flush_timer = None
|
|
55
|
+
pending = self._stream_flush_callback
|
|
56
|
+
self._stream_flush_callback = None
|
|
57
|
+
if pending is not None:
|
|
58
|
+
pending()
|
|
59
|
+
|
|
60
|
+
self._stream_flush_timer = self.set_timer(STREAM_PAINT_INTERVAL_S, _run)
|
|
61
|
+
|
|
62
|
+
def set_context_metrics(self, tokens_used: int, context_limit: int) -> None:
|
|
63
|
+
"""Restore context usage for a resumed session before its first run."""
|
|
64
|
+
metrics = self._ui_state.metrics
|
|
65
|
+
metrics.tokens_used = tokens_used
|
|
66
|
+
metrics.context_limit = context_limit
|
|
67
|
+
metrics.context_left = max(context_limit - tokens_used, 0)
|
|
68
|
+
metrics.utilization = tokens_used / context_limit if context_limit else None
|
|
69
|
+
self._set_status("")
|
|
70
|
+
|
|
71
|
+
@work(exclusive=False)
|
|
72
|
+
async def load_session_history(self) -> None:
|
|
73
|
+
if self._agent is None:
|
|
74
|
+
return
|
|
75
|
+
await load_session_history(self._agent, self)
|
|
76
|
+
await self.restore_subagents()
|
|
77
|
+
|
|
78
|
+
def _collect_run_events(self, payload: Mapping[str, Any]) -> None:
|
|
79
|
+
"""Tag mid-run journal events as collected after the final output."""
|
|
80
|
+
agent = getattr(self, "_agent", None)
|
|
81
|
+
store = getattr(agent, "persistence", None)
|
|
82
|
+
collect = getattr(store, "collect_run_events", None)
|
|
83
|
+
if not callable(collect):
|
|
84
|
+
return
|
|
85
|
+
session_id = str(
|
|
86
|
+
payload.get("session_id") or getattr(agent, "session_id", "") or ""
|
|
87
|
+
)
|
|
88
|
+
run_id = str(payload.get("run_id") or "")
|
|
89
|
+
if not session_id:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
async def _persist() -> None:
|
|
93
|
+
annotate = getattr(store, "annotate_run_completed", None)
|
|
94
|
+
if callable(annotate):
|
|
95
|
+
elapsed = payload.get("elapsed_seconds")
|
|
96
|
+
await annotate(
|
|
97
|
+
session_id=session_id,
|
|
98
|
+
run_id=run_id,
|
|
99
|
+
completion_verb=str(payload.get("completion_verb") or ""),
|
|
100
|
+
duration=str(payload.get("duration") or ""),
|
|
101
|
+
elapsed_seconds=float(elapsed) if isinstance(elapsed, (int, float)) else None,
|
|
102
|
+
)
|
|
103
|
+
await collect(session_id=session_id, run_id=run_id or None)
|
|
104
|
+
|
|
105
|
+
self.run_worker(_persist, exclusive=False, group="collect_run_events")
|
|
106
|
+
|
|
107
|
+
def feed_voice(self, text: str) -> None:
|
|
108
|
+
from coding_agent.tui.composer.voice_mode import feed_voice
|
|
109
|
+
|
|
110
|
+
feed_voice(self, text)
|
|
111
|
+
|
|
112
|
+
def on_harness_event(self, message: HarnessEvent) -> None:
|
|
113
|
+
payload = message.payload or {}
|
|
114
|
+
if message.event_type == "agent_spawned":
|
|
115
|
+
self._on_agent_spawned(payload)
|
|
116
|
+
return
|
|
117
|
+
if message.event_type in {"agent_completed", "agent_failed"}:
|
|
118
|
+
self._on_agent_finished(message.event_type, payload)
|
|
119
|
+
return
|
|
120
|
+
if payload.get("parent_id"):
|
|
121
|
+
self._on_child_event(message.event_type, payload)
|
|
122
|
+
if message.event_type == "question_asked":
|
|
123
|
+
self._show_child_question(payload)
|
|
124
|
+
return
|
|
125
|
+
if message.event_type == "question_asked":
|
|
126
|
+
self._show_question(payload)
|
|
127
|
+
return
|
|
128
|
+
if self._presenter is not None:
|
|
129
|
+
self._presenter.handle(message.event_type, payload)
|
|
130
|
+
if message.event_type in {
|
|
131
|
+
"run_completed",
|
|
132
|
+
"run_failed",
|
|
133
|
+
"run_cancelled",
|
|
134
|
+
"run_limit_exceeded",
|
|
135
|
+
}:
|
|
136
|
+
self._collect_run_events(payload)
|
|
137
|
+
|
|
138
|
+
on_control_plane_event = on_harness_event
|
|
139
|
+
|
|
140
|
+
@work(exclusive=True, group="run_agent")
|
|
141
|
+
async def run_agent(self, user_input: Content) -> None:
|
|
142
|
+
generation = self._run_generation
|
|
143
|
+
try:
|
|
144
|
+
await self._run_agent_turn(user_input)
|
|
145
|
+
except (HarnessCancelled, HarnessLimitExceeded, asyncio.CancelledError):
|
|
146
|
+
if self._presenter is not None:
|
|
147
|
+
self._presenter.flush_stream_to_log()
|
|
148
|
+
except Exception as exc: # noqa: BLE001
|
|
149
|
+
if self._presenter is not None:
|
|
150
|
+
self._presenter.flush_stream_to_log()
|
|
151
|
+
# run_failed already raised the composer banner. Anything else
|
|
152
|
+
# that escaped the harness, including SSL, uses that same banner.
|
|
153
|
+
if self._ui_state.detail != "failed" and self._presenter is not None:
|
|
154
|
+
self._presenter._show_banner("run_failed", {"message": str(exc)})
|
|
155
|
+
finally:
|
|
156
|
+
if generation != self._run_generation:
|
|
157
|
+
# A newer turn already started; do not clobber its busy state
|
|
158
|
+
# or dispatch the queue a second time.
|
|
159
|
+
return
|
|
160
|
+
child_question_pending = bool(
|
|
161
|
+
self._pending_question_id and self._pending_question_agent_id
|
|
162
|
+
)
|
|
163
|
+
cancel_reason = self.sink.cancel_reason if self.sink.cancelled else None
|
|
164
|
+
dispatch_queued = (
|
|
165
|
+
not child_question_pending
|
|
166
|
+
and bool(self._queued_turns)
|
|
167
|
+
and cancel_reason not in {"user_cancel", "quit"}
|
|
168
|
+
)
|
|
169
|
+
if dispatch_queued:
|
|
170
|
+
# Skip the idle chrome/focus pass — it forced a full layout
|
|
171
|
+
# immediately before the next turn mounted more widgets.
|
|
172
|
+
self.call_after_refresh(self._start_turn, self.pop_queued_turn())
|
|
173
|
+
return
|
|
174
|
+
if self._presenter is not None:
|
|
175
|
+
self._ui_state.phase = "paused" if child_question_pending else "idle"
|
|
176
|
+
self._presenter.refresh_chrome()
|
|
177
|
+
self._busy = False
|
|
178
|
+
if self._reload_queued:
|
|
179
|
+
from coding_agent.tui.commands.manager import reload_project
|
|
180
|
+
|
|
181
|
+
self._reload_queued = False
|
|
182
|
+
self.run_worker(reload_project(self), exclusive=False)
|
|
183
|
+
if not child_question_pending:
|
|
184
|
+
self._pending_question_id = None
|
|
185
|
+
self._pending_question_default = ""
|
|
186
|
+
self.sink.reset_cancel()
|
|
187
|
+
voice_addon = getattr(self, "_voice_addon", None)
|
|
188
|
+
if getattr(voice_addon, "_armed", False):
|
|
189
|
+
# Failed/cancelled runs do not reach the harness after_run hook.
|
|
190
|
+
from coding_agent.tui.composer.voice_mode import close_voice_addon
|
|
191
|
+
|
|
192
|
+
close_voice_addon(self)
|
|
193
|
+
if getattr(self, "_voice_active", False):
|
|
194
|
+
# The composer is hidden. Leave focus alone until voice mode ends.
|
|
195
|
+
return
|
|
196
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
197
|
+
if not child_question_pending:
|
|
198
|
+
prompt.submit_on_enter = True
|
|
199
|
+
prompt.disabled = False
|
|
200
|
+
self._update_composer_hint()
|
|
201
|
+
prompt.focus()
|
|
202
|
+
|
|
203
|
+
async def _run_agent_turn(self, user_input: Content) -> HarnessResult:
|
|
204
|
+
assert self._agent is not None
|
|
205
|
+
result = await self._agent.run(user_input)
|
|
206
|
+
return result
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""TUI screens: modal host plus resume, history, file selector, and inspectors."""
|
|
2
|
+
|
|
3
|
+
from coding_agent.tui.screens.context import ContextBucketChip, ContextModal
|
|
4
|
+
from coding_agent.tui.screens.diff import DiffFileCard, DiffModal
|
|
5
|
+
from coding_agent.tui.screens.extensions import ExtensionsModal
|
|
6
|
+
from coding_agent.tui.screens.file_selector import (
|
|
7
|
+
FileOption,
|
|
8
|
+
WorkspaceFileIndex,
|
|
9
|
+
active_file_mention,
|
|
10
|
+
complete_file_mention,
|
|
11
|
+
file_matches,
|
|
12
|
+
)
|
|
13
|
+
from coding_agent.tui.screens.history import load_session_history
|
|
14
|
+
from coding_agent.tui.screens.learning import LearningCard, LearningModal
|
|
15
|
+
from coding_agent.tui.screens.langfuse import LangfuseSetupScreen
|
|
16
|
+
from coding_agent.tui.screens.modal import (
|
|
17
|
+
ContentModal,
|
|
18
|
+
EmptyState,
|
|
19
|
+
ModalBase,
|
|
20
|
+
ModalCloseButton,
|
|
21
|
+
ModalScroll,
|
|
22
|
+
)
|
|
23
|
+
from coding_agent.tui.screens.onboard import OnboardApp, ProviderOnboardScreen, ProviderWizard
|
|
24
|
+
from coding_agent.tui.screens.resume import ResumeApp, SessionOption, load_session_options
|
|
25
|
+
from coding_agent.tui.tools.images import ImageModal
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ContentModal",
|
|
29
|
+
"ContextBucketChip",
|
|
30
|
+
"ContextModal",
|
|
31
|
+
"DiffFileCard",
|
|
32
|
+
"DiffModal",
|
|
33
|
+
"ExtensionsModal",
|
|
34
|
+
"EmptyState",
|
|
35
|
+
"FileOption",
|
|
36
|
+
"WorkspaceFileIndex",
|
|
37
|
+
"ImageModal",
|
|
38
|
+
"LearningCard",
|
|
39
|
+
"LearningModal",
|
|
40
|
+
"LangfuseSetupScreen",
|
|
41
|
+
"ModalBase",
|
|
42
|
+
"ModalCloseButton",
|
|
43
|
+
"ModalScroll",
|
|
44
|
+
"OnboardApp",
|
|
45
|
+
"ProviderOnboardScreen",
|
|
46
|
+
"ProviderWizard",
|
|
47
|
+
"ResumeApp",
|
|
48
|
+
"SessionOption",
|
|
49
|
+
"active_file_mention",
|
|
50
|
+
"complete_file_mention",
|
|
51
|
+
"file_matches",
|
|
52
|
+
"load_session_history",
|
|
53
|
+
"load_session_options",
|
|
54
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Ask-user question and approval flow mixed into CodingAgentApp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from core_harness import Answer
|
|
8
|
+
|
|
9
|
+
from coding_agent.tui.composer.input import PromptInput, QueuedPrompt
|
|
10
|
+
from coding_agent.tui.composer.slash_menu import SlashMenu
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QuestionSurface:
|
|
14
|
+
"""Ask-user composer flow mixed into CodingAgentApp."""
|
|
15
|
+
|
|
16
|
+
def _show_question(self, payload: Mapping[str, Any]) -> None:
|
|
17
|
+
request_id = str(payload.get("request_id") or "")
|
|
18
|
+
question = str(payload.get("question") or "")
|
|
19
|
+
choices = [str(choice) for choice in payload.get("choices") or []]
|
|
20
|
+
default = str(payload.get("default") or "")
|
|
21
|
+
kind = str(payload.get("kind") or "")
|
|
22
|
+
if not request_id or not question:
|
|
23
|
+
self.add_notice("The agent sent an invalid question request.", "error")
|
|
24
|
+
return
|
|
25
|
+
self._pending_question_id = request_id
|
|
26
|
+
self._pending_question_agent_id = str(payload.get("agent_id") or "") if payload.get("parent_id") else ""
|
|
27
|
+
self._pending_question_default = default
|
|
28
|
+
self._ui_state.phase = "paused"
|
|
29
|
+
self._ui_state.detail = "waiting for user"
|
|
30
|
+
self._update_composer_hint()
|
|
31
|
+
menu = self.query_one(
|
|
32
|
+
"#approval-menu" if kind == "approval" else "#slash-menu",
|
|
33
|
+
SlashMenu,
|
|
34
|
+
)
|
|
35
|
+
menu.set_question(
|
|
36
|
+
question,
|
|
37
|
+
choices,
|
|
38
|
+
default=default,
|
|
39
|
+
kind=kind,
|
|
40
|
+
)
|
|
41
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
42
|
+
prompt.submit_on_enter = kind == "approval"
|
|
43
|
+
prompt.disabled = False
|
|
44
|
+
# Keep a follow-up the user is already typing; approval choices live
|
|
45
|
+
# in the menu, not in the composer.
|
|
46
|
+
if not prompt.value.strip():
|
|
47
|
+
prompt.value = "" if choices else default
|
|
48
|
+
prompt.cursor_position = len(prompt.value)
|
|
49
|
+
prompt.focus()
|
|
50
|
+
queued = getattr(self, "_queued_turns", None)
|
|
51
|
+
if queued:
|
|
52
|
+
self.query_one("#queued-prompt-row", QueuedPrompt).refresh_queue(queued)
|
|
53
|
+
|
|
54
|
+
async def _answer_question(self, answer: str) -> None:
|
|
55
|
+
request_id = self._pending_question_id
|
|
56
|
+
if request_id is None:
|
|
57
|
+
return
|
|
58
|
+
self._pending_question_id = None
|
|
59
|
+
self._pending_question_agent_id = ""
|
|
60
|
+
self._pending_question_default = ""
|
|
61
|
+
self.query_one("#slash-menu", SlashMenu).set_commands(())
|
|
62
|
+
self.query_one("#approval-menu", SlashMenu).set_commands(())
|
|
63
|
+
self.submit_op(Answer(request_id, answer or ""))
|
|
64
|
+
self._ui_state.phase = "thinking" if self._busy else "idle"
|
|
65
|
+
self._ui_state.detail = "resuming" if self._busy else "ready"
|
|
66
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
67
|
+
prompt.submit_on_enter = True
|
|
68
|
+
prompt.disabled = False
|
|
69
|
+
self._update_composer_hint()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Context inspector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual import events
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
|
|
13
|
+
from coding_agent.tui.screens.modal import EmptyState, ModalBase, ModalCloseButton, ModalScroll
|
|
14
|
+
from coding_agent.tui.theme import CONTEXT_MODAL_CSS
|
|
15
|
+
from core_harness.context import ContextMessage, ContextReport
|
|
16
|
+
|
|
17
|
+
_ROLE_COLORS = {
|
|
18
|
+
"system": "#738794",
|
|
19
|
+
"user": "#8eafc2",
|
|
20
|
+
"assistant": "#c7b66e",
|
|
21
|
+
"tool": "#79a985",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_ROLE_SHORT = {
|
|
25
|
+
"system": "SYS",
|
|
26
|
+
"user": "MSG",
|
|
27
|
+
"assistant": "AST",
|
|
28
|
+
"tool": "TOOL",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _token_label(value: int) -> str:
|
|
33
|
+
if value >= 10_000:
|
|
34
|
+
return f"{value / 1000:.1f}k"
|
|
35
|
+
return f"{value:,}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _meter_bar(fraction: float, width: int = 22) -> str:
|
|
39
|
+
clamped = max(0.0, min(1.0, fraction))
|
|
40
|
+
filled = int(round(clamped * width))
|
|
41
|
+
return "█" * filled + "░" * (width - filled)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContextBucketChip(Static, can_focus=True):
|
|
45
|
+
"""Clickable role filter for the context breakdown."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, role: str, caption: str, *, active: bool = False) -> None:
|
|
48
|
+
self.role = role
|
|
49
|
+
classes = "context-chip context-chip-active" if active else "context-chip"
|
|
50
|
+
super().__init__(caption, classes=classes)
|
|
51
|
+
|
|
52
|
+
def on_click(self, event: events.Click) -> None:
|
|
53
|
+
event.stop()
|
|
54
|
+
screen = self.screen
|
|
55
|
+
if isinstance(screen, ContextModal):
|
|
56
|
+
screen.set_filter(self.role)
|
|
57
|
+
|
|
58
|
+
def on_key(self, event: events.Key) -> None:
|
|
59
|
+
if event.key in {"enter", "space"}:
|
|
60
|
+
event.stop()
|
|
61
|
+
screen = self.screen
|
|
62
|
+
if isinstance(screen, ContextModal):
|
|
63
|
+
screen.set_filter(self.role)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ContextModal(ModalBase[None]):
|
|
67
|
+
"""Small interactive breakdown of model context."""
|
|
68
|
+
|
|
69
|
+
CSS = CONTEXT_MODAL_CSS
|
|
70
|
+
BINDINGS = [
|
|
71
|
+
Binding("escape", "close_modal", "Close", show=False, priority=True),
|
|
72
|
+
Binding("left", "prev_filter", "Previous", show=False),
|
|
73
|
+
Binding("right", "next_filter", "Next", show=False),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def __init__(self, report: ContextReport) -> None:
|
|
77
|
+
super().__init__()
|
|
78
|
+
self.report = report
|
|
79
|
+
self._filter: Optional[str] = None
|
|
80
|
+
self._filters: tuple[str, ...] = ("all",) + tuple(
|
|
81
|
+
bucket.role for bucket in report.buckets if bucket.count
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
85
|
+
with Container(id="context-pane", classes="modal-pane"):
|
|
86
|
+
with Horizontal(id="context-header"):
|
|
87
|
+
yield Static("Context", id="context-title")
|
|
88
|
+
yield ModalCloseButton("Esc", id="modal-close")
|
|
89
|
+
yield Static(self._render_meters(), id="context-meters")
|
|
90
|
+
with Horizontal(id="context-buckets"):
|
|
91
|
+
for role, caption in self._chip_captions():
|
|
92
|
+
yield ContextBucketChip(role, caption, active=role == "all")
|
|
93
|
+
yield Static(self._render_meta(), id="context-meta")
|
|
94
|
+
with ModalScroll(id="context-body", classes="modal-body"):
|
|
95
|
+
if not self.report.messages:
|
|
96
|
+
yield EmptyState(
|
|
97
|
+
"No conversation yet",
|
|
98
|
+
"Send a prompt first. /context then splits messages vs tool results.",
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
yield Static(self._render_rows(), id="context-list", markup=False)
|
|
102
|
+
yield Static(
|
|
103
|
+
"← → switch view · click to filter",
|
|
104
|
+
id="context-hint",
|
|
105
|
+
classes="modal-footer",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def set_filter(self, role: str) -> None:
|
|
109
|
+
self._filter = None if role in {"", "all"} else role
|
|
110
|
+
self._refresh()
|
|
111
|
+
|
|
112
|
+
def action_prev_filter(self) -> None:
|
|
113
|
+
self._nudge_filter(-1)
|
|
114
|
+
|
|
115
|
+
def action_next_filter(self) -> None:
|
|
116
|
+
self._nudge_filter(1)
|
|
117
|
+
|
|
118
|
+
def _nudge_filter(self, step: int) -> None:
|
|
119
|
+
current = self._filter or "all"
|
|
120
|
+
if current not in self._filters:
|
|
121
|
+
current = "all"
|
|
122
|
+
index = self._filters.index(current)
|
|
123
|
+
self.set_filter(self._filters[(index + step) % len(self._filters)])
|
|
124
|
+
|
|
125
|
+
def _chip_captions(self) -> list[tuple[str, str]]:
|
|
126
|
+
chips = [("all", f"All {_token_label(self.report.stored_tokens)}")]
|
|
127
|
+
for bucket in self.report.buckets:
|
|
128
|
+
if not bucket.count:
|
|
129
|
+
continue
|
|
130
|
+
chips.append(
|
|
131
|
+
(bucket.role, f"{bucket.label} {_token_label(bucket.tokens)}")
|
|
132
|
+
)
|
|
133
|
+
return chips
|
|
134
|
+
|
|
135
|
+
def _render_meters(self) -> Text:
|
|
136
|
+
stored = self.report.stored_tokens
|
|
137
|
+
limit = self.report.context_limit
|
|
138
|
+
stored_frac = stored / limit if limit else 0.0
|
|
139
|
+
text = Text()
|
|
140
|
+
text.append("context ", style="#686868")
|
|
141
|
+
text.append(f"{stored:,}", style="bold #d0d0d0")
|
|
142
|
+
text.append(" tok ", style="#686868")
|
|
143
|
+
text.append(_meter_bar(stored_frac), style="#8eafc2")
|
|
144
|
+
if limit:
|
|
145
|
+
text.append(f" {min(stored_frac * 100, 100):.0f}% of {limit:,}", style="#686868")
|
|
146
|
+
return text
|
|
147
|
+
|
|
148
|
+
def _render_meta(self) -> str:
|
|
149
|
+
active = "all messages" if self._filter is None else f"{self._filter} only"
|
|
150
|
+
meta = (
|
|
151
|
+
f"{self.report.message_count} messages · "
|
|
152
|
+
f"{self.report.tool_result_count} tool results"
|
|
153
|
+
)
|
|
154
|
+
if self.report.stubbed_result_count:
|
|
155
|
+
meta += f" · {self.report.stubbed_result_count} legacy stubs"
|
|
156
|
+
return f"{meta} · {active}"
|
|
157
|
+
|
|
158
|
+
def _visible_messages(self) -> tuple[ContextMessage, ...]:
|
|
159
|
+
if self._filter is None:
|
|
160
|
+
return self.report.messages
|
|
161
|
+
return tuple(item for item in self.report.messages if item.role == self._filter)
|
|
162
|
+
|
|
163
|
+
def _render_rows(self) -> Text:
|
|
164
|
+
rows = Text()
|
|
165
|
+
visible = self._visible_messages()
|
|
166
|
+
if not visible:
|
|
167
|
+
rows.append("Nothing in this bucket.", style="#686868")
|
|
168
|
+
return rows
|
|
169
|
+
rows.append("TYPE TOKENS STATE CONTENT\n", style="#555555")
|
|
170
|
+
for item in visible:
|
|
171
|
+
color = _ROLE_COLORS.get(item.role, "#bdbdbd")
|
|
172
|
+
short = _ROLE_SHORT.get(item.role, item.role[:3].upper())
|
|
173
|
+
rows.append(f"{short:<4} ", style=color)
|
|
174
|
+
rows.append(f"{_token_label(item.tokens):>6}", style="#c8c8c8")
|
|
175
|
+
if item.stubbed:
|
|
176
|
+
rows.append(" stub", style="#c67b82")
|
|
177
|
+
elif item.role == "tool":
|
|
178
|
+
rows.append(" full", style="#686868")
|
|
179
|
+
else:
|
|
180
|
+
rows.append(" ", style="#101010")
|
|
181
|
+
label = item.tool_name or item.preview
|
|
182
|
+
if item.tool_name and item.preview:
|
|
183
|
+
label = f"{item.tool_name} · {item.preview}"
|
|
184
|
+
rows.append(f" {label[:64]}", style="#8a8a8a")
|
|
185
|
+
rows.append("\n")
|
|
186
|
+
return rows
|
|
187
|
+
|
|
188
|
+
def _refresh(self) -> None:
|
|
189
|
+
self.query_one("#context-meta", Static).update(self._render_meta())
|
|
190
|
+
listing = self.query("#context-list")
|
|
191
|
+
if listing:
|
|
192
|
+
listing.first().update(self._render_rows())
|
|
193
|
+
current = self._filter or "all"
|
|
194
|
+
for chip in self.query(ContextBucketChip):
|
|
195
|
+
chip.set_class(chip.role == current, "context-chip-active")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Live view of Symphony agents and the CPU, memory, and disk they use.
|
|
2
|
+
|
|
3
|
+
The layout matches the subagent task list (``ctrl+g``): a centered option
|
|
4
|
+
list whose rows refresh in place. Open it with ``/dashboard``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.widgets import OptionList
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
from coding_agent.addons.persistence.active import ActiveSession, list_active
|
|
16
|
+
from coding_agent.resources.usage import sample_usage
|
|
17
|
+
from coding_agent.tui.screens.modal import ModalBase
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_KIND_COLOR = {"tui": "#d7a84b", "run": "#72a57a"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _Sample:
|
|
25
|
+
cpu_seconds: float | None = None
|
|
26
|
+
sampled_at: float | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentDashboard(ModalBase[None]):
|
|
30
|
+
"""Small list of every live Symphony agent and its resource use."""
|
|
31
|
+
|
|
32
|
+
CSS = """
|
|
33
|
+
AgentDashboard { align: center middle; background: #000000 60%; }
|
|
34
|
+
#agent-dashboard { width: 90%; height: 70%; border: round #484F58; background: #0A0A0A; }
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._samples: dict[int, _Sample] = {}
|
|
40
|
+
|
|
41
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
42
|
+
yield OptionList(id="agent-dashboard")
|
|
43
|
+
|
|
44
|
+
def on_mount(self) -> None:
|
|
45
|
+
self.query_one(OptionList).border_title = "Agents · CPU RAM disk · Esc back"
|
|
46
|
+
self.refresh_agents()
|
|
47
|
+
self.set_interval(1, self.refresh_agents)
|
|
48
|
+
self.query_one(OptionList).focus()
|
|
49
|
+
|
|
50
|
+
def refresh_agents(self) -> None:
|
|
51
|
+
options = self.query_one(OptionList)
|
|
52
|
+
highlighted = options.highlighted
|
|
53
|
+
options.clear_options()
|
|
54
|
+
records = list_active()
|
|
55
|
+
seen: set[int] = set()
|
|
56
|
+
for record in records:
|
|
57
|
+
seen.add(record.pid)
|
|
58
|
+
options.add_option(Option(self._row(record), id=str(record.pid)))
|
|
59
|
+
self._samples = {pid: sample for pid, sample in self._samples.items() if pid in seen}
|
|
60
|
+
if not records:
|
|
61
|
+
options.add_option(Option("No agents are running.", disabled=True))
|
|
62
|
+
else:
|
|
63
|
+
options.highlighted = min(highlighted or 0, len(records) - 1)
|
|
64
|
+
|
|
65
|
+
def _row(self, record: ActiveSession) -> Text:
|
|
66
|
+
previous = self._samples.get(record.pid, _Sample())
|
|
67
|
+
usage, cpu_seconds, sampled_at = sample_usage(
|
|
68
|
+
record.pid,
|
|
69
|
+
workspace=record.workspace or None,
|
|
70
|
+
previous_cpu_seconds=previous.cpu_seconds,
|
|
71
|
+
previous_at=previous.sampled_at,
|
|
72
|
+
)
|
|
73
|
+
self._samples[record.pid] = _Sample(cpu_seconds, sampled_at)
|
|
74
|
+
color = _KIND_COLOR.get(record.kind, "#a2adb8")
|
|
75
|
+
label = record.label.strip() or _workspace_name(record.workspace) or record.session_id[:8]
|
|
76
|
+
content = Text(f"{record.kind:4} ", style=color)
|
|
77
|
+
content.append(f"{_clip(label, 24):<24} ")
|
|
78
|
+
content.append(f"{_clip(record.model_id or 'model unset', 24):<24}", style="#737373")
|
|
79
|
+
content.append(f" {usage.line()}", style="#a2adb8")
|
|
80
|
+
return content
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _workspace_name(workspace: str) -> str:
|
|
84
|
+
return workspace.rstrip("/").rsplit("/", 1)[-1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _clip(value: str, limit: int) -> str:
|
|
88
|
+
text = " ".join(value.split())
|
|
89
|
+
if len(text) <= limit:
|
|
90
|
+
return text
|
|
91
|
+
return text[: limit - 1].rstrip() + "…"
|