feat(tui): refactor TUI components for improved text rendering and styling
- Removed unused escape_markup function and integrated rich.text for better text handling. - Updated various renderers to utilize Text for consistent styling and formatting. - Enhanced chat and agent message displays with dynamic text features. - Improved error handling and display for various tool components. - Refined TUI styles for better visual consistency across components.
This commit is contained in:
@@ -48,7 +48,7 @@ Screen {
|
||||
#agents_tree {
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
border: round #1a1a1a;
|
||||
border: round #333333;
|
||||
border-title-color: #a8a29e;
|
||||
border-title-style: bold;
|
||||
padding: 1;
|
||||
|
||||
@@ -1,43 +1,163 @@
|
||||
import re
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||
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
|
||||
|
||||
|
||||
def markdown_to_rich(text: str) -> str:
|
||||
# Fenced code blocks: ```lang\n...\n``` or ```\n...\n```
|
||||
text = re.sub(
|
||||
r"```(?:\w*)\n(.*?)```",
|
||||
r"[dim]\1[/dim]",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEADER_STYLES = [
|
||||
("###### ", 7, "bold #4ade80"),
|
||||
("##### ", 6, "bold #22c55e"),
|
||||
("#### ", 5, "bold #16a34a"),
|
||||
("### ", 4, "bold #15803d"),
|
||||
("## ", 3, "bold #22c55e"),
|
||||
("# ", 2, "bold #4ade80"),
|
||||
]
|
||||
|
||||
# Headers
|
||||
text = re.sub(r"^#### (.+)$", r"[bold]\1[/bold]", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^### (.+)$", r"[bold]\1[/bold]", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^## (.+)$", r"[bold]\1[/bold]", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^# (.+)$", r"[bold]\1[/bold]", text, flags=re.MULTILINE)
|
||||
|
||||
# Links
|
||||
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"[underline]\1[/underline] [dim](\2)[/dim]", text)
|
||||
@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"]}
|
||||
|
||||
# Bold
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"[bold]\1[/bold]", text)
|
||||
text = re.sub(r"__(.+?)__", r"[bold]\1[/bold]", text)
|
||||
|
||||
# Italic
|
||||
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"[italic]\1[/italic]", text)
|
||||
text = re.sub(r"(?<![_\w])_(?!_)(.+?)(?<!_)_(?![_\w])", r"[italic]\1[/italic]", 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
|
||||
|
||||
# Inline code
|
||||
text = re.sub(r"`([^`]+)`", r"[bold dim]\1[/bold dim]", text)
|
||||
|
||||
# Strikethrough
|
||||
return re.sub(r"~~(.+?)~~", r"[strike]\1[/strike]", text)
|
||||
def _highlight_code(code: str, language: str | None = None) -> Text:
|
||||
text = Text()
|
||||
|
||||
try:
|
||||
lexer = get_lexer_by_name(language) if language else guess_lexer(code)
|
||||
except ClassNotFound:
|
||||
text.append(code, style="#d4d4d4")
|
||||
return 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 _try_parse_header(line: str) -> tuple[str, str] | None:
|
||||
for prefix, strip_len, style in _HEADER_STYLES:
|
||||
if line.startswith(prefix):
|
||||
return (line[strip_len:], style)
|
||||
return None
|
||||
|
||||
|
||||
def _apply_markdown_styles(text: str) -> Text: # noqa: PLR0912
|
||||
result = Text()
|
||||
lines = text.split("\n")
|
||||
|
||||
in_code_block = False
|
||||
code_block_lang: str | None = None
|
||||
code_block_lines: list[str] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if i > 0 and not in_code_block:
|
||||
result.append("\n")
|
||||
|
||||
if line.startswith("```"):
|
||||
if not in_code_block:
|
||||
in_code_block = True
|
||||
code_block_lang = line[3:].strip() or None
|
||||
code_block_lines = []
|
||||
if i > 0:
|
||||
result.append("\n")
|
||||
else:
|
||||
in_code_block = False
|
||||
code_content = "\n".join(code_block_lines)
|
||||
if code_content:
|
||||
result.append_text(_highlight_code(code_content, code_block_lang))
|
||||
code_block_lines = []
|
||||
code_block_lang = None
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
code_block_lines.append(line)
|
||||
continue
|
||||
|
||||
header = _try_parse_header(line)
|
||||
if header:
|
||||
result.append(header[0], style=header[1])
|
||||
elif line.startswith("> "):
|
||||
result.append("┃ ", style="#22c55e")
|
||||
result.append_text(_process_inline_formatting(line[2:]))
|
||||
elif line.startswith(("- ", "* ")):
|
||||
result.append("• ", style="#22c55e")
|
||||
result.append_text(_process_inline_formatting(line[2:]))
|
||||
elif len(line) > 2 and line[0].isdigit() and line[1:3] in (". ", ") "):
|
||||
result.append(line[0] + ". ", style="#22c55e")
|
||||
result.append_text(_process_inline_formatting(line[2:]))
|
||||
elif line.strip() in ("---", "***", "___"):
|
||||
result.append("─" * 40, style="#22c55e")
|
||||
else:
|
||||
result.append_text(_process_inline_formatting(line))
|
||||
|
||||
if in_code_block and code_block_lines:
|
||||
code_content = "\n".join(code_block_lines)
|
||||
result.append_text(_highlight_code(code_content, code_block_lang))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _process_inline_formatting(line: str) -> Text:
|
||||
result = Text()
|
||||
i = 0
|
||||
n = len(line)
|
||||
|
||||
while i < n:
|
||||
if i + 1 < n and line[i : i + 2] in ("**", "__"):
|
||||
marker = line[i : i + 2]
|
||||
end = line.find(marker, i + 2)
|
||||
if end != -1:
|
||||
result.append(line[i + 2 : end], style="bold #4ade80")
|
||||
i = end + 2
|
||||
continue
|
||||
|
||||
if i + 1 < n and line[i : i + 2] == "~~":
|
||||
end = line.find("~~", i + 2)
|
||||
if end != -1:
|
||||
result.append(line[i + 2 : end], style="strike #525252")
|
||||
i = end + 2
|
||||
continue
|
||||
|
||||
if line[i] == "`":
|
||||
end = line.find("`", i + 1)
|
||||
if end != -1:
|
||||
result.append(line[i + 1 : end], style="bold #22c55e on #0a0a0a")
|
||||
i = end + 1
|
||||
continue
|
||||
|
||||
if line[i] in ("*", "_"):
|
||||
marker = line[i]
|
||||
if i + 1 < n and line[i + 1] != marker:
|
||||
end = line.find(marker, i + 1)
|
||||
if end != -1 and (end + 1 >= n or line[end + 1] != marker):
|
||||
result.append(line[i + 1 : end], style="italic #86efac")
|
||||
i = end + 1
|
||||
continue
|
||||
|
||||
result.append(line[i])
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -46,25 +166,19 @@ class AgentMessageRenderer(BaseToolRenderer):
|
||||
css_classes: ClassVar[list[str]] = ["chat-message", "agent-message"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, message_data: dict[str, Any]) -> Static:
|
||||
content = message_data.get("content", "")
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
content = tool_data.get("content", "")
|
||||
|
||||
if not content:
|
||||
return Static("", classes=cls.css_classes)
|
||||
return Static(Text(), classes=" ".join(cls.css_classes))
|
||||
|
||||
formatted_content = cls._format_agent_message(content)
|
||||
styled_text = _apply_markdown_styles(content)
|
||||
|
||||
css_classes = " ".join(cls.css_classes)
|
||||
return Static(formatted_content, classes=css_classes)
|
||||
return Static(styled_text, classes=" ".join(cls.css_classes))
|
||||
|
||||
@classmethod
|
||||
def render_simple(cls, content: str) -> str:
|
||||
def render_simple(cls, content: str) -> Text:
|
||||
if not content:
|
||||
return ""
|
||||
return Text()
|
||||
|
||||
return cls._format_agent_message(content)
|
||||
|
||||
@classmethod
|
||||
def _format_agent_message(cls, content: str) -> str:
|
||||
escaped_content = cls.escape_markup(content)
|
||||
return markdown_to_rich(escaped_content)
|
||||
return _apply_markdown_styles(content)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -13,10 +14,12 @@ class ViewAgentGraphRenderer(BaseToolRenderer):
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: ARG003
|
||||
content_text = "🕸️ [bold #fbbf24]Viewing agents graph[/]"
|
||||
text = Text()
|
||||
text.append("🕸️ ")
|
||||
text.append("Viewing agents graph", style="bold #fbbf24")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -31,16 +34,19 @@ class CreateAgentRenderer(BaseToolRenderer):
|
||||
task = args.get("task", "")
|
||||
name = args.get("name", "Agent")
|
||||
|
||||
header = f"🤖 [bold #fbbf24]Creating {cls.escape_markup(name)}[/]"
|
||||
text = Text()
|
||||
text.append("🤖 ")
|
||||
text.append(f"Creating {name}", style="bold #fbbf24")
|
||||
|
||||
if task:
|
||||
task_display = task[:400] + "..." if len(task) > 400 else task
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(task_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(task, 400), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Spawning agent...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Spawning agent...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -54,16 +60,19 @@ class SendMessageToAgentRenderer(BaseToolRenderer):
|
||||
|
||||
message = args.get("message", "")
|
||||
|
||||
header = "💬 [bold #fbbf24]Sending message[/]"
|
||||
text = Text()
|
||||
text.append("💬 ")
|
||||
text.append("Sending message", style="bold #fbbf24")
|
||||
|
||||
if message:
|
||||
message_display = message[:400] + "..." if len(message) > 400 else message
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(message_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(message, 400), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Sending...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Sending...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -79,25 +88,28 @@ class AgentFinishRenderer(BaseToolRenderer):
|
||||
findings = args.get("findings", [])
|
||||
success = args.get("success", True)
|
||||
|
||||
header = (
|
||||
"🏁 [bold #fbbf24]Agent completed[/]" if success else "🏁 [bold #fbbf24]Agent failed[/]"
|
||||
)
|
||||
text = Text()
|
||||
text.append("🏁 ")
|
||||
|
||||
if success:
|
||||
text.append("Agent completed", style="bold #fbbf24")
|
||||
else:
|
||||
text.append("Agent failed", style="bold #fbbf24")
|
||||
|
||||
if result_summary:
|
||||
content_parts = [f"{header}\n [bold]{cls.escape_markup(result_summary)}[/]"]
|
||||
text.append("\n ")
|
||||
text.append(result_summary, style="bold")
|
||||
|
||||
if findings and isinstance(findings, list):
|
||||
finding_lines = [f"• {finding}" for finding in findings]
|
||||
content_parts.append(
|
||||
f" [dim]{chr(10).join([cls.escape_markup(line) for line in finding_lines])}[/]"
|
||||
)
|
||||
|
||||
content_text = "\n".join(content_parts)
|
||||
for finding in findings:
|
||||
text.append("\n • ")
|
||||
text.append(str(finding), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Completing task...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Completing task...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -111,13 +123,16 @@ class WaitForMessageRenderer(BaseToolRenderer):
|
||||
|
||||
reason = args.get("reason", "Waiting for messages from other agents or user input")
|
||||
|
||||
header = "⏸️ [bold #fbbf24]Waiting for messages[/]"
|
||||
text = Text()
|
||||
text.append("⏸️ ")
|
||||
text.append("Waiting for messages", style="bold #fbbf24")
|
||||
|
||||
if reason:
|
||||
reason_display = reason[:400] + "..." if len(reason) > 400 else reason
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(reason_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(reason, 400), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Agent paused until message received...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Agent paused until message received...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, ClassVar, cast
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.markup import escape as rich_escape
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class BaseToolRenderer(ABC):
|
||||
tool_name: ClassVar[str] = ""
|
||||
|
||||
css_classes: ClassVar[list[str]] = ["tool-call"]
|
||||
|
||||
@classmethod
|
||||
@@ -16,47 +15,86 @@ class BaseToolRenderer(ABC):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def escape_markup(cls, text: str) -> str:
|
||||
return cast("str", rich_escape(text))
|
||||
def build_text(cls, tool_data: dict[str, Any]) -> Text: # noqa: ARG003
|
||||
return Text()
|
||||
|
||||
@classmethod
|
||||
def format_args(cls, args: dict[str, Any], max_length: int = 500) -> str:
|
||||
if not args:
|
||||
return ""
|
||||
|
||||
args_parts = []
|
||||
for k, v in args.items():
|
||||
str_v = str(v)
|
||||
if len(str_v) > max_length:
|
||||
str_v = str_v[: max_length - 3] + "..."
|
||||
args_parts.append(f" [dim]{k}:[/] {cls.escape_markup(str_v)}")
|
||||
return "\n".join(args_parts)
|
||||
def create_static(cls, content: Text, status: str) -> Static:
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(content, classes=css_classes)
|
||||
|
||||
@classmethod
|
||||
def format_result(cls, result: Any, max_length: int = 1000) -> str:
|
||||
if result is None:
|
||||
return ""
|
||||
|
||||
str_result = str(result).strip()
|
||||
if not str_result:
|
||||
return ""
|
||||
|
||||
if len(str_result) > max_length:
|
||||
str_result = str_result[: max_length - 3] + "..."
|
||||
return cls.escape_markup(str_result)
|
||||
def truncate(cls, text: str, max_length: int = 500) -> str:
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[: max_length - 3] + "..."
|
||||
|
||||
@classmethod
|
||||
def get_status_icon(cls, status: str) -> str:
|
||||
status_icons = {
|
||||
"running": "[#f59e0b]●[/#f59e0b] In progress...",
|
||||
"completed": "[#22c55e]✓[/#22c55e] Done",
|
||||
"failed": "[#dc2626]✗[/#dc2626] Failed",
|
||||
"error": "[#dc2626]✗[/#dc2626] Error",
|
||||
def status_icon(cls, status: str) -> tuple[str, str]:
|
||||
icons = {
|
||||
"running": ("● In progress...", "#f59e0b"),
|
||||
"completed": ("✓ Done", "#22c55e"),
|
||||
"failed": ("✗ Failed", "#dc2626"),
|
||||
"error": ("✗ Error", "#dc2626"),
|
||||
}
|
||||
return status_icons.get(status, "[dim]○[/dim] Unknown")
|
||||
return icons.get(status, ("○ Unknown", "dim"))
|
||||
|
||||
@classmethod
|
||||
def get_css_classes(cls, status: str) -> str:
|
||||
base_classes = cls.css_classes.copy()
|
||||
base_classes.append(f"status-{status}")
|
||||
return " ".join(base_classes)
|
||||
|
||||
@classmethod
|
||||
def text_with_style(cls, content: str, style: str | None = None) -> Text:
|
||||
text = Text()
|
||||
text.append(content, style=style)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def text_icon_label(
|
||||
cls,
|
||||
icon: str,
|
||||
label: str,
|
||||
icon_style: str | None = None,
|
||||
label_style: str | None = None,
|
||||
) -> Text:
|
||||
text = Text()
|
||||
text.append(icon, style=icon_style)
|
||||
text.append(" ")
|
||||
text.append(label, style=label_style)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def text_header(
|
||||
cls,
|
||||
icon: str,
|
||||
title: str,
|
||||
subtitle: str = "",
|
||||
title_style: str = "bold",
|
||||
subtitle_style: str = "dim",
|
||||
) -> Text:
|
||||
text = Text()
|
||||
text.append(icon)
|
||||
text.append(" ")
|
||||
text.append(title, style=title_style)
|
||||
if subtitle:
|
||||
text.append(" ")
|
||||
text.append(subtitle, style=subtitle_style)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def text_key_value(
|
||||
cls,
|
||||
key: str,
|
||||
value: str,
|
||||
key_style: str = "dim",
|
||||
value_style: str | None = None,
|
||||
indent: int = 2,
|
||||
) -> Text:
|
||||
text = Text()
|
||||
text.append(" " * indent)
|
||||
text.append(key, style=key_style)
|
||||
text.append(": ")
|
||||
text.append(value, style=value_style)
|
||||
return text
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -20,6 +21,22 @@ class BrowserRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "browser_action"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
|
||||
|
||||
SIMPLE_ACTIONS: ClassVar[dict[str, str]] = {
|
||||
"back": "going back in browser history",
|
||||
"forward": "going forward in browser history",
|
||||
"scroll_down": "scrolling down",
|
||||
"scroll_up": "scrolling up",
|
||||
"refresh": "refreshing browser tab",
|
||||
"close_tab": "closing browser tab",
|
||||
"switch_tab": "switching browser tab",
|
||||
"list_tabs": "listing browser tabs",
|
||||
"view_source": "viewing page source",
|
||||
"get_console_logs": "getting console logs",
|
||||
"screenshot": "taking screenshot of browser tab",
|
||||
"wait": "waiting...",
|
||||
"close": "closing browser",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_token_color(cls, token_type: Any) -> str | None:
|
||||
colors = _get_style_colors()
|
||||
@@ -30,23 +47,17 @@ class BrowserRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_js(cls, code: str) -> str:
|
||||
def _highlight_js(cls, code: str) -> Text:
|
||||
lexer = get_lexer_by_name("javascript")
|
||||
result_parts: list[str] = []
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
|
||||
escaped_value = cls.escape_markup(token_value)
|
||||
color = cls._get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
|
||||
if color:
|
||||
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||
else:
|
||||
result_parts.append(escaped_value)
|
||||
|
||||
return "".join(result_parts)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
@@ -54,103 +65,71 @@ class BrowserRenderer(BaseToolRenderer):
|
||||
status = tool_data.get("status", "unknown")
|
||||
|
||||
action = args.get("action", "unknown")
|
||||
|
||||
content = cls._build_sleek_content(action, args)
|
||||
content = cls._build_content(action, args)
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(content, classes=css_classes)
|
||||
|
||||
@classmethod
|
||||
def _build_sleek_content(cls, action: str, args: dict[str, Any]) -> str:
|
||||
browser_icon = "🌐"
|
||||
def _build_url_action(cls, text: Text, label: str, url: str | None, suffix: str = "") -> None:
|
||||
text.append(label, style="#06b6d4")
|
||||
if url:
|
||||
text.append(cls.truncate(url, 300), style="#06b6d4")
|
||||
if suffix:
|
||||
text.append(suffix, style="#06b6d4")
|
||||
|
||||
@classmethod
|
||||
def _build_content(cls, action: str, args: dict[str, Any]) -> Text:
|
||||
text = Text()
|
||||
text.append("🌐 ")
|
||||
|
||||
if action in cls.SIMPLE_ACTIONS:
|
||||
text.append(cls.SIMPLE_ACTIONS[action], style="#06b6d4")
|
||||
return text
|
||||
|
||||
url = args.get("url")
|
||||
text = args.get("text")
|
||||
js_code = args.get("js_code")
|
||||
key = args.get("key")
|
||||
file_path = args.get("file_path")
|
||||
|
||||
if action in [
|
||||
"launch",
|
||||
"goto",
|
||||
"new_tab",
|
||||
"type",
|
||||
"execute_js",
|
||||
"click",
|
||||
"double_click",
|
||||
"hover",
|
||||
"press_key",
|
||||
"save_pdf",
|
||||
]:
|
||||
if action == "launch":
|
||||
display_url = cls._format_url(url) if url else None
|
||||
message = (
|
||||
f"launching {display_url} on browser" if display_url else "launching browser"
|
||||
)
|
||||
elif action == "goto":
|
||||
display_url = cls._format_url(url) if url else None
|
||||
message = f"navigating to {display_url}" if display_url else "navigating"
|
||||
elif action == "new_tab":
|
||||
display_url = cls._format_url(url) if url else None
|
||||
message = f"opening tab {display_url}" if display_url else "opening tab"
|
||||
elif action == "type":
|
||||
display_text = cls._format_text(text) if text else None
|
||||
message = f"typing {display_text}" if display_text else "typing"
|
||||
elif action == "execute_js":
|
||||
display_js = cls._format_js(js_code) if js_code else None
|
||||
message = (
|
||||
f"executing javascript\n{display_js}" if display_js else "executing javascript"
|
||||
)
|
||||
elif action == "press_key":
|
||||
display_key = cls.escape_markup(key) if key else None
|
||||
message = f"pressing key {display_key}" if display_key else "pressing key"
|
||||
elif action == "save_pdf":
|
||||
display_path = cls.escape_markup(file_path) if file_path else None
|
||||
message = f"saving PDF to {display_path}" if display_path else "saving PDF"
|
||||
else:
|
||||
action_words = {
|
||||
"click": "clicking",
|
||||
"double_click": "double clicking",
|
||||
"hover": "hovering",
|
||||
}
|
||||
message = cls.escape_markup(action_words[action])
|
||||
|
||||
return f"{browser_icon} [#06b6d4]{message}[/]"
|
||||
|
||||
simple_actions = {
|
||||
"back": "going back in browser history",
|
||||
"forward": "going forward in browser history",
|
||||
"scroll_down": "scrolling down",
|
||||
"scroll_up": "scrolling up",
|
||||
"refresh": "refreshing browser tab",
|
||||
"close_tab": "closing browser tab",
|
||||
"switch_tab": "switching browser tab",
|
||||
"list_tabs": "listing browser tabs",
|
||||
"view_source": "viewing page source",
|
||||
"get_console_logs": "getting console logs",
|
||||
"screenshot": "taking screenshot of browser tab",
|
||||
"wait": "waiting...",
|
||||
"close": "closing browser",
|
||||
url_actions = {
|
||||
"launch": ("launching ", " on browser" if url else "browser"),
|
||||
"goto": ("navigating to ", ""),
|
||||
"new_tab": ("opening tab ", ""),
|
||||
}
|
||||
if action in url_actions:
|
||||
label, suffix = url_actions[action]
|
||||
if action == "launch" and not url:
|
||||
text.append("launching browser", style="#06b6d4")
|
||||
else:
|
||||
cls._build_url_action(text, label, url, suffix)
|
||||
return text
|
||||
|
||||
if action in simple_actions:
|
||||
return f"{browser_icon} [#06b6d4]{cls.escape_markup(simple_actions[action])}[/]"
|
||||
click_actions = {
|
||||
"click": "clicking",
|
||||
"double_click": "double clicking",
|
||||
"hover": "hovering",
|
||||
}
|
||||
if action in click_actions:
|
||||
text.append(click_actions[action], style="#06b6d4")
|
||||
return text
|
||||
|
||||
return f"{browser_icon} [#06b6d4]{cls.escape_markup(action)}[/]"
|
||||
handlers: dict[str, tuple[str, str | None]] = {
|
||||
"type": ("typing ", args.get("text")),
|
||||
"press_key": ("pressing key ", args.get("key")),
|
||||
"save_pdf": ("saving PDF to ", args.get("file_path")),
|
||||
}
|
||||
if action in handlers:
|
||||
label, value = handlers[action]
|
||||
text.append(label, style="#06b6d4")
|
||||
if value:
|
||||
text.append(cls.truncate(str(value), 200), style="#06b6d4")
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _format_url(cls, url: str) -> str:
|
||||
if len(url) > 300:
|
||||
url = url[:297] + "..."
|
||||
return cls.escape_markup(url)
|
||||
if action == "execute_js":
|
||||
text.append("executing javascript", style="#06b6d4")
|
||||
js_code = args.get("js_code")
|
||||
if js_code:
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_js(cls.truncate(js_code, 2000)))
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _format_text(cls, text: str) -> str:
|
||||
if len(text) > 200:
|
||||
text = text[:197] + "..."
|
||||
return cls.escape_markup(text)
|
||||
|
||||
@classmethod
|
||||
def _format_js(cls, js_code: str) -> str:
|
||||
code_display = js_code[:2000] + "..." if len(js_code) > 2000 else js_code
|
||||
return cls._highlight_js(code_display)
|
||||
text.append(action, style="#06b6d4")
|
||||
return text
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -38,23 +39,17 @@ class StrReplaceEditorRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_code(cls, code: str, path: str) -> str:
|
||||
def _highlight_code(cls, code: str, path: str) -> Text:
|
||||
lexer = _get_lexer_for_file(path)
|
||||
result_parts: list[str] = []
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
|
||||
escaped_value = cls.escape_markup(token_value)
|
||||
color = cls._get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
|
||||
if color:
|
||||
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||
else:
|
||||
result_parts.append(escaped_value)
|
||||
|
||||
return "".join(result_parts)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
@@ -67,48 +62,64 @@ class StrReplaceEditorRenderer(BaseToolRenderer):
|
||||
new_str = args.get("new_str", "")
|
||||
file_text = args.get("file_text", "")
|
||||
|
||||
if command == "view":
|
||||
header = "📖 [bold #10b981]Reading file[/]"
|
||||
elif command == "str_replace":
|
||||
header = "✏️ [bold #10b981]Editing file[/]"
|
||||
elif command == "create":
|
||||
header = "📝 [bold #10b981]Creating file[/]"
|
||||
elif command == "insert":
|
||||
header = "✏️ [bold #10b981]Inserting text[/]"
|
||||
elif command == "undo_edit":
|
||||
header = "↩️ [bold #10b981]Undoing edit[/]"
|
||||
else:
|
||||
header = "📄 [bold #10b981]File operation[/]"
|
||||
text = Text()
|
||||
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
content_parts = [f"{header} [dim]{cls.escape_markup(path_display)}[/]"]
|
||||
icons_and_labels = {
|
||||
"view": ("📖 ", "Reading file", "#10b981"),
|
||||
"str_replace": ("✏️ ", "Editing file", "#10b981"),
|
||||
"create": ("📝 ", "Creating file", "#10b981"),
|
||||
"insert": ("✏️ ", "Inserting text", "#10b981"),
|
||||
"undo_edit": ("↩️ ", "Undoing edit", "#10b981"),
|
||||
}
|
||||
|
||||
icon, label, color = icons_and_labels.get(command, ("📄 ", "File operation", "#10b981"))
|
||||
text.append(icon)
|
||||
text.append(label, style=f"bold {color}")
|
||||
|
||||
if path:
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
text.append(" ")
|
||||
text.append(path_display, style="dim")
|
||||
|
||||
if command == "str_replace" and (old_str or new_str):
|
||||
if old_str:
|
||||
old_display = old_str[:1000] + "..." if len(old_str) > 1000 else old_str
|
||||
old_display = cls.truncate(old_str, 1000)
|
||||
highlighted_old = cls._highlight_code(old_display, path)
|
||||
old_lines = highlighted_old.split("\n")
|
||||
content_parts.extend(f"[#ef4444]-[/] {line}" for line in old_lines)
|
||||
if new_str:
|
||||
new_display = new_str[:1000] + "..." if len(new_str) > 1000 else new_str
|
||||
highlighted_new = cls._highlight_code(new_display, path)
|
||||
new_lines = highlighted_new.split("\n")
|
||||
content_parts.extend(f"[#22c55e]+[/] {line}" for line in new_lines)
|
||||
elif command == "create" and file_text:
|
||||
text_display = file_text[:1500] + "..." if len(file_text) > 1500 else file_text
|
||||
highlighted_text = cls._highlight_code(text_display, path)
|
||||
content_parts.append(highlighted_text)
|
||||
elif command == "insert" and new_str:
|
||||
new_display = new_str[:1000] + "..." if len(new_str) > 1000 else new_str
|
||||
highlighted_new = cls._highlight_code(new_display, path)
|
||||
new_lines = highlighted_new.split("\n")
|
||||
content_parts.extend(f"[#22c55e]+[/] {line}" for line in new_lines)
|
||||
elif not (result and isinstance(result, dict) and "content" in result) and not path:
|
||||
content_parts = [f"{header} [dim]Processing...[/]"]
|
||||
for line in highlighted_old.plain.split("\n"):
|
||||
text.append("\n")
|
||||
text.append("-", style="#ef4444")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
|
||||
if new_str:
|
||||
new_display = cls.truncate(new_str, 1000)
|
||||
highlighted_new = cls._highlight_code(new_display, path)
|
||||
for line in highlighted_new.plain.split("\n"):
|
||||
text.append("\n")
|
||||
text.append("+", style="#22c55e")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
|
||||
elif command == "create" and file_text:
|
||||
text_display = cls.truncate(file_text, 1500)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_code(text_display, path))
|
||||
|
||||
elif command == "insert" and new_str:
|
||||
new_display = cls.truncate(new_str, 1000)
|
||||
highlighted_new = cls._highlight_code(new_display, path)
|
||||
for line in highlighted_new.plain.split("\n"):
|
||||
text.append("\n")
|
||||
text.append("+", style="#22c55e")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
|
||||
elif not (result and isinstance(result, dict) and "content" in result) and not path:
|
||||
text.append(" ")
|
||||
text.append("Processing...", style="dim")
|
||||
|
||||
content_text = "\n".join(content_parts)
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -119,19 +130,21 @@ class ListFilesRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
|
||||
path = args.get("path", "")
|
||||
|
||||
header = "📂 [bold #10b981]Listing files[/]"
|
||||
text = Text()
|
||||
text.append("📂 ")
|
||||
text.append("Listing files", style="bold #10b981")
|
||||
text.append(" ")
|
||||
|
||||
if path:
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
content_text = f"{header} [dim]{cls.escape_markup(path_display)}[/]"
|
||||
text.append(path_display, style="dim")
|
||||
else:
|
||||
content_text = f"{header} [dim]Current directory[/]"
|
||||
text.append("Current directory", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -142,27 +155,31 @@ class SearchFilesRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
|
||||
path = args.get("path", "")
|
||||
regex = args.get("regex", "")
|
||||
|
||||
header = "🔍 [bold purple]Searching files[/]"
|
||||
text = Text()
|
||||
text.append("🔍 ")
|
||||
text.append("Searching files", style="bold purple")
|
||||
text.append(" ")
|
||||
|
||||
if path and regex:
|
||||
path_display = path[-30:] if len(path) > 30 else path
|
||||
regex_display = regex[:30] if len(regex) > 30 else regex
|
||||
content_text = (
|
||||
f"{header} [dim]{cls.escape_markup(path_display)} for "
|
||||
f"'{cls.escape_markup(regex_display)}'[/]"
|
||||
)
|
||||
text.append(path_display, style="dim")
|
||||
text.append(" for '", style="dim")
|
||||
text.append(regex_display, style="dim")
|
||||
text.append("'", style="dim")
|
||||
elif path:
|
||||
path_display = path[-60:] if len(path) > 60 else path
|
||||
content_text = f"{header} [dim]{cls.escape_markup(path_display)}[/]"
|
||||
text.append(path_display, style="dim")
|
||||
elif regex:
|
||||
regex_display = regex[:60] if len(regex) > 60 else regex
|
||||
content_text = f"{header} [dim]'{cls.escape_markup(regex_display)}'[/]"
|
||||
text.append("'", style="dim")
|
||||
text.append(regex_display, style="dim")
|
||||
text.append("'", style="dim")
|
||||
else:
|
||||
content_text = f"{header} [dim]Searching...[/]"
|
||||
text.append("Searching...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -18,14 +19,20 @@ class FinishScanRenderer(BaseToolRenderer):
|
||||
content = args.get("content", "")
|
||||
success = args.get("success", True)
|
||||
|
||||
header = (
|
||||
"🏁 [bold #dc2626]Finishing Scan[/]" if success else "🏁 [bold #dc2626]Scan Failed[/]"
|
||||
)
|
||||
text = Text()
|
||||
text.append("🏁 ")
|
||||
|
||||
if success:
|
||||
text.append("Finishing Scan", style="bold #dc2626")
|
||||
else:
|
||||
text.append("Scan Failed", style="bold #dc2626")
|
||||
|
||||
text.append("\n ")
|
||||
|
||||
if content:
|
||||
content_text = f"{header}\n [bold]{cls.escape_markup(content)}[/]"
|
||||
text.append(content, style="bold")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Generating final report...[/]"
|
||||
text.append("Generating final report...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
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
|
||||
|
||||
|
||||
def _truncate(text: str, length: int = 800) -> str:
|
||||
if len(text) <= length:
|
||||
return text
|
||||
return text[: length - 3] + "..."
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_note"
|
||||
@@ -25,22 +20,26 @@ class CreateNoteRenderer(BaseToolRenderer):
|
||||
content = args.get("content", "")
|
||||
category = args.get("category", "general")
|
||||
|
||||
header = f"📝 [bold #fbbf24]Note[/] [dim]({category})[/]"
|
||||
text = Text()
|
||||
text.append("📝 ")
|
||||
text.append("Note", style="bold #fbbf24")
|
||||
text.append(" ")
|
||||
text.append(f"({category})", style="dim")
|
||||
|
||||
lines = [header]
|
||||
if title:
|
||||
title_display = _truncate(title.strip(), 300)
|
||||
lines.append(f" {cls.escape_markup(title_display)}")
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(title.strip(), 300))
|
||||
|
||||
if content:
|
||||
content_display = _truncate(content.strip(), 800)
|
||||
lines.append(f" [dim]{cls.escape_markup(content_display)}[/]")
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(content.strip(), 800), style="dim")
|
||||
|
||||
if len(lines) == 1:
|
||||
lines.append(" [dim]Capturing...[/]")
|
||||
if not title and not content:
|
||||
text.append("\n ")
|
||||
text.append("Capturing...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static("\n".join(lines), classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -50,11 +49,12 @@ class DeleteNoteRenderer(BaseToolRenderer):
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: ARG003
|
||||
header = "📝 [bold #94a3b8]Note Removed[/]"
|
||||
content_text = header
|
||||
text = Text()
|
||||
text.append("📝 ")
|
||||
text.append("Note Removed", style="bold #94a3b8")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -69,21 +69,24 @@ class UpdateNoteRenderer(BaseToolRenderer):
|
||||
title = args.get("title")
|
||||
content = args.get("content")
|
||||
|
||||
header = "📝 [bold #fbbf24]Note Updated[/]"
|
||||
lines = [header]
|
||||
text = Text()
|
||||
text.append("📝 ")
|
||||
text.append("Note Updated", style="bold #fbbf24")
|
||||
|
||||
if title:
|
||||
lines.append(f" {cls.escape_markup(_truncate(title, 300))}")
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(title, 300))
|
||||
|
||||
if content:
|
||||
content_display = _truncate(content.strip(), 800)
|
||||
lines.append(f" [dim]{cls.escape_markup(content_display)}[/]")
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(content.strip(), 800), style="dim")
|
||||
|
||||
if len(lines) == 1:
|
||||
lines.append(" [dim]Updating...[/]")
|
||||
if not title and not content:
|
||||
text.append("\n ")
|
||||
text.append("Updating...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static("\n".join(lines), classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -95,34 +98,38 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
|
||||
header = "📝 [bold #fbbf24]Notes[/]"
|
||||
text = Text()
|
||||
text.append("📝 ")
|
||||
text.append("Notes", style="bold #fbbf24")
|
||||
|
||||
if result and isinstance(result, dict) and result.get("success"):
|
||||
count = result.get("total_count", 0)
|
||||
notes = result.get("notes", []) or []
|
||||
lines = [header]
|
||||
|
||||
if count == 0:
|
||||
lines.append(" [dim]No notes[/]")
|
||||
text.append("\n ")
|
||||
text.append("No notes", style="dim")
|
||||
else:
|
||||
for note in notes[:5]:
|
||||
title = note.get("title", "").strip() or "(untitled)"
|
||||
category = note.get("category", "general")
|
||||
content = note.get("content", "").strip()
|
||||
note_content = note.get("content", "").strip()
|
||||
|
||||
lines.append(
|
||||
f" - {cls.escape_markup(_truncate(title, 300))} [dim]({category})[/]"
|
||||
)
|
||||
if content:
|
||||
content_preview = _truncate(content, 400)
|
||||
lines.append(f" [dim]{cls.escape_markup(content_preview)}[/]")
|
||||
text.append("\n - ")
|
||||
text.append(cls.truncate(title, 300))
|
||||
text.append(f" ({category})", style="dim")
|
||||
|
||||
if note_content:
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(note_content, 400), style="dim")
|
||||
|
||||
remaining = max(count - 5, 0)
|
||||
if remaining:
|
||||
lines.append(f" [dim]... +{remaining} more[/]")
|
||||
content_text = "\n".join(lines)
|
||||
text.append("\n ")
|
||||
text.append(f"... +{remaining} more", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -18,38 +19,37 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
|
||||
httpql_filter = args.get("httpql_filter")
|
||||
|
||||
header = "📋 [bold #06b6d4]Listing requests[/]"
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Listing requests", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict) and "requests" in result:
|
||||
requests = result["requests"]
|
||||
if isinstance(requests, list) and requests:
|
||||
request_lines = []
|
||||
for req in requests[:3]:
|
||||
if isinstance(req, dict):
|
||||
method = req.get("method", "?")
|
||||
path = req.get("path", "?")
|
||||
response = req.get("response") or {}
|
||||
status = response.get("statusCode", "?")
|
||||
line = f"{method} {path} → {status}"
|
||||
request_lines.append(line)
|
||||
text.append("\n ")
|
||||
text.append(f"{method} {path} → {status}", style="dim")
|
||||
|
||||
if len(requests) > 3:
|
||||
request_lines.append(f"... +{len(requests) - 3} more")
|
||||
|
||||
escaped_lines = [cls.escape_markup(line) for line in request_lines]
|
||||
content_text = f"{header}\n [dim]{chr(10).join(escaped_lines)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(f"... +{len(requests) - 3} more", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]No requests found[/]"
|
||||
text.append("\n ")
|
||||
text.append("No requests found", style="dim")
|
||||
elif httpql_filter:
|
||||
filter_display = (
|
||||
httpql_filter[:300] + "..." if len(httpql_filter) > 300 else httpql_filter
|
||||
)
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(filter_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(httpql_filter, 300), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]All requests[/]"
|
||||
text.append("\n ")
|
||||
text.append("All requests", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -64,34 +64,37 @@ class ViewRequestRenderer(BaseToolRenderer):
|
||||
|
||||
part = args.get("part", "request")
|
||||
|
||||
header = f"👀 [bold #06b6d4]Viewing {cls.escape_markup(part)}[/]"
|
||||
text = Text()
|
||||
text.append("👀 ")
|
||||
text.append(f"Viewing {part}", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if "content" in result:
|
||||
content = result["content"]
|
||||
content_preview = content[:500] + "..." if len(content) > 500 else content
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(content_preview)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(content, 500), style="dim")
|
||||
elif "matches" in result:
|
||||
matches = result["matches"]
|
||||
if isinstance(matches, list) and matches:
|
||||
match_lines = [
|
||||
match["match"]
|
||||
for match in matches[:3]
|
||||
if isinstance(match, dict) and "match" in match
|
||||
]
|
||||
for match in matches[:3]:
|
||||
if isinstance(match, dict) and "match" in match:
|
||||
text.append("\n ")
|
||||
text.append(match["match"], style="dim")
|
||||
if len(matches) > 3:
|
||||
match_lines.append(f"... +{len(matches) - 3} more matches")
|
||||
escaped_lines = [cls.escape_markup(line) for line in match_lines]
|
||||
content_text = f"{header}\n [dim]{chr(10).join(escaped_lines)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(f"... +{len(matches) - 3} more matches", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]No matches found[/]"
|
||||
text.append("\n ")
|
||||
text.append("No matches found", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Viewing content...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Viewing content...", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -107,30 +110,32 @@ class SendRequestRenderer(BaseToolRenderer):
|
||||
method = args.get("method", "GET")
|
||||
url = args.get("url", "")
|
||||
|
||||
header = f"📤 [bold #06b6d4]Sending {cls.escape_markup(method)}[/]"
|
||||
text = Text()
|
||||
text.append("📤 ")
|
||||
text.append(f"Sending {method}", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
status_code = result.get("status_code")
|
||||
response_body = result.get("body", "")
|
||||
|
||||
if status_code:
|
||||
response_preview = f"Status: {status_code}"
|
||||
text.append("\n ")
|
||||
text.append(f"Status: {status_code}", style="dim")
|
||||
if response_body:
|
||||
body_preview = (
|
||||
response_body[:300] + "..." if len(response_body) > 300 else response_body
|
||||
)
|
||||
response_preview += f"\n{body_preview}"
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(response_preview)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(response_body, 300), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Response received[/]"
|
||||
text.append("\n ")
|
||||
text.append("Response received", style="dim")
|
||||
elif url:
|
||||
url_display = url[:400] + "..." if len(url) > 400 else url
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(url_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(url, 400), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Sending...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Sending...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -145,31 +150,32 @@ class RepeatRequestRenderer(BaseToolRenderer):
|
||||
|
||||
modifications = args.get("modifications", {})
|
||||
|
||||
header = "🔄 [bold #06b6d4]Repeating request[/]"
|
||||
text = Text()
|
||||
text.append("🔄 ")
|
||||
text.append("Repeating request", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
status_code = result.get("status_code")
|
||||
response_body = result.get("body", "")
|
||||
|
||||
if status_code:
|
||||
response_preview = f"Status: {status_code}"
|
||||
text.append("\n ")
|
||||
text.append(f"Status: {status_code}", style="dim")
|
||||
if response_body:
|
||||
body_preview = (
|
||||
response_body[:300] + "..." if len(response_body) > 300 else response_body
|
||||
)
|
||||
response_preview += f"\n{body_preview}"
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(response_preview)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(response_body, 300), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Response received[/]"
|
||||
text.append("\n ")
|
||||
text.append("Response received", style="dim")
|
||||
elif modifications:
|
||||
mod_text = str(modifications)
|
||||
mod_display = mod_text[:400] + "..." if len(mod_text) > 400 else mod_text
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(mod_display)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(str(modifications), 400), style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]No modifications[/]"
|
||||
text.append("\n ")
|
||||
text.append("No modifications", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -179,11 +185,14 @@ class ScopeRulesRenderer(BaseToolRenderer):
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: ARG003
|
||||
header = "⚙️ [bold #06b6d4]Updating proxy scope[/]"
|
||||
content_text = f"{header}\n [dim]Configuring...[/]"
|
||||
text = Text()
|
||||
text.append("⚙️ ")
|
||||
text.append("Updating proxy scope", style="bold #06b6d4")
|
||||
text.append("\n ")
|
||||
text.append("Configuring...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -195,31 +204,32 @@ class ListSitemapRenderer(BaseToolRenderer):
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
|
||||
header = "🗺️ [bold #06b6d4]Listing sitemap[/]"
|
||||
text = Text()
|
||||
text.append("🗺️ ")
|
||||
text.append("Listing sitemap", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict) and "entries" in result:
|
||||
entries = result["entries"]
|
||||
if isinstance(entries, list) and entries:
|
||||
entry_lines = []
|
||||
for entry in entries[:4]:
|
||||
if isinstance(entry, dict):
|
||||
label = entry.get("label", "?")
|
||||
kind = entry.get("kind", "?")
|
||||
line = f"{kind}: {label}"
|
||||
entry_lines.append(line)
|
||||
text.append("\n ")
|
||||
text.append(f"{kind}: {label}", style="dim")
|
||||
|
||||
if len(entries) > 4:
|
||||
entry_lines.append(f"... +{len(entries) - 4} more")
|
||||
|
||||
escaped_lines = [cls.escape_markup(line) for line in entry_lines]
|
||||
content_text = f"{header}\n [dim]{chr(10).join(escaped_lines)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(f"... +{len(entries) - 4} more", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]No entries found[/]"
|
||||
text.append("\n ")
|
||||
text.append("No entries found", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -231,25 +241,27 @@ class ViewSitemapEntryRenderer(BaseToolRenderer):
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
|
||||
header = "📍 [bold #06b6d4]Viewing sitemap entry[/]"
|
||||
text = Text()
|
||||
text.append("📍 ")
|
||||
text.append("Viewing sitemap entry", style="bold #06b6d4")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if "entry" in result:
|
||||
entry = result["entry"]
|
||||
if isinstance(entry, dict):
|
||||
label = entry.get("label", "")
|
||||
kind = entry.get("kind", "")
|
||||
if label and kind:
|
||||
entry_info = f"{kind}: {label}"
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(entry_info)}[/]"
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Entry details loaded[/]"
|
||||
if result and isinstance(result, dict) and "entry" in result:
|
||||
entry = result["entry"]
|
||||
if isinstance(entry, dict):
|
||||
label = entry.get("label", "")
|
||||
kind = entry.get("kind", "")
|
||||
if label and kind:
|
||||
text.append("\n ")
|
||||
text.append(f"{kind}: {label}", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Entry details loaded[/]"
|
||||
text.append("\n ")
|
||||
text.append("Entry details loaded", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading entry...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Entry details loaded", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -30,23 +31,17 @@ class PythonRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_python(cls, code: str) -> str:
|
||||
def _highlight_python(cls, code: str) -> Text:
|
||||
lexer = PythonLexer()
|
||||
result_parts: list[str] = []
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
|
||||
escaped_value = cls.escape_markup(token_value)
|
||||
color = cls._get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
|
||||
if color:
|
||||
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||
else:
|
||||
result_parts.append(escaped_value)
|
||||
|
||||
return "".join(result_parts)
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
@@ -55,18 +50,23 @@ class PythonRenderer(BaseToolRenderer):
|
||||
action = args.get("action", "")
|
||||
code = args.get("code", "")
|
||||
|
||||
header = "</> [bold #3b82f6]Python[/]"
|
||||
text = Text()
|
||||
text.append("</> ")
|
||||
text.append("Python", style="bold #3b82f6")
|
||||
text.append("\n")
|
||||
|
||||
if code and action in ["new_session", "execute"]:
|
||||
code_display = code[:2000] + "..." if len(code) > 2000 else code
|
||||
highlighted_code = cls._highlight_python(code_display)
|
||||
content_text = f"{header}\n{highlighted_code}"
|
||||
code_display = cls.truncate(code, 2000)
|
||||
text.append_text(cls._highlight_python(code_display))
|
||||
elif action == "close":
|
||||
content_text = f"{header}\n [dim]Closing session...[/]"
|
||||
text.append(" ")
|
||||
text.append("Closing session...", style="dim")
|
||||
elif action == "list_sessions":
|
||||
content_text = f"{header}\n [dim]Listing sessions...[/]"
|
||||
text.append(" ")
|
||||
text.append("Listing sessions...", style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Running...[/]"
|
||||
text.append(" ")
|
||||
text.append("Running...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -47,26 +48,36 @@ def render_tool_widget(tool_data: dict[str, Any]) -> Static:
|
||||
|
||||
|
||||
def _render_default_tool_widget(tool_data: dict[str, Any]) -> Static:
|
||||
tool_name = BaseToolRenderer.escape_markup(tool_data.get("tool_name", "Unknown Tool"))
|
||||
tool_name = tool_data.get("tool_name", "Unknown Tool")
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
result = tool_data.get("result")
|
||||
|
||||
status_text = BaseToolRenderer.get_status_icon(status)
|
||||
text = Text()
|
||||
|
||||
header = f"→ Using tool [bold blue]{BaseToolRenderer.escape_markup(tool_name)}[/]"
|
||||
content_parts = [header]
|
||||
text.append("→ Using tool ", style="dim")
|
||||
text.append(tool_name, style="bold blue")
|
||||
text.append("\n")
|
||||
|
||||
args_str = BaseToolRenderer.format_args(args)
|
||||
if args_str:
|
||||
content_parts.append(args_str)
|
||||
for k, v in list(args.items())[:2]:
|
||||
str_v = str(v)
|
||||
if len(str_v) > 80:
|
||||
str_v = str_v[:77] + "..."
|
||||
text.append(" ")
|
||||
text.append(k, style="dim")
|
||||
text.append(": ")
|
||||
text.append(str_v)
|
||||
text.append("\n")
|
||||
|
||||
if status in ["completed", "failed", "error"] and result is not None:
|
||||
result_str = BaseToolRenderer.format_result(result)
|
||||
if result_str:
|
||||
content_parts.append(f"[bold]Result:[/] {result_str}")
|
||||
result_str = str(result)
|
||||
if len(result_str) > 150:
|
||||
result_str = result_str[:147] + "..."
|
||||
text.append("Result: ", style="bold")
|
||||
text.append(result_str)
|
||||
else:
|
||||
content_parts.append(status_text)
|
||||
icon, color = BaseToolRenderer.status_icon(status)
|
||||
text.append(icon, style=color)
|
||||
|
||||
css_classes = BaseToolRenderer.get_css_classes(status)
|
||||
return Static("\n".join(content_parts), classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -11,6 +12,14 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_vulnerability_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
@@ -19,35 +28,25 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
severity = args.get("severity", "")
|
||||
content = args.get("content", "")
|
||||
|
||||
header = "🐞 [bold #ea580c]Vulnerability Report[/]"
|
||||
text = Text()
|
||||
text.append("🐞 ")
|
||||
text.append("Vulnerability Report", style="bold #ea580c")
|
||||
|
||||
if title:
|
||||
content_parts = [f"{header}\n [bold]{cls.escape_markup(title)}[/]"]
|
||||
text.append("\n ")
|
||||
text.append(title, style="bold")
|
||||
|
||||
if severity:
|
||||
severity_color = cls._get_severity_color(severity.lower())
|
||||
content_parts.append(
|
||||
f" [dim]Severity: [{severity_color}]"
|
||||
f"{cls.escape_markup(severity.upper())}[/{severity_color}][/]"
|
||||
)
|
||||
severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
||||
text.append("\n Severity: ")
|
||||
text.append(severity.upper(), style=severity_color)
|
||||
|
||||
if content:
|
||||
content_parts.append(f" [dim]{cls.escape_markup(content)}[/]")
|
||||
|
||||
content_text = "\n".join(content_parts)
|
||||
text.append("\n ")
|
||||
text.append(content, style="dim")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Creating report...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Creating report...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
|
||||
@classmethod
|
||||
def _get_severity_color(cls, severity: str) -> str:
|
||||
severity_colors = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
return severity_colors.get(severity, "#6b7280")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -15,29 +16,28 @@ class ScanStartInfoRenderer(BaseToolRenderer):
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
|
||||
targets = args.get("targets", [])
|
||||
|
||||
text = Text()
|
||||
text.append("🚀 Starting penetration test")
|
||||
|
||||
if len(targets) == 1:
|
||||
target_display = cls._build_single_target_display(targets[0])
|
||||
content = f"🚀 Starting penetration test on {target_display}"
|
||||
text.append(" on ")
|
||||
text.append(cls._get_target_display(targets[0]))
|
||||
elif len(targets) > 1:
|
||||
content = f"🚀 Starting penetration test on {len(targets)} targets"
|
||||
text.append(f" on {len(targets)} targets")
|
||||
for target_info in targets:
|
||||
target_display = cls._build_single_target_display(target_info)
|
||||
content += f"\n • {target_display}"
|
||||
else:
|
||||
content = "🚀 Starting penetration test"
|
||||
text.append("\n • ")
|
||||
text.append(cls._get_target_display(target_info))
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(content, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@classmethod
|
||||
def _build_single_target_display(cls, target_info: dict[str, Any]) -> str:
|
||||
def _get_target_display(cls, target_info: dict[str, Any]) -> str:
|
||||
original = target_info.get("original")
|
||||
if original:
|
||||
return cls.escape_markup(str(original))
|
||||
|
||||
return str(original)
|
||||
return "unknown target"
|
||||
|
||||
|
||||
@@ -51,14 +51,16 @@ class SubagentStartInfoRenderer(BaseToolRenderer):
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
|
||||
name = args.get("name", "Unknown Agent")
|
||||
task = args.get("task", "")
|
||||
name = str(args.get("name", "Unknown Agent"))
|
||||
task = str(args.get("task", ""))
|
||||
|
||||
text = Text()
|
||||
text.append("🤖 Spawned subagent ")
|
||||
text.append(name)
|
||||
|
||||
name = cls.escape_markup(str(name))
|
||||
content = f"🤖 Spawned subagent {name}"
|
||||
if task:
|
||||
task = cls.escape_markup(str(task))
|
||||
content += f"\n Task: {task}"
|
||||
text.append("\n Task: ")
|
||||
text.append(task)
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(content, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -20,6 +21,69 @@ class TerminalRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "terminal_execute"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
||||
|
||||
CONTROL_SEQUENCES: ClassVar[set[str]] = {
|
||||
"C-c",
|
||||
"C-d",
|
||||
"C-z",
|
||||
"C-a",
|
||||
"C-e",
|
||||
"C-k",
|
||||
"C-l",
|
||||
"C-u",
|
||||
"C-w",
|
||||
"C-r",
|
||||
"C-s",
|
||||
"C-t",
|
||||
"C-y",
|
||||
"^c",
|
||||
"^d",
|
||||
"^z",
|
||||
"^a",
|
||||
"^e",
|
||||
"^k",
|
||||
"^l",
|
||||
"^u",
|
||||
"^w",
|
||||
"^r",
|
||||
"^s",
|
||||
"^t",
|
||||
"^y",
|
||||
}
|
||||
SPECIAL_KEYS: ClassVar[set[str]] = {
|
||||
"Enter",
|
||||
"Escape",
|
||||
"Space",
|
||||
"Tab",
|
||||
"BTab",
|
||||
"BSpace",
|
||||
"DC",
|
||||
"IC",
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"PgUp",
|
||||
"PgDn",
|
||||
"PPage",
|
||||
"NPage",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_token_color(cls, token_type: Any) -> str | None:
|
||||
colors = _get_style_colors()
|
||||
@@ -30,137 +94,66 @@ class TerminalRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_bash(cls, code: str) -> str:
|
||||
def _highlight_bash(cls, code: str) -> Text:
|
||||
lexer = get_lexer_by_name("bash")
|
||||
result_parts: list[str] = []
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
if not token_value:
|
||||
continue
|
||||
|
||||
escaped_value = cls.escape_markup(token_value)
|
||||
color = cls._get_token_color(token_type)
|
||||
text.append(token_value, style=color)
|
||||
|
||||
if color:
|
||||
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||
else:
|
||||
result_parts.append(escaped_value)
|
||||
|
||||
return "".join(result_parts)
|
||||
return text
|
||||
|
||||
@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", {})
|
||||
|
||||
command = args.get("command", "")
|
||||
is_input = args.get("is_input", False)
|
||||
terminal_id = args.get("terminal_id", "default")
|
||||
timeout = args.get("timeout")
|
||||
|
||||
content = cls._build_sleek_content(command, is_input, terminal_id, timeout, result)
|
||||
content = cls._build_content(command, is_input)
|
||||
|
||||
css_classes = cls.get_css_classes(status)
|
||||
return Static(content, classes=css_classes)
|
||||
|
||||
@classmethod
|
||||
def _build_sleek_content(
|
||||
cls,
|
||||
command: str,
|
||||
is_input: bool,
|
||||
terminal_id: str, # noqa: ARG003
|
||||
timeout: float | None, # noqa: ARG003
|
||||
result: dict[str, Any], # noqa: ARG003
|
||||
) -> str:
|
||||
def _build_content(cls, command: str, is_input: bool) -> Text:
|
||||
text = Text()
|
||||
terminal_icon = ">_"
|
||||
|
||||
if not command.strip():
|
||||
return f"{terminal_icon} [dim]getting logs...[/]"
|
||||
|
||||
control_sequences = {
|
||||
"C-c",
|
||||
"C-d",
|
||||
"C-z",
|
||||
"C-a",
|
||||
"C-e",
|
||||
"C-k",
|
||||
"C-l",
|
||||
"C-u",
|
||||
"C-w",
|
||||
"C-r",
|
||||
"C-s",
|
||||
"C-t",
|
||||
"C-y",
|
||||
"^c",
|
||||
"^d",
|
||||
"^z",
|
||||
"^a",
|
||||
"^e",
|
||||
"^k",
|
||||
"^l",
|
||||
"^u",
|
||||
"^w",
|
||||
"^r",
|
||||
"^s",
|
||||
"^t",
|
||||
"^y",
|
||||
}
|
||||
special_keys = {
|
||||
"Enter",
|
||||
"Escape",
|
||||
"Space",
|
||||
"Tab",
|
||||
"BTab",
|
||||
"BSpace",
|
||||
"DC",
|
||||
"IC",
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"PgUp",
|
||||
"PgDn",
|
||||
"PPage",
|
||||
"NPage",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
}
|
||||
text.append(terminal_icon)
|
||||
text.append(" ")
|
||||
text.append("getting logs...", style="dim")
|
||||
return text
|
||||
|
||||
is_special = (
|
||||
command in control_sequences
|
||||
or command in special_keys
|
||||
command in cls.CONTROL_SEQUENCES
|
||||
or command in cls.SPECIAL_KEYS
|
||||
or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
|
||||
)
|
||||
|
||||
text.append(terminal_icon)
|
||||
text.append(" ")
|
||||
|
||||
if is_special:
|
||||
return f"{terminal_icon} [#ef4444]{cls.escape_markup(command)}[/]"
|
||||
text.append(command, style="#ef4444")
|
||||
elif is_input:
|
||||
text.append(">>>", style="#3b82f6")
|
||||
text.append(" ")
|
||||
text.append_text(cls._format_command(command))
|
||||
else:
|
||||
text.append("$", style="#22c55e")
|
||||
text.append(" ")
|
||||
text.append_text(cls._format_command(command))
|
||||
|
||||
if is_input:
|
||||
formatted_command = cls._format_command_display(command)
|
||||
return f"{terminal_icon} [#3b82f6]>>>[/] {formatted_command}"
|
||||
|
||||
formatted_command = cls._format_command_display(command)
|
||||
return f"{terminal_icon} [#22c55e]$[/] {formatted_command}"
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _format_command_display(cls, command: str) -> str:
|
||||
if not command:
|
||||
return ""
|
||||
|
||||
cmd_display = command[:2000] + "..." if len(command) > 2000 else command
|
||||
return cls._highlight_bash(cmd_display)
|
||||
def _format_command(cls, command: str) -> Text:
|
||||
if len(command) > 2000:
|
||||
command = command[:2000] + "..."
|
||||
return cls._highlight_bash(command)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -14,16 +15,18 @@ class ThinkRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
args = tool_data.get("args", {})
|
||||
|
||||
thought = args.get("thought", "")
|
||||
|
||||
header = "🧠 [bold #a855f7]Thinking[/]"
|
||||
text = Text()
|
||||
text.append("🧠 ")
|
||||
text.append("Thinking", style="bold #a855f7")
|
||||
text.append("\n ")
|
||||
|
||||
if thought:
|
||||
thought_display = thought[:600] + "..." if len(thought) > 600 else thought
|
||||
content = f"{header}\n [italic dim]{cls.escape_markup(thought_display)}[/]"
|
||||
thought_display = cls.truncate(thought, 600)
|
||||
text.append(thought_display, style="italic dim")
|
||||
else:
|
||||
content = f"{header}\n [italic dim]Thinking...[/]"
|
||||
text.append("Thinking...", style="italic dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,57 +1,53 @@
|
||||
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
|
||||
|
||||
|
||||
STATUS_MARKERS = {
|
||||
STATUS_MARKERS: dict[str, str] = {
|
||||
"pending": "[ ]",
|
||||
"in_progress": "[~]",
|
||||
"done": "[•]",
|
||||
}
|
||||
|
||||
|
||||
def _truncate(text: str, length: int = 80) -> str:
|
||||
if len(text) <= length:
|
||||
return text
|
||||
return text[: length - 3] + "..."
|
||||
|
||||
|
||||
def _format_todo_lines(
|
||||
cls: type[BaseToolRenderer], result: dict[str, Any], limit: int = 25
|
||||
) -> list[str]:
|
||||
def _format_todo_lines(text: Text, result: dict[str, Any], limit: int = 25) -> None:
|
||||
todos = result.get("todos")
|
||||
if not isinstance(todos, list) or not todos:
|
||||
return [" [dim]No todos[/]"]
|
||||
text.append("\n ")
|
||||
text.append("No todos", style="dim")
|
||||
return
|
||||
|
||||
lines: list[str] = []
|
||||
total = len(todos)
|
||||
|
||||
for index, todo in enumerate(todos):
|
||||
if index >= limit:
|
||||
remaining = total - limit
|
||||
if remaining > 0:
|
||||
lines.append(f" [dim]... +{remaining} more[/]")
|
||||
text.append("\n ")
|
||||
text.append(f"... +{remaining} more", style="dim")
|
||||
break
|
||||
|
||||
status = todo.get("status", "pending")
|
||||
marker = STATUS_MARKERS.get(status, STATUS_MARKERS["pending"])
|
||||
|
||||
title = todo.get("title", "").strip() or "(untitled)"
|
||||
title = cls.escape_markup(_truncate(title, 90))
|
||||
if len(title) > 90:
|
||||
title = title[:87] + "..."
|
||||
|
||||
text.append("\n ")
|
||||
text.append(marker)
|
||||
text.append(" ")
|
||||
|
||||
if status == "done":
|
||||
title_markup = f"[dim strike]{title}[/]"
|
||||
text.append(title, style="dim strike")
|
||||
elif status == "in_progress":
|
||||
title_markup = f"[italic]{title}[/]"
|
||||
text.append(title, style="italic")
|
||||
else:
|
||||
title_markup = title
|
||||
|
||||
lines.append(f" {marker} {title_markup}")
|
||||
|
||||
return lines
|
||||
text.append(title)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -62,21 +58,24 @@ class CreateTodoRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #a78bfa]Todo[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todo", style="bold #a78bfa")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Failed to create todo")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Creating...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Creating...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -87,21 +86,24 @@ class ListTodosRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #a78bfa]Todos[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todos", style="bold #a78bfa")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Unable to list todos")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Loading...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -112,21 +114,24 @@ class UpdateTodoRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #a78bfa]Todo Updated[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todo Updated", style="bold #a78bfa")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Failed to update todo")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Updating...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Updating...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -137,21 +142,24 @@ class MarkTodoDoneRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #a78bfa]Todo Completed[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todo Completed", style="bold #a78bfa")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Failed to mark todo done")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Marking done...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Marking done...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -162,21 +170,24 @@ class MarkTodoPendingRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #f59e0b]Todo Reopened[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todo Reopened", style="bold #f59e0b")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Failed to reopen todo")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Reopening...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Reopening...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
@@ -187,18 +198,21 @@ class DeleteTodoRenderer(BaseToolRenderer):
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = tool_data.get("result")
|
||||
header = "📋 [bold #94a3b8]Todo Removed[/]"
|
||||
|
||||
text = Text()
|
||||
text.append("📋 ")
|
||||
text.append("Todo Removed", style="bold #94a3b8")
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
if result.get("success"):
|
||||
lines = [header]
|
||||
lines.extend(_format_todo_lines(cls, result))
|
||||
content_text = "\n".join(lines)
|
||||
_format_todo_lines(text, result)
|
||||
else:
|
||||
error = result.get("error", "Failed to remove todo")
|
||||
content_text = f"{header}\n [#ef4444]{cls.escape_markup(error)}[/]"
|
||||
text.append("\n ")
|
||||
text.append(error, style="#ef4444")
|
||||
else:
|
||||
content_text = f"{header}\n [dim]Removing...[/]"
|
||||
text.append("\n ")
|
||||
text.append("Removing...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -12,32 +13,41 @@ class UserMessageRenderer(BaseToolRenderer):
|
||||
css_classes: ClassVar[list[str]] = ["chat-message", "user-message"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, message_data: dict[str, Any]) -> Static:
|
||||
content = message_data.get("content", "")
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
content = tool_data.get("content", "")
|
||||
|
||||
if not content:
|
||||
return Static("", classes=cls.css_classes)
|
||||
return Static(Text(), classes=" ".join(cls.css_classes))
|
||||
|
||||
if len(content) > 300:
|
||||
content = content[:297] + "..."
|
||||
styled_text = cls._format_user_message(content)
|
||||
|
||||
lines = content.split("\n")
|
||||
bordered_lines = [f"[#3b82f6]▍[/#3b82f6] {line}" for line in lines]
|
||||
bordered_content = "\n".join(bordered_lines)
|
||||
formatted_content = f"[#3b82f6]▍[/#3b82f6] [bold]You:[/]\n{bordered_content}"
|
||||
|
||||
css_classes = " ".join(cls.css_classes)
|
||||
return Static(formatted_content, classes=css_classes)
|
||||
return Static(styled_text, classes=" ".join(cls.css_classes))
|
||||
|
||||
@classmethod
|
||||
def render_simple(cls, content: str) -> str:
|
||||
def render_simple(cls, content: str) -> Text:
|
||||
if not content:
|
||||
return ""
|
||||
return Text()
|
||||
|
||||
return cls._format_user_message(content)
|
||||
|
||||
@classmethod
|
||||
def _format_user_message(cls, content: str) -> Text:
|
||||
text = Text()
|
||||
|
||||
if len(content) > 300:
|
||||
content = content[:297] + "..."
|
||||
|
||||
text.append("▍", style="#3b82f6")
|
||||
text.append(" ")
|
||||
text.append("You:", style="bold")
|
||||
text.append("\n")
|
||||
|
||||
lines = content.split("\n")
|
||||
bordered_lines = [f"[#3b82f6]▍[/#3b82f6] {line}" for line in lines]
|
||||
bordered_content = "\n".join(bordered_lines)
|
||||
return f"[#3b82f6]▍[/#3b82f6] [bold]You:[/]\n{bordered_content}"
|
||||
for i, line in enumerate(lines):
|
||||
if i > 0:
|
||||
text.append("\n")
|
||||
text.append("▍", style="#3b82f6")
|
||||
text.append(" ")
|
||||
text.append(line)
|
||||
|
||||
return text
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
@@ -16,13 +17,13 @@ class WebSearchRenderer(BaseToolRenderer):
|
||||
args = tool_data.get("args", {})
|
||||
query = args.get("query", "")
|
||||
|
||||
header = "🌐 [bold #60a5fa]Searching the web...[/]"
|
||||
text = Text()
|
||||
text.append("🌐 ")
|
||||
text.append("Searching the web...", style="bold #60a5fa")
|
||||
|
||||
if query:
|
||||
query_display = query[:100] + "..." if len(query) > 100 else query
|
||||
content_text = f"{header}\n [dim]{cls.escape_markup(query_display)}[/]"
|
||||
else:
|
||||
content_text = f"{header}"
|
||||
text.append("\n ")
|
||||
text.append(cls.truncate(query, 100), style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(content_text, classes=css_classes)
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as pkg_version
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -17,7 +17,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import Group
|
||||
from rich.markup import escape as rich_escape
|
||||
from rich.panel import Panel
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
@@ -36,10 +35,6 @@ from strix.llm.config import LLMConfig
|
||||
from strix.telemetry.tracer import Tracer, set_global_tracer
|
||||
|
||||
|
||||
def escape_markup(text: str) -> str:
|
||||
return cast("str", rich_escape(text))
|
||||
|
||||
|
||||
def get_package_version() -> str:
|
||||
try:
|
||||
return pkg_version("strix-agent")
|
||||
@@ -259,7 +254,7 @@ class StopAgentScreen(ModalScreen): # type: ignore[misc]
|
||||
class QuitScreen(ModalScreen): # type: ignore[misc]
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label("🦉 Quit Strix? ", id="quit_title"),
|
||||
Label("Quit Strix?", id="quit_title"),
|
||||
Grid(
|
||||
Button("Yes", variant="error", id="quit"),
|
||||
Button("No", variant="default", id="cancel"),
|
||||
@@ -555,7 +550,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "🔵")
|
||||
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
||||
agent_name = f"{status_icon} {agent_name_raw}"
|
||||
|
||||
if status == "running":
|
||||
self._start_agent_verb_timer(agent_id)
|
||||
@@ -614,8 +609,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._displayed_events = current_event_ids
|
||||
|
||||
chat_display = self.query_one("#chat_display", Static)
|
||||
self._update_static_content_safe(chat_display, content)
|
||||
|
||||
self._safe_widget_operation(chat_display.update, content)
|
||||
chat_display.set_classes(css_class)
|
||||
|
||||
if is_at_bottom:
|
||||
@@ -623,54 +617,70 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _get_chat_placeholder_content(
|
||||
self, message: str, placeholder_class: str
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[Text, str]:
|
||||
self._displayed_events = [placeholder_class]
|
||||
return message, f"chat-placeholder {placeholder_class}"
|
||||
text = Text()
|
||||
text.append(message)
|
||||
return text, f"chat-placeholder {placeholder_class}"
|
||||
|
||||
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Text:
|
||||
result = Text()
|
||||
|
||||
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> str:
|
||||
if not events:
|
||||
return ""
|
||||
return result
|
||||
|
||||
content_lines = []
|
||||
first = True
|
||||
for event in events:
|
||||
if event["type"] == "chat":
|
||||
chat_content = self._render_chat_content(event["data"])
|
||||
if chat_content:
|
||||
content_lines.append(chat_content)
|
||||
elif event["type"] == "tool":
|
||||
tool_content = self._render_tool_content_simple(event["data"])
|
||||
if tool_content:
|
||||
content_lines.append(tool_content)
|
||||
content: Text | None = None
|
||||
|
||||
return "\n\n".join(content_lines)
|
||||
if event["type"] == "chat":
|
||||
content = self._render_chat_content(event["data"])
|
||||
elif event["type"] == "tool":
|
||||
content = self._render_tool_content_simple(event["data"])
|
||||
|
||||
if content:
|
||||
if not first:
|
||||
result.append("\n\n")
|
||||
result.append_text(content)
|
||||
first = False
|
||||
|
||||
return result
|
||||
|
||||
def _get_status_display_content(
|
||||
self, agent_id: str, agent_data: dict[str, Any]
|
||||
) -> tuple[str | Text, str, bool]:
|
||||
) -> tuple[Text | None, Text, bool]:
|
||||
status = agent_data.get("status", "running")
|
||||
|
||||
simple_statuses = {
|
||||
"stopping": ("Agent stopping...", "", False),
|
||||
"stopped": ("Agent stopped", "", False),
|
||||
"completed": ("Agent completed", "", False),
|
||||
def keymap_text(msg: str) -> Text:
|
||||
t = Text()
|
||||
t.append(msg, style="dim")
|
||||
return t
|
||||
|
||||
simple_statuses: dict[str, tuple[str, str]] = {
|
||||
"stopping": ("Agent stopping...", ""),
|
||||
"stopped": ("Agent stopped", ""),
|
||||
"completed": ("Agent completed", ""),
|
||||
}
|
||||
|
||||
if status in simple_statuses:
|
||||
return simple_statuses[status]
|
||||
msg, km = simple_statuses[status]
|
||||
text = Text()
|
||||
text.append(msg)
|
||||
return (text, keymap_text(km), False)
|
||||
|
||||
if status == "llm_failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
display_msg = (
|
||||
f"[red]{escape_markup(error_msg)}[/red]"
|
||||
if error_msg
|
||||
else "[red]LLM request failed[/red]"
|
||||
)
|
||||
text = Text()
|
||||
if error_msg:
|
||||
text.append(error_msg, style="red")
|
||||
else:
|
||||
text.append("LLM request failed", style="red")
|
||||
self._stop_dot_animation()
|
||||
return (display_msg, "[dim]Send message to retry[/dim]", False)
|
||||
return (text, keymap_text("Send message to retry"), False)
|
||||
|
||||
if status == "waiting":
|
||||
animated_text = self._get_animated_waiting_text(agent_id)
|
||||
return (animated_text, "[dim]Send message to resume[/dim]", True)
|
||||
return (animated_text, keymap_text("Send message to resume"), True)
|
||||
|
||||
if status == "running":
|
||||
verb = (
|
||||
@@ -679,9 +689,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
else "Initializing Agent"
|
||||
)
|
||||
animated_text = self._get_animated_verb_text(agent_id, verb)
|
||||
return (animated_text, "[dim]ESC to stop | CTRL-C to quit and save[/dim]", True)
|
||||
return (animated_text, keymap_text("ESC to stop | CTRL-C to quit and save"), True)
|
||||
|
||||
return ("", "", False)
|
||||
return (None, Text(), False)
|
||||
|
||||
def _update_agent_status_display(self) -> None:
|
||||
try:
|
||||
@@ -738,7 +748,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
stats_panel = Panel(
|
||||
stats_content,
|
||||
border_style="#22c55e",
|
||||
border_style="#333333",
|
||||
padding=(0, 1),
|
||||
)
|
||||
|
||||
@@ -793,17 +803,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
return text
|
||||
|
||||
def _get_animated_waiting_text(self, agent_id: str) -> Text:
|
||||
if agent_id not in self._agent_dot_states:
|
||||
self._agent_dot_states[agent_id] = 0.0
|
||||
|
||||
shine_pos = self._agent_dot_states[agent_id]
|
||||
word = "Waiting"
|
||||
def _get_animated_waiting_text(self, agent_id: str) -> Text: # noqa: ARG002
|
||||
text = Text()
|
||||
for i, char in enumerate(word):
|
||||
dist = abs(i - shine_pos)
|
||||
text.append(char, style=self._get_shine_style(dist))
|
||||
|
||||
text.append("Waiting", style="#fbbf24")
|
||||
return text
|
||||
|
||||
def _start_dot_animation(self) -> None:
|
||||
@@ -957,7 +959,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "🔵")
|
||||
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
||||
agent_name = f"{status_icon} {agent_name_raw}"
|
||||
|
||||
if status in ["running", "waiting"]:
|
||||
self._start_agent_verb_timer(agent_id)
|
||||
@@ -1025,7 +1027,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
}
|
||||
|
||||
status_icon = status_indicators.get(status, "🔵")
|
||||
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
||||
agent_name = f"{status_icon} {agent_name_raw}"
|
||||
|
||||
new_node = new_parent.add(
|
||||
agent_name,
|
||||
@@ -1071,23 +1073,23 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
parent_node.allow_expand = True
|
||||
self._expand_all_agent_nodes()
|
||||
|
||||
def _render_chat_content(self, msg_data: dict[str, Any]) -> str:
|
||||
def _render_chat_content(self, msg_data: dict[str, Any]) -> Text | None:
|
||||
role = msg_data.get("role")
|
||||
content = msg_data.get("content", "")
|
||||
|
||||
if not content:
|
||||
return ""
|
||||
return None
|
||||
|
||||
if role == "user":
|
||||
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
|
||||
|
||||
return UserMessageRenderer.render_simple(escape_markup(content))
|
||||
return UserMessageRenderer.render_simple(content)
|
||||
|
||||
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
|
||||
|
||||
return AgentMessageRenderer.render_simple(content)
|
||||
|
||||
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> str:
|
||||
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Text | None:
|
||||
tool_name = tool_data.get("tool_name", "Unknown Tool")
|
||||
args = tool_data.get("args", {})
|
||||
status = tool_data.get("status", "unknown")
|
||||
@@ -1099,42 +1101,57 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
if renderer:
|
||||
widget = renderer.render(tool_data)
|
||||
content = str(widget.renderable)
|
||||
elif tool_name == "llm_error_details":
|
||||
lines = ["[red]✗ LLM Request Failed[/red]"]
|
||||
renderable = widget.renderable
|
||||
if isinstance(renderable, Text):
|
||||
return renderable
|
||||
text = Text()
|
||||
text.append(str(renderable))
|
||||
return text
|
||||
|
||||
text = Text()
|
||||
|
||||
if tool_name == "llm_error_details":
|
||||
text.append("✗ LLM Request Failed", style="red")
|
||||
if args.get("details"):
|
||||
details = args["details"]
|
||||
details = str(args["details"])
|
||||
if len(details) > 300:
|
||||
details = details[:297] + "..."
|
||||
lines.append(f"[dim]Details:[/dim] {escape_markup(details)}")
|
||||
content = "\n".join(lines)
|
||||
else:
|
||||
status_icons = {
|
||||
"running": "[yellow]●[/yellow]",
|
||||
"completed": "[green]✓[/green]",
|
||||
"failed": "[red]✗[/red]",
|
||||
"error": "[red]✗[/red]",
|
||||
}
|
||||
status_icon = status_icons.get(status, "[dim]○[/dim]")
|
||||
text.append("\nDetails: ", style="dim")
|
||||
text.append(details)
|
||||
return text
|
||||
|
||||
lines = [f"→ Using tool [bold blue]{escape_markup(tool_name)}[/] {status_icon}"]
|
||||
text.append("→ Using tool ")
|
||||
text.append(tool_name, style="bold blue")
|
||||
|
||||
if args:
|
||||
for k, v in list(args.items())[:2]:
|
||||
str_v = str(v)
|
||||
if len(str_v) > 80:
|
||||
str_v = str_v[:77] + "..."
|
||||
lines.append(f" [dim]{k}:[/] {escape_markup(str_v)}")
|
||||
status_styles = {
|
||||
"running": ("●", "yellow"),
|
||||
"completed": ("✓", "green"),
|
||||
"failed": ("✗", "red"),
|
||||
"error": ("✗", "red"),
|
||||
}
|
||||
icon, style = status_styles.get(status, ("○", "dim"))
|
||||
text.append(" ")
|
||||
text.append(icon, style=style)
|
||||
|
||||
if status in ["completed", "failed", "error"] and result:
|
||||
result_str = str(result)
|
||||
if len(result_str) > 150:
|
||||
result_str = result_str[:147] + "..."
|
||||
lines.append(f"[bold]Result:[/] {escape_markup(result_str)}")
|
||||
if args:
|
||||
for k, v in list(args.items())[:2]:
|
||||
str_v = str(v)
|
||||
if len(str_v) > 80:
|
||||
str_v = str_v[:77] + "..."
|
||||
text.append("\n ")
|
||||
text.append(k, style="dim")
|
||||
text.append(": ")
|
||||
text.append(str_v)
|
||||
|
||||
content = "\n".join(lines)
|
||||
if status in ["completed", "failed", "error"] and result:
|
||||
result_str = str(result)
|
||||
if len(result_str) > 150:
|
||||
result_str = result_str[:147] + "..."
|
||||
text.append("\n")
|
||||
text.append("Result: ", style="bold")
|
||||
text.append(result_str)
|
||||
|
||||
return content
|
||||
return text
|
||||
|
||||
@on(Tree.NodeHighlighted) # type: ignore[misc]
|
||||
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
|
||||
@@ -1322,19 +1339,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
else:
|
||||
return True
|
||||
|
||||
def _update_static_content_safe(self, widget: Static, content: str) -> None:
|
||||
try:
|
||||
widget.update(content)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
safe_text = Text.from_markup(content)
|
||||
widget.update(safe_text)
|
||||
except Exception: # noqa: BLE001
|
||||
import re
|
||||
|
||||
plain_text = re.sub(r"\[.*?\]", "", content)
|
||||
widget.update(plain_text)
|
||||
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
if self.show_splash or not self.is_mounted:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user