Skip to content
25 changes: 16 additions & 9 deletions openhands_cli/refactor/widgets/richlog_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ def _add_widget_to_ui(self, widget: NonClickableCollapsible) -> None:
self._container.scroll_end(animate=False)

def _escape_rich_markup(self, text: str) -> str:
"""Escape Rich markup characters in text to prevent markup errors."""
"""Escape Rich markup characters in text to prevent markup errors.

This is needed to handle content with special characters (e.g., Chinese text
with brackets) that would otherwise cause MarkupError when rendered in Static
widgets with markup=True.
"""
# Escape square brackets which are used for Rich markup
return text.replace("[", r"\[").replace("]", r"\]")

Expand Down Expand Up @@ -233,7 +238,7 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
title = self._extract_meaningful_title(event, "Agent Action")

# Create content string with metrics subtitle if available
content_string = str(content)
content_string = self._escape_rich_markup(str(content))
metrics = self._format_metrics_subtitle()
if metrics:
content_string = f"{content_string}\n\n{metrics}"
Expand All @@ -247,15 +252,15 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
elif isinstance(event, ObservationEvent):
title = self._extract_meaningful_title(event, "Observation")
return NonClickableCollapsible(
str(content),
self._escape_rich_markup(str(content)),
title=title,
collapsed=False, # Start expanded for observations
border_color=_get_event_border_color(event),
)
elif isinstance(event, UserRejectObservation):
title = self._extract_meaningful_title(event, "User Rejected Action")
return NonClickableCollapsible(
str(content),
self._escape_rich_markup(str(content)),
title=title,
collapsed=False, # Start expanded by default
border_color=_get_event_border_color(event),
Expand All @@ -275,7 +280,7 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
title = self._extract_meaningful_title(event, "Agent Message")

# Create content string with metrics if available
content_string = str(content)
content_string = self._escape_rich_markup(str(content))
metrics = self._format_metrics_subtitle()
if metrics and event.llm_message.role == "assistant":
content_string = f"{content_string}\n\n{metrics}"
Expand All @@ -288,7 +293,7 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
)
elif isinstance(event, AgentErrorEvent):
title = self._extract_meaningful_title(event, "Agent Error")
content_string = str(content)
content_string = self._escape_rich_markup(str(content))
metrics = self._format_metrics_subtitle()
if metrics:
content_string = f"{content_string}\n\n{metrics}"
Expand All @@ -302,14 +307,14 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
elif isinstance(event, PauseEvent):
title = self._extract_meaningful_title(event, "User Paused")
return NonClickableCollapsible(
str(content),
self._escape_rich_markup(str(content)),
title=title,
collapsed=False, # Start expanded for pauses
border_color=_get_event_border_color(event),
)
elif isinstance(event, Condensation):
title = self._extract_meaningful_title(event, "Condensation")
content_string = str(content)
content_string = self._escape_rich_markup(str(content))
metrics = self._format_metrics_subtitle()
if metrics:
content_string = f"{content_string}\n\n{metrics}"
Expand All @@ -325,7 +330,9 @@ def _create_event_collapsible(self, event: Event) -> NonClickableCollapsible | N
title = self._extract_meaningful_title(
event, f"UNKNOWN Event: {event.__class__.__name__}"
)
content_string = f"{content}\n\nSource: {event.source}"
content_string = (
f"{self._escape_rich_markup(str(content))}\n\nSource: {event.source}"
)
return NonClickableCollapsible(
content_string,
title=title,
Expand Down
55 changes: 33 additions & 22 deletions tests/refactor/widgets/test_input_field.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for InputField widget component."""

from collections.abc import Generator
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, Mock, PropertyMock, patch

import pytest
from textual.widgets import Input, TextArea
Expand Down Expand Up @@ -75,27 +75,38 @@ def test_toggle_input_mode_converts_and_toggles_visibility(
expected_singleline_content,
) -> None:
"""Toggling mode converts newline representation and flips displays + signal."""
# Set mutliline mode
field_with_mocks.action_toggle_input_mode()
assert field_with_mocks.is_multiline_mode is True
assert field_with_mocks.input_widget.display is False
assert field_with_mocks.textarea_widget.display is True

# Seed instructions
field_with_mocks.textarea_widget.text = mutliline_content

field_with_mocks.action_toggle_input_mode()
field_with_mocks.mutliline_mode_status.publish.assert_called() # type: ignore

# Mutli-line -> single-line
assert field_with_mocks.input_widget.value == expected_singleline_content

# Single-line -> multi-line
field_with_mocks.action_toggle_input_mode()
field_with_mocks.mutliline_mode_status.publish.assert_called() # type: ignore

# Check original content is preserved
assert field_with_mocks.textarea_widget.text == mutliline_content
# Mock the screen and query_one for input_area
mock_screen = MagicMock()
mock_input_area = MagicMock()
mock_screen.query_one = Mock(return_value=mock_input_area)

with patch.object(
type(field_with_mocks),
"screen",
new_callable=PropertyMock,
return_value=mock_screen,
):
# Set mutliline mode
field_with_mocks.action_toggle_input_mode()
assert field_with_mocks.is_multiline_mode is True
assert field_with_mocks.input_widget.display is False
assert field_with_mocks.textarea_widget.display is True

# Seed instructions
field_with_mocks.textarea_widget.text = mutliline_content

field_with_mocks.action_toggle_input_mode()
field_with_mocks.mutliline_mode_status.publish.assert_called() # type: ignore

# Mutli-line -> single-line
assert field_with_mocks.input_widget.value == expected_singleline_content

# Single-line -> multi-line
field_with_mocks.action_toggle_input_mode()
field_with_mocks.mutliline_mode_status.publish.assert_called() # type: ignore

# Check original content is preserved
assert field_with_mocks.textarea_widget.text == mutliline_content

@pytest.mark.parametrize(
"content, should_submit",
Expand Down
Loading