diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index a65cc07bffb5..0537663d1737 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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) { diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 3b57db75119b..ef9f70b560ab 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -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; diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index f9b2753539e8..95e2ad888119 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -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; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0c19e8c8a9db..812c36b4f2fd 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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 { diff --git a/ui/src/ui/chat/code-block-copy-payload.ts b/ui/src/ui/chat/code-block-copy-payload.ts new file mode 100644 index 000000000000..16a61d080f3c --- /dev/null +++ b/ui/src/ui/chat/code-block-copy-payload.ts @@ -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; + } +} diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index d566dcc311ee..e9d54093bea0 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -32,6 +32,7 @@ vi.mock("../../local-storage.ts", () => ({ })); vi.mock("../markdown.ts", () => ({ + isMarkdownBlockArtText: () => false, toSanitizedMarkdownHtml: markdownRenderMock, toStreamingMarkdownHtml: streamingMarkdownRenderMock, toStreamingPlainTextHtml: streamingTextRenderMock, diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index 854b297e8aea..c4626528ea6f 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -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(".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(); diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index c1a2f85ded9e..84338089a983 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -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`
@@ -559,7 +561,9 @@ function renderToolDataBlock(params: { ${empty ? html`
${text}
` : expanded - ? html`
${text}
` + ? html`
${text}
` : html`
${getTruncatedPreview(text)}
`} diff --git a/ui/src/ui/chat/tool-helpers.test.ts b/ui/src/ui/chat/tool-helpers.test.ts index cd3373dddcf4..6aac48f145ab 100644 --- a/ui/src/ui/chat/tool-helpers.test.ts +++ b/ui/src/ui/chat/tool-helpers.test.ts @@ -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); diff --git a/ui/src/ui/chat/tool-helpers.ts b/ui/src/ui/chat/tool-helpers.ts index 322b6058f6a9..16675248cfae 100644 --- a/ui/src/ui/chat/tool-helpers.ts +++ b/ui/src/ui/chat/tool-helpers.ts @@ -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("[")) { diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index fe08cb6d3fd4..48854ecfd9ab 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + function withControlUiBasePath(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(".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( - '

text

\n
indented code\n
', + `

text

\n
indented code\n
`, ); }); it("includes copy button", () => { const html = toSanitizedMarkdownHtml("```\ncode\n```"); expect(html).toBe( - '
code\n
', + `
code\n
`, ); }); @@ -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(".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(".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(".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(".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"); diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 63e89c25069a..1de72cca8b9a 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -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 `
${escapeHtml(text)}
`; + } + const highlighted = highlightCode(text, lang); + const classAttr = codeClassAttribute(lang, highlighted); + return `
${highlighted}
`; +} + +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 ? `${escapeHtml(lang)}` : ""; + const copyText = options.copyText ?? text; + const copyPayload = blockArt ? encodeBlockArtCodeBlockCopyPayload(copyText) : copyText; + const attrSafe = escapeHtml(copyPayload); + const encodingAttr = blockArt + ? ` data-code-encoding="${blockArtCodeBlockCopyPayloadEncoding}"` + : ""; + const copyBtn = ``; + const header = `
${langLabel}${copyBtn}
`; + + 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 · ${lineCount} lines` : "JSON"; + return `
${label}
${header}${codeBlock}
`; + } + + return `
${header}${codeBlock}
`; +} + +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 = `
${highlighted}
`; - if (!shouldRenderCodeBlockCopy(env)) { - return codeBlock; - } - const langLabel = lang ? `${escapeHtml(lang)}` : ""; - const attrSafe = escapeHtml(text); - const copyBtn = ``; - const header = `
${langLabel}${copyBtn}
`; - - 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 · ${lineCount} lines` : "JSON"; - return `
${label}
${header}${codeBlock}
`; - } - - return `
${header}${codeBlock}
`; + 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 = `
${highlighted}
`; - if (!shouldRenderCodeBlockCopy(env)) { - return codeBlock; - } - const attrSafe = escapeHtml(text); - const copyBtn = ``; - const header = `
${copyBtn}
`; - - 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 · ${lineCount} lines` : "JSON"; - return `
${label}
${header}${codeBlock}
`; - } - - return `
${header}${codeBlock}
`; + 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 = `
${escaped}
`; } 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 ""; diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 00772fcf466d..83a4408f0414 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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) => ({ diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index d7adbf44d184..e59a4ce1ce5e 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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;