d3ab3f836b
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>
267 lines
8.3 KiB
Python
267 lines
8.3 KiB
Python
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))
|