Add TUI renderers for the seven previously-unstyled tools
exec_command, write_stdin, apply_patch, view_image, load_skill, list_sitemap, and view_sitemap_entry were falling through to the generic dict-dumper. They now render in the same visual language as the rest of the toolset: the terminal pair uses the >_ icon with pygments bash highlighting; apply_patch and view_image use the file- edit diamond with colored +/- diff lines and per-language syntax highlighting; sitemap and load_skill mirror the proxy and skill patterns already established. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
from . import (
|
||||
agents_graph_renderer,
|
||||
filesystem_renderer,
|
||||
finish_renderer,
|
||||
load_skill_renderer,
|
||||
notes_renderer,
|
||||
proxy_renderer,
|
||||
reporting_renderer,
|
||||
shell_renderer,
|
||||
thinking_renderer,
|
||||
todo_renderer,
|
||||
web_search_renderer,
|
||||
@@ -13,11 +16,14 @@ from .registry import render_tool_widget
|
||||
|
||||
__all__ = [
|
||||
"agents_graph_renderer",
|
||||
"filesystem_renderer",
|
||||
"finish_renderer",
|
||||
"load_skill_renderer",
|
||||
"notes_renderer",
|
||||
"proxy_renderer",
|
||||
"render_tool_widget",
|
||||
"reporting_renderer",
|
||||
"shell_renderer",
|
||||
"thinking_renderer",
|
||||
"todo_renderer",
|
||||
"web_search_renderer",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
|
||||
from pygments.styles import get_style_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
_ADD_FILE = "*** Add File: "
|
||||
_DELETE_FILE = "*** Delete File: "
|
||||
_UPDATE_FILE = "*** Update File: "
|
||||
_BEGIN_PATCH = "*** Begin Patch"
|
||||
_END_PATCH = "*** End Patch"
|
||||
|
||||
_VIEW_IMAGE_ERROR_PREFIXES = (
|
||||
"image path ",
|
||||
"unable to read image",
|
||||
"manifest path",
|
||||
"exceeded the allowed size",
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _get_style_colors() -> dict[Any, str]:
|
||||
style = get_style_by_name("native")
|
||||
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||
|
||||
|
||||
def _get_lexer_for_file(path: str) -> Any:
|
||||
try:
|
||||
return get_lexer_for_filename(path)
|
||||
except ClassNotFound:
|
||||
return get_lexer_by_name("text")
|
||||
|
||||
|
||||
def _get_token_color(token_type: Any) -> str | None:
|
||||
colors = _get_style_colors()
|
||||
while token_type:
|
||||
if token_type in colors:
|
||||
return colors[token_type]
|
||||
token_type = token_type.parent
|
||||
return None
|
||||
|
||||
|
||||
def _highlight_code(code: str, path: str) -> Text:
|
||||
lexer = _get_lexer_for_file(path)
|
||||
text = Text()
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
color = _get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
return text
|
||||
|
||||
|
||||
def _extract_patch_text(args: dict[str, Any]) -> str:
|
||||
"""apply_patch input arrives as either {"patch": text} or raw text in
|
||||
the "input" field, depending on whether the tool is wrapped as a
|
||||
chat-completions FunctionTool or routed through as a CustomTool.
|
||||
"""
|
||||
raw = args.get("patch")
|
||||
if isinstance(raw, str):
|
||||
return raw
|
||||
if isinstance(raw, dict):
|
||||
inner = raw.get("patch")
|
||||
if isinstance(inner, str):
|
||||
return inner
|
||||
fallback = args.get("input") if isinstance(args, dict) else None
|
||||
if isinstance(fallback, str):
|
||||
return fallback
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_patch_operations(
|
||||
patch_text: str,
|
||||
) -> list[tuple[str, str, list[str], list[str]]]:
|
||||
"""Return [(kind, path, old_lines, new_lines), ...] for each file op."""
|
||||
ops: list[tuple[str, str, list[str], list[str]]] = []
|
||||
current_kind: str | None = None
|
||||
current_path: str | None = None
|
||||
old_lines: list[str] = []
|
||||
new_lines: list[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current_kind, current_path, old_lines, new_lines
|
||||
if current_kind and current_path is not None:
|
||||
ops.append((current_kind, current_path, old_lines, new_lines))
|
||||
current_kind = None
|
||||
current_path = None
|
||||
old_lines = []
|
||||
new_lines = []
|
||||
|
||||
for line in patch_text.splitlines():
|
||||
if line in (_BEGIN_PATCH, _END_PATCH):
|
||||
continue
|
||||
if line.startswith(_ADD_FILE):
|
||||
flush()
|
||||
current_kind = "add"
|
||||
current_path = line[len(_ADD_FILE) :].strip()
|
||||
elif line.startswith(_UPDATE_FILE):
|
||||
flush()
|
||||
current_kind = "update"
|
||||
current_path = line[len(_UPDATE_FILE) :].strip()
|
||||
elif line.startswith(_DELETE_FILE):
|
||||
flush()
|
||||
current_kind = "delete"
|
||||
current_path = line[len(_DELETE_FILE) :].strip()
|
||||
elif current_kind == "update":
|
||||
if line.startswith("@@"):
|
||||
continue
|
||||
if line.startswith("-") and not line.startswith("---"):
|
||||
old_lines.append(line[1:])
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
new_lines.append(line[1:])
|
||||
elif current_kind == "add":
|
||||
if line.startswith("+"):
|
||||
new_lines.append(line[1:])
|
||||
elif line.strip():
|
||||
new_lines.append(line)
|
||||
flush()
|
||||
return ops
|
||||
|
||||
|
||||
_OP_LABEL = {
|
||||
"add": "create",
|
||||
"update": "edit",
|
||||
"delete": "delete",
|
||||
}
|
||||
|
||||
|
||||
def _render_operation(text: Text, kind: str, path: str, old: list[str], new: list[str]) -> None:
|
||||
label = _OP_LABEL.get(kind, "file")
|
||||
|
||||
text.append("◇ ", style="#10b981")
|
||||
text.append(label, style="dim")
|
||||
|
||||
if path:
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
text.append(" ")
|
||||
text.append(path_display, style="dim")
|
||||
|
||||
if kind == "update":
|
||||
if old:
|
||||
highlighted_old = _highlight_code("\n".join(old), path)
|
||||
for line in highlighted_old.plain.split("\n"):
|
||||
text.append("\n")
|
||||
text.append("-", style="#ef4444")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
if new:
|
||||
highlighted_new = _highlight_code("\n".join(new), path)
|
||||
for line in highlighted_new.plain.split("\n"):
|
||||
text.append("\n")
|
||||
text.append("+", style="#22c55e")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
elif kind == "add" and new:
|
||||
text.append("\n")
|
||||
text.append_text(_highlight_code("\n".join(new), path))
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ApplyPatchRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "apply_patch"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result")
|
||||
status = tool_data.get("status", "completed")
|
||||
|
||||
patch_text = _extract_patch_text(args)
|
||||
ops = _parse_patch_operations(patch_text)
|
||||
|
||||
text = Text()
|
||||
|
||||
if not ops:
|
||||
text.append("◇ ", style="#10b981")
|
||||
text.append("patch", style="dim")
|
||||
if isinstance(result, str) and result.strip():
|
||||
text.append("\n ")
|
||||
text.append(result.strip(), style="dim")
|
||||
elif not result:
|
||||
text.append(" ")
|
||||
text.append("Processing...", style="dim")
|
||||
return Static(text, classes=cls.get_css_classes(status))
|
||||
|
||||
for i, (kind, path, old, new) in enumerate(ops):
|
||||
if i > 0:
|
||||
text.append("\n")
|
||||
_render_operation(text, kind, path, old, new)
|
||||
|
||||
if status == "failed" and isinstance(result, str) and result.strip():
|
||||
text.append("\n ")
|
||||
text.append(result.strip(), style="#ef4444")
|
||||
|
||||
return Static(text, classes=cls.get_css_classes(status))
|
||||
|
||||
|
||||
def _is_image_success(result: Any) -> bool:
|
||||
if isinstance(result, dict) and result.get("type") == "image":
|
||||
return True
|
||||
if isinstance(result, str):
|
||||
stripped = result.lstrip()
|
||||
if stripped.startswith("data:image/"):
|
||||
return True
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return isinstance(obj, dict) and obj.get("type") == "image"
|
||||
return False
|
||||
|
||||
|
||||
def _image_error_text(result: Any) -> str | None:
|
||||
if not isinstance(result, str):
|
||||
return None
|
||||
stripped = result.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
lower = stripped.lower()
|
||||
if lower.startswith(_VIEW_IMAGE_ERROR_PREFIXES) or "not a supported image" in lower:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ViewImageRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "view_image"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result")
|
||||
status = tool_data.get("status", "completed")
|
||||
|
||||
path = str(args.get("path", "")).strip()
|
||||
|
||||
text = Text()
|
||||
text.append("◇ ", style="#10b981")
|
||||
text.append("view image", style="dim")
|
||||
|
||||
if path:
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
text.append(" ")
|
||||
text.append(path_display, style="dim")
|
||||
|
||||
err = _image_error_text(result)
|
||||
if err is not None:
|
||||
text.append("\n ")
|
||||
text.append(err, style="#ef4444")
|
||||
elif _is_image_success(result):
|
||||
text.append(" ")
|
||||
text.append("✓", style="#22c55e")
|
||||
|
||||
return Static(text, classes=cls.get_css_classes(status))
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class LoadSkillRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "load_skill"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "load-skill-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "completed")
|
||||
|
||||
raw_skills = args.get("skills", "")
|
||||
if isinstance(raw_skills, list):
|
||||
requested = ", ".join(str(s) for s in raw_skills)
|
||||
else:
|
||||
requested = str(raw_skills)
|
||||
|
||||
text = Text()
|
||||
text.append("◇ ", style="#10b981")
|
||||
text.append("loading skill", style="dim")
|
||||
|
||||
if requested:
|
||||
text.append(" ")
|
||||
text.append(requested, style="#10b981")
|
||||
elif not tool_data.get("result"):
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
return Static(text, classes=cls.get_css_classes(status))
|
||||
@@ -293,6 +293,155 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ListSitemapRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "list_sitemap"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result")
|
||||
status = tool_data.get("status", "running")
|
||||
|
||||
parent_id = args.get("parent_id")
|
||||
scope_id = args.get("scope_id")
|
||||
depth = args.get("depth")
|
||||
|
||||
text = Text()
|
||||
text.append(PROXY_ICON, style="dim")
|
||||
text.append(" listing sitemap", style="#06b6d4")
|
||||
|
||||
if parent_id:
|
||||
text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim")
|
||||
|
||||
meta_parts = []
|
||||
if scope_id and isinstance(scope_id, str):
|
||||
meta_parts.append(f"scope:{scope_id[:8]}")
|
||||
if depth and depth != "DIRECT":
|
||||
meta_parts.append(depth.lower())
|
||||
if meta_parts:
|
||||
text.append(f" ({', '.join(meta_parts)})", style="dim")
|
||||
|
||||
if status == "completed" and isinstance(result, dict):
|
||||
if "error" in result:
|
||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
else:
|
||||
total = result.get("total_count", 0)
|
||||
entries = result.get("entries", [])
|
||||
|
||||
text.append(f" [{total} entries]", style="dim")
|
||||
|
||||
if entries and isinstance(entries, list):
|
||||
text.append("\n")
|
||||
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
kind = entry.get("kind") or "?"
|
||||
label = entry.get("label") or "?"
|
||||
has_children = entry.get("has_descendants", False)
|
||||
req = entry.get("request") or {}
|
||||
|
||||
kind_style = {
|
||||
"DOMAIN": "#f59e0b",
|
||||
"DIRECTORY": "#3b82f6",
|
||||
"REQUEST": "#22c55e",
|
||||
}.get(kind, "dim")
|
||||
|
||||
text.append(" ")
|
||||
kind_abbr = kind[:3] if isinstance(kind, str) else "?"
|
||||
text.append(f"{kind_abbr:3}", style=kind_style)
|
||||
text.append(f" {_truncate(label, 150)}", style="dim")
|
||||
|
||||
if req:
|
||||
method = req.get("method", "")
|
||||
code = req.get("status_code")
|
||||
if method:
|
||||
text.append(f" {method}", style="#a78bfa")
|
||||
if code:
|
||||
text.append(f" {code}", style=_status_style(code))
|
||||
|
||||
if has_children:
|
||||
text.append(" +", style="dim italic")
|
||||
|
||||
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(entries) > MAX_REQUESTS_DISPLAY:
|
||||
text.append("\n")
|
||||
text.append(
|
||||
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic"
|
||||
)
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ViewSitemapEntryRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "view_sitemap_entry"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result")
|
||||
status = tool_data.get("status", "running")
|
||||
|
||||
entry_id = args.get("entry_id", "")
|
||||
|
||||
text = Text()
|
||||
text.append(PROXY_ICON, style="dim")
|
||||
text.append(" viewing sitemap", style="#06b6d4")
|
||||
|
||||
if entry_id:
|
||||
text.append(f" #{_truncate(str(entry_id), 20)}", style="dim")
|
||||
|
||||
if status == "completed" and isinstance(result, dict):
|
||||
if "error" in result:
|
||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
elif "entry" in result:
|
||||
entry = result.get("entry") or {}
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
kind = entry.get("kind", "")
|
||||
label = entry.get("label", "")
|
||||
related = entry.get("related_requests") or {}
|
||||
related_reqs = related.get("requests", []) if isinstance(related, dict) else []
|
||||
total_related = related.get("total_count", 0) if isinstance(related, dict) else 0
|
||||
|
||||
if kind and label:
|
||||
text.append(f" {kind}: {_truncate(label, 120)}", style="dim")
|
||||
|
||||
if total_related:
|
||||
text.append(f" [{total_related} requests]", style="dim")
|
||||
|
||||
if related_reqs and isinstance(related_reqs, list):
|
||||
text.append("\n")
|
||||
for i, req in enumerate(related_reqs[:10]):
|
||||
if not isinstance(req, dict):
|
||||
continue
|
||||
method = req.get("method", "?")
|
||||
path = req.get("path", "/")
|
||||
code = req.get("status_code")
|
||||
|
||||
text.append(" ")
|
||||
text.append(f"{method:6}", style="#a78bfa")
|
||||
text.append(f" {_truncate(path, 180)}", style="dim")
|
||||
if code:
|
||||
text.append(f" {code}", style=_status_style(code))
|
||||
|
||||
if i < min(len(related_reqs), 10) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(related_reqs) > 10:
|
||||
text.append("\n")
|
||||
text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic")
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ScopeRulesRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "scope_rules"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import re
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
MAX_OUTPUT_LINES = 50
|
||||
MAX_LINE_LENGTH = 200
|
||||
|
||||
STRIP_PATTERNS = [
|
||||
r"^Chunk ID: [0-9a-f]+\s*$",
|
||||
r"^Wall time: [\d.]+ seconds\s*$",
|
||||
r"^Process exited with code -?\d+\s*$",
|
||||
r"^Process running with session ID \d+\s*$",
|
||||
r"^Original token count: \d+\s*$",
|
||||
]
|
||||
|
||||
_EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
|
||||
_SESSION_RE = re.compile(r"Process running with session ID (\d+)")
|
||||
_OUTPUT_HEADER = "\nOutput:\n"
|
||||
|
||||
|
||||
@cache
|
||||
def _get_style_colors() -> dict[Any, str]:
|
||||
style = get_style_by_name("native")
|
||||
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||
|
||||
|
||||
def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
|
||||
"""Translate the SDK's terminal-output string into the dict shape the
|
||||
renderer's `_append_output` helper expects.
|
||||
|
||||
The SDK returns a header-prefixed string ending with `Output:\\n<actual>`.
|
||||
We extract `content`, `exit_code`, and `session_id`; anything else (or a
|
||||
non-string result) flows through unchanged so renderers can handle errors.
|
||||
"""
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
if not isinstance(result, str):
|
||||
return {"content": "" if result is None else str(result)}
|
||||
|
||||
exit_match = _EXIT_RE.search(result)
|
||||
session_match = _SESSION_RE.search(result)
|
||||
idx = result.find(_OUTPUT_HEADER)
|
||||
content = result[idx + len(_OUTPUT_HEADER) :] if idx >= 0 else result
|
||||
|
||||
parsed: dict[str, Any] = {"content": content}
|
||||
if exit_match:
|
||||
parsed["exit_code"] = int(exit_match.group(1))
|
||||
if session_match:
|
||||
parsed["session_id"] = int(session_match.group(1))
|
||||
return parsed
|
||||
|
||||
|
||||
def _truncate_line(line: str) -> str:
|
||||
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
if len(clean_line) > MAX_LINE_LENGTH:
|
||||
return line[: MAX_LINE_LENGTH - 3] + "..."
|
||||
return line
|
||||
|
||||
|
||||
def _clean_output(output: str) -> str:
|
||||
cleaned = output
|
||||
for pattern in STRIP_PATTERNS:
|
||||
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
|
||||
|
||||
if cleaned.strip():
|
||||
lines = cleaned.splitlines()
|
||||
filtered_lines: list[str] = []
|
||||
for line in lines:
|
||||
if not filtered_lines and not line.strip():
|
||||
continue
|
||||
if line.strip() == "Output:":
|
||||
continue
|
||||
filtered_lines.append(line)
|
||||
while filtered_lines and not filtered_lines[-1].strip():
|
||||
filtered_lines.pop()
|
||||
cleaned = "\n".join(filtered_lines)
|
||||
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _format_output(output: str) -> Text:
|
||||
text = Text()
|
||||
lines = output.splitlines()
|
||||
total_lines = len(lines)
|
||||
|
||||
head_count = MAX_OUTPUT_LINES // 2
|
||||
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
||||
|
||||
if total_lines <= MAX_OUTPUT_LINES:
|
||||
display_lines = lines
|
||||
truncated = False
|
||||
hidden_count = 0
|
||||
else:
|
||||
display_lines = lines[:head_count]
|
||||
truncated = True
|
||||
hidden_count = total_lines - head_count - tail_count
|
||||
|
||||
for i, line in enumerate(display_lines):
|
||||
text.append(" ")
|
||||
text.append(_truncate_line(line), style="dim")
|
||||
if i < len(display_lines) - 1 or truncated:
|
||||
text.append("\n")
|
||||
|
||||
if truncated:
|
||||
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
||||
text.append("\n")
|
||||
tail_lines = lines[-tail_count:]
|
||||
for i, line in enumerate(tail_lines):
|
||||
text.append(" ")
|
||||
text.append(_truncate_line(line), style="dim")
|
||||
if i < len(tail_lines) - 1:
|
||||
text.append("\n")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _get_token_color(token_type: Any) -> str | None:
|
||||
colors = _get_style_colors()
|
||||
while token_type:
|
||||
if token_type in colors:
|
||||
return colors[token_type]
|
||||
token_type = token_type.parent
|
||||
return None
|
||||
|
||||
|
||||
def _highlight_bash(code: str) -> Text:
|
||||
lexer = get_lexer_by_name("bash")
|
||||
text = Text()
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
color = _get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
return text
|
||||
|
||||
|
||||
def _append_output(text: Text, parsed: dict[str, Any], tool_status: str) -> None:
|
||||
raw_output = parsed.get("content", "") or ""
|
||||
output = _clean_output(raw_output) if isinstance(raw_output, str) else ""
|
||||
exit_code = parsed.get("exit_code")
|
||||
|
||||
if tool_status == "running":
|
||||
if output:
|
||||
text.append("\n")
|
||||
text.append_text(_format_output(output))
|
||||
return
|
||||
|
||||
if not output:
|
||||
if exit_code is not None and exit_code != 0:
|
||||
text.append("\n")
|
||||
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||
return
|
||||
|
||||
text.append("\n")
|
||||
text.append_text(_format_output(output))
|
||||
|
||||
if exit_code is not None and exit_code != 0:
|
||||
text.append("\n")
|
||||
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||
|
||||
|
||||
def _build_terminal_content(
|
||||
*,
|
||||
prompt: str,
|
||||
prompt_style: str,
|
||||
command: str,
|
||||
parsed_result: dict[str, Any] | None,
|
||||
tool_status: str,
|
||||
meta: str | None = None,
|
||||
) -> Text:
|
||||
text = Text()
|
||||
text.append(">_", style="dim")
|
||||
text.append(" ")
|
||||
|
||||
if not command.strip():
|
||||
text.append("getting logs...", style="dim")
|
||||
else:
|
||||
text.append(prompt, style=prompt_style)
|
||||
text.append(" ")
|
||||
text.append_text(_highlight_bash(command))
|
||||
|
||||
if meta:
|
||||
text.append(f" {meta}", style="dim")
|
||||
|
||||
if parsed_result is not None:
|
||||
_append_output(text, parsed_result, tool_status)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ExecCommandRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "exec_command"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
result = tool_data.get("result")
|
||||
|
||||
cmd = str(args.get("cmd", ""))
|
||||
workdir = args.get("workdir")
|
||||
tty = bool(args.get("tty"))
|
||||
|
||||
meta_parts: list[str] = []
|
||||
if workdir:
|
||||
meta_parts.append(f"cwd:{workdir}")
|
||||
if tty:
|
||||
meta_parts.append("tty")
|
||||
meta = ", ".join(meta_parts) if meta_parts else None
|
||||
|
||||
parsed = _parse_sdk_shell_result(result) if result is not None else None
|
||||
|
||||
content = _build_terminal_content(
|
||||
prompt="$",
|
||||
prompt_style="#22c55e",
|
||||
command=cmd,
|
||||
parsed_result=parsed,
|
||||
tool_status=status,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
return Static(content, classes=cls.get_css_classes(status))
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class WriteStdinRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "write_stdin"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
result = tool_data.get("result")
|
||||
|
||||
chars = str(args.get("chars", ""))
|
||||
session_id = args.get("session_id")
|
||||
meta = f"session #{session_id}" if session_id is not None else None
|
||||
|
||||
parsed = _parse_sdk_shell_result(result) if result is not None else None
|
||||
|
||||
content = _build_terminal_content(
|
||||
prompt=">>>",
|
||||
prompt_style="#3b82f6",
|
||||
command=chars,
|
||||
parsed_result=parsed,
|
||||
tool_status=status,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
return Static(content, classes=cls.get_css_classes(status))
|
||||
Reference in New Issue
Block a user