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