feat(tui): show tool output in terminal and python renderers
- Terminal renderer now displays command output with smart filtering - Strips PS1 prompts, command echoes, and hardcoded status messages - Python renderer now shows stdout/stderr from execution results - Both renderers support line truncation (50 lines max, 200 chars/line) - Removed smart coloring in favor of consistent dim styling - Added proper error and exit code display
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
@@ -10,6 +11,14 @@ from .base_renderer import BaseToolRenderer
|
|||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_OUTPUT_LINES = 50
|
||||||
|
MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
STRIP_PATTERNS = [
|
||||||
|
r"\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _get_style_colors() -> dict[Any, str]:
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
style = get_style_by_name("native")
|
style = get_style_by_name("native")
|
||||||
@@ -43,29 +52,99 @@ class PythonRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _clean_output(cls, output: str) -> str:
|
||||||
|
cleaned = output
|
||||||
|
for pattern in STRIP_PATTERNS:
|
||||||
|
cleaned = re.sub(pattern, "", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _truncate_line(cls, line: str) -> str:
|
||||||
|
if len(line) > MAX_LINE_LENGTH:
|
||||||
|
return line[: MAX_LINE_LENGTH - 3] + "..."
|
||||||
|
return line
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_output(cls, output: str) -> Text:
|
||||||
|
text = Text()
|
||||||
|
lines = output.splitlines()
|
||||||
|
total_lines = len(lines)
|
||||||
|
|
||||||
|
head_count = MAX_OUTPUT_LINES // 2
|
||||||
|
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
||||||
|
|
||||||
|
if total_lines <= MAX_OUTPUT_LINES:
|
||||||
|
display_lines = lines
|
||||||
|
truncated = False
|
||||||
|
hidden_count = 0
|
||||||
|
else:
|
||||||
|
display_lines = lines[:head_count]
|
||||||
|
truncated = True
|
||||||
|
hidden_count = total_lines - head_count - tail_count
|
||||||
|
|
||||||
|
for i, line in enumerate(display_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(display_lines) - 1 or truncated:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
||||||
|
text.append("\n")
|
||||||
|
tail_lines = lines[-tail_count:]
|
||||||
|
for i, line in enumerate(tail_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(tail_lines) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_output(cls, text: Text, result: dict[str, Any]) -> None:
|
||||||
|
stdout = result.get("stdout", "")
|
||||||
|
stderr = result.get("stderr", "")
|
||||||
|
|
||||||
|
stdout = cls._clean_output(stdout) if stdout else ""
|
||||||
|
stderr = cls._clean_output(stderr) if stderr else ""
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(stdout)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
|
||||||
|
if stderr:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" stderr: ", style="bold #ef4444")
|
||||||
|
formatted_stderr = cls._format_output(stderr)
|
||||||
|
text.append_text(formatted_stderr)
|
||||||
|
|
||||||
@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", {})
|
||||||
|
status = tool_data.get("status", "unknown")
|
||||||
|
result = tool_data.get("result")
|
||||||
|
|
||||||
action = args.get("action", "")
|
action = args.get("action", "")
|
||||||
code = args.get("code", "")
|
code = args.get("code", "")
|
||||||
|
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append("</> ")
|
text.append("</> ", style="dim")
|
||||||
text.append("Python", style="bold #3b82f6")
|
|
||||||
text.append("\n")
|
|
||||||
|
|
||||||
if code and action in ["new_session", "execute"]:
|
if code and action in ["new_session", "execute"]:
|
||||||
text.append_text(cls._highlight_python(code))
|
text.append_text(cls._highlight_python(code))
|
||||||
elif action == "close":
|
elif action == "close":
|
||||||
text.append(" ")
|
|
||||||
text.append("Closing session...", style="dim")
|
text.append("Closing session...", style="dim")
|
||||||
elif action == "list_sessions":
|
elif action == "list_sessions":
|
||||||
text.append(" ")
|
|
||||||
text.append("Listing sessions...", style="dim")
|
text.append("Listing sessions...", style="dim")
|
||||||
else:
|
else:
|
||||||
text.append(" ")
|
|
||||||
text.append("Running...", style="dim")
|
text.append("Running...", style="dim")
|
||||||
|
|
||||||
css_classes = cls.get_css_classes("completed")
|
if result and isinstance(result, dict):
|
||||||
|
cls._append_output(text, result)
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(status)
|
||||||
return Static(text, classes=css_classes)
|
return Static(text, classes=css_classes)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
@@ -10,6 +11,23 @@ from .base_renderer import BaseToolRenderer
|
|||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_OUTPUT_LINES = 50
|
||||||
|
MAX_LINE_LENGTH = 200
|
||||||
|
|
||||||
|
STRIP_PATTERNS = [
|
||||||
|
(
|
||||||
|
r"\n?\[Command still running after [\d.]+s - showing output so far\.?"
|
||||||
|
r"\s*(?:Use C-c to interrupt if needed\.)?\]"
|
||||||
|
),
|
||||||
|
r"^\[Below is the output of the previous command\.\]\n?",
|
||||||
|
r"^No command is currently running\. Cannot send input\.$",
|
||||||
|
(
|
||||||
|
r"^A command is already running\. Use is_input=true to send input to it, "
|
||||||
|
r"or interrupt it first \(e\.g\., with C-c\)\.$"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def _get_style_colors() -> dict[Any, str]:
|
def _get_style_colors() -> dict[Any, str]:
|
||||||
style = get_style_by_name("native")
|
style = get_style_by_name("native")
|
||||||
@@ -110,24 +128,29 @@ class TerminalRenderer(BaseToolRenderer):
|
|||||||
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", {})
|
||||||
status = tool_data.get("status", "unknown")
|
status = tool_data.get("status", "unknown")
|
||||||
|
result = tool_data.get("result")
|
||||||
|
|
||||||
command = args.get("command", "")
|
command = args.get("command", "")
|
||||||
is_input = args.get("is_input", False)
|
is_input = args.get("is_input", False)
|
||||||
|
|
||||||
content = cls._build_content(command, is_input)
|
content = cls._build_content(command, is_input, status, result)
|
||||||
|
|
||||||
css_classes = cls.get_css_classes(status)
|
css_classes = cls.get_css_classes(status)
|
||||||
return Static(content, classes=css_classes)
|
return Static(content, classes=css_classes)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_content(cls, command: str, is_input: bool) -> Text:
|
def _build_content(
|
||||||
|
cls, command: str, is_input: bool, status: str, result: dict[str, Any] | None
|
||||||
|
) -> Text:
|
||||||
text = Text()
|
text = Text()
|
||||||
terminal_icon = ">_"
|
terminal_icon = ">_"
|
||||||
|
|
||||||
if not command.strip():
|
if not command.strip():
|
||||||
text.append(terminal_icon)
|
text.append(terminal_icon, style="dim")
|
||||||
text.append(" ")
|
text.append(" ")
|
||||||
text.append("getting logs...", style="dim")
|
text.append("getting logs...", style="dim")
|
||||||
|
if result:
|
||||||
|
cls._append_output(text, result, status, command)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
is_special = (
|
is_special = (
|
||||||
@@ -136,7 +159,7 @@ class TerminalRenderer(BaseToolRenderer):
|
|||||||
or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
|
or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
|
||||||
)
|
)
|
||||||
|
|
||||||
text.append(terminal_icon)
|
text.append(terminal_icon, style="dim")
|
||||||
text.append(" ")
|
text.append(" ")
|
||||||
|
|
||||||
if is_special:
|
if is_special:
|
||||||
@@ -150,8 +173,133 @@ class TerminalRenderer(BaseToolRenderer):
|
|||||||
text.append(" ")
|
text.append(" ")
|
||||||
text.append_text(cls._format_command(command))
|
text.append_text(cls._format_command(command))
|
||||||
|
|
||||||
|
if result:
|
||||||
|
cls._append_output(text, result, status, command)
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _clean_output(cls, output: str, command: str = "") -> str:
|
||||||
|
cleaned = output
|
||||||
|
|
||||||
|
for pattern in STRIP_PATTERNS:
|
||||||
|
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
if cleaned.strip():
|
||||||
|
lines = cleaned.splitlines()
|
||||||
|
filtered_lines: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not filtered_lines and not line.strip():
|
||||||
|
continue
|
||||||
|
if re.match(r"^\[STRIX_\d+\]\$\s*", line):
|
||||||
|
continue
|
||||||
|
if command and line.strip() == command.strip():
|
||||||
|
continue
|
||||||
|
if command and re.match(r"^[\$#>]\s*" + re.escape(command.strip()) + r"\s*$", line):
|
||||||
|
continue
|
||||||
|
filtered_lines.append(line)
|
||||||
|
|
||||||
|
while filtered_lines and re.match(r"^\[STRIX_\d+\]\$\s*", filtered_lines[-1]):
|
||||||
|
filtered_lines.pop()
|
||||||
|
|
||||||
|
cleaned = "\n".join(filtered_lines)
|
||||||
|
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_output(
|
||||||
|
cls, text: Text, result: dict[str, Any], tool_status: str, command: str = ""
|
||||||
|
) -> None:
|
||||||
|
raw_output = result.get("content", "")
|
||||||
|
output = cls._clean_output(raw_output, command)
|
||||||
|
error = result.get("error")
|
||||||
|
exit_code = result.get("exit_code")
|
||||||
|
result_status = result.get("status", "")
|
||||||
|
|
||||||
|
if error and not cls._is_status_message(error):
|
||||||
|
text.append("\n")
|
||||||
|
text.append(" error: ", style="bold #ef4444")
|
||||||
|
text.append(cls._truncate_line(error), style="#ef4444")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result_status == "running" or tool_status == "running":
|
||||||
|
if output and output.strip():
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(output)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not output or not output.strip():
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||||
|
return
|
||||||
|
|
||||||
|
text.append("\n")
|
||||||
|
formatted_output = cls._format_output(output)
|
||||||
|
text.append_text(formatted_output)
|
||||||
|
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
text.append("\n")
|
||||||
|
text.append(f" exit {exit_code}", style="dim #ef4444")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_status_message(cls, message: str) -> bool:
|
||||||
|
status_patterns = [
|
||||||
|
r"No command is currently running",
|
||||||
|
r"A command is already running",
|
||||||
|
r"Cannot send input",
|
||||||
|
r"Use is_input=true",
|
||||||
|
r"Use C-c to interrupt",
|
||||||
|
r"showing output so far",
|
||||||
|
]
|
||||||
|
return any(re.search(pattern, message) for pattern in status_patterns)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _format_output(cls, output: str) -> Text:
|
||||||
|
text = Text()
|
||||||
|
lines = output.splitlines()
|
||||||
|
total_lines = len(lines)
|
||||||
|
|
||||||
|
head_count = MAX_OUTPUT_LINES // 2
|
||||||
|
tail_count = MAX_OUTPUT_LINES - head_count - 1
|
||||||
|
|
||||||
|
if total_lines <= MAX_OUTPUT_LINES:
|
||||||
|
display_lines = lines
|
||||||
|
truncated = False
|
||||||
|
hidden_count = 0
|
||||||
|
else:
|
||||||
|
display_lines = lines[:head_count]
|
||||||
|
truncated = True
|
||||||
|
hidden_count = total_lines - head_count - tail_count
|
||||||
|
|
||||||
|
for i, line in enumerate(display_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(display_lines) - 1 or truncated:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
|
||||||
|
text.append("\n")
|
||||||
|
tail_lines = lines[-tail_count:]
|
||||||
|
for i, line in enumerate(tail_lines):
|
||||||
|
truncated_line = cls._truncate_line(line)
|
||||||
|
text.append(" ")
|
||||||
|
text.append(truncated_line, style="dim")
|
||||||
|
if i < len(tail_lines) - 1:
|
||||||
|
text.append("\n")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _truncate_line(cls, line: str) -> str:
|
||||||
|
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||||
|
if len(clean_line) > MAX_LINE_LENGTH:
|
||||||
|
return line[: MAX_LINE_LENGTH - 3] + "..."
|
||||||
|
return line
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _format_command(cls, command: str) -> Text:
|
def _format_command(cls, command: str) -> Text:
|
||||||
return cls._highlight_bash(command)
|
return cls._highlight_bash(command)
|
||||||
|
|||||||
Reference in New Issue
Block a user