From fd5b5075d6493b17807c09e2c73c0d2559e5b9a5 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 23 Aug 2025 13:23:06 -0400 Subject: [PATCH] Simplify CLI markdown rendering; remove python-markdown deps; update tests (#10538) Co-authored-by: openhands --- openhands/cli/tui.py | 52 ++++++++++++++-------------------- poetry.lock | 47 ++++++------------------------ pyproject.toml | 2 -- tests/unit/cli/test_cli_tui.py | 43 +++++++++++++++++----------- 4 files changed, 56 insertions(+), 88 deletions(-) diff --git a/openhands/cli/tui.py b/openhands/cli/tui.py index faa247e77a..e356ecf181 100644 --- a/openhands/cli/tui.py +++ b/openhands/cli/tui.py @@ -5,13 +5,14 @@ import asyncio import contextlib import datetime +import html import json +import re import sys import threading import time from typing import Generator -import markdown # type: ignore from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit.application import Application from prompt_toolkit.completion import CompleteEvent, Completer, Completion @@ -317,8 +318,8 @@ def display_message(message: str, is_agent_message: bool = False) -> None: print_formatted_text('') try: - # Convert markdown to HTML for all messages - html_content = convert_markdown_to_html(message) + # Render only basic markdown (bold/underline), escaping any HTML + html_content = _render_basic_markdown(message) if is_agent_message: # Use prompt_toolkit's HTML renderer with the agent color @@ -339,38 +340,27 @@ def display_message(message: str, is_agent_message: bool = False) -> None: print_formatted_text(message) -def convert_markdown_to_html(text: str) -> str: - """Convert markdown to HTML for prompt_toolkit's HTML renderer using the markdown library. +def _render_basic_markdown(text: str | None) -> str | None: + """Render a very small subset of markdown directly to prompt_toolkit HTML. - Args: - text: Markdown text to convert + Supported: + - Bold: **text** -> text + - Underline: __text__ -> text - Returns: - HTML formatted text with custom styling for headers and bullet points + Any existing HTML in input is escaped to avoid injection into the renderer. + If input is None, return None. """ - if not text: - return text + if text is None: + return None + if text == '': + return '' - # Use the markdown library to convert markdown to HTML - # Enable the 'extra' extension for tables, fenced code, etc. - html = markdown.markdown(text, extensions=['extra']) - - # Customize headers - for i in range(1, 7): - # Get the appropriate number of # characters for this heading level - prefix = '#' * i + ' ' - - # Replace

with the prefix and bold text - html = html.replace(f'', f'{prefix}') - html = html.replace(f'', '\n') - - # Customize bullet points to use dashes instead of dots with compact spacing - html = html.replace('
    ', '') - html = html.replace('
', '') - html = html.replace('
  • ', '- ') - html = html.replace('
  • ', '') - - return html + safe = html.escape(text) + # Bold: greedy within a line, non-overlapping + safe = re.sub(r'\*\*(.+?)\*\*', r'\1', safe) + # Underline: double underscore + safe = re.sub(r'__(.+?)__', r'\1', safe) + return safe def display_error(error: str) -> None: diff --git a/poetry.lock b/poetry.lock index 38300753bb..376e5c7598 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiofiles" @@ -404,7 +404,7 @@ description = "LTS Port of Python audioop" optional = false python-versions = ">=3.13" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"}, {file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"}, @@ -2997,8 +2997,8 @@ files = [ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" proto-plus = [ - {version = ">=1.22.3,<2.0.0dev"}, {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev"}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" @@ -3020,8 +3020,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} proto-plus = [ - {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" requests = ">=2.18.0,<3.0.0" @@ -3239,8 +3239,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" grpc-google-iam-v1 = ">=0.14.0,<1.0.0" proto-plus = [ - {version = ">=1.22.3,<2.0.0"}, {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" @@ -5229,22 +5229,6 @@ files = [ [package.dependencies] cobble = ">=0.1.3,<0.2" -[[package]] -name = "markdown" -version = "3.8.2" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"}, - {file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - [[package]] name = "markdown-it-py" version = "3.0.0" @@ -6663,8 +6647,8 @@ files = [ [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" grpcio = [ - {version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""}, {version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""}, ] opentelemetry-api = ">=1.15,<2.0" opentelemetry-exporter-otlp-proto-common = "1.34.1" @@ -9438,7 +9422,6 @@ files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] -markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] @@ -9682,7 +9665,7 @@ description = "Standard library aifc redistribution. \"dead battery\"." optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"}, {file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"}, @@ -9699,7 +9682,7 @@ description = "Standard library chunk redistribution. \"dead battery\"." optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.13\"" +markers = "python_version == \"3.13\"" files = [ {file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"}, {file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"}, @@ -10547,18 +10530,6 @@ files = [ ] markers = {main = "extra == \"third-party-runtimes\""} -[[package]] -name = "types-markdown" -version = "3.8.0.20250809" -description = "Typing stubs for Markdown" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "types_markdown-3.8.0.20250809-py3-none-any.whl", hash = "sha256:3f34a38c2259a3158e90ab0cb058cd8f4fdd3d75e2a0b335cb57f25dc2bc77d3"}, - {file = "types_markdown-3.8.0.20250809.tar.gz", hash = "sha256:fa619e735878a244332a4bbe16bcfc44e49ff6264c2696056278f0642cdfa223"}, -] - [[package]] name = "types-python-dateutil" version = "2.9.0.20250516" @@ -11879,4 +11850,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"] [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "469b54a3f7f5d104f68503fc70a89c016cbb7d9b7dc019226ed62e93ee928b98" +content-hash = "a0ae2cee596dde71f89c06e9669efda58ee8f8f019fad3dbe9df068005c32904" diff --git a/pyproject.toml b/pyproject.toml index e2e934d0c0..ce480e3084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ numpy = "*" json-repair = "*" browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface html2text = "*" -markdown = "*" # For markdown to HTML conversion deprecated = "*" pexpect = "*" jinja2 = "^3.1.3" @@ -116,7 +115,6 @@ pre-commit = "4.2.0" build = "*" types-setuptools = "*" pytest = "^8.4.0" -types-markdown = "^3.8.0.20250809" [tool.poetry.group.test] optional = true diff --git a/tests/unit/cli/test_cli_tui.py b/tests/unit/cli/test_cli_tui.py index 5775871562..86ab1e33ca 100644 --- a/tests/unit/cli/test_cli_tui.py +++ b/tests/unit/cli/test_cli_tui.py @@ -6,6 +6,7 @@ from openhands.cli.tui import ( CustomDiffLexer, UsageMetrics, UserCancelledError, + _render_basic_markdown, display_banner, display_command, display_event, @@ -26,7 +27,6 @@ from openhands.events import EventSource from openhands.events.action import ( Action, ActionConfirmationStatus, - ActionSecurityRisk, CmdRunAction, MCPAction, MessageAction, @@ -398,26 +398,35 @@ class TestReadConfirmationInput: async def test_read_confirmation_input_smart(self, mock_confirm): mock_confirm.return_value = 2 # user picked third menu item - cfg = MagicMock() # <- no spec for simplicity - cfg.cli = MagicMock(vi_mode=False) - result = await read_confirmation_input( - config=cfg, security_risk=ActionSecurityRisk.LOW +class TestMarkdownRendering: + def test_empty_string(self): + assert _render_basic_markdown('') == '' + + def test_plain_text(self): + assert _render_basic_markdown('hello world') == 'hello world' + + def test_bold(self): + assert _render_basic_markdown('**bold**') == 'bold' + + def test_underline(self): + assert _render_basic_markdown('__under__') == 'under' + + def test_combined(self): + assert ( + _render_basic_markdown('mix **bold** and __under__ here') + == 'mix bold and under here' ) - assert result == 'auto_highrisk' - @pytest.mark.asyncio - @patch('openhands.cli.tui.cli_confirm') - async def test_read_confirmation_input_high_risk_always(self, mock_confirm): - mock_confirm.return_value = 2 # user picked third menu item - - cfg = MagicMock() # <- no spec for simplicity - cfg.cli = MagicMock(vi_mode=False) - - result = await read_confirmation_input( - config=cfg, security_risk=ActionSecurityRisk.HIGH + def test_html_is_escaped(self): + assert _render_basic_markdown('') == ( + '<script>alert(1)</script>' + ) + + def test_bold_with_special_chars(self): + assert _render_basic_markdown('**a < b & c > d**') == ( + 'a < b & c > d' ) - assert result == 'always' """Tests for CLI TUI MCP functionality."""