diff --git a/CHANGELOG.md b/CHANGELOG.md
index 644ec5a17..0526927c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,11 @@
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
+## 2026.1.4-1
+
+### Fixes
+- Control UI: render Markdown in chat messages (sanitized).
+
## 2026.1.4
diff --git a/package.json b/package.json
index a432fe812..94a2b7952 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
- "version": "2026.1.4",
+ "version": "2026.1.4-1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9303e34d2..2eb154c0c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,9 +200,15 @@ importers:
ui:
dependencies:
+ dompurify:
+ specifier: ^3.3.1
+ version: 3.3.1
lit:
specifier: ^3.3.2
version: 3.3.2
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.1
devDependencies:
'@vitest/browser-playwright':
specifier: 4.0.16
@@ -1627,6 +1633,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dompurify@3.3.1:
+ resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
+
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
@@ -2200,6 +2209,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
+ marked@17.0.1:
+ resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -4418,6 +4432,10 @@ snapshots:
dom-accessibility-api@0.5.16:
optional: true
+ dompurify@3.3.1:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dotenv@17.2.3: {}
dunder-proto@1.0.1:
@@ -5032,6 +5050,8 @@ snapshots:
marked@16.4.2: {}
+ marked@17.0.1: {}
+
math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
diff --git a/ui/package.json b/ui/package.json
index 0a72c92e9..a9300bd12 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -9,7 +9,9 @@
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
- "lit": "^3.3.2"
+ "dompurify": "^3.3.1",
+ "lit": "^3.3.2",
+ "marked": "^17.0.1"
},
"devDependencies": {
"@vitest/browser-playwright": "4.0.16",
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index 98586e949..7665f661f 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -569,10 +569,106 @@
}
.chat-text {
- white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
color: var(--chat-text);
+ line-height: 1.5;
+}
+
+.chat-text :where(p, ul, ol, pre, blockquote, table) {
+ margin: 0;
+}
+
+.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table) {
+ margin-top: 0.75em;
+}
+
+.chat-text :where(ul, ol) {
+ padding-left: 1.1em;
+}
+
+.chat-text :where(li + li) {
+ margin-top: 0.25em;
+}
+
+.chat-text :where(a) {
+ color: var(--accent);
+ text-decoration-thickness: 2px;
+ text-underline-offset: 2px;
+}
+
+.chat-text :where(a:hover) {
+ text-decoration-thickness: 3px;
+}
+
+.chat-text :where(blockquote) {
+ border-left: 2px solid rgba(255, 255, 255, 0.14);
+ padding-left: 12px;
+ color: var(--muted);
+}
+
+:root[data-theme="light"] .chat-text :where(blockquote) {
+ border-left-color: rgba(16, 24, 40, 0.16);
+}
+
+.chat-text :where(hr) {
+ border: 0;
+ border-top: 1px solid var(--border);
+ opacity: 0.6;
+ margin: 0.9em 0;
+}
+
+.chat-text :where(code) {
+ font-family: var(--font-mono);
+ font-size: 0.92em;
+}
+
+.chat-text :where(:not(pre) > code) {
+ padding: 0.15em 0.35em;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.2);
+}
+
+:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
+ background: rgba(16, 24, 40, 0.05);
+}
+
+.chat-text :where(pre) {
+ margin-top: 0.75em;
+ padding: 10px 12px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.22);
+ overflow: auto;
+}
+
+:root[data-theme="light"] .chat-text :where(pre) {
+ background: rgba(16, 24, 40, 0.04);
+}
+
+.chat-text :where(pre code) {
+ font-size: 12px;
+ white-space: pre;
+}
+
+.chat-text :where(table) {
+ margin-top: 0.75em;
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 12px;
+}
+
+.chat-text :where(th, td) {
+ border: 1px solid var(--border);
+ padding: 6px 8px;
+ vertical-align: top;
+}
+
+.chat-text :where(th) {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--muted);
}
.chat-tool-card {
diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts
new file mode 100644
index 000000000..da2c4aca0
--- /dev/null
+++ b/ui/src/ui/markdown.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { toSanitizedMarkdownHtml } from "./markdown";
+
+describe("toSanitizedMarkdownHtml", () => {
+ it("renders basic markdown", () => {
+ const html = toSanitizedMarkdownHtml("Hello **world**");
+ expect(html).toContain("world");
+ });
+
+ it("strips scripts and unsafe links", () => {
+ const html = toSanitizedMarkdownHtml(
+ [
+ "",
+ "",
+ "[x](javascript:alert(1))",
+ "",
+ "[ok](https://example.com)",
+ ].join("\n"),
+ );
+ expect(html).not.toContain("