diff --git a/openhands_cli/shared/__init__.py b/openhands_cli/shared/__init__.py index 40ec3913b..9b5c3d7ba 100644 --- a/openhands_cli/shared/__init__.py +++ b/openhands_cli/shared/__init__.py @@ -2,6 +2,7 @@ from openhands_cli.shared.conversation_summary import extract_conversation_summary from openhands_cli.shared.slash_commands import parse_slash_command +from openhands_cli.shared.text_utils import truncate_text -__all__ = ["extract_conversation_summary", "parse_slash_command"] +__all__ = ["extract_conversation_summary", "parse_slash_command", "truncate_text"] diff --git a/openhands_cli/shared/text_utils.py b/openhands_cli/shared/text_utils.py new file mode 100644 index 000000000..c3ee9f0d9 --- /dev/null +++ b/openhands_cli/shared/text_utils.py @@ -0,0 +1,35 @@ +"""Text utilities shared across the codebase.""" + +ELLIPSIS = "..." + + +def truncate_text( + text: str, + max_length: int, + *, + from_start: bool = True, + collapse_whitespace: bool = True, +) -> str: + """Truncate text with ellipsis if it exceeds max_length. + + Args: + text: The text to truncate. + max_length: Maximum length before truncation. + from_start: If True, keep the start and add ellipsis at end. + If False, keep the end and add ellipsis at start (useful for paths). + collapse_whitespace: If True, replace newlines and carriage returns with spaces. + + Returns: + The truncated text, or the original text if it's within the limit. + """ + if collapse_whitespace: + text = text.replace("\n", " ").replace("\r", " ") + + if len(text) <= max_length: + return text + + ellipsis_len = len(ELLIPSIS) + if from_start: + return text[: max_length - ellipsis_len] + ELLIPSIS + else: + return ELLIPSIS + text[-(max_length - ellipsis_len) :] diff --git a/openhands_cli/tui/panels/history_side_panel.py b/openhands_cli/tui/panels/history_side_panel.py index 24e892693..2c15bbb17 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.text_utils import truncate_text from openhands_cli.theme import OPENHANDS_THEME from openhands_cli.tui.panels.history_panel_style import HISTORY_PANEL_STYLE @@ -58,7 +59,7 @@ def __init__( # 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_text(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,7 +83,7 @@ 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)) + title_text = _escape_rich_markup(truncate_text(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 @@ -460,11 +461,3 @@ def _format_time(dt: datetime) -> str: return f"{diff.days}d ago" else: return dt.strftime("%Y-%m-%d") - - -def _truncate(text: str, max_length: int) -> str: - """Truncate text for display.""" - text = text.replace("\n", " ").replace("\r", " ") - if len(text) <= max_length: - return text - return text[: max_length - 3] + "..." diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index f24adde1b..7e0b2abc3 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.text_utils import truncate_text from openhands_cli.stores import CliSettings from openhands_cli.theme import OPENHANDS_THEME from openhands_cli.tui.widgets.collapsible import ( @@ -46,7 +47,6 @@ # Maximum line length for truncating titles/commands in collapsed view MAX_LINE_LENGTH = 70 -ELLIPSIS = "..." # Default agent name - don't show prefix for this agent DEFAULT_AGENT_NAME = "OpenHands Agent" @@ -544,17 +544,14 @@ def _truncate_for_display( from_start: If True, keep the start and add ellipsis at end. If False, keep the end and add ellipsis at start (for paths). """ - if len(text) > max_length: - if from_start: - return text[: max_length - len(ELLIPSIS)] + ELLIPSIS - else: - return ELLIPSIS + text[-(max_length - len(ELLIPSIS)) :] - return text + return truncate_text( + text, max_length, from_start=from_start, collapse_whitespace=False + ) 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) + text = str(text).strip() + text = truncate_text(text, MAX_LINE_LENGTH, from_start=from_start) return self._escape_rich_markup(text) def _extract_meaningful_title(self, event, fallback_title: str) -> str: diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index acf5c455c..592c9975d 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -15,10 +15,10 @@ 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.text_utils import ELLIPSIS from openhands_cli.stores import CliSettings from openhands_cli.tui.textual_app import OpenHandsApp from openhands_cli.tui.widgets.richlog_visualizer import ( - ELLIPSIS, MAX_LINE_LENGTH, ConversationVisualizer, )