Skip to content
Merged
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
3 changes: 2 additions & 1 deletion openhands_cli/shared/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
10 changes: 10 additions & 0 deletions openhands_cli/shared/rich_utils.py
Original file line number Diff line number Diff line change
@@ -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"\]")
14 changes: 5 additions & 9 deletions openhands_cli/tui/panels/history_side_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

Expand All @@ -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]"
Expand All @@ -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

Expand Down
29 changes: 9 additions & 20 deletions openhands_cli/tui/widgets/richlog_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -471,15 +472,15 @@ 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 ""
)
action = event.action

# 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]"
Expand All @@ -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]"
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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,
)
Expand All @@ -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}",
Expand Down
11 changes: 6 additions & 5 deletions tests/tui/widgets/test_richlog_visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading