diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 4ec0c5d7e..91d40793d 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -15,6 +15,7 @@ interface ResolvedMarkdownSnapshot { themeKey: string highlightEnabled: boolean escapeRawHtml: boolean + defaultCodeBlockWrap: boolean partId: string | undefined cacheId: string version: string @@ -96,6 +97,7 @@ interface MarkdownProps { size?: "base" | "sm" | "tight" disableHighlight?: boolean escapeRawHtml?: boolean + defaultCodeBlockWrap?: boolean onRendered?: () => void } @@ -105,11 +107,54 @@ export function Markdown(props: MarkdownProps) { let containerRef: HTMLDivElement | undefined let latestRequestKey = "" let cleanupLanguageListener: (() => void) | undefined + const codeBlockWrapOverrides = new Map() const notifyRendered = () => { Promise.resolve().then(() => props.onRendered?.()) } + const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => { + const key = codeBlock.getAttribute("data-code-block-key") + if (!key) { + return null + } + return `${resolved().cacheId}:${key}` + } + + const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => { + codeBlock.setAttribute("data-wrap-lines", enabled ? "true" : "false") + + const button = codeBlock.querySelector(".code-block-wrap") + if (!button) { + return + } + + const label = enabled ? t("markdown.codeBlock.wrap.disable") : t("markdown.codeBlock.wrap.enable") + button.classList.toggle("active", enabled) + button.setAttribute("aria-pressed", enabled ? "true" : "false") + button.setAttribute("aria-label", label) + button.setAttribute("title", label) + + const text = button.querySelector(".wrap-text") + if (text) { + text.textContent = label + } + } + + const syncCodeBlockWrapStates = () => { + if (!containerRef) { + return + } + + const codeBlocks = containerRef.querySelectorAll(".markdown-code-block") + for (const codeBlock of codeBlocks) { + const key = codeBlockWrapKey(codeBlock) + const defaultEnabled = codeBlock.getAttribute("data-wrap-lines") !== "false" + const enabled = key ? (codeBlockWrapOverrides.get(key) ?? defaultEnabled) : defaultEnabled + applyCodeBlockWrapState(codeBlock, enabled) + } + } + const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" @@ -117,11 +162,23 @@ export function Markdown(props: MarkdownProps) { const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const escapeRawHtml = Boolean(props.escapeRawHtml) + const defaultCodeBlockWrap = props.defaultCodeBlockWrap ?? true const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const cacheId = resolvePartCacheId(part, text) const version = resolvePartVersion(part, text) - const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}` - return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey } + const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}:${version}` + return { + part, + text, + themeKey, + highlightEnabled, + escapeRawHtml, + defaultCodeBlockWrap, + partId, + cacheId, + version, + requestKey, + } }) const cacheHandle = useGlobalCache({ @@ -129,8 +186,8 @@ export function Markdown(props: MarkdownProps) { sessionId: () => props.sessionId, scope: "markdown", cacheId: () => { - const { cacheId, themeKey, highlightEnabled } = resolved() - return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}` + const { cacheId, themeKey, highlightEnabled, escapeRawHtml, defaultCodeBlockWrap } = resolved() + return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}` }, version: () => resolved().version, }) @@ -144,7 +201,7 @@ export function Markdown(props: MarkdownProps) { text: snapshot.text, html: renderedHtml, theme: snapshot.themeKey, - mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, + mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`, } setHtml(renderedHtml) if (options?.cache ?? true) { @@ -159,6 +216,7 @@ export function Markdown(props: MarkdownProps) { const rendered = await markdown.renderMarkdown(snapshot.text, { suppressHighlight: !snapshot.highlightEnabled, escapeRawHtml: snapshot.escapeRawHtml, + defaultCodeBlockWrap: snapshot.defaultCodeBlockWrap, }) const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text) @@ -170,7 +228,7 @@ export function Markdown(props: MarkdownProps) { createEffect(() => { const snapshot = resolved() latestRequestKey = snapshot.requestKey - const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}` + const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}` const cacheMatches = (cache: RenderCache | undefined) => { if (!cache) return false @@ -202,9 +260,33 @@ export function Markdown(props: MarkdownProps) { }) }) + createEffect(() => { + html() + Promise.resolve().then(syncCodeBlockWrapStates) + }) + onMount(() => { const handleClick = async (event: Event) => { const target = event.target as HTMLElement + const wrapButton = target.closest(".code-block-wrap") as HTMLButtonElement + if (wrapButton) { + event.preventDefault() + const codeBlock = wrapButton.closest(".markdown-code-block") as HTMLElement | null + if (!codeBlock) { + return + } + + const key = codeBlockWrapKey(codeBlock) + const current = codeBlock.getAttribute("data-wrap-lines") !== "false" + const next = !current + if (key) { + codeBlockWrapOverrides.set(key, next) + } + applyCodeBlockWrapState(codeBlock, next) + props.onRendered?.() + return + } + const copyButton = target.closest(".code-block-copy") as HTMLButtonElement if (!copyButton) { diff --git a/packages/ui/src/components/tool-call/markdown-render.tsx b/packages/ui/src/components/tool-call/markdown-render.tsx index a2f385d1f..2c389ac48 100644 --- a/packages/ui/src/components/tool-call/markdown-render.tsx +++ b/packages/ui/src/components/tool-call/markdown-render.tsx @@ -30,7 +30,8 @@ export function createMarkdownContentRenderer(params: { const size = options.size || "default" const disableHighlight = options.disableHighlight || false - const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${options.wrap ? " tool-call-markdown-wrap" : ""}` + const wrapEnabled = options.wrap ?? true + const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${wrapEnabled ? " tool-call-markdown-wrap" : ""}` const state = params.toolState() const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending") const registerRef = disableScrollTracking ? registerUntracked : registerTracked @@ -73,6 +74,7 @@ export function createMarkdownContentRenderer(params: { sessionId={params.sessionId} isDark={params.isDark()} disableHighlight={disableHighlight} + defaultCodeBlockWrap={wrapEnabled} onRendered={handleMarkdownRendered} /> {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} diff --git a/packages/ui/src/lib/i18n/messages/de/markdown.ts b/packages/ui/src/lib/i18n/messages/de/markdown.ts index 83d1e7fc1..a37d77922 100644 --- a/packages/ui/src/lib/i18n/messages/de/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/de/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Kopieren", "markdown.codeBlock.copy.copied": "Kopiert!", "markdown.codeBlock.copy.failed": "Fehlgeschlagen", + "markdown.codeBlock.wrap.enable": "Zeilenumbruch aktivieren", + "markdown.codeBlock.wrap.disable": "Zeilenumbruch deaktivieren", "markdown.copy": "Kopieren", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/markdown.ts b/packages/ui/src/lib/i18n/messages/en/markdown.ts index c7aea2cc8..5a47fc0d6 100644 --- a/packages/ui/src/lib/i18n/messages/en/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/en/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copy", "markdown.codeBlock.copy.copied": "Copied!", "markdown.codeBlock.copy.failed": "Failed", + "markdown.codeBlock.wrap.enable": "Enable word wrap", + "markdown.codeBlock.wrap.disable": "Disable word wrap", "markdown.copy": "Copy", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/markdown.ts b/packages/ui/src/lib/i18n/messages/es/markdown.ts index f92d46b97..f0b89ac0c 100644 --- a/packages/ui/src/lib/i18n/messages/es/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/es/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copiar", "markdown.codeBlock.copy.copied": "¡Copiado!", "markdown.codeBlock.copy.failed": "Error", + "markdown.codeBlock.wrap.enable": "Activar ajuste de línea", + "markdown.codeBlock.wrap.disable": "Desactivar ajuste de línea", "markdown.copy": "Copiar", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/markdown.ts b/packages/ui/src/lib/i18n/messages/fr/markdown.ts index 42a6915a5..c8766d120 100644 --- a/packages/ui/src/lib/i18n/messages/fr/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/fr/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copier", "markdown.codeBlock.copy.copied": "Copié !", "markdown.codeBlock.copy.failed": "Échec", + "markdown.codeBlock.wrap.enable": "Activer le retour à la ligne", + "markdown.codeBlock.wrap.disable": "Désactiver le retour à la ligne", "markdown.copy": "Copier", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/markdown.ts b/packages/ui/src/lib/i18n/messages/he/markdown.ts index 5505c65bb..bfa236820 100644 --- a/packages/ui/src/lib/i18n/messages/he/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/he/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "העתק", "markdown.codeBlock.copy.copied": "הועתק!", "markdown.codeBlock.copy.failed": "נכשל", + "markdown.codeBlock.wrap.enable": "הפעל גלישת שורות", + "markdown.codeBlock.wrap.disable": "כבה גלישת שורות", "markdown.copy": "העתק", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/markdown.ts b/packages/ui/src/lib/i18n/messages/ja/markdown.ts index 786f471cb..e8f1cfafc 100644 --- a/packages/ui/src/lib/i18n/messages/ja/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ja/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "コピー", "markdown.codeBlock.copy.copied": "コピーしました!", "markdown.codeBlock.copy.failed": "失敗", + "markdown.codeBlock.wrap.enable": "折り返しを有効にする", + "markdown.codeBlock.wrap.disable": "折り返しを無効にする", "markdown.copy": "コピー", } as const diff --git a/packages/ui/src/lib/i18n/messages/ne/markdown.ts b/packages/ui/src/lib/i18n/messages/ne/markdown.ts index 842ceff7b..defe0b095 100644 --- a/packages/ui/src/lib/i18n/messages/ne/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ne/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "प्रतिलिपि गर्नुहोस्", "markdown.codeBlock.copy.copied": "प्रतिलिपि गरियो!", "markdown.codeBlock.copy.failed": "असफल", + "markdown.codeBlock.wrap.enable": "लाइन र्‍याप सक्षम गर्नुहोस्", + "markdown.codeBlock.wrap.disable": "लाइन र्‍याप असक्षम गर्नुहोस्", "markdown.copy": "प्रतिलिपि गर्नुहोस्", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/markdown.ts b/packages/ui/src/lib/i18n/messages/ru/markdown.ts index 1c4050380..ee4211f2f 100644 --- a/packages/ui/src/lib/i18n/messages/ru/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ru/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Копировать", "markdown.codeBlock.copy.copied": "Скопировано!", "markdown.codeBlock.copy.failed": "Не удалось", + "markdown.codeBlock.wrap.enable": "Включить перенос строк", + "markdown.codeBlock.wrap.disable": "Отключить перенос строк", "markdown.copy": "Копировать", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts index 0bae28249..dfcb3fe53 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "复制", "markdown.codeBlock.copy.copied": "已复制!", "markdown.codeBlock.copy.failed": "失败", + "markdown.codeBlock.wrap.enable": "启用自动换行", + "markdown.codeBlock.wrap.disable": "禁用自动换行", "markdown.copy": "复制", } as const diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts index 50b19161c..9d40506ae 100644 --- a/packages/ui/src/lib/markdown.ts +++ b/packages/ui/src/lib/markdown.ts @@ -14,9 +14,11 @@ let currentTheme: "light" | "dark" = "light" let isInitialized = false let highlightSuppressed = false let escapeRawHtmlEnabled = false +let defaultCodeBlockWrapEnabled = true let rendererSetup = false let shikiModulePromise: Promise | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null +const codeBlockRenderOccurrences = new Map() // Dollar-delimited math is handled by marked-katex-extension; bracket delimiters // use the small parser-native rules registered in setupRenderer. @@ -178,6 +180,19 @@ function sanitizeRawHtmlFragment(html: string): string { return template.innerHTML } +function hashString(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16) +} + +function resetCodeBlockRenderState() { + codeBlockRenderOccurrences.clear() +} + // Track loaded languages and queue for on-demand loading const loadedLanguages = new Set() const queuedLanguages = new Set() @@ -507,29 +522,50 @@ function setupRenderer(isDark: boolean) { // Use "text" as default when no language is specified const resolvedLang = lang && lang.trim() ? lang.trim() : "text" + const occurrenceBaseKey = `${resolvedLang}\u0000${decodedCode}` + const occurrence = codeBlockRenderOccurrences.get(occurrenceBaseKey) ?? 0 + codeBlockRenderOccurrences.set(occurrenceBaseKey, occurrence + 1) + const codeBlockKey = hashString(`${occurrenceBaseKey}\u0000${occurrence}`) const escapedLang = escapeHtml(resolvedLang) const copyLabel = escapeHtml(tGlobal("markdown.copy")) + const defaultWrapEnabled = defaultCodeBlockWrapEnabled + const wrapLabel = escapeHtml(tGlobal(defaultWrapEnabled ? "markdown.codeBlock.wrap.disable" : "markdown.codeBlock.wrap.enable")) + const wrapActiveClass = defaultWrapEnabled ? " active" : "" + const wrapPressed = defaultWrapEnabled ? "true" : "false" const header = `
${escapedLang} - -
- `.trim() + + + + + + `.trim() + + const renderCodeBlock = (body: string) => `
${header}${body}
` if (highlightSuppressed) { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Resolve language and check if it's loaded @@ -538,23 +574,23 @@ function setupRenderer(isDark: boolean) { // Skip highlighting for "text" aliases if (langKey === "text" || raw === "text") { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Use highlighting if language is loaded, otherwise fall back to plain code if (loadedLanguages.has(langKey)) { try { - const html = highlighter!.codeToHtml(decodedCode, { - lang: langKey, - theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", - }) - return `
${header}${html}
` + const html = highlighter!.codeToHtml(decodedCode, { + lang: langKey, + theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", + }) + return renderCodeBlock(html) } catch { // Fall through to plain code if highlighting fails } } - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } renderer.link = (href: string, title: string | null | undefined, text: string) => { @@ -599,6 +635,7 @@ export async function renderMarkdown( options?: { suppressHighlight?: boolean escapeRawHtml?: boolean + defaultCodeBlockWrap?: boolean }, ): Promise { if (!isInitialized) { @@ -608,6 +645,7 @@ export async function renderMarkdown( const suppressHighlight = options?.suppressHighlight ?? false const escapeRawHtml = options?.escapeRawHtml ?? false + const defaultCodeBlockWrap = options?.defaultCodeBlockWrap ?? true const decoded = decodeHtmlEntities(content) if (!suppressHighlight) { @@ -617,15 +655,20 @@ export async function renderMarkdown( const previousSuppressed = highlightSuppressed const previousEscapeRawHtml = escapeRawHtmlEnabled + const previousDefaultCodeBlockWrap = defaultCodeBlockWrapEnabled highlightSuppressed = suppressHighlight escapeRawHtmlEnabled = escapeRawHtml + defaultCodeBlockWrapEnabled = defaultCodeBlockWrap try { // Proceed to parse immediately - highlighting will be available on next render + resetCodeBlockRenderState() return marked.parse(decoded) as Promise } finally { + resetCodeBlockRenderState() highlightSuppressed = previousSuppressed escapeRawHtmlEnabled = previousEscapeRawHtml + defaultCodeBlockWrapEnabled = previousDefaultCodeBlockWrap } } diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 5e95c4719..40a488aa7 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -242,6 +242,15 @@ border-bottom: 1px solid var(--border-base); } + .code-block-actions { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex: 0 0 auto; + margin-inline-start: auto; + margin-inline-end: var(--space-sm); + } + .code-block-language { font-family: var(--message-part-header-font-family); color: var(--text-primary); @@ -252,7 +261,8 @@ line-height: var(--message-part-header-title-line-height); } - .code-block-copy { + .code-block-copy, + .code-block-wrap { display: inline-flex; align-items: center; justify-content: center; @@ -262,28 +272,30 @@ padding: 0; background-color: transparent; border: none; - border-radius: 0.25rem; + border-radius: 0; cursor: pointer; color: var(--text-secondary); flex: 0 0 auto; transition: background-color 150ms ease, color 150ms ease; - margin-inline-start: auto; - margin-inline-end: var(--space-sm); } - .code-block-copy:hover { + .code-block-copy:hover, + .code-block-wrap:hover, + .code-block-wrap.active { background-color: transparent; color: var(--text-primary); } - .code-block-copy .copy-icon { + .code-block-copy .copy-icon, + .code-block-wrap .wrap-icon { width: 0.875rem; height: 0.875rem; } - .code-block-copy .copy-text { + .code-block-copy .copy-text, + .code-block-wrap .wrap-text { position: absolute; width: 1px; height: 1px; @@ -311,4 +323,14 @@ background: transparent !important; padding: 0 !important; } + + .markdown-code-block[data-wrap-lines="true"] pre, + .markdown-code-block[data-wrap-lines="true"] code, + .markdown-code-block[data-wrap-lines="true"] pre.shiki, + .markdown-code-block[data-wrap-lines="true"] pre.shiki code, + .markdown-code-block[data-wrap-lines="true"] .shiki { + white-space: pre-wrap !important; + word-break: break-word; + overflow-wrap: anywhere; + } }