mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Simplify CLI markdown rendering; remove python-markdown deps; update tests (#10538)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
f5cd7b256d
commit
fd5b5075d6
@ -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
47
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>') == (
|
||||
'<script>alert(1)</script>'
|
||||
)
|
||||
|
||||
def test_bold_with_special_chars(self):
|
||||
assert _render_basic_markdown('**a < b & c > d**') == (
|
||||
'<b>a < b & c > d</b>'
|
||||
)
|
||||
assert result == 'always'
|
||||
|
||||
|
||||
"""Tests for CLI TUI MCP functionality."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user