Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions openhands_cli/tui/widgets/richlog_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
67 changes: 67 additions & 0 deletions tests/tui/widgets/test_richlog_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
enyst marked this conversation as resolved.
# 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
Loading