From e0d26c1f4e51d20b0f04f83b000e819112b48cc3 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Fri, 7 Nov 2025 14:45:01 -0500 Subject: [PATCH] CLI: custom visualizer (#11677) --- openhands-cli/openhands_cli/setup.py | 4 +- openhands-cli/openhands_cli/tui/visualizer.py | 312 ++++++++++++++++++ openhands-cli/tests/test_confirmation_mode.py | 2 + .../tests/visualizer/test_visualizer.py | 238 +++++++++++++ openhands-cli/uv.lock | 6 +- 5 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 openhands-cli/openhands_cli/tui/visualizer.py create mode 100644 openhands-cli/tests/visualizer/test_visualizer.py diff --git a/openhands-cli/openhands_cli/setup.py b/openhands-cli/openhands_cli/setup.py index 5c7688e106..8897eefdb3 100644 --- a/openhands-cli/openhands_cli/setup.py +++ b/openhands-cli/openhands_cli/setup.py @@ -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 / to this path persistence_dir=CONVERSATIONS_DIR, conversation_id=conversation_id, + visualizer=CLIVisualizer ) if include_security_analyzer: diff --git a/openhands-cli/openhands_cli/tui/visualizer.py b/openhands-cli/openhands_cli/tui/visualizer.py new file mode 100644 index 0000000000..efcdb338bd --- /dev/null +++ b/openhands-cli/openhands_cli/tui/visualizer.py @@ -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) diff --git a/openhands-cli/tests/test_confirmation_mode.py b/openhands-cli/tests/test_confirmation_mode.py index ff9e03ed1d..e5832e7522 100644 --- a/openhands-cli/tests/test_confirmation_mode.py +++ b/openhands-cli/tests/test_confirmation_mode.py @@ -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: diff --git a/openhands-cli/tests/visualizer/test_visualizer.py b/openhands-cli/tests/visualizer/test_visualizer.py new file mode 100644 index 0000000000..92ead3643a --- /dev/null +++ b/openhands-cli/tests/visualizer/test_visualizer.py @@ -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 diff --git a/openhands-cli/uv.lock b/openhands-cli/uv.lock index 77af49cc15..7714eb18df 100644 --- a/openhands-cli/uv.lock +++ b/openhands-cli/uv.lock @@ -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" }, ]