Files
EvoScientist-Multi/EvoScientist/cli/widgets/subagent_widget.py
T

308 lines
11 KiB
Python

"""Sub-agent widget — bordered area with nested tool calls."""
from __future__ import annotations
from rich.text import Text
from textual.containers import Vertical
from textual.widgets import Static
from .tool_call_widget import ToolCallWidget
_SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
# Keep in sync with display.py MAX_SA_VISIBLE / MAX_SA_RUNNING
_MAX_VISIBLE_COMPLETED = 3
_MAX_VISIBLE_RUNNING = 2
class SubAgentWidget(Vertical):
"""Displays a sub-agent's activity with a bordered frame.
Active state::
┌ ▶ Cooking with research-agent — Search literature ─┐
│ ✓ 8 completed │
│ ● tavily_search query="LLM attention" │
│ ✓ 3 results │
└─────────────────────────────────────────────────────┘
Completed state::
✓ Cooking with research-agent (3 tools)
"""
DEFAULT_CSS = """
SubAgentWidget {
height: auto;
margin: 0 0;
}
SubAgentWidget .sa-header {
height: auto;
color: #22d3ee;
}
SubAgentWidget .sa-tools {
height: auto;
padding: 0 0 0 2;
}
SubAgentWidget .sa-collapse-summary {
height: auto;
padding: 0 0 0 2;
display: none;
}
SubAgentWidget .sa-collapse-summary.--visible {
display: block;
}
SubAgentWidget .sa-footer {
height: auto;
color: #22d3ee;
}
SubAgentWidget.--completed .sa-header {
color: #4ade80;
}
SubAgentWidget.--completed .sa-footer {
color: #4ade80;
}
"""
def __init__(self, name: str, description: str = "") -> None:
super().__init__()
self._sa_name = name
self._description = description
self._is_active = True
self._frame = 0
self._tool_count = 0
self._timer_handle = None
self._tool_widgets: dict[str, ToolCallWidget] = {}
# Ordered lists to track completed / running tools for collapsing
self._completed_ids: list[str] = []
self._running_ids: list[str] = []
@property
def sa_name(self) -> str:
return self._sa_name
def update_name(self, name: str, description: str = "") -> None:
"""Update the sub-agent display name after resolution."""
self._sa_name = name
if description:
self._description = description
try:
self._render_header()
except Exception:
pass # Widget may not be mounted yet
def compose(self):
yield Static("", classes="sa-header")
yield Static("", classes="sa-collapse-summary")
yield Vertical(classes="sa-tools")
yield Static("", classes="sa-footer")
def on_mount(self) -> None:
self._timer_handle = self.set_interval(0.1, self._tick)
self._render_header()
self._render_footer()
def _tick(self) -> None:
if self._is_active:
self._frame = (self._frame + 1) % len(_SPINNER_FRAMES)
self._render_header()
def _display_name(self) -> str:
name = f"Cooking with {self._sa_name}"
if self._description:
desc = self._description.split("\n")[0].strip()
if len(desc) > 50:
desc = desc[:47] + "\u2026"
name += f" \u2014 {desc}"
return name
def _render_header(self) -> None:
header = self.query_one(".sa-header", Static)
line = Text()
if self._is_active:
char = _SPINNER_FRAMES[self._frame]
line.append(
f"\u250c \u25b6 {self._display_name()} {char}", style="bold cyan"
)
else:
line.append(f"\u2713 {self._display_name()}", style="bold green")
line.append(f" ({self._tool_count} tools)", style="dim")
header.update(line)
def _render_footer(self) -> None:
footer = self.query_one(".sa-footer", Static)
if self._is_active:
footer.update(Text("\u2514 running...", style="dim cyan"))
else:
footer.update(Text(""))
async def add_tool_call(
self,
tool_name: str,
tool_args: dict | None = None,
tool_id: str = "",
) -> ToolCallWidget:
"""Mount a new ToolCallWidget inside this sub-agent.
If a widget with the same *tool_id* already exists (re-emitted with
updated args during incremental streaming), update it in place instead
of creating a duplicate.
"""
if tool_id and tool_id in self._tool_widgets:
# Re-emitted with updated args — update in place
existing = self._tool_widgets[tool_id]
existing._tool_name = tool_name
existing._tool_args = tool_args or {}
try:
existing._render_header()
except Exception:
pass # Widget may not be mounted yet
return existing
self._tool_count += 1
w = ToolCallWidget(tool_name, tool_args, tool_id)
tools_container = self.query_one(".sa-tools", Vertical)
await tools_container.mount(w)
key = tool_id or f"_anon_{self._tool_count}"
self._tool_widgets[key] = w
self._running_ids.append(key)
self._update_visibility()
return w
def complete_tool(
self,
tool_name: str,
content: str,
success: bool = True,
tool_id: str = "",
) -> None:
"""Update the matching ToolCallWidget with its result."""
widget = None
matched_key = ""
if tool_id and tool_id in self._tool_widgets:
widget = self._tool_widgets[tool_id]
matched_key = tool_id
else:
# Match by name — find first running tool with this name
for key, w in self._tool_widgets.items():
if w.tool_name == tool_name and w._status == "running":
widget = w
matched_key = key
break
if widget is None:
# Fallback: find any running tool
tools = self.query_one(".sa-tools", Vertical)
for child in tools.children:
if isinstance(child, ToolCallWidget) and child._status == "running":
if child.tool_name == tool_name:
widget = child
# Find the key for this widget
for key, w in self._tool_widgets.items():
if w is widget:
matched_key = key
break
break
if widget is not None:
if success:
widget.set_success(content)
else:
widget.set_error(content)
# Move from running to completed (dedup guards against repeat
# deliveries of the same tool result inflating the collapse summary).
if matched_key and matched_key in self._running_ids:
self._running_ids.remove(matched_key)
if matched_key and matched_key not in self._completed_ids:
self._completed_ids.append(matched_key)
self._update_visibility()
def _update_visibility(self) -> None:
"""Hide older completed tools, keep recent ones visible.
Mirrors the Rich display.py collapsing logic:
- At most ``_MAX_VISIBLE_COMPLETED`` completed tools shown
(fewer if running tools take up slots).
- At most ``_MAX_VISIBLE_RUNNING`` running tools shown.
"""
# Determine how many completed slots are available
running_visible = self._running_ids[-_MAX_VISIBLE_RUNNING:]
completed_slots = max(0, _MAX_VISIBLE_COMPLETED - len(running_visible))
completed_visible = (
self._completed_ids[-completed_slots:] if completed_slots else []
)
completed_hidden = (
self._completed_ids[:-completed_slots]
if completed_slots and len(self._completed_ids) > completed_slots
else (self._completed_ids if not completed_slots else [])
)
# Running tools to hide
running_hidden = (
self._running_ids[:-_MAX_VISIBLE_RUNNING]
if len(self._running_ids) > _MAX_VISIBLE_RUNNING
else []
)
# Apply visibility
visible_keys = set(completed_visible) | set(running_visible)
hidden_keys = set(completed_hidden) | set(running_hidden)
for key in visible_keys:
w = self._tool_widgets.get(key)
if w is not None:
w.display = True
for key in hidden_keys:
w = self._tool_widgets.get(key)
if w is not None:
w.display = False
# Update collapse summary
total_hidden = len(completed_hidden)
hidden_running_count = len(running_hidden)
summary_w = self.query_one(".sa-collapse-summary", Static)
if total_hidden > 0 or hidden_running_count > 0:
line = Text()
if total_hidden > 0:
# Count successes/failures among hidden completed
ok = 0
fail = 0
for key in completed_hidden:
w = self._tool_widgets.get(key)
if w is not None:
if w._status == "error":
fail += 1
else:
ok += 1
line.append(f"\u2713 {ok} completed", style="dim green")
if fail > 0:
line.append(f" | {fail} failed", style="dim red")
if hidden_running_count > 0:
if total_hidden > 0:
line.append(" | ", style="dim")
line.append(
f"\u25cf {hidden_running_count} more running...", style="dim yellow"
)
summary_w.update(line)
summary_w.add_class("--visible")
else:
summary_w.remove_class("--visible")
def finalize(self) -> None:
"""Mark sub-agent as completed and stop all nested timers."""
self._is_active = False
if self._timer_handle is not None:
self._timer_handle.stop()
self._timer_handle = None
# Mark any nested ToolCallWidgets still running as interrupted
for tw in self._tool_widgets.values():
if tw._status == "running":
try:
tw.set_interrupted()
except Exception:
pass
self.add_class("--completed")
self._render_header()
self._render_footer()