zjcode 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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,969 @@
1
+ """Live panel showing subagents fanned out from within `js_eval` calls.
2
+
3
+ When the agent writes code that calls the top-level `task()` global, each
4
+ dispatch runs as a subagent *inside* a single `js_eval` tool call which is
5
+ invisible to the normal message stream. The QuickJS task bridge emits
6
+ lifecycle events on the custom stream. This widget consumes them and renders
7
+ a docked, live-updating fan-out panel.
8
+
9
+ Trust note: `description`/`subagent_type` and `error` strings originate
10
+ from LLM-authored JavaScript executed in the sandbox, so they are untrusted.
11
+ We route every rendered string through `sanitize_control_chars` which strips
12
+ control/escape/bidi characters and only ever render via `Content.styled` /
13
+ `markup=False` `Static` updates, so embedded Textual markup and terminal
14
+ escapes cannot influence rendering or panel state.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import logging
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from typing import TYPE_CHECKING, Any, Literal
24
+
25
+ from textual.containers import Horizontal, Vertical, VerticalScroll
26
+ from textual.content import Content
27
+ from textual.css.query import NoMatches, TooManyMatches
28
+ from textual.reactive import reactive
29
+ from textual.widgets import Static
30
+
31
+ from deepagents_code.config import get_glyphs
32
+ from deepagents_code.formatting import format_duration
33
+ from deepagents_code.theme import get_theme_colors
34
+ from deepagents_code.tui.widgets.loading import Spinner
35
+ from deepagents_code.unicode_security import sanitize_control_chars
36
+
37
+ if TYPE_CHECKING:
38
+ from textual import events
39
+ from textual.app import ComposeResult
40
+ from textual.timer import Timer
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ SubagentStatus = Literal["running", "done", "error", "cancelled"]
45
+
46
+ _MODEL_COL = 16
47
+ _TIMING_COL = 6
48
+ _STATUS_COL = 5
49
+ _MIN_TASK_COL = 16
50
+ _SCROLLBAR_RESERVE = 2
51
+ _FALLBACK_WIDTH = 100
52
+ _MIN_BODY_HEIGHT = 3
53
+ _MAX_BODY_HEIGHT = 12
54
+ _AGENTS_CHROME_LINES = 1
55
+ _TICK_INTERVAL = 0.1
56
+ _LABEL_FALLBACK_MAX_CHARS = 60
57
+
58
+
59
+ def _right_block_width() -> int:
60
+ """Total width of the right-aligned metadata block (model→time).
61
+
62
+ Returns:
63
+ The combined character width of the model and time columns.
64
+ """
65
+ gap = 2
66
+ return _MODEL_COL + gap + _TIMING_COL
67
+
68
+
69
+ @dataclass
70
+ class _SubagentRecord:
71
+ """One subagent's live state within a phase."""
72
+
73
+ id: str
74
+ """Per-dispatch subagent id from the stream event."""
75
+
76
+ label: str
77
+ """Sanitized, display-ready task label for the row."""
78
+
79
+ status: SubagentStatus = "running"
80
+ """Lifecycle state; starts running and moves to a terminal value once."""
81
+
82
+ started_monotonic: float = field(default_factory=time.monotonic)
83
+ """Monotonic timestamp captured when the record was created."""
84
+
85
+ duration_ms: int | None = None
86
+ """Measured duration once finished; None while still running."""
87
+
88
+ error: str | None = None
89
+ """Failure reason, set only when status is error."""
90
+
91
+ def elapsed_seconds(self) -> float:
92
+ """Seconds since this subagent started (live for running rows).
93
+
94
+ Returns:
95
+ The measured duration once finished, else the live elapsed time.
96
+ """
97
+ if self.duration_ms is not None:
98
+ return self.duration_ms / 1000
99
+ return max(0.0, time.monotonic() - self.started_monotonic)
100
+
101
+
102
+ @dataclass
103
+ class _Phase:
104
+ """One `js_eval` fan-out batch, keyed by the eval's tool-call id."""
105
+
106
+ eval_id: str
107
+ """Parent `js_eval` tool-call id, or empty string when none was provided."""
108
+
109
+ index: int
110
+ """1-based display ordinal assigned when the phase is created."""
111
+
112
+ records: dict[str, _SubagentRecord] = field(default_factory=dict)
113
+ """Subagent records keyed by id; kept in sync with `order` via `add`."""
114
+
115
+ order: list[str] = field(default_factory=list)
116
+ """Record ids in arrival order, defining render sequence."""
117
+
118
+ def add(self, record: _SubagentRecord) -> None:
119
+ """Insert or replace a subagent record, preserving arrival order."""
120
+ if record.id not in self.records:
121
+ self.order.append(record.id)
122
+ self.records[record.id] = record
123
+
124
+ def counts(self) -> tuple[int, int]:
125
+ """Return (finished, total) subagent counts for this phase."""
126
+ total = len(self.records)
127
+ done = sum(1 for r in self.records.values() if r.status != "running")
128
+ return done, total
129
+
130
+ def any_running(self) -> bool:
131
+ """Whether any subagent in this phase is still running.
132
+
133
+ Returns:
134
+ True if at least one subagent has not finished.
135
+ """
136
+ return any(r.status == "running" for r in self.records.values())
137
+
138
+ def any_error(self) -> bool:
139
+ """Whether any subagent in this phase ended in error.
140
+
141
+ Returns:
142
+ True if at least one subagent ended in error.
143
+ """
144
+ return any(r.status == "error" for r in self.records.values())
145
+
146
+ def any_cancelled(self) -> bool:
147
+ """Whether any subagent in this phase was cancelled.
148
+
149
+ Returns:
150
+ True if at least one subagent was cancelled.
151
+ """
152
+ return any(r.status == "cancelled" for r in self.records.values())
153
+
154
+ def all_terminal(self) -> bool:
155
+ """Whether the phase has records and none are still running.
156
+
157
+ Returns:
158
+ True if the phase has at least one record and all have finished.
159
+ """
160
+ return bool(self.records) and not self.any_running()
161
+
162
+ def elapsed_seconds(self) -> float:
163
+ """Wall-clock elapsed for the phase (frozen once all subagents end).
164
+
165
+ Measured from the first subagent's start to the last one's finish, so
166
+ the value is continuous: the live "now - first start" simply freezes
167
+ when the final subagent ends (rather than collapsing to the longest
168
+ single duration).
169
+
170
+ Returns:
171
+ Live elapsed while running, else first-start to last-finish.
172
+ """
173
+ if not self.records:
174
+ return 0.0
175
+ earliest = min(r.started_monotonic for r in self.records.values())
176
+ if self.all_terminal():
177
+ latest_end = max(
178
+ r.started_monotonic + r.elapsed_seconds() for r in self.records.values()
179
+ )
180
+ return max(0.0, latest_end - earliest)
181
+ return max(0.0, time.monotonic() - earliest)
182
+
183
+
184
+ def _format_timing(seconds: float) -> str:
185
+ """Stable-width elapsed string for the table.
186
+
187
+ `format_duration` drops the decimal on whole seconds (`4s` vs `4.2s`),
188
+ which makes a live-ticking value jump left/right by a character each tick.
189
+ Always keep one decimal under a minute so the width stays constant.
190
+
191
+ Returns:
192
+ e.g. `4.0s` or `4.2s` under a minute, else `format_duration`'s output.
193
+ """
194
+ if seconds < 60: # noqa: PLR2004
195
+ return f"{seconds:.1f}s"
196
+ return format_duration(seconds)
197
+
198
+
199
+ def _sanitize(text: str, *, max_chars: int) -> str:
200
+ """Neutralize control/escape/bidi chars and bound length for a one-line label.
201
+
202
+ Inputs are LLM/JS-authored and untrusted. This flattens to a single line (newlines
203
+ and ANSI escapes become spaces) so a crafted description cannot inject terminal
204
+ escapes or extra rows.
205
+
206
+ Returns:
207
+ A single-line, length-bounded string safe to render as plain text.
208
+ """
209
+ return sanitize_control_chars(text, keep_newlines=False, max_length=max_chars)
210
+
211
+
212
+ class SubagentPanel(Vertical):
213
+ """Docked two-pane panel visualizing `js_eval` subagent fan-out by phase.
214
+
215
+ Hidden until the first spawn event. Phases (one per `js_eval`) list on the
216
+ left and the selected phase's subagents render as a scrollable table on the
217
+ right. Focus the panel and use up/down to revisit finished phases. Expands
218
+ while any phase runs, collapses to the header when the turn goes idle, and
219
+ re-expands when a new phase starts.
220
+ """
221
+
222
+ can_focus = True
223
+ can_focus_children = False
224
+
225
+ DEFAULT_CSS = """
226
+ SubagentPanel {
227
+ height: auto;
228
+ background: $surface;
229
+ border-top: solid $primary;
230
+ display: none;
231
+ padding: 1 2;
232
+ }
233
+
234
+ SubagentPanel.-collapsed {
235
+ padding: 0 2;
236
+ }
237
+
238
+ SubagentPanel.-visible {
239
+ display: block;
240
+ }
241
+
242
+ SubagentPanel:focus {
243
+ border-top: solid $accent;
244
+ }
245
+
246
+ SubagentPanel #subagent-header {
247
+ width: 1fr;
248
+ height: 1;
249
+ text-style: bold;
250
+ }
251
+
252
+ SubagentPanel #subagent-body {
253
+ width: 1fr;
254
+ height: auto;
255
+ margin-top: 1;
256
+ }
257
+
258
+ SubagentPanel #subagent-body.-collapsed {
259
+ display: none;
260
+ }
261
+
262
+ SubagentPanel #subagent-phases-scroll {
263
+ width: 24;
264
+ height: 100%;
265
+ border-right: solid $primary-darken-2;
266
+ padding-right: 2;
267
+ margin-right: 2;
268
+ }
269
+
270
+ SubagentPanel #subagent-phases-scroll.-hidden {
271
+ display: none;
272
+ }
273
+
274
+ SubagentPanel #subagent-agents-scroll {
275
+ width: 1fr;
276
+ height: 100%;
277
+ }
278
+ """
279
+
280
+ expanded: reactive[bool] = reactive(default=True, init=False)
281
+
282
+ def __init__(self, **kwargs: Any) -> None:
283
+ """Initialize an empty, hidden panel."""
284
+ super().__init__(**kwargs)
285
+ self._phases: dict[str, _Phase] = {}
286
+ self._phase_order: list[str] = []
287
+ self._active_eval_id: str | None = None
288
+ self._selected_eval_id: str | None = None
289
+ self._model_label: str | None = None
290
+ self._applied_height: int | None = None
291
+ self._last_render: dict[str, str] = {}
292
+ self._spinner = Spinner()
293
+ self._timer: Timer | None = None
294
+ # When True, the next subagent `start` clears the previous workflow's
295
+ # fan-out before adding the new row (armed by `prepare_turn`). Lets the
296
+ # panel persist across turns until a new workflow actually begins.
297
+ self._pending_reset = False
298
+
299
+ def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method
300
+ """Yield the header line and the two-pane body (phases | agents)."""
301
+ yield Static("", id="subagent-header", markup=False)
302
+ with Horizontal(id="subagent-body"):
303
+ with VerticalScroll(id="subagent-phases-scroll"):
304
+ yield Static("", id="subagent-phases", markup=False)
305
+ with VerticalScroll(id="subagent-agents-scroll"):
306
+ yield Static("", id="subagent-agents", markup=False)
307
+
308
+ @property
309
+ def _active_phase(self) -> _Phase | None:
310
+ if self._active_eval_id is None:
311
+ return self._phases.get("")
312
+ return self._phases.get(self._active_eval_id)
313
+
314
+ def _displayed_phase(self) -> _Phase | None:
315
+ """The phase whose table is shown — the user's pick, else the active one.
316
+
317
+ Returns:
318
+ The selected phase if the user navigated to one, else the active
319
+ (latest) phase, or None when no phase has started.
320
+ """
321
+ if self._selected_eval_id is not None:
322
+ phase = self._phases.get(self._selected_eval_id)
323
+ if phase is not None:
324
+ return phase
325
+ return self._active_phase
326
+
327
+ def on_subagent_event(self, event: dict[str, Any]) -> None:
328
+ """Apply one validated subagent lifecycle event.
329
+
330
+ The caller (textual adapter) has already checked `type == "subagent"`
331
+ and that this is the main-agent namespace. We defensively re-validate
332
+ every field here so malformed payloads can never corrupt panel state.
333
+ """
334
+ phase = event.get("phase")
335
+ sub_id = event.get("id")
336
+ if not isinstance(sub_id, str) or not sub_id:
337
+ # Producer/consumer contract drift — leave a breadcrumb rather than
338
+ # dropping the event with no trace.
339
+ logger.debug("Dropping subagent event with missing/invalid id: %r", event)
340
+ return
341
+ eval_id = event.get("eval_id")
342
+ eval_key = eval_id if isinstance(eval_id, str) else ""
343
+
344
+ if phase == "start":
345
+ self._handle_start(sub_id, eval_key, event)
346
+ elif phase in {"complete", "error"}:
347
+ self._handle_finish(sub_id, eval_key, phase, event)
348
+ else:
349
+ logger.debug(
350
+ "Dropping subagent event with unrecognized phase %r (id=%s)",
351
+ phase,
352
+ sub_id,
353
+ )
354
+ return
355
+
356
+ self._refresh()
357
+
358
+ def _handle_start(self, sub_id: str, eval_key: str, event: dict[str, Any]) -> None:
359
+ """Create/replace a running record and (re-)show the panel."""
360
+ if self._pending_reset:
361
+ # A new workflow is starting — drop the previous turn's fan-out now.
362
+ self._clear()
363
+ phase = self._ensure_phase(eval_key)
364
+ self._active_eval_id = eval_key
365
+
366
+ record = _SubagentRecord(
367
+ id=sub_id,
368
+ label=_sanitize(self._row_label(event), max_chars=200),
369
+ )
370
+ phase.add(record)
371
+ self._show()
372
+ self._apply_body_height()
373
+ self._ensure_timer()
374
+
375
+ def _ensure_phase(self, eval_key: str) -> _Phase:
376
+ """Return the phase for `eval_key`, creating and ordering it if new.
377
+
378
+ Returns:
379
+ The existing or newly created `_Phase` for this eval batch.
380
+ """
381
+ phase = self._phases.get(eval_key)
382
+ if phase is None:
383
+ phase = _Phase(eval_id=eval_key, index=len(self._phase_order) + 1)
384
+ self._phases[eval_key] = phase
385
+ self._phase_order.append(eval_key)
386
+ return phase
387
+
388
+ @staticmethod
389
+ def _row_label(event: dict[str, Any]) -> str:
390
+ """Build the row's task label: `"<type>: <label>"`.
391
+
392
+ Returns:
393
+ The combined `"<type>: <label>"` string for the task column.
394
+ """
395
+ sub_type = event.get("subagent_type", "subagent")
396
+ label = event.get("label")
397
+ if not isinstance(label, str) or not label:
398
+ description = event.get("description")
399
+ label = description if isinstance(description, str) else ""
400
+ label = " ".join(label.split())[:_LABEL_FALLBACK_MAX_CHARS]
401
+ return f"{sub_type}: {label}"
402
+
403
+ def _handle_finish(
404
+ self, sub_id: str, eval_key: str, outcome: str, event: dict[str, Any]
405
+ ) -> None:
406
+ """Mark a record done/error, recording duration and stopping the timer.
407
+
408
+ An error with no matching `start` is adopted as a fresh row (see
409
+ `_adopt_orphan_finish`) so a dropped-start failure still surfaces.
410
+ """
411
+ record = self._find_record(sub_id)
412
+ if record is None:
413
+ if outcome != "error":
414
+ # A success with no matching `start` has no row or label to
415
+ # attach to, so ignore it rather than creating a phantom phase.
416
+ # Log it, though: a dropped `start` is the same producer/consumer
417
+ # contract drift the other drop paths leave breadcrumbs for.
418
+ logger.debug(
419
+ "Dropping subagent complete event with no matching start "
420
+ "(id=%s, eval_id=%s)",
421
+ sub_id,
422
+ eval_key,
423
+ )
424
+ return
425
+ # An error with no matching `start` (e.g. the start event was
426
+ # dropped on the wire) carries the failure string — synthesize a
427
+ # minimal record so it still surfaces instead of vanishing silently.
428
+ record = self._adopt_orphan_finish(sub_id, eval_key, event)
429
+ record.status = "done" if outcome == "complete" else "error"
430
+ duration = event.get("duration_ms")
431
+ if isinstance(duration, (int, float)):
432
+ record.duration_ms = int(duration)
433
+ if outcome == "error":
434
+ raw_err = event.get("error")
435
+ record.error = (
436
+ _sanitize(raw_err, max_chars=120) if isinstance(raw_err, str) else None
437
+ )
438
+ if not self._any_running():
439
+ self._stop_timer()
440
+
441
+ def _adopt_orphan_finish(
442
+ self, sub_id: str, eval_key: str, event: dict[str, Any]
443
+ ) -> _SubagentRecord:
444
+ """Create a record for a terminal event that has no matching `start`.
445
+
446
+ Places it in the event's phase (creating the phase if needed) and shows
447
+ the panel so a dropped-start failure is still visible to the user.
448
+
449
+ Returns:
450
+ The newly created record, already inserted into its phase.
451
+ """
452
+ if self._pending_reset:
453
+ # A dropped-start error is still evidence that a new workflow
454
+ # started, so clear the previous turn before surfacing it.
455
+ self._clear()
456
+ phase = self._ensure_phase(eval_key)
457
+ self._active_eval_id = eval_key
458
+ record = _SubagentRecord(
459
+ id=sub_id,
460
+ label=_sanitize(self._row_label(event), max_chars=200),
461
+ )
462
+ phase.add(record)
463
+ self._show()
464
+ self._apply_body_height()
465
+ return record
466
+
467
+ def _find_record(self, sub_id: str) -> _SubagentRecord | None:
468
+ """Locate a record by id across all phases.
469
+
470
+ Returns:
471
+ The matching record, or None if no phase holds that id.
472
+ """
473
+ for phase in self._phases.values():
474
+ record = phase.records.get(sub_id)
475
+ if record is not None:
476
+ return record
477
+ return None
478
+
479
+ def _any_running(self) -> bool:
480
+ """Whether any phase still has a running subagent.
481
+
482
+ Returns:
483
+ True if any subagent in any phase is still running.
484
+ """
485
+ return any(phase.any_running() for phase in self._phases.values())
486
+
487
+ def on_click(self, event: events.Click) -> None:
488
+ """Click the header to toggle; click a phase row to select it."""
489
+ if self._header_clicked(event):
490
+ self.toggle()
491
+ event.stop()
492
+ return
493
+ if len(self._phase_order) <= 1:
494
+ return
495
+ row = self._clicked_phase_row(event)
496
+ if row is not None and 0 <= row < len(self._phase_order):
497
+ self._selected_eval_id = self._phase_order[row]
498
+ self._refresh()
499
+ event.stop()
500
+
501
+ def _header_clicked(self, event: events.Click) -> bool:
502
+ """Whether the click landed on the header line.
503
+
504
+ Returns:
505
+ True if the click offset maps onto the header widget.
506
+ """
507
+ try:
508
+ header = self.query_one("#subagent-header", Static)
509
+ except (NoMatches, TooManyMatches): # not mounted yet
510
+ return False
511
+ return event.get_content_offset(header) is not None
512
+
513
+ def _clicked_phase_row(self, event: events.Click) -> int | None:
514
+ """Map a click in the phases pane to a phase index (0-based), or None.
515
+
516
+ Row 0 is the "Phases" title; rows 1..N map to phases in order.
517
+
518
+ Returns:
519
+ The 0-based phase index for the clicked row, or None if the click
520
+ was outside the phases pane.
521
+ """
522
+ try:
523
+ phases = self.query_one("#subagent-phases", Static)
524
+ except (NoMatches, TooManyMatches): # not mounted yet
525
+ return None
526
+ offset = event.get_content_offset(phases)
527
+ if offset is None:
528
+ return None
529
+ return offset.y - 1
530
+
531
+ def on_key(self, event: events.Key) -> None:
532
+ """Navigate phases with up/down (or j/k) while the panel is focused."""
533
+ if len(self._phase_order) <= 1:
534
+ return
535
+ if event.key in {"down", "j"}:
536
+ self._move_selection(1)
537
+ event.stop()
538
+ elif event.key in {"up", "k"}:
539
+ self._move_selection(-1)
540
+ event.stop()
541
+
542
+ def _move_selection(self, delta: int) -> None:
543
+ """Move the selected phase by `delta`, clamped to the phase list."""
544
+ if not self._phase_order:
545
+ return
546
+ current = self._displayed_phase()
547
+ current_key = current.eval_id if current else self._phase_order[0]
548
+ try:
549
+ index = self._phase_order.index(current_key)
550
+ except ValueError:
551
+ index = 0
552
+ new_index = max(0, min(len(self._phase_order) - 1, index + delta))
553
+ self._selected_eval_id = self._phase_order[new_index]
554
+ self._refresh()
555
+
556
+ def prepare_turn(self, *, model_label: str | None = None) -> None:
557
+ """Arm a deferred clear for a new turn without touching the panel yet.
558
+
559
+ The visible fan-out persists across turns; it is cleared lazily when the
560
+ next workflow actually starts a subagent (see `_handle_start`), so a turn
561
+ that spawns none leaves the previous results on screen. Refreshes the
562
+ session model used to label rows.
563
+ """
564
+ self._model_label = (
565
+ _sanitize(model_label, max_chars=_MODEL_COL) if model_label else None
566
+ )
567
+ # If the previous turn left rows stuck "running" — e.g. it was cancelled
568
+ # before the bridge emitted terminal events (CancelledError is a
569
+ # BaseException, so it bypasses the bridge's `except Exception`) — clear
570
+ # now instead of persisting a stale, still-spinning fan-out. Otherwise
571
+ # defer the clear so a completed workflow survives no-subagent turns.
572
+ if self._any_running():
573
+ self._clear()
574
+ else:
575
+ self._pending_reset = True
576
+
577
+ def reset(self, *, model_label: str | None = None, **_kwargs: Any) -> None:
578
+ """Clear all phases and hide the panel immediately (e.g. on `/clear`)."""
579
+ self._clear()
580
+ self._model_label = (
581
+ _sanitize(model_label, max_chars=_MODEL_COL) if model_label else None
582
+ )
583
+
584
+ def _clear(self) -> None:
585
+ """Drop all phase state, stop the timer, and hide the panel.
586
+
587
+ Leaves `expanded` and `_model_label` untouched so the user's open/closed
588
+ choice and the session model survive a clear.
589
+ """
590
+ self._phases.clear()
591
+ self._phase_order.clear()
592
+ self._active_eval_id = None
593
+ self._selected_eval_id = None
594
+ self._applied_height = None
595
+ self._last_render.clear()
596
+ self._pending_reset = False
597
+ self._stop_timer()
598
+ self.remove_class("-visible")
599
+
600
+ def finalize_running(self) -> None:
601
+ """Mark any still-running subagents as cancelled and stop ticking.
602
+
603
+ Called when a turn is interrupted: the QuickJS bridge does not emit
604
+ terminal events for `asyncio.CancelledError` (a BaseException, so it
605
+ bypasses the bridge's `except Exception`), which would otherwise leave
606
+ rows spinning forever. Freezes each affected row's elapsed time.
607
+ """
608
+ changed = False
609
+ for phase in self._phases.values():
610
+ for record in phase.records.values():
611
+ if record.status == "running":
612
+ record.status = "cancelled"
613
+ if record.duration_ms is None:
614
+ record.duration_ms = int(record.elapsed_seconds() * 1000)
615
+ changed = True
616
+ if not changed:
617
+ return
618
+ self._stop_timer()
619
+ self._refresh()
620
+
621
+ def _show(self) -> None:
622
+ """Make the panel visible (idempotent)."""
623
+ self.add_class("-visible")
624
+
625
+ def toggle(self) -> None:
626
+ """Toggle the body open/closed. This is the only thing that changes it."""
627
+ self.expanded = not self.expanded
628
+
629
+ def watch_expanded(self, expanded: bool) -> None:
630
+ """Show/hide the body when the expanded state changes."""
631
+ try:
632
+ body = self.query_one("#subagent-body")
633
+ except (NoMatches, TooManyMatches): # body not mounted yet
634
+ return
635
+ body.set_class(not expanded, "-collapsed")
636
+ # Drop vertical padding when collapsed so the header bar is thin.
637
+ self.set_class(not expanded, "-collapsed")
638
+ if expanded:
639
+ self._apply_body_height()
640
+ self._refresh()
641
+ # On re-expand the body was just shown (it had zero width while
642
+ # collapsed), so the agents pane width isn't known yet. Re-render once
643
+ # layout settles so the right-aligned columns use the real width.
644
+ if expanded:
645
+ self.call_after_refresh(self._refresh)
646
+
647
+ def on_resize(self, _event: events.Resize) -> None:
648
+ """Re-render so width-dependent column alignment tracks the new size."""
649
+ self._refresh()
650
+
651
+ def _ensure_timer(self) -> None:
652
+ """Start the refresh timer if it isn't already running."""
653
+ if self._timer is None:
654
+ self._timer = self.set_interval(_TICK_INTERVAL, self._refresh)
655
+
656
+ def _stop_timer(self) -> None:
657
+ """Stop and drop the refresh timer if running."""
658
+ if self._timer is not None:
659
+ self._timer.stop()
660
+ self._timer = None
661
+
662
+ def _counts(self) -> tuple[int, int]:
663
+ """(finished, total) for the displayed phase.
664
+
665
+ Returns:
666
+ A `(finished, total)` count for the displayed phase, or `(0, 0)`.
667
+ """
668
+ phase = self._displayed_phase()
669
+ return phase.counts() if phase else (0, 0)
670
+
671
+ def _turn_counts(self) -> tuple[int, int, int, int]:
672
+ """Sum subagent counts across all phases.
673
+
674
+ Returns:
675
+ A `(finished, total, failed, cancelled)` tuple over the whole turn.
676
+ """
677
+ total = done = failed = cancelled = 0
678
+ for phase in self._phases.values():
679
+ for record in phase.records.values():
680
+ total += 1
681
+ if record.status != "running":
682
+ done += 1
683
+ if record.status == "error":
684
+ failed += 1
685
+ elif record.status == "cancelled":
686
+ cancelled += 1
687
+ return done, total, failed, cancelled
688
+
689
+ def _body_height(self) -> int:
690
+ """Constant body height for the turn — sized to the largest phase.
691
+
692
+ Returns:
693
+ A cell height clamped to the configured bounds, stable across phase
694
+ switches.
695
+ """
696
+ if not self._phases:
697
+ return _MIN_BODY_HEIGHT
698
+ max_rows = max(len(p.records) for p in self._phases.values())
699
+ agents_lines = _AGENTS_CHROME_LINES + max_rows
700
+ phases_lines = 1 + len(self._phases) # "Phases" title + one per phase
701
+ return min(_MAX_BODY_HEIGHT, max(_MIN_BODY_HEIGHT, agents_lines, phases_lines))
702
+
703
+ def _apply_body_height(self) -> None:
704
+ """Lock the body to a constant height; only re-assign when it changes.
705
+
706
+ Re-assigning `styles.height` every timer tick forces a relayout and
707
+ causes visible flicker, so we cache the applied value and only set it
708
+ when the phase composition actually changes the needed height.
709
+ """
710
+ if not self.expanded:
711
+ return
712
+ height = self._body_height()
713
+ if height == self._applied_height:
714
+ return
715
+ with contextlib.suppress(NoMatches, TooManyMatches): # not mounted yet
716
+ self.query_one("#subagent-body").styles.height = height
717
+ self._applied_height = height
718
+
719
+ def _update_cached(self, widget_id: str, content: Content) -> None:
720
+ """Update a Static only when its rendered text changed (anti-flicker)."""
721
+ if self._last_render.get(widget_id) == content.plain:
722
+ return
723
+ try:
724
+ self.query_one(f"#{widget_id}", Static).update(content)
725
+ except (NoMatches, TooManyMatches): # not mounted yet
726
+ return
727
+ self._last_render[widget_id] = content.plain
728
+
729
+ def _refresh(self) -> None:
730
+ """Re-render all three regions (header, phases pane, agents pane)."""
731
+ self._refresh_header()
732
+ self._refresh_phases()
733
+ self._refresh_agents()
734
+
735
+ def _refresh_header(self) -> None:
736
+ """Render the header: status icon, label, whole-turn totals, toggle hint."""
737
+ colors = get_theme_colors(self)
738
+ glyphs = get_glyphs()
739
+ caret = (
740
+ glyphs.disclosure_expanded if self.expanded else glyphs.disclosure_collapsed
741
+ )
742
+ done, total, failed, cancelled = self._turn_counts()
743
+ if self._any_running() or not total:
744
+ icon, tint = self._spinner.next_frame(), colors.warning
745
+ elif failed:
746
+ icon, tint = glyphs.error, colors.error
747
+ elif cancelled:
748
+ icon, tint = glyphs.circle_empty, colors.muted
749
+ else:
750
+ icon, tint = glyphs.checkmark, colors.success
751
+ lead_text = f"{caret} {icon} dynamic subagents"
752
+ parts: list[Content] = [Content.styled(lead_text, tint)]
753
+ left_len = len(lead_text)
754
+
755
+ if self.expanded and total:
756
+ meta = self._header_meta_parts(done, total, failed, cancelled, colors)
757
+ parts.extend(meta)
758
+ left_len += sum(len(p.plain) for p in meta)
759
+ hint = (
760
+ "click or Ctrl+G to collapse"
761
+ if self.expanded
762
+ else "click or Ctrl+G to expand"
763
+ )
764
+ spacer = max(2, self._header_width() - left_len - len(hint))
765
+ parts.append(Content.styled(" " * spacer + hint, colors.muted))
766
+ self._update_cached("subagent-header", Content.assemble(*parts))
767
+
768
+ def _header_meta_parts(
769
+ self,
770
+ done: int,
771
+ total: int,
772
+ failed: int,
773
+ cancelled: int,
774
+ colors: Any, # noqa: ANN401 — ThemeColors
775
+ ) -> list[Content]:
776
+ """Whole-turn totals (phase count, failures, cancellations) when expanded.
777
+
778
+ Returns:
779
+ The styled `Content` pieces appended after the header label.
780
+ """
781
+ meta_text = f" {done}/{total} done"
782
+ count = len(self._phase_order)
783
+ if count:
784
+ plural = "phase" if count == 1 else "phases"
785
+ meta_text += f" · {count} {plural}"
786
+ parts: list[Content] = [Content.styled(meta_text, colors.muted)]
787
+ if failed:
788
+ parts.append(Content.styled(f" · {failed} failed", colors.error))
789
+ if cancelled:
790
+ parts.append(Content.styled(f" · {cancelled} cancelled", colors.muted))
791
+ return parts
792
+
793
+ def _header_width(self) -> int:
794
+ """Current cell width of the header line (fallback until laid out).
795
+
796
+ Returns:
797
+ The header width, or a fallback before first layout.
798
+ """
799
+ try:
800
+ width = self.query_one("#subagent-header", Static).size.width
801
+ except (NoMatches, TooManyMatches): # not mounted yet
802
+ width = 0
803
+ return width if width and width > 0 else _FALLBACK_WIDTH
804
+
805
+ def _refresh_phases(self) -> None:
806
+ """Render the left pane: one selectable row per phase (eval batch)."""
807
+ try:
808
+ scroll = self.query_one("#subagent-phases-scroll")
809
+ except (NoMatches, TooManyMatches): # not mounted yet
810
+ return
811
+ # Hide only when there are no phases at all; otherwise always show the
812
+ # list (even a single phase) for a consistent two-pane view.
813
+ if not self._phase_order:
814
+ scroll.add_class("-hidden")
815
+ self._update_cached("subagent-phases", Content(""))
816
+ return
817
+ scroll.remove_class("-hidden")
818
+ colors = get_theme_colors(self)
819
+ displayed = self._displayed_phase()
820
+ displayed_key = displayed.eval_id if displayed else None
821
+ rows: list[Content] = [Content.styled("Phases", colors.muted)]
822
+ rows.extend(
823
+ self._phase_row(
824
+ self._phases[key], selected=key == displayed_key, colors=colors
825
+ )
826
+ for key in self._phase_order
827
+ )
828
+ self._update_cached("subagent-phases", Content("\n").join(rows))
829
+
830
+ def _phase_row(
831
+ self,
832
+ phase: _Phase,
833
+ *,
834
+ selected: bool,
835
+ colors: Any, # noqa: ANN401 — ThemeColors
836
+ ) -> Content:
837
+ """Render one phase row: caret, status glyph, index, counts, elapsed.
838
+
839
+ Returns:
840
+ The styled `Content` for the phase's row in the left pane.
841
+ """
842
+ glyphs = get_glyphs()
843
+ done, total = phase.counts()
844
+ if phase.all_terminal():
845
+ if phase.any_error():
846
+ mark = glyphs.error
847
+ elif phase.any_cancelled():
848
+ mark = glyphs.circle_empty
849
+ else:
850
+ mark = glyphs.checkmark
851
+ elif phase.eval_id == self._active_eval_id:
852
+ mark = glyphs.disclosure_collapsed
853
+ else:
854
+ mark = glyphs.bullet
855
+ caret = glyphs.cursor if selected else " "
856
+ tint = colors.primary if selected else colors.muted
857
+ elapsed = _format_timing(phase.elapsed_seconds())
858
+ return Content.styled(
859
+ f"{caret} {mark} {phase.index} {done}/{total} · {elapsed}", tint
860
+ )
861
+
862
+ def _agents_width(self) -> int:
863
+ """Usable width of the agents pane (fallback until laid out).
864
+
865
+ Returns:
866
+ The agents pane width minus a scrollbar reserve, floored at the
867
+ minimum task width.
868
+ """
869
+ try:
870
+ width = self.query_one("#subagent-agents", Static).size.width
871
+ except (NoMatches, TooManyMatches): # not mounted yet
872
+ width = 0
873
+ if not width or width <= 0:
874
+ width = _FALLBACK_WIDTH
875
+ # Keep the flush-right column off the scroll bar.
876
+ return max(_MIN_TASK_COL, width - _SCROLLBAR_RESERVE)
877
+
878
+ def _task_col(self) -> int:
879
+ """Width of the flexible task column so the right block sits flush-right.
880
+
881
+ Returns:
882
+ The task column width, clamped to a sensible minimum.
883
+ """
884
+ width = self._agents_width()
885
+ return max(_MIN_TASK_COL, width - _STATUS_COL - _right_block_width())
886
+
887
+ def _refresh_agents(self) -> None:
888
+ """Render the right pane: heading + one row per subagent in the phase."""
889
+ phase = self._displayed_phase()
890
+ glyphs = get_glyphs()
891
+ colors = get_theme_colors(self)
892
+ rows: list[Content] = []
893
+ if phase is not None and phase.order:
894
+ task_col = self._task_col()
895
+ rows.append(self._heading_row(task_col, colors))
896
+ rows.extend(
897
+ self._render_row(phase.records[sub_id], task_col, glyphs, colors)
898
+ for sub_id in phase.order
899
+ )
900
+ self._update_cached(
901
+ "subagent-agents", Content("\n").join(rows) if rows else Content("")
902
+ )
903
+
904
+ @staticmethod
905
+ def _right_block(model: str, timing: str) -> str:
906
+ """Format the fixed-width, flush-right metadata columns (model, time).
907
+
908
+ Returns:
909
+ The concatenated, column-aligned metadata string.
910
+ """
911
+ return (
912
+ f"{model[:_MODEL_COL].ljust(_MODEL_COL)} "
913
+ f"{timing[:_TIMING_COL].rjust(_TIMING_COL)}"
914
+ )
915
+
916
+ def _render_row(
917
+ self,
918
+ record: _SubagentRecord,
919
+ task_col: int,
920
+ glyphs: Any, # noqa: ANN401 — Glyphs
921
+ colors: Any, # noqa: ANN401 — ThemeColors
922
+ ) -> Content:
923
+ """Render one row: status | task (left) | model · time (right).
924
+
925
+ On failure the reason is appended to the (wide) task column rather than
926
+ the 6-char time column, so it stays legible instead of being truncated.
927
+
928
+ Returns:
929
+ The styled, width-filling row `Content`.
930
+ """
931
+ if record.status == "running":
932
+ icon = self._spinner.current_frame()
933
+ tint = colors.warning
934
+ elif record.status == "done":
935
+ icon = glyphs.checkmark
936
+ tint = colors.success
937
+ elif record.status == "cancelled":
938
+ icon = glyphs.circle_empty
939
+ tint = colors.muted
940
+ else:
941
+ icon = glyphs.error
942
+ tint = colors.error
943
+ label = record.label
944
+ if record.status == "error" and record.error:
945
+ label = f"{record.label} - {record.error}"
946
+ timing = _format_timing(record.elapsed_seconds())
947
+ task = _sanitize(label, max_chars=task_col - 1).ljust(task_col)
948
+ model = self._model_label or ""
949
+ right = self._right_block(model, timing)
950
+ return Content.assemble(
951
+ Content.styled(f" {icon} ", tint),
952
+ Content.styled(task, colors.foreground),
953
+ Content.styled(right, colors.muted),
954
+ )
955
+
956
+ def _heading_row(
957
+ self,
958
+ task_col: int,
959
+ colors: Any, # noqa: ANN401 — ThemeColors
960
+ ) -> Content:
961
+ """Column heading row aligned to the data rows.
962
+
963
+ Returns:
964
+ A single dim heading line spanning the same columns as the rows.
965
+ """
966
+ prefix = " " * _STATUS_COL
967
+ task = "TASK".ljust(task_col)
968
+ right = self._right_block("MODEL", "TIME")
969
+ return Content.styled(f"{prefix}{task}{right}", colors.muted)