From a2142cc9851ff69e5585a2850aecc65530646cba Mon Sep 17 00:00:00 2001 From: 0xallam Date: Mon, 5 Jan 2026 00:07:54 -0800 Subject: [PATCH] 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. --- strix/interface/assets/tui_styles.tcss | 2 +- .../tool_components/agent_message_renderer.py | 194 +++++++++++++---- .../tool_components/agents_graph_renderer.py | 73 ++++--- .../tool_components/base_renderer.py | 106 +++++++--- .../tool_components/browser_renderer.py | 173 +++++++-------- .../tool_components/file_edit_renderer.py | 137 ++++++------ .../tool_components/finish_renderer.py | 19 +- .../tool_components/notes_renderer.py | 87 ++++---- .../tool_components/proxy_renderer.py | 182 ++++++++-------- .../tool_components/python_renderer.py | 36 ++-- strix/interface/tool_components/registry.py | 35 ++-- .../tool_components/reporting_renderer.py | 45 ++-- .../tool_components/scan_info_renderer.py | 40 ++-- .../tool_components/terminal_renderer.py | 195 +++++++++-------- .../tool_components/thinking_renderer.py | 15 +- .../tool_components/todo_renderer.py | 140 +++++++------ .../tool_components/user_message_renderer.py | 44 ++-- .../tool_components/web_search_renderer.py | 13 +- strix/interface/tui.py | 198 +++++++++--------- 19 files changed, 980 insertions(+), 754 deletions(-) diff --git a/strix/interface/assets/tui_styles.tcss b/strix/interface/assets/tui_styles.tcss index b200c19..0ef880a 100644 --- a/strix/interface/assets/tui_styles.tcss +++ b/strix/interface/assets/tui_styles.tcss @@ -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; diff --git a/strix/interface/tool_components/agent_message_renderer.py b/strix/interface/tool_components/agent_message_renderer.py index a4fb72e..ea16653 100644 --- a/strix/interface/tool_components/agent_message_renderer.py +++ b/strix/interface/tool_components/agent_message_renderer.py @@ -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"(? 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) diff --git a/strix/interface/tool_components/agents_graph_renderer.py b/strix/interface/tool_components/agents_graph_renderer.py index ef8c47d..d414984 100644 --- a/strix/interface/tool_components/agents_graph_renderer.py +++ b/strix/interface/tool_components/agents_graph_renderer.py @@ -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) diff --git a/strix/interface/tool_components/base_renderer.py b/strix/interface/tool_components/base_renderer.py index f1006be..ef25c37 100644 --- a/strix/interface/tool_components/base_renderer.py +++ b/strix/interface/tool_components/base_renderer.py @@ -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 diff --git a/strix/interface/tool_components/browser_renderer.py b/strix/interface/tool_components/browser_renderer.py index 8185f0d..0d22079 100644 --- a/strix/interface/tool_components/browser_renderer.py +++ b/strix/interface/tool_components/browser_renderer.py @@ -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 diff --git a/strix/interface/tool_components/file_edit_renderer.py b/strix/interface/tool_components/file_edit_renderer.py index cf37636..b7fee27 100644 --- a/strix/interface/tool_components/file_edit_renderer.py +++ b/strix/interface/tool_components/file_edit_renderer.py @@ -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) diff --git a/strix/interface/tool_components/finish_renderer.py b/strix/interface/tool_components/finish_renderer.py index 12cf596..d1701e9 100644 --- a/strix/interface/tool_components/finish_renderer.py +++ b/strix/interface/tool_components/finish_renderer.py @@ -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) diff --git a/strix/interface/tool_components/notes_renderer.py b/strix/interface/tool_components/notes_renderer.py index d89300f..a44542d 100644 --- a/strix/interface/tool_components/notes_renderer.py +++ b/strix/interface/tool_components/notes_renderer.py @@ -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) diff --git a/strix/interface/tool_components/proxy_renderer.py b/strix/interface/tool_components/proxy_renderer.py index 5c829d8..f42e9c2 100644 --- a/strix/interface/tool_components/proxy_renderer.py +++ b/strix/interface/tool_components/proxy_renderer.py @@ -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) diff --git a/strix/interface/tool_components/python_renderer.py b/strix/interface/tool_components/python_renderer.py index 1b0d51f..5e1bef3 100644 --- a/strix/interface/tool_components/python_renderer.py +++ b/strix/interface/tool_components/python_renderer.py @@ -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) diff --git a/strix/interface/tool_components/registry.py b/strix/interface/tool_components/registry.py index 583ab3a..f9567ae 100644 --- a/strix/interface/tool_components/registry.py +++ b/strix/interface/tool_components/registry.py @@ -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) diff --git a/strix/interface/tool_components/reporting_renderer.py b/strix/interface/tool_components/reporting_renderer.py index f2b67b2..f978385 100644 --- a/strix/interface/tool_components/reporting_renderer.py +++ b/strix/interface/tool_components/reporting_renderer.py @@ -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) diff --git a/strix/interface/tool_components/scan_info_renderer.py b/strix/interface/tool_components/scan_info_renderer.py index 602fb80..8a3c942 100644 --- a/strix/interface/tool_components/scan_info_renderer.py +++ b/strix/interface/tool_components/scan_info_renderer.py @@ -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) diff --git a/strix/interface/tool_components/terminal_renderer.py b/strix/interface/tool_components/terminal_renderer.py index 332852c..56decec 100644 --- a/strix/interface/tool_components/terminal_renderer.py +++ b/strix/interface/tool_components/terminal_renderer.py @@ -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) diff --git a/strix/interface/tool_components/thinking_renderer.py b/strix/interface/tool_components/thinking_renderer.py index 74e1595..dd7ff09 100644 --- a/strix/interface/tool_components/thinking_renderer.py +++ b/strix/interface/tool_components/thinking_renderer.py @@ -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) diff --git a/strix/interface/tool_components/todo_renderer.py b/strix/interface/tool_components/todo_renderer.py index 1a3b3a5..2ba58ef 100644 --- a/strix/interface/tool_components/todo_renderer.py +++ b/strix/interface/tool_components/todo_renderer.py @@ -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) diff --git a/strix/interface/tool_components/user_message_renderer.py b/strix/interface/tool_components/user_message_renderer.py index 4494575..ad80924 100644 --- a/strix/interface/tool_components/user_message_renderer.py +++ b/strix/interface/tool_components/user_message_renderer.py @@ -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 diff --git a/strix/interface/tool_components/web_search_renderer.py b/strix/interface/tool_components/web_search_renderer.py index d933d7d..ef07972 100644 --- a/strix/interface/tool_components/web_search_renderer.py +++ b/strix/interface/tool_components/web_search_renderer.py @@ -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) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 7cc5629..deae4b7 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -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