Simplify CLI markdown rendering; remove python-markdown deps; update tests (#10538)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang 2025-08-23 13:23:06 -04:00 committed by GitHub
parent f5cd7b256d
commit fd5b5075d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 56 additions and 88 deletions

View File

@ -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** -> <b>text</b>
- Underline: __text__ -> <u>text</u>
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 <h1> with the prefix and bold text
html = html.replace(f'<h{i}>', f'<b>{prefix}')
html = html.replace(f'</h{i}>', '</b>\n')
# Customize bullet points to use dashes instead of dots with compact spacing
html = html.replace('<ul>', '')
html = html.replace('</ul>', '')
html = html.replace('<li>', '- ')
html = html.replace('</li>', '')
return html
safe = html.escape(text)
# Bold: greedy within a line, non-overlapping
safe = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', safe)
# Underline: double underscore
safe = re.sub(r'__(.+?)__', r'<u>\1</u>', safe)
return safe
def display_error(error: str) -> None:

47
poetry.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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**') == '<b>bold</b>'
def test_underline(self):
assert _render_basic_markdown('__under__') == '<u>under</u>'
def test_combined(self):
assert (
_render_basic_markdown('mix **bold** and __under__ here')
== 'mix <b>bold</b> and <u>under</u> 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>') == (
'&lt;script&gt;alert(1)&lt;/script&gt;'
)
def test_bold_with_special_chars(self):
assert _render_basic_markdown('**a < b & c > d**') == (
'<b>a &lt; b &amp; c &gt; d</b>'
)
assert result == 'always'
"""Tests for CLI TUI MCP functionality."""