UI: render half-block QR output in web chat (#93869)

Co-authored-by: MG <1900448+emg110@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-06-28 23:12:28 -07:00
committed by GitHub
parent e7d6566b8f
commit f5482efbd7
14 changed files with 450 additions and 68 deletions
+20
View File
@@ -697,6 +697,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: var(--mono);
overflow-x: auto;
margin-top: 0.75em;
}
@@ -705,14 +706,33 @@
background: rgba(0, 0, 0, 0.04);
}
.sidebar-markdown pre:has(> code.markdown-block-art) {
background: var(--bg);
}
.sidebar-markdown :where(pre code) {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 12.5px;
line-height: 1.55;
}
.sidebar-markdown :where(pre code.markdown-block-art) {
display: block;
min-width: 100%;
width: max-content;
color: var(--text);
font-size: 12px;
font-variant-ligatures: none;
letter-spacing: 0;
line-height: 0.86;
overflow-wrap: normal;
white-space: pre;
word-break: normal;
}
/* ── Blockquotes ── */
.sidebar-markdown :where(blockquote) {
+19
View File
@@ -112,11 +112,30 @@
overflow-x: auto;
}
.chat-text :where(pre:has(> code.markdown-block-art)) {
background: var(--bg);
}
.chat-text :where(pre code) {
background: none;
padding: 0;
}
.chat-text :where(pre code.markdown-block-art) {
display: block;
min-width: 100%;
width: max-content;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-variant-ligatures: none;
letter-spacing: 0;
line-height: 0.86;
overflow-wrap: normal;
white-space: pre;
word-break: normal;
}
.chat-text :where(blockquote) {
border-left: 3px solid var(--border-strong);
padding-left: 12px;
+19
View File
@@ -471,6 +471,25 @@
word-break: inherit;
}
.chat-tool-card__block-content:has(> code.markdown-block-art) {
background: var(--bg);
}
.chat-tool-card__block-content code.markdown-block-art {
display: block;
min-width: 100%;
width: max-content;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-variant-ligatures: none;
letter-spacing: 0;
line-height: 0.86;
overflow-wrap: normal;
white-space: pre;
word-break: normal;
}
.chat-tool-card__inline {
margin-top: 10px;
white-space: pre-wrap;
+23 -1
View File
@@ -2210,13 +2210,35 @@
.code-block-wrapper {
position: relative;
border-radius: var(--radius-sm);
overflow: hidden;
overflow: visible;
margin-top: 0.75em;
}
.code-block-wrapper pre {
margin: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
overflow-x: auto;
}
.code-block-wrapper pre:has(> code.markdown-block-art),
.chat-text :where(pre:has(> code.markdown-block-art)),
.sidebar-markdown :where(pre:has(> code.markdown-block-art)) {
background: var(--bg);
}
:is(.code-block-wrapper, .chat-text, .sidebar-markdown) :where(pre code.markdown-block-art) {
display: block;
min-width: 100%;
width: max-content;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-variant-ligatures: none;
letter-spacing: 0;
line-height: 0.86;
overflow-wrap: normal;
white-space: pre;
word-break: normal;
}
.code-block-header {
+21
View File
@@ -0,0 +1,21 @@
const blockArtCopyPayloadPrefix = "openclaw:block-art-code:";
export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json";
export function encodeBlockArtCodeBlockCopyPayload(value: string): string {
return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`;
}
export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string {
if (
encoding !== blockArtCodeBlockCopyPayloadEncoding ||
!value.startsWith(blockArtCopyPayloadPrefix)
) {
return value;
}
try {
const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length));
return typeof decoded === "string" ? decoded : value;
} catch {
return value;
}
}
+1
View File
@@ -32,6 +32,7 @@ vi.mock("../../local-storage.ts", () => ({
}));
vi.mock("../markdown.ts", () => ({
isMarkdownBlockArtText: () => false,
toSanitizedMarkdownHtml: markdownRenderMock,
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
toStreamingPlainTextHtml: streamingTextRenderMock,
+31
View File
@@ -252,6 +252,7 @@ describe("tool-cards", () => {
expect(rawToggle!.getAttribute("aria-expanded")).toBe("true");
expect(rawBody!.hidden).toBe(false);
expect(rawBody!.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool output");
expect(rawBody!.querySelector("code.markdown-block-art")).toBeNull();
expect(JSON.parse(rawBody!.querySelector("code")?.textContent ?? "{}")).toEqual({
kind: "canvas",
presentation: {
@@ -267,6 +268,36 @@ describe("tool-cards", () => {
});
});
it("marks expanded raw block-art output so QR whitespace uses block-art rendering", () => {
const container = document.createElement("div");
const blockArt = " ▄▄▄▄▄▄▄ \n █ ▄▄▄ █ \n █▄▄▄▄▄█ ";
render(
renderToolCard(
{
id: "msg:view:block-art",
name: "canvas_render",
outputText: blockArt,
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "qr_preview",
url: "/__openclaw__/canvas/documents/qr_preview/index.html",
},
},
{ expanded: true, onToggleExpanded: vi.fn() },
),
container,
);
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
rawToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const code = container.querySelector("code.markdown-block-art");
expect(code).not.toBeNull();
expect(code?.textContent).toBe(blockArt);
});
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
const container = document.createElement("div");
const onOpenSidebar = vi.fn();
+5 -1
View File
@@ -6,6 +6,7 @@ import { t } from "../../i18n/index.ts";
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
import { icons } from "../icons.ts";
import { isMarkdownBlockArtText } from "../markdown.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
import type { ToolCard } from "../types/chat-types.ts";
@@ -550,6 +551,7 @@ function renderToolDataBlock(params: {
empty?: boolean;
}) {
const { label, text, expanded, empty } = params;
const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : "";
return html`
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
<div class="chat-tool-card__block-header">
@@ -559,7 +561,9 @@ function renderToolDataBlock(params: {
${empty
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
: expanded
? html`<pre class="chat-tool-card__block-content"><code>${text}</code></pre>`
? html`<pre
class="chat-tool-card__block-content"
><code class=${codeClass}>${text}</code></pre>`
: html`<div class="chat-tool-card__block-preview mono">
${getTruncatedPreview(text)}
</div>`}
+9
View File
@@ -60,6 +60,15 @@ describe("tool-helpers", () => {
expect(result).toBe("This is plain text output");
});
it("wraps block art output in a fence while preserving quiet-zone whitespace", () => {
const input = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
const result = formatToolOutputForSidebar(input);
expect(result).toBe(`\`\`\`
${input}
\`\`\``);
});
it("returns as-is for invalid JSON starting with {", () => {
const input = "{not valid json";
const result = formatToolOutputForSidebar(input);
+6 -1
View File
@@ -2,13 +2,18 @@
* Helper functions for tool card rendering.
*/
import { isMarkdownBlockArtText } from "../markdown.ts";
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants.ts";
/**
* Format tool output content for display in the sidebar.
* Detects JSON and wraps it in a code block with formatting.
* Detects block art and JSON, wrapping content in code blocks when needed.
*/
export function formatToolOutputForSidebar(text: string): string {
if (isMarkdownBlockArtText(text)) {
return "```\n" + text + "\n```";
}
const trimmed = text.trim();
// Try to detect and format JSON
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+102 -3
View File
@@ -2,6 +2,10 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { i18n } from "../i18n/index.ts";
import {
blockArtCodeBlockCopyPayloadEncoding,
decodeCodeBlockCopyPayload,
} from "./chat/code-block-copy-payload.ts";
import {
md,
toSanitizedMarkdownHtml,
@@ -16,6 +20,15 @@ function htmlFragment(html: string): HTMLElement {
return container;
}
function escapedCodeBlockCopyAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function withControlUiBasePath<T>(basePath: string, fn: () => T): T {
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
value: basePath,
@@ -359,6 +372,8 @@ describe("toSanitizedMarkdownHtml", () => {
});
describe("code blocks", () => {
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
it("renders fenced code blocks", () => {
const html = toSanitizedMarkdownHtml("```ts\nconsole.log(1)\n```");
const fragment = htmlFragment(html);
@@ -366,23 +381,42 @@ describe("toSanitizedMarkdownHtml", () => {
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("ts");
expect(copy?.dataset.code).toBe("console.log(1)");
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe("console.log(1)");
expect(copy?.dataset.codeEncoding).toBeUndefined();
expect(code?.classList.contains("language-ts")).toBe(true);
expect(code?.textContent).toBe("console.log(1)\n");
});
it("renders raw block art as a whitespace-preserving code block", () => {
const html = toSanitizedMarkdownHtml(blockArt);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code.markdown-block-art");
expect(fragment.querySelector("p")).toBeNull();
expect(code?.textContent).toBe(blockArt);
});
it("marks fenced block art without syntax highlighting", () => {
const html = toSanitizedMarkdownHtml(`\`\`\`\n${blockArt}\n\`\`\``);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code.markdown-block-art");
expect(code?.classList.contains("hljs")).toBe(false);
expect(code?.textContent).toBe(`${blockArt}\n`);
});
it("renders indented code blocks", () => {
// markdown-it requires a blank line before indented code
const html = toSanitizedMarkdownHtml("text\n\n indented code");
expect(html).toBe(
'<p>text</p>\n<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="indented code" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>indented code\n</code></pre></div>',
`<p>text</p>\n<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="${escapedCodeBlockCopyAttribute("indented code")}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>indented code\n</code></pre></div>`,
);
});
it("includes copy button", () => {
const html = toSanitizedMarkdownHtml("```\ncode\n```");
expect(html).toBe(
'<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="code" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>code\n</code></pre></div>',
`<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="${escapedCodeBlockCopyAttribute("code")}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>code\n</code></pre></div>`,
);
});
@@ -427,11 +461,41 @@ PY
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("js");
expect(copy?.dataset.code).toBe(source.trimEnd());
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe(source.trimEnd());
expect(copy?.dataset.codeEncoding).toBeUndefined();
expect(code?.textContent).toBe(source);
expect(code?.querySelector(".hljs-keyword")?.textContent).toBe("const");
expect(code?.querySelector(".hljs-string")?.textContent).toBe('"yes"');
});
it("keeps ordinary code blocks raw when they start with the block-art prefix", () => {
const source = 'openclaw:block-art-code:"literal"\n';
const html = toSanitizedMarkdownHtml(`\`\`\`txt\n${source}\`\`\``);
const fragment = htmlFragment(html);
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(copy?.dataset.code).toBe(source.trimEnd());
expect(copy?.dataset.codeEncoding).toBeUndefined();
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
source.trimEnd(),
);
});
it("keeps boundary spaces in encoded copy payloads after sanitization", () => {
const source = " ▀▀▀▀ \n ▄▄▄▄ ";
const html = toSanitizedMarkdownHtml(`\`\`\`\n${source}\n\`\`\``);
const fragment = htmlFragment(html);
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(copy?.dataset.code).not.toMatch(/^\s|\s$/);
expect(copy?.dataset.code).toContain("openclaw:block-art-code:");
expect(copy?.dataset.codeEncoding).toBe(blockArtCodeBlockCopyPayloadEncoding);
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
source,
);
expect(fragment.querySelector("pre code")?.textContent).toBe(`${source}\n`);
});
it("highlights collapsed JSON code blocks", () => {
const html = toSanitizedMarkdownHtml('```json\n{"ok": true}\n```');
const fragment = htmlFragment(html);
@@ -478,6 +542,9 @@ PY
const chineseCopy = chineseFragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(englishCopy?.dataset.code).toBe("const localizedCopy = true;");
expect(decodeCodeBlockCopyPayload(englishCopy?.dataset.code ?? "")).toBe(
"const localizedCopy = true;",
);
expect(englishCopy?.getAttribute("aria-label")).toBe("Copy code");
expect(englishCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("Copy");
expect(englishCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("Copied!");
@@ -486,6 +553,9 @@ PY
);
expect(chineseCopy?.dataset.code).toBe("const localizedCopy = true;");
expect(decodeCodeBlockCopyPayload(chineseCopy?.dataset.code ?? "")).toBe(
"const localizedCopy = true;",
);
expect(chineseCopy?.getAttribute("aria-label")).toBe("复制代码");
expect(chineseCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("复制");
expect(chineseCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("已复制!");
@@ -507,6 +577,7 @@ PY
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
expect(details?.querySelector(".code-block-lang")?.textContent).toBe("json");
expect(copy?.dataset.code).toBe('{"key": "value"}');
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe('{"key": "value"}');
expect(code?.classList.contains("language-json")).toBe(true);
expect(code?.textContent).toBe('{"key": "value"}\n');
});
@@ -739,6 +810,34 @@ describe("toStreamingPlainTextHtml", () => {
});
describe("toStreamingMarkdownHtml", () => {
it("renders streaming raw block art without collapsing quiet-zone spaces", () => {
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
const html = toStreamingMarkdownHtml(blockArt);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code.markdown-block-art");
expect(fragment.querySelector("p")).toBeNull();
expect(code?.textContent).toBe(blockArt);
});
it("truncates oversized streaming raw block art before rendering", () => {
const line = " ▀▀▀▀ ";
const blockArt = Array.from({ length: 20_000 }, () => line).join("\n");
const html = toStreamingMarkdownHtml(blockArt);
const fragment = htmlFragment(html);
const code = fragment.querySelector("pre code.markdown-block-art");
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
expect(code?.textContent).toContain("… truncated");
expect(code?.textContent).toContain(`showing first 140000`);
expect(code?.textContent?.length).toBeLessThan(blockArt.length);
expect(copy?.dataset.code).toContain("openclaw:block-art-code:");
expect(copy?.dataset.codeEncoding).toBe(blockArtCodeBlockCopyPayloadEncoding);
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
code?.textContent,
);
});
it("renders completed block prefixes as markdown and keeps the open tail plain", () => {
const html = toStreamingMarkdownHtml("## Done\n\nworking **tail");
+130 -61
View File
@@ -19,6 +19,10 @@ import MarkdownIt from "markdown-it";
import markdownItTaskLists from "markdown-it-task-lists";
import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js";
import { i18n, t } from "../i18n/index.ts";
import {
blockArtCodeBlockCopyPayloadEncoding,
encodeBlockArtCodeBlockCopyPayload,
} from "./chat/code-block-copy-payload.ts";
import { truncateText } from "./format.ts";
import { inferBasePathFromPathname, normalizeBasePath, tabFromPath } from "./navigation.ts";
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
@@ -71,6 +75,7 @@ const allowedAttrs = [
"src",
"alt",
"data-code",
"data-code-encoding",
"type",
"aria-label",
];
@@ -86,6 +91,8 @@ const MARKDOWN_PARSE_LIMIT = 40_000;
const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
const BLOCK_ART_LINE_RE = /^[\t \u00a0]+$/u;
const BLOCK_ART_GLYPH_RE = /[]/u;
const HOST_LOCAL_FILE_HREF_RE =
/^(?:~\/|\/(?:Users|home|tmp|private\/tmp|var\/folders|private\/var\/folders)\/|\/[A-Za-z]:\/|[A-Za-z]:[\\/])/;
const DOCS_ORIGIN = "https://docs.openclaw.ai";
@@ -533,10 +540,37 @@ function normalizeMarkdownInput(markdownLocal: string): string {
return "";
}
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated
return appendMarkdownTruncationNotice(truncated).replace(/\r\n?/g, "\n");
}
function appendMarkdownTruncationNotice(truncated: {
text: string;
truncated: boolean;
total: number;
}): string {
const notice = truncated.truncated
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
: "";
return `${truncated.text}${suffix}`.replace(/\r\n?/g, "\n");
return `${truncated.text}${notice}`;
}
export function isMarkdownBlockArtText(value: string): boolean {
const lines = value.replace(/\r\n?/g, "\n").split("\n");
const artLines = lines.filter((line) => line.trim().length > 0);
if (artLines.length < 2) {
return false;
}
// QR generators commonly use spaces plus upper/lower/full block glyphs.
// Require multiple glyph-only lines so ordinary prose with a stray block character stays markdown.
let glyphCount = 0;
for (const line of artLines) {
if (!BLOCK_ART_LINE_RE.test(line) || !BLOCK_ART_GLYPH_RE.test(line)) {
return false;
}
glyphCount += Array.from(line).filter((char) => BLOCK_ART_GLYPH_RE.test(char)).length;
}
return glyphCount >= 8;
}
function getFenceMarker(line: string): { marker: "`" | "~"; length: number } | null {
@@ -693,6 +727,60 @@ function codeClassAttribute(lang: string, highlighted: string): string {
return classes.length > 0 ? ` class="${escapeHtml(classes.join(" "))}"` : "";
}
function renderCodeElement(
text: string,
lang: string,
options: { blockArt?: boolean } = {},
): string {
if (options.blockArt || isMarkdownBlockArtText(text)) {
return `<pre><code class="markdown-block-art">${escapeHtml(text)}</code></pre>`;
}
const highlighted = highlightCode(text, lang);
const classAttr = codeClassAttribute(lang, highlighted);
return `<pre><code${classAttr}>${highlighted}</code></pre>`;
}
function renderCodeBlock(
text: string,
lang: string,
env: unknown,
options: { blockArt?: boolean; copyText?: string } = {},
): string {
const blockArt = options.blockArt || isMarkdownBlockArtText(text);
const codeBlock = renderCodeElement(text, lang, { blockArt });
if (!shouldRenderCodeBlockCopy(env)) {
return codeBlock;
}
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
const copyText = options.copyText ?? text;
const copyPayload = blockArt ? encodeBlockArtCodeBlockCopyPayload(copyText) : copyText;
const attrSafe = escapeHtml(copyPayload);
const encodingAttr = blockArt
? ` data-code-encoding="${blockArtCodeBlockCopyPayloadEncoding}"`
: "";
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}"${encodingAttr} aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
const trimmed = text.trim();
const isJson =
lang === "json" ||
(!lang &&
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
if (isJson) {
const lineCount = text.split("\n").length;
const label = lineCount > 1 ? `JSON &middot; ${lineCount} lines` : "JSON";
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
}
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
}
function codeBlockCopyTextFromMarkdownToken(content: string): string {
return content.endsWith("\n") ? content.slice(0, -1) : content;
}
export const md = new MarkdownIt({
html: true, // Enable HTML recognition so html_block/html_inline overrides can escape it
breaks: true,
@@ -964,60 +1052,17 @@ md.renderer.rules.fence = (tokens, idx, _options, env) => {
// token.info contains the full fence info string (e.g., "json title=foo");
// extract only the first whitespace-separated token as the language.
const lang = token.info.trim().split(/\s+/)[0] || "";
const text = token.content;
const highlighted = highlightCode(text, lang);
const classAttr = codeClassAttribute(lang, highlighted);
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
if (!shouldRenderCodeBlockCopy(env)) {
return codeBlock;
}
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
const attrSafe = escapeHtml(text);
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
const trimmed = text.trim();
const isJson =
lang === "json" ||
(!lang &&
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
if (isJson) {
const lineCount = text.split("\n").length;
const label = lineCount > 1 ? `JSON &middot; ${lineCount} lines` : "JSON";
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
}
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
return renderCodeBlock(token.content, lang, env, {
copyText: codeBlockCopyTextFromMarkdownToken(token.content),
});
};
// Override indented code blocks (code_block) with the same treatment as fence
md.renderer.rules.code_block = (tokens, idx, _options, env) => {
const token = tokens[idx];
const text = token.content;
const highlighted = highlightCode(text, "");
const classAttr = codeClassAttribute("", highlighted);
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
if (!shouldRenderCodeBlockCopy(env)) {
return codeBlock;
}
const attrSafe = escapeHtml(text);
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
const header = `<div class="code-block-header">${copyBtn}</div>`;
const trimmed = text.trim();
const isJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
if (isJson) {
const lineCount = text.split("\n").length;
const label = lineCount > 1 ? `JSON &middot; ${lineCount} lines` : "JSON";
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
}
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
const content = tokens[idx].content;
return renderCodeBlock(content, "", env, {
copyText: codeBlockCopyTextFromMarkdownToken(content),
});
};
export function toSanitizedMarkdownHtml(
@@ -1025,27 +1070,36 @@ export function toSanitizedMarkdownHtml(
options: MarkdownRenderOptions = {},
): string {
const renderOptions = normalizeMarkdownRenderOptions(options);
const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim();
const rawInput = stripUnsupportedCitationControlMarkers(markdownLocal).replace(/\r\n?/g, "\n");
const input = rawInput.trim();
if (!input) {
return "";
}
installHooks();
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${input}`;
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${renderInput}`;
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
const cached = getCachedMarkdown(cacheKey);
if (cached !== null) {
return cached;
}
}
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
: "";
const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT);
if (isMarkdownBlockArtText(truncated.text)) {
const rendered = renderCodeBlock(appendMarkdownTruncationNotice(truncated), "", renderOptions, {
blockArt: true,
});
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(cacheKey, sanitized);
}
return sanitized;
}
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
// Large plain-text replies should stay readable without inheriting the
// capped code-block chrome, while still preserving whitespace for logs
// and other structured text that commonly trips the parse guard.
const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
const html = toEscapedPlainTextHtml(appendMarkdownTruncationNotice(truncated));
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(cacheKey, sanitized);
@@ -1054,11 +1108,11 @@ export function toSanitizedMarkdownHtml(
}
let rendered: string;
try {
rendered = md.render(`${truncated.text}${suffix}`, renderOptions);
rendered = md.render(appendMarkdownTruncationNotice(truncated), renderOptions);
} catch (err) {
// Fall back to escaped plain text when md.render() throws (#36213).
console.warn("[markdown] md.render failed, falling back to plain text:", err);
const escaped = escapeHtml(`${truncated.text}${suffix}`);
const escaped = escapeHtml(appendMarkdownTruncationNotice(truncated));
rendered = `<pre class="code-block">${escaped}</pre>`;
}
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
@@ -1084,6 +1138,21 @@ export function toStreamingMarkdownHtml(
markdownLocal: string,
options: MarkdownRenderOptions = {},
): string {
const rawInput = stripUnsupportedCitationControlMarkers(markdownLocal).replace(/\r\n?/g, "\n");
if (isMarkdownBlockArtText(rawInput)) {
const truncated = truncateText(rawInput, MARKDOWN_CHAR_LIMIT);
installHooks();
return DOMPurify.sanitize(
renderCodeBlock(
appendMarkdownTruncationNotice(truncated),
"",
normalizeMarkdownRenderOptions(options),
{ blockArt: true },
),
sanitizeOptions,
);
}
const input = normalizeMarkdownInput(markdownLocal);
if (!input) {
return "";
+61
View File
@@ -17,6 +17,10 @@ import {
import { renderChatQueue } from "../chat/chat-queue.ts";
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
import { renderWelcomeState } from "../chat/chat-welcome.ts";
import {
blockArtCodeBlockCopyPayloadEncoding,
encodeBlockArtCodeBlockCopyPayload,
} from "../chat/code-block-copy-payload.ts";
import { renderChatSessionSelect } from "../chat/session-controls.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../types.ts";
@@ -166,6 +170,7 @@ vi.mock("../chat/grouped-render.ts", () => ({
}));
vi.mock("../markdown.ts", () => ({
isMarkdownBlockArtText: () => false,
toSanitizedMarkdownHtml: (value: string) => value,
}));
@@ -618,6 +623,62 @@ describe("chat compaction divider", () => {
});
});
describe("chat code-block copy", () => {
it("copies decoded QR block-art boundary spaces from the delegated button handler", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const container = renderChatView();
const thread = requireElement(container, ".chat-thread", "chat thread");
const payload = " ▀▀▀▀ \n ▄▄▄▄ ";
const button = document.createElement("button");
button.type = "button";
button.className = "code-block-copy";
button.dataset.code = encodeBlockArtCodeBlockCopyPayload(payload);
button.dataset.codeEncoding = blockArtCodeBlockCopyPayloadEncoding;
thread.appendChild(button);
button.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith(payload);
});
it("keeps legacy raw data-code payloads copyable", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const container = renderChatView();
const thread = requireElement(container, ".chat-thread", "chat thread");
const button = document.createElement("button");
button.type = "button";
button.className = "code-block-copy";
button.dataset.code = "legacy text";
thread.appendChild(button);
button.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("legacy text");
});
it("does not decode unmarked raw data-code payloads that start with the block-art prefix", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const container = renderChatView();
const thread = requireElement(container, ".chat-thread", "chat thread");
const payload = 'openclaw:block-art-code:"literal"';
const button = document.createElement("button");
button.type = "button";
button.className = "code-block-copy";
button.dataset.code = payload;
thread.appendChild(button);
button.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith(payload);
});
});
describe("chat history render window", () => {
it("starts freshly loaded large histories with a small render window", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
+3 -1
View File
@@ -20,6 +20,7 @@ import { renderChatQueue } from "../chat/chat-queue.ts";
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts";
import { copyToClipboard } from "../chat/clipboard.ts";
import { decodeCodeBlockCopyPayload } from "../chat/code-block-copy-payload.ts";
import { renderContextNotice } from "../chat/context-notice.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import { exportChatMarkdown } from "../chat/export.ts";
@@ -2072,7 +2073,8 @@ export function renderChat(props: ChatProps) {
if (!btn) {
return;
}
const code = (btn as HTMLElement).dataset.code ?? "";
const button = btn as HTMLElement;
const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding);
void copyToClipboard(code).then((copied) => {
if (!copied) {
return;