diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index f24adde1b..f2cd35bfb 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -416,7 +416,7 @@ def render_refinement_message(self, content: str) -> None: self._render_message_widget(content) def _update_widget_in_ui( - self, collapsible: Collapsible, new_title: str, new_content: str + self, collapsible: Collapsible, new_title: str | Text, new_content: str | Text ) -> None: """Update an existing widget in the UI (must be called from main thread).""" collapsible.update_title(new_title) @@ -514,14 +514,13 @@ def _build_action_title(self, event: ActionEvent) -> str: def _build_observation_content( self, event: ObservationEvent | UserRejectObservation | AgentErrorEvent - ) -> str: - """Build content string from an observation event. + ) -> str | Text: + """Build content from an observation event. - Returns the Rich-formatted content to preserve colors and styling. + Returns the Rich Text object directly to preserve colors and styling + (e.g., red/green diff highlighting from FileEditorObservation). """ - # Return the visualize content directly (Rich Text object) - # The Collapsible widget can handle Rich renderables - return str(event.visualize) + return event.visualize def _escape_rich_markup(self, text: str) -> str: """Escape Rich markup characters in text to prevent markup errors. @@ -770,9 +769,9 @@ def _create_titled_collapsible( ) -> Collapsible: """Create a standard titled collapsible for non-action events.""" title = self._extract_meaningful_title(event, fallback_title) - content_string = self._escape_rich_markup(str(event.visualize)) + content = event.visualize return self._make_collapsible( - content_string, + content, f"{self._get_agent_prefix()}{title}", event, ) @@ -816,7 +815,7 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: if isinstance(event, ActionEvent): title = self._build_action_title(event) collapsible = self._make_collapsible( - self._escape_rich_markup(str(content)), + content, title, event, ) @@ -838,11 +837,9 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: title = self._extract_meaningful_title( event, f"UNKNOWN Event: {event.__class__.__name__}" ) - content_string = ( - f"{self._escape_rich_markup(str(content))}\n\nSource: {event.source}" - ) + full_content = Text.assemble(content, f"\n\nSource: {event.source}") return self._make_collapsible( - content_string, + full_content, f"{self._get_agent_prefix()}{title}", event, ) diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index acf5c455c..426b89269 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1500,3 +1500,70 @@ def test_action_title_has_prefix_for_non_default_agent(self, mock_cli_settings): assert "(Code Reviewer Agent)" in title # Should contain the command assert "git diff" in title + + +class TestRichTextPreservation: + """Tests that Rich Text styling is preserved through the rendering pipeline. + + Relates to: https://github.com/OpenHands/OpenHands-CLI/issues/308 + """ + + def test_build_observation_content_returns_text_object(self, visualizer): + """_build_observation_content should return Text, not str.""" + event = AgentErrorEvent( + tool_name="terminal", + tool_call_id="call_1", + error="test error", + ) + result = visualizer._build_observation_content(event) + assert isinstance(result, Text) + + def test_build_observation_content_preserves_styles(self, visualizer): + """_build_observation_content should preserve Rich Text spans/styles.""" + event = AgentErrorEvent( + tool_name="terminal", + tool_call_id="call_1", + error="test error", + ) + result = visualizer._build_observation_content(event) + # The Text object should have style spans (bold "Tool:", etc.) + assert isinstance(result, Text) + assert len(result._spans) > 0, "Text object should have style spans" + + def test_create_titled_collapsible_passes_text_object( + self, visualizer, mock_cli_settings + ): + """_create_titled_collapsible should pass Text to collapsible, not str.""" + event = ConversationErrorEvent( + source="agent", + code="test_error", + detail="Test error", + ) + with mock_cli_settings(visualizer=visualizer): + collapsible = visualizer._create_titled_collapsible(event, "Test Error") + # Content widget should have received a Text object, not plain str + content = collapsible._content_widget._Static__content + assert isinstance(content, Text) + + def test_create_event_collapsible_action_preserves_text( + self, visualizer, mock_cli_settings + ): + """Action collapsible content should be Text, not escaped str.""" + action = RichLogMockAction(command="test") + tool_call = create_tool_call("call_1", "test") + event = ActionEvent( + thought=[TextContent(text="testing")], + action=action, + tool_name="test", + tool_call_id="call_1", + tool_call=tool_call, + llm_response_id="resp_1", + ) + with mock_cli_settings(visualizer=visualizer): + collapsible = visualizer._create_event_collapsible(event) + assert collapsible is not None + # Verify content is a Text object, not plain str + content = collapsible._content_widget._Static__content + assert isinstance(content, Text) + # Verify it was stored in pending actions + assert "call_1" in visualizer._pending_actions