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,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() + "…"