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:
0xallam
2026-01-05 00:07:54 -08:00
committed by Ahmed Allam
parent 7bcdedfb18
commit a2142cc985
19 changed files with 980 additions and 754 deletions

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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