CLI: custom visualizer (#11677)

This commit is contained in:
Rohit Malhotra 2025-11-07 14:45:01 -05:00 committed by GitHub
parent 27c8c330f4
commit e0d26c1f4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 558 additions and 4 deletions

View File

@ -1,5 +1,6 @@
import uuid
from openhands.sdk.conversation import visualizer
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
@ -9,7 +10,7 @@ from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
)
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.visualizer import CLIVisualizer
# register tools
from openhands.tools.terminal import TerminalTool
@ -86,6 +87,7 @@ def setup_conversation(
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
visualizer=CLIVisualizer
)
if include_security_analyzer:

View File

@ -0,0 +1,312 @@
import re
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from openhands.sdk.conversation.visualizer.base import (
ConversationVisualizerBase,
)
from openhands.sdk.event import (
ActionEvent,
AgentErrorEvent,
MessageEvent,
ObservationEvent,
PauseEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.event.base import Event
from openhands.sdk.event.condenser import Condensation
# These are external inputs
_OBSERVATION_COLOR = "yellow"
_MESSAGE_USER_COLOR = "gold3"
_PAUSE_COLOR = "bright_yellow"
# These are internal system stuff
_SYSTEM_COLOR = "magenta"
_THOUGHT_COLOR = "bright_black"
_ERROR_COLOR = "red"
# These are agent actions
_ACTION_COLOR = "blue"
_MESSAGE_ASSISTANT_COLOR = _ACTION_COLOR
DEFAULT_HIGHLIGHT_REGEX = {
r"^Reasoning:": f"bold {_THOUGHT_COLOR}",
r"^Thought:": f"bold {_THOUGHT_COLOR}",
r"^Action:": f"bold {_ACTION_COLOR}",
r"^Arguments:": f"bold {_ACTION_COLOR}",
r"^Tool:": f"bold {_OBSERVATION_COLOR}",
r"^Result:": f"bold {_OBSERVATION_COLOR}",
r"^Rejection Reason:": f"bold {_ERROR_COLOR}",
# Markdown-style
r"\*\*(.*?)\*\*": "bold",
r"\*(.*?)\*": "italic",
}
_PANEL_PADDING = (1, 1)
class CLIVisualizer(ConversationVisualizerBase):
"""Handles visualization of conversation events with Rich formatting.
Provides Rich-formatted output with panels and complete content display.
"""
_console: Console
_skip_user_messages: bool
_highlight_patterns: dict[str, str]
def __init__(
self,
name: str | None = None,
highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX,
skip_user_messages: bool = False,
):
"""Initialize the visualizer.
Args:
name: Optional name to prefix in panel titles to identify
which agent/conversation is speaking.
highlight_regex: Dictionary mapping regex patterns to Rich color styles
for highlighting keywords in the visualizer.
For example: {"Reasoning:": "bold blue",
"Thought:": "bold green"}
skip_user_messages: If True, skip displaying user messages. Useful for
scenarios where user input is not relevant to show.
"""
super().__init__(
name=name,
)
self._console = Console()
self._skip_user_messages = skip_user_messages
self._highlight_patterns = highlight_regex or {}
def on_event(self, event: Event) -> None:
"""Main event handler that displays events with Rich formatting."""
panel = self._create_event_panel(event)
if panel:
self._console.print(panel)
self._console.print() # Add spacing between events
def _apply_highlighting(self, text: Text) -> Text:
"""Apply regex-based highlighting to text content.
Args:
text: The Rich Text object to highlight
Returns:
A new Text object with highlighting applied
"""
if not self._highlight_patterns:
return text
# Create a copy to avoid modifying the original
highlighted = text.copy()
# Apply each pattern using Rich's built-in highlight_regex method
for pattern, style in self._highlight_patterns.items():
pattern_compiled = re.compile(pattern, re.MULTILINE)
highlighted.highlight_regex(pattern_compiled, style)
return highlighted
def _create_event_panel(self, event: Event) -> Panel | None:
"""Create a Rich Panel for the event with appropriate styling."""
# Use the event's visualize property for content
content = event.visualize
if not content.plain.strip():
return None
# Apply highlighting if configured
if self._highlight_patterns:
content = self._apply_highlighting(content)
# Don't emit system prompt in CLI
if isinstance(event, SystemPromptEvent):
title = f"[bold {_SYSTEM_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"System Prompt[/bold {_SYSTEM_COLOR}]"
return None
elif isinstance(event, ActionEvent):
# Check if action is None (non-executable)
title = f"[bold {_ACTION_COLOR}]"
if self._name:
title += f"{self._name} "
if event.action is None:
title += f"Agent Action (Not Executed)[/bold {_ACTION_COLOR}]"
else:
title += f"Agent Action[/bold {_ACTION_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_ACTION_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, ObservationEvent):
title = f"[bold {_OBSERVATION_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Observation[/bold {_OBSERVATION_COLOR}]"
return Panel(
content,
title=title,
border_style=_OBSERVATION_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, UserRejectObservation):
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"User Rejected Action[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, MessageEvent):
if (
self._skip_user_messages
and event.llm_message
and event.llm_message.role == "user"
):
return
assert event.llm_message is not None
# Role-based styling
role_colors = {
"user": _MESSAGE_USER_COLOR,
"assistant": _MESSAGE_ASSISTANT_COLOR,
}
role_color = role_colors.get(event.llm_message.role, "white")
# "User Message To [Name] Agent" for user
# "Message from [Name] Agent" for agent
agent_name = f"{self._name} " if self._name else ""
if event.llm_message.role == "user":
title_text = (
f"[bold {role_color}]User Message to "
f"{agent_name}Agent[/bold {role_color}]"
)
else:
title_text = (
f"[bold {role_color}]Message from "
f"{agent_name}Agent[/bold {role_color}]"
)
return Panel(
content,
title=title_text,
subtitle=self._format_metrics_subtitle(),
border_style=role_color,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, AgentErrorEvent):
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Agent Error[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, PauseEvent):
title = f"[bold {_PAUSE_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"User Paused[/bold {_PAUSE_COLOR}]"
return Panel(
content,
title=title,
border_style=_PAUSE_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
elif isinstance(event, Condensation):
title = f"[bold {_SYSTEM_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"Condensation[/bold {_SYSTEM_COLOR}]"
return Panel(
content,
title=title,
subtitle=self._format_metrics_subtitle(),
border_style=_SYSTEM_COLOR,
expand=True,
)
else:
# Fallback panel for unknown event types
title = f"[bold {_ERROR_COLOR}]"
if self._name:
title += f"{self._name} "
title += f"UNKNOWN Event: {event.__class__.__name__}[/bold {_ERROR_COLOR}]"
return Panel(
content,
title=title,
subtitle=f"({event.source})",
border_style=_ERROR_COLOR,
padding=_PANEL_PADDING,
expand=True,
)
def _format_metrics_subtitle(self) -> str | None:
"""Format LLM metrics as a visually appealing subtitle string with icons,
colors, and k/m abbreviations using conversation stats."""
stats = self.conversation_stats
if not stats:
return None
combined_metrics = stats.get_combined_metrics()
if not combined_metrics or not combined_metrics.accumulated_token_usage:
return None
usage = combined_metrics.accumulated_token_usage
cost = combined_metrics.accumulated_cost or 0.0
# helper: 1234 -> "1.2K", 1200000 -> "1.2M"
def abbr(n: int | float) -> str:
n = int(n or 0)
if n >= 1_000_000_000:
val, suffix = n / 1_000_000_000, "B"
elif n >= 1_000_000:
val, suffix = n / 1_000_000, "M"
elif n >= 1_000:
val, suffix = n / 1_000, "K"
else:
return str(n)
return f"{val:.2f}".rstrip("0").rstrip(".") + suffix
input_tokens = abbr(usage.prompt_tokens or 0)
output_tokens = abbr(usage.completion_tokens or 0)
# Cache hit rate (prompt + cache)
prompt = usage.prompt_tokens or 0
cache_read = usage.cache_read_tokens or 0
cache_rate = f"{(cache_read / prompt * 100):.2f}%" if prompt > 0 else "N/A"
reasoning_tokens = usage.reasoning_tokens or 0
# Cost
cost_str = f"{cost:.4f}" if cost > 0 else "0.00"
# Build with fixed color scheme
parts: list[str] = []
parts.append(f"[cyan]↑ input {input_tokens}[/cyan]")
parts.append(f"[magenta]cache hit {cache_rate}[/magenta]")
if reasoning_tokens > 0:
parts.append(f"[yellow] reasoning {abbr(reasoning_tokens)}[/yellow]")
parts.append(f"[blue]↓ output {output_tokens}[/blue]")
parts.append(f"[green]$ {cost_str}[/green]")
return "Tokens: " + "".join(parts)

View File

@ -45,6 +45,7 @@ class TestConfirmationMode:
patch('openhands_cli.setup.print_formatted_text') as mock_print,
patch('openhands_cli.setup.HTML'),
patch('openhands_cli.setup.uuid') as mock_uuid,
patch('openhands_cli.setup.CLIVisualizer') as mock_visualizer,
):
# Mock dependencies
mock_conversation_id = MagicMock()
@ -72,6 +73,7 @@ class TestConfirmationMode:
workspace=ANY,
persistence_dir=ANY,
conversation_id=mock_conversation_id,
visualizer=mock_visualizer
)
def test_setup_conversation_raises_missing_agent_spec(self) -> None:

View File

@ -0,0 +1,238 @@
"""Tests for the conversation visualizer and event visualization."""
import json
from rich.text import Text
from openhands_cli.tui.visualizer import (
CLIVisualizer,
)
from openhands.sdk.event import (
ActionEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.llm import (
MessageToolCall,
TextContent,
)
from openhands.sdk.tool import Action
class VisualizerMockAction(Action):
"""Mock action for testing."""
command: str = "test command"
working_dir: str = "/tmp"
class VisualizerCustomAction(Action):
"""Custom action with overridden visualize method."""
task_list: list[dict] = []
@property
def visualize(self) -> Text:
"""Custom visualization for task tracker."""
content = Text()
content.append("Task Tracker Action\n", style="bold")
content.append(f"Tasks: {len(self.task_list)}")
for i, task in enumerate(self.task_list):
content.append(f"\n {i + 1}. {task.get('title', 'Untitled')}")
return content
def create_tool_call(
call_id: str, function_name: str, arguments: dict
) -> MessageToolCall:
"""Helper to create a MessageToolCall."""
return MessageToolCall(
id=call_id,
name=function_name,
arguments=json.dumps(arguments),
origin="completion",
)
def test_conversation_visualizer_initialization():
"""Test DefaultConversationVisualizer can be initialized."""
visualizer = CLIVisualizer()
assert visualizer is not None
assert hasattr(visualizer, "on_event")
assert hasattr(visualizer, "_create_event_panel")
def test_visualizer_event_panel_creation():
"""Test that visualizer creates panels for different event types."""
conv_viz = CLIVisualizer()
# Test with a simple action event
action = VisualizerMockAction(command="test")
tool_call = create_tool_call("call_1", "test", {})
action_event = ActionEvent(
thought=[TextContent(text="Testing")],
action=action,
tool_name="test",
tool_call_id="call_1",
tool_call=tool_call,
llm_response_id="response_1",
)
panel = conv_viz._create_event_panel(action_event)
assert panel is not None
assert hasattr(panel, "renderable")
def test_visualizer_action_event_with_none_action_panel():
"""ActionEvent with action=None should render as 'Agent Action (Not Executed)'."""
visualizer = CLIVisualizer()
tc = create_tool_call("call_ne_1", "missing_fn", {})
action_event = ActionEvent(
thought=[TextContent(text="...")],
tool_call=tc,
tool_name=tc.name,
tool_call_id=tc.id,
llm_response_id="resp_viz_1",
action=None,
)
panel = visualizer._create_event_panel(action_event)
assert panel is not None
# Ensure it doesn't fall back to UNKNOWN
assert "UNKNOWN Event" not in str(panel.title)
# And uses the 'Agent Action (Not Executed)' title
assert "Agent Action (Not Executed)" in str(panel.title)
def test_visualizer_user_reject_observation_panel():
"""UserRejectObservation should render a dedicated panel."""
visualizer = CLIVisualizer()
event = UserRejectObservation(
tool_name="demo_tool",
tool_call_id="fc_call_1",
action_id="action_1",
rejection_reason="User rejected the proposed action.",
)
panel = visualizer._create_event_panel(event)
assert panel is not None
title = str(panel.title)
assert "UNKNOWN Event" not in title
assert "User Rejected Action" in title
# ensure the reason is part of the renderable text
renderable = panel.renderable
assert isinstance(renderable, Text)
assert "User rejected the proposed action." in renderable.plain
def test_metrics_formatting():
"""Test metrics subtitle formatting."""
from unittest.mock import MagicMock
from openhands.sdk.conversation.conversation_stats import ConversationStats
from openhands.sdk.llm.utils.metrics import Metrics
# Create conversation stats with metrics
conversation_stats = ConversationStats()
# Create metrics and add to conversation stats
metrics = Metrics(model_name="test-model")
metrics.add_cost(0.0234)
metrics.add_token_usage(
prompt_tokens=1500,
completion_tokens=500,
cache_read_tokens=300,
cache_write_tokens=0,
reasoning_tokens=200,
context_window=8000,
response_id="test_response",
)
# Add metrics to conversation stats
conversation_stats.usage_to_metrics["test_usage"] = metrics
# Create visualizer and initialize with mock state
visualizer = CLIVisualizer()
mock_state = MagicMock()
mock_state.stats = conversation_stats
visualizer.initialize(mock_state)
# Test the metrics subtitle formatting
subtitle = visualizer._format_metrics_subtitle()
assert subtitle is not None
assert "1.5K" in subtitle # Input tokens abbreviated (trailing zeros removed)
assert "500" in subtitle # Output tokens
assert "20.00%" in subtitle # Cache hit rate
assert "200" in subtitle # Reasoning tokens
assert "0.0234" in subtitle # Cost
def test_metrics_abbreviation_formatting():
"""Test number abbreviation with various edge cases."""
from unittest.mock import MagicMock
from openhands.sdk.conversation.conversation_stats import ConversationStats
from openhands.sdk.llm.utils.metrics import Metrics
test_cases = [
# (input_tokens, expected_abbr)
(999, "999"), # Below threshold
(1000, "1K"), # Exact K boundary, trailing zeros removed
(1500, "1.5K"), # K with one decimal, trailing zero removed
(89080, "89.08K"), # K with two decimals (regression test for bug)
(89000, "89K"), # K with trailing zeros removed
(1000000, "1M"), # Exact M boundary
(1234567, "1.23M"), # M with decimals
(1000000000, "1B"), # Exact B boundary
]
for tokens, expected in test_cases:
stats = ConversationStats()
metrics = Metrics(model_name="test-model")
metrics.add_token_usage(
prompt_tokens=tokens,
completion_tokens=100,
cache_read_tokens=0,
cache_write_tokens=0,
reasoning_tokens=0,
context_window=8000,
response_id="test",
)
stats.usage_to_metrics["test"] = metrics
visualizer = CLIVisualizer()
mock_state = MagicMock()
mock_state.stats = stats
visualizer.initialize(mock_state)
subtitle = visualizer._format_metrics_subtitle()
assert subtitle is not None, f"Failed for {tokens}"
assert expected in subtitle, (
f"Expected '{expected}' in subtitle for {tokens}, got: {subtitle}"
)
def test_event_base_fallback_visualize():
"""Test that Event provides fallback visualization."""
from openhands.sdk.event.base import Event
from openhands.sdk.event.types import SourceType
class UnknownEvent(Event):
source: SourceType = "agent"
event = UnknownEvent()
conv_viz = CLIVisualizer()
panel = conv_viz._create_event_panel(event)
assert "UNKNOWN Event" in str(panel.title)
def test_visualizer_does_not_render_system_prompt():
"""Test that Event provides fallback visualization."""
system_prompt_event = SystemPromptEvent(
source="agent",
system_prompt=TextContent(text="dummy"),
tools=[]
)
conv_viz = CLIVisualizer()
panel = conv_viz._create_event_panel(system_prompt_event)
assert panel is None

6
openhands-cli/uv.lock generated
View File

@ -1902,7 +1902,7 @@ wheels = [
[[package]]
name = "openhands"
version = "1.0.5"
version = "1.0.6"
source = { editable = "." }
dependencies = [
{ name = "openhands-sdk" },
@ -1929,8 +1929,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", specifier = "==1.0.0" },
{ name = "openhands-tools", specifier = "==1.0.0" },
{ name = "openhands-sdk", specifier = "==1" },
{ name = "openhands-tools", specifier = "==1" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]