diff --git a/openhands_cli/shared/__init__.py b/openhands_cli/shared/__init__.py index 40ec3913b..0b691a693 100644 --- a/openhands_cli/shared/__init__.py +++ b/openhands_cli/shared/__init__.py @@ -1,7 +1,8 @@ # Shared utilities for openhands_cli from openhands_cli.shared.conversation_summary import extract_conversation_summary +from openhands_cli.shared.rich_utils import escape_rich_markup from openhands_cli.shared.slash_commands import parse_slash_command -__all__ = ["extract_conversation_summary", "parse_slash_command"] +__all__ = ["escape_rich_markup", "extract_conversation_summary", "parse_slash_command"] diff --git a/openhands_cli/shared/rich_utils.py b/openhands_cli/shared/rich_utils.py new file mode 100644 index 000000000..01b9edddb --- /dev/null +++ b/openhands_cli/shared/rich_utils.py @@ -0,0 +1,10 @@ +"""Rich text utilities shared across the codebase.""" + + +def escape_rich_markup(text: str) -> str: + """Escape Rich markup characters in text to prevent markup errors. + + This is needed to handle content with special characters (e.g., brackets) + that would otherwise cause MarkupError when rendered in widgets with markup=True. + """ + return text.replace("[", r"\[").replace("]", r"\]") diff --git a/openhands_cli/tui/panels/history_side_panel.py b/openhands_cli/tui/panels/history_side_panel.py index 24e892693..8d7e46a13 100644 --- a/openhands_cli/tui/panels/history_side_panel.py +++ b/openhands_cli/tui/panels/history_side_panel.py @@ -23,6 +23,7 @@ from openhands_cli.conversations.models import ConversationMetadata from openhands_cli.conversations.store.local import LocalFileStore +from openhands_cli.shared.rich_utils import escape_rich_markup from openhands_cli.theme import OPENHANDS_THEME from openhands_cli.tui.panels.history_panel_style import HISTORY_PANEL_STYLE @@ -31,11 +32,6 @@ from openhands_cli.tui.textual_app import OpenHandsApp -def _escape_rich_markup(text: str) -> str: - """Escape Rich markup characters in text to prevent markup errors.""" - return text.replace("[", r"\[").replace("]", r"\]") - - class HistoryItemContent(Static): """Content widget for a conversation item in the history panel.""" @@ -53,12 +49,12 @@ def __init__( """ # Build the content string - show title, id as secondary time_str = _format_time(conversation.created_at) - conv_id = _escape_rich_markup(conversation.id) + conv_id = escape_rich_markup(conversation.id) # Use title if available, otherwise use ID has_title = bool(conversation.title) if conversation.title: - title = _escape_rich_markup(_truncate(conversation.title, 100)) + title = escape_rich_markup(_truncate(conversation.title, 100)) content = f"{title}\n[dim]{conv_id} • {time_str}[/dim]" else: content = f"[dim]New conversation[/dim]\n[dim]{conv_id} • {time_str}[/dim]" @@ -82,8 +78,8 @@ def has_title(self) -> bool: def set_title(self, title: str) -> None: """Update the displayed title for this history item.""" time_str = _format_time(self._created_at) - title_text = _escape_rich_markup(_truncate(title, 100)) - conv_id = _escape_rich_markup(self.conversation_id) + title_text = escape_rich_markup(_truncate(title, 100)) + conv_id = escape_rich_markup(self.conversation_id) self.update(f"{title_text}\n[dim]{conv_id} • {time_str}[/dim]") self._has_title = True diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index f24adde1b..322aacec6 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -30,6 +30,7 @@ from openhands.tools.task_tracker.definition import TaskTrackerObservation from openhands.tools.terminal.definition import TerminalAction from openhands_cli.shared.delegate_formatter import format_delegate_title +from openhands_cli.shared.rich_utils import escape_rich_markup from openhands_cli.stores import CliSettings from openhands_cli.theme import OPENHANDS_THEME from openhands_cli.tui.widgets.collapsible import ( @@ -471,7 +472,7 @@ def _build_action_title(self, event: ActionEvent) -> str: """ agent_prefix = self._get_agent_prefix() summary = ( - self._escape_rich_markup(str(event.summary).strip().replace("\n", " ")) + escape_rich_markup(str(event.summary).strip().replace("\n", " ")) if event.summary else "" ) @@ -479,7 +480,7 @@ def _build_action_title(self, event: ActionEvent) -> str: # Terminal actions: show summary + command (truncated for display) if isinstance(action, TerminalAction) and action.command: - cmd = self._escape_rich_markup(action.command.strip().replace("\n", " ")) + cmd = escape_rich_markup(action.command.strip().replace("\n", " ")) cmd = self._truncate_for_display(cmd) if summary: return f"{agent_prefix}[bold]{summary}[/bold][dim]: $ {cmd}[/dim]" @@ -488,7 +489,7 @@ def _build_action_title(self, event: ActionEvent) -> str: # File operations: include path with Reading/Editing elif isinstance(action, FileEditorAction) and action.path: op = "Reading" if action.command == "view" else "Editing" - path = self._escape_rich_markup(action.path) + path = escape_rich_markup(action.path) if summary: return f"{agent_prefix}[bold]{summary}[/bold][dim]: {op} {path}[/dim]" return f"{agent_prefix}[bold]{op}[/bold][dim] {path}[/dim]" @@ -523,16 +524,6 @@ def _build_observation_content( # The Collapsible widget can handle Rich renderables return str(event.visualize) - def _escape_rich_markup(self, text: str) -> str: - """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 - Collapsible widgets with markup=True. - """ - # Escape square brackets which are used for Rich markup - return text.replace("[", r"\[").replace("]", r"\]") - def _truncate_for_display( self, text: str, max_length: int = MAX_LINE_LENGTH, *, from_start: bool = True ) -> str: @@ -555,7 +546,7 @@ def _clean_and_truncate(self, text: str, *, from_start: bool = True) -> str: """Strip, collapse newlines, truncate, and escape Rich markup for display.""" text = str(text).strip().replace("\n", " ") text = self._truncate_for_display(text, from_start=from_start) - return self._escape_rich_markup(text) + return escape_rich_markup(text) def _extract_meaningful_title(self, event, fallback_title: str) -> str: """Extract a meaningful title from an event, with fallback to truncated @@ -635,7 +626,7 @@ def _extract_meaningful_title(self, event, fallback_title: str) -> str: content_str = self._truncate_for_display(content_str) if content_str.strip(): - return f"{fallback_title}: {self._escape_rich_markup(content_str)}" + return f"{fallback_title}: {escape_rich_markup(content_str)}" except Exception: pass @@ -770,7 +761,7 @@ 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_string = escape_rich_markup(str(event.visualize)) return self._make_collapsible( content_string, f"{self._get_agent_prefix()}{title}", @@ -816,7 +807,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)), + escape_rich_markup(str(content)), title, event, ) @@ -838,9 +829,7 @@ 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}" - ) + content_string = f"{escape_rich_markup(str(content))}\n\nSource: {event.source}" return self._make_collapsible( content_string, f"{self._get_agent_prefix()}{title}", diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index acf5c455c..1630400a9 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -15,6 +15,7 @@ from openhands.sdk.event.conversation_error import ConversationErrorEvent from openhands.sdk.llm import MessageToolCall from openhands.tools.terminal.definition import TerminalAction +from openhands_cli.shared.rich_utils import escape_rich_markup from openhands_cli.stores import CliSettings from openhands_cli.tui.textual_app import OpenHandsApp from openhands_cli.tui.widgets.richlog_visualizer import ( @@ -126,16 +127,16 @@ def test_escape_rich_markup_escapes_brackets(self, visualizer): ] for input_text, expected_output in test_cases: - result = visualizer._escape_rich_markup(input_text) + result = escape_rich_markup(input_text) assert result == expected_output, ( f"Failed to escape '{input_text}': expected '{expected_output}', " f"got '{result}'" ) def test_safe_content_string_escapes_problematic_content(self, visualizer): - """Test that _escape_rich_markup escapes MarkupError content.""" + """Test that escape_rich_markup escapes MarkupError content.""" problematic_content = "+0.3%,月变化+0.8%,处于历史40%分位]" - safe_content = visualizer._escape_rich_markup(str(problematic_content)) + safe_content = escape_rich_markup(str(problematic_content)) # Verify brackets are escaped assert r"\]" in safe_content @@ -161,7 +162,7 @@ def test_escaped_chinese_content_renders_successfully(self, visualizer): This test demonstrates that the fix resolves the issue. """ problematic_content = "+0.3%,月变化+0.8%,处于历史40%分位]" - safe_content = visualizer._escape_rich_markup(str(problematic_content)) + safe_content = escape_rich_markup(str(problematic_content)) # This should NOT raise an error widget = Static(safe_content, markup=True) @@ -215,7 +216,7 @@ def test_visualizer_handles_chinese_message_event(self, visualizer): ) def test_various_chinese_patterns_are_escaped(self, visualizer, test_content): """Test that various patterns of Chinese text with special chars are handled.""" - safe_content = visualizer._escape_rich_markup(str(test_content)) + safe_content = escape_rich_markup(str(test_content)) # Verify brackets are escaped assert "[" not in safe_content or r"\[" in safe_content