From d3ab3f836b1ccad4d28065d0927e528309b64085 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Mon, 25 May 2026 23:22:56 -0700 Subject: [PATCH] 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 --- strix/interface/tui/renderers/__init__.py | 6 + .../tui/renderers/filesystem_renderer.py | 266 ++++++++++++++++++ .../tui/renderers/load_skill_renderer.py | 37 +++ .../interface/tui/renderers/proxy_renderer.py | 149 ++++++++++ .../interface/tui/renderers/shell_renderer.py | 262 +++++++++++++++++ 5 files changed, 720 insertions(+) create mode 100644 strix/interface/tui/renderers/filesystem_renderer.py create mode 100644 strix/interface/tui/renderers/load_skill_renderer.py create mode 100644 strix/interface/tui/renderers/shell_renderer.py diff --git a/strix/interface/tui/renderers/__init__.py b/strix/interface/tui/renderers/__init__.py index 51e76d0..75f60d7 100644 --- a/strix/interface/tui/renderers/__init__.py +++ b/strix/interface/tui/renderers/__init__.py @@ -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", diff --git a/strix/interface/tui/renderers/filesystem_renderer.py b/strix/interface/tui/renderers/filesystem_renderer.py new file mode 100644 index 0000000..341addc --- /dev/null +++ b/strix/interface/tui/renderers/filesystem_renderer.py @@ -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)) diff --git a/strix/interface/tui/renderers/load_skill_renderer.py b/strix/interface/tui/renderers/load_skill_renderer.py new file mode 100644 index 0000000..52cde4b --- /dev/null +++ b/strix/interface/tui/renderers/load_skill_renderer.py @@ -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)) diff --git a/strix/interface/tui/renderers/proxy_renderer.py b/strix/interface/tui/renderers/proxy_renderer.py index fe48b71..94bf3c9 100644 --- a/strix/interface/tui/renderers/proxy_renderer.py +++ b/strix/interface/tui/renderers/proxy_renderer.py @@ -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" diff --git a/strix/interface/tui/renderers/shell_renderer.py b/strix/interface/tui/renderers/shell_renderer.py new file mode 100644 index 0000000..d15c0c5 --- /dev/null +++ b/strix/interface/tui/renderers/shell_renderer.py @@ -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`. + 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))