Files
EvoScientist-Multi/EvoScientist/cli/widgets/tool_call_widget.py
T
Xi Zhang 65db3a4fcd Add status bar and compact summary widgets with context window resolu… (#152)
* Add status bar and compact summary widgets with context window resolution

- Implemented a shared status bar for CLI and TUI frontends, including helpers for managing session metrics and context windows.
- Created a `CompactSummaryWidget` for displaying manual summaries in a collapsible format.
- Introduced a `CompactingWidget` to indicate ongoing compacting processes.
- Added a base class `TimedStatusWidget` for widgets that require a timer.
- Developed context window resolution helpers to retrieve context window sizes from various model attributes.
- Enhanced tests for context window resolution and status bar functionalities, ensuring accurate behavior across different scenarios.
- Updated existing tests to cover new features and maintain code quality.

* refactor(Channel): simplify lambda function in _send_with_retry method

* feat: enhance context editing logic and improve error handling in StreamState

* refactor(Channel): streamline lambda function in _send_with_retry method

* feat: rename auto-approve option to auto-mode for unattended execution; update checkpoint queries to filter by agent name; improve compatibility validation logic

* feat: rename auto-approve option to auto-mode; update related logic and tests for improved unattended execution

* fix: correct formatting of console message for MCP server configuration status

* feat: add check for None summary_message in _apply_summarization_event to prevent errors

* feat: enhance _load_checkpoint_messages to validate message format and apply summarization event
2026-04-12 17:47:37 +01:00

285 lines
9.9 KiB
Python

"""Tool call widget with status lifecycle and collapsible output."""
from __future__ import annotations
import time
from rich.text import Text
from textual.containers import Vertical
from textual.events import Click
from textual.widgets import Static
from ...stream.diff_format import build_edit_diff
from ...stream.utils import format_tool_compact_with_result
from .timestamp_mixin import show_timestamp_toast
_SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
# Output is collapsed when it exceeds these limits
_COLLAPSE_LINES = 6
_COLLAPSE_CHARS = 400
class ToolCallWidget(Vertical):
"""Displays a single tool call with running spinner → result.
Lifecycle: ``running`` → ``success`` | ``error`` | ``interrupted`` | ``rejected``
Usage::
w = ToolCallWidget("read_file", {"path": "/foo.py"}, tool_id="abc")
await container.mount(w)
# ... later ...
w.set_success("[OK] 42 lines")
"""
DEFAULT_CSS = """
ToolCallWidget {
height: auto;
padding: 0 0;
}
ToolCallWidget .tool-header {
height: auto;
}
ToolCallWidget .tool-status {
height: auto;
padding: 0 0 0 2;
}
ToolCallWidget .tool-output {
height: auto;
padding: 0 0 0 4;
color: #9ca3af;
display: none;
}
ToolCallWidget .tool-output.--visible {
display: block;
}
"""
def __init__(
self,
tool_name: str,
tool_args: dict | None = None,
tool_id: str = "",
) -> None:
super().__init__()
self._tool_name = tool_name
self._tool_args = tool_args or {}
self._tool_id = tool_id
self._created_at: float = time.time()
self._status = "running"
self._result_content = ""
self._diff_markup: str | None = None # cached full diff for toggle
self._frame = 0
self._elapsed = 0.0
self._timer_handle = None
self._collapsed = True
@property
def tool_id(self) -> str:
return self._tool_id
@property
def tool_name(self) -> str:
return self._tool_name
def compose(self):
yield Static("", classes="tool-header")
yield Static("", classes="tool-status")
yield Static("", classes="tool-output")
def on_mount(self) -> None:
self._timer_handle = self.set_interval(0.1, self._tick)
self._render_header()
self._render_status()
def _tick(self) -> None:
if self._status == "running":
self._frame = (self._frame + 1) % len(_SPINNER_FRAMES)
self._elapsed += 0.1
self._render_status()
def _render_header(self) -> None:
compact = format_tool_compact_with_result(
self._tool_name,
self._tool_args,
self._result_content,
)
header = self.query_one(".tool-header", Static)
line = Text()
if self._status == "running":
line.append("\u25cf ", style="bold yellow")
line.append(compact, style="bold yellow")
elif self._status == "success":
line.append("\u2713 ", style="bold green")
line.append(compact, style="bold green")
elif self._status == "interrupted":
line.append("\u25a0 ", style="bold yellow")
line.append(compact, style="bold yellow dim")
elif self._status == "rejected":
line.append("\u25a0 ", style="bold yellow")
line.append(compact, style="bold yellow dim")
else:
line.append("\u2717 ", style="bold red")
line.append(compact, style="bold red")
header.update(line)
def _render_status(self) -> None:
status_w = self.query_one(".tool-status", Static)
if self._status == "running":
char = _SPINNER_FRAMES[self._frame]
secs = int(self._elapsed)
status_w.update(Text(f"{char} Running... ({secs}s)", style="yellow dim"))
elif self._status == "success":
# Show first line of result as summary
summary = self._result_summary()
status_w.update(Text(f"\u2713 {summary}", style="green dim"))
elif self._status == "interrupted":
status_w.update(Text("\u25a0 interrupted", style="yellow dim"))
elif self._status == "rejected":
status_w.update(Text("\u25a0 rejected", style="yellow dim"))
else:
summary = self._result_summary()
status_w.update(Text(f"\u2717 {summary}", style="red dim"))
def _result_summary(self) -> str:
"""One-line summary of the result."""
if not self._result_content:
return "done"
first_line = self._result_content.strip().split("\n")[0]
if len(first_line) > 60:
first_line = first_line[:57] + "\u2026"
return first_line
def _should_collapse(self) -> bool:
lines = self._result_content.strip().split("\n")
return (
len(lines) > _COLLAPSE_LINES or len(self._result_content) > _COLLAPSE_CHARS
)
def set_success(self, content: str) -> None:
"""Mark tool call as successfully completed."""
self._status = "success"
self._result_content = content
self._stop_timer()
with self.app.batch_update():
self._render_header()
self._render_status()
self._render_output()
def set_interrupted(self) -> None:
"""Mark tool call as interrupted/cancelled."""
self._status = "interrupted"
self._stop_timer()
with self.app.batch_update():
self._render_header()
self._render_status()
def set_rejected(self) -> None:
"""Mark tool call as rejected by HITL approval."""
self._status = "rejected"
self._stop_timer()
with self.app.batch_update():
self._render_header()
self._render_status()
def set_error(self, content: str) -> None:
"""Mark tool call as failed."""
self._status = "error"
self._result_content = content
self._stop_timer()
with self.app.batch_update():
self._render_header()
self._render_status()
self._render_output()
def _render_output(self) -> None:
output_w = self.query_one(".tool-output", Static)
if not self._result_content.strip():
return
# Diff rendering for edit_file (never truncates — collapses instead)
if (
self._tool_name == "edit_file"
and self._status == "success"
and self._tool_args
):
old_str = self._tool_args.get("old_string", "")
new_str = self._tool_args.get("new_string", "")
path = self._tool_args.get("path", self._tool_args.get("file_path", ""))
if old_str and new_str and old_str != new_str:
diff_markup = build_edit_diff(path, old_str, new_str)
if diff_markup:
self._diff_markup = diff_markup
if diff_markup.count("\n") > _COLLAPSE_LINES:
# Long diff: collapse, click to expand
self._collapsed = True
output_w.update(
Text(" [click to expand diff]", style="dim italic"),
)
else:
# Short diff: show inline
self._collapsed = False
output_w.update(diff_markup)
output_w.add_class("--visible")
return
if self._status == "error" or not self._should_collapse():
# Show full output for errors or short output
self._collapsed = False
style = "red dim" if self._status == "error" else "dim"
content = self._result_content.strip()
if len(content) > 800:
content = content[:800] + "\n... (truncated)"
output_w.update(Text(content, style=style))
output_w.add_class("--visible")
else:
# Collapsed — show hint, click to expand
self._collapsed = True
output_w.update(
Text(" [click to expand output]", style="dim italic"),
)
output_w.add_class("--visible")
def on_click(self, event: Click) -> None:
"""Toggle collapsed output on click, or show timestamp."""
if self._status == "running" or not self._result_content.strip():
show_timestamp_toast(self)
return
output_w = self.query_one(".tool-output", Static)
# Diff toggle (separate path — _should_collapse checks plain text,
# not the diff markup, so we handle it independently)
if self._diff_markup is not None:
if self._collapsed:
self._collapsed = False
output_w.update(self._diff_markup)
else:
self._collapsed = True
output_w.update(
Text(" [click to expand diff]", style="dim italic"),
)
return
if not self._should_collapse():
# TODO: re-enable after UX review
# show_timestamp_toast(self)
return # Short output is always visible, nothing to toggle
if self._collapsed:
# Expand
self._collapsed = False
content = self._result_content.strip()
if len(content) > 2000:
content = content[:2000] + "\n... (truncated)"
style = "red dim" if self._status == "error" else "dim"
output_w.update(Text(content, style=style))
else:
# Collapse back
self._collapsed = True
output_w.update(
Text(" [click to expand output]", style="dim italic"),
)
def _stop_timer(self) -> None:
if self._timer_handle is not None:
self._timer_handle.stop()
self._timer_handle = None