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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|