feat(tui): add syntax highlighting for tool renderers (#195)
Add Pygments-based syntax highlighting with native hacker theme: - Python renderer: Python code highlighting - Browser renderer: JavaScript code highlighting - Terminal renderer: Bash command highlighting - File edit renderer: Auto-detect language from file extension, diff-style display
This commit is contained in:
@@ -1,16 +1,53 @@
|
|||||||
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@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"]}
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class BrowserRenderer(BaseToolRenderer):
|
class BrowserRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "browser_action"
|
tool_name: ClassVar[str] = "browser_action"
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "browser-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, 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
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_js(cls, code: str) -> str:
|
||||||
|
lexer = get_lexer_by_name("javascript")
|
||||||
|
result_parts: list[str] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||||
|
else:
|
||||||
|
result_parts.append(escaped_value)
|
||||||
|
|
||||||
|
return "".join(result_parts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
@@ -115,6 +152,5 @@ class BrowserRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _format_js(cls, js_code: str) -> str:
|
def _format_js(cls, js_code: str) -> str:
|
||||||
if len(js_code) > 200:
|
code_display = js_code[:2000] + "..." if len(js_code) > 2000 else js_code
|
||||||
js_code = js_code[:197] + "..."
|
return cls._highlight_js(code_display)
|
||||||
return f"[white]{cls.escape_markup(js_code)}[/white]"
|
|
||||||
|
|||||||
@@ -1,16 +1,61 @@
|
|||||||
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
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 textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
|
style = get_style_by_name("native")
|
||||||
|
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_lexer_for_file(path: str) -> Any:
|
||||||
|
try:
|
||||||
|
return get_lexer_for_filename(path)
|
||||||
|
except ClassNotFound:
|
||||||
|
return get_lexer_by_name("text")
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class StrReplaceEditorRenderer(BaseToolRenderer):
|
class StrReplaceEditorRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "str_replace_editor"
|
tool_name: ClassVar[str] = "str_replace_editor"
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, 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
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_code(cls, code: str, path: str) -> str:
|
||||||
|
lexer = _get_lexer_for_file(path)
|
||||||
|
result_parts: list[str] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||||
|
else:
|
||||||
|
result_parts.append(escaped_value)
|
||||||
|
|
||||||
|
return "".join(result_parts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
@@ -18,6 +63,9 @@ class StrReplaceEditorRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
command = args.get("command", "")
|
command = args.get("command", "")
|
||||||
path = args.get("path", "")
|
path = args.get("path", "")
|
||||||
|
old_str = args.get("old_str", "")
|
||||||
|
new_str = args.get("new_str", "")
|
||||||
|
file_text = args.get("file_text", "")
|
||||||
|
|
||||||
if command == "view":
|
if command == "view":
|
||||||
header = "📖 [bold #10b981]Reading file[/]"
|
header = "📖 [bold #10b981]Reading file[/]"
|
||||||
@@ -32,12 +80,33 @@ class StrReplaceEditorRenderer(BaseToolRenderer):
|
|||||||
else:
|
else:
|
||||||
header = "📄 [bold #10b981]File operation[/]"
|
header = "📄 [bold #10b981]File operation[/]"
|
||||||
|
|
||||||
if (result and isinstance(result, dict) and "content" in result) or path:
|
|
||||||
path_display = path[-60:] if len(path) > 60 else path
|
path_display = path[-60:] if len(path) > 60 else path
|
||||||
content_text = f"{header} [dim]{cls.escape_markup(path_display)}[/]"
|
content_parts = [f"{header} [dim]{cls.escape_markup(path_display)}[/]"]
|
||||||
else:
|
|
||||||
content_text = f"{header} [dim]Processing...[/]"
|
|
||||||
|
|
||||||
|
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
|
||||||
|
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...[/]"]
|
||||||
|
|
||||||
|
content_text = "\n".join(content_parts)
|
||||||
css_classes = cls.get_css_classes("completed")
|
css_classes = cls.get_css_classes("completed")
|
||||||
return Static(content_text, classes=css_classes)
|
return Static(content_text, classes=css_classes)
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import PythonLexer
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@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"]}
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class PythonRenderer(BaseToolRenderer):
|
class PythonRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "python_action"
|
tool_name: ClassVar[str] = "python_action"
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "python-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "python-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, 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
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_python(cls, code: str) -> str:
|
||||||
|
lexer = PythonLexer()
|
||||||
|
result_parts: list[str] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||||
|
else:
|
||||||
|
result_parts.append(escaped_value)
|
||||||
|
|
||||||
|
return "".join(result_parts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
@@ -21,8 +58,9 @@ class PythonRenderer(BaseToolRenderer):
|
|||||||
header = "</> [bold #3b82f6]Python[/]"
|
header = "</> [bold #3b82f6]Python[/]"
|
||||||
|
|
||||||
if code and action in ["new_session", "execute"]:
|
if code and action in ["new_session", "execute"]:
|
||||||
code_display = code[:600] + "..." if len(code) > 600 else code
|
code_display = code[:2000] + "..." if len(code) > 2000 else code
|
||||||
content_text = f"{header}\n [italic white]{cls.escape_markup(code_display)}[/]"
|
highlighted_code = cls._highlight_python(code_display)
|
||||||
|
content_text = f"{header}\n{highlighted_code}"
|
||||||
elif action == "close":
|
elif action == "close":
|
||||||
content_text = f"{header}\n [dim]Closing session...[/]"
|
content_text = f"{header}\n [dim]Closing session...[/]"
|
||||||
elif action == "list_sessions":
|
elif action == "list_sessions":
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from pygments.lexers import get_lexer_by_name
|
||||||
|
from pygments.styles import get_style_by_name
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@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"]}
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class TerminalRenderer(BaseToolRenderer):
|
class TerminalRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "terminal_execute"
|
tool_name: ClassVar[str] = "terminal_execute"
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_token_color(cls, 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
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _highlight_bash(cls, code: str) -> str:
|
||||||
|
lexer = get_lexer_by_name("bash")
|
||||||
|
result_parts: list[str] = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if color:
|
||||||
|
result_parts.append(f"[{color}]{escaped_value}[/]")
|
||||||
|
else:
|
||||||
|
result_parts.append(escaped_value)
|
||||||
|
|
||||||
|
return "".join(result_parts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
args = tool_data.get("args", {})
|
args = tool_data.get("args", {})
|
||||||
@@ -115,17 +152,15 @@ class TerminalRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
if is_input:
|
if is_input:
|
||||||
formatted_command = cls._format_command_display(command)
|
formatted_command = cls._format_command_display(command)
|
||||||
return f"{terminal_icon} [#3b82f6]>>>[/] [#22c55e]{formatted_command}[/]"
|
return f"{terminal_icon} [#3b82f6]>>>[/] {formatted_command}"
|
||||||
|
|
||||||
formatted_command = cls._format_command_display(command)
|
formatted_command = cls._format_command_display(command)
|
||||||
return f"{terminal_icon} [#22c55e]$ {formatted_command}[/]"
|
return f"{terminal_icon} [#22c55e]$[/] {formatted_command}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _format_command_display(cls, command: str) -> str:
|
def _format_command_display(cls, command: str) -> str:
|
||||||
if not command:
|
if not command:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if len(command) > 400:
|
cmd_display = command[:2000] + "..." if len(command) > 2000 else command
|
||||||
command = command[:397] + "..."
|
return cls._highlight_bash(cmd_display)
|
||||||
|
|
||||||
return cls.escape_markup(command)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user