feat(chat): render <details>/<summary> blocks as collapsible sections
markdown-it runs with html: false, so raw HTML in a message body is escaped and shown as literal text. Agents legitimately use <details>/<summary> to hide the long tail of a reply, and it rendered as a wall of visible tags. MarkdownRenderer now splits the message into plain segments and <details> blocks, renders the plain segments through markdown-it as before, and builds real <details> elements from the blocks. Nested blocks are handled by recursion; an unclosed tag does not swallow the rest of the message. The html option stays false on purpose: enabling it would let model output inject arbitrary markup into v-html. Every fragment still goes through markdown-it, so nothing unsanitised reaches the DOM. Group chat shares this component, so it picks the change up too.
This commit is contained in:
@@ -8,6 +8,7 @@ import katex from 'katex'
|
||||
import markdownItKatex from '@vscode/markdown-it-katex'
|
||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
||||
import { splitDetails } from './markdownDetails'
|
||||
import {
|
||||
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
|
||||
MERMAID_MAX_SOURCE_LENGTH,
|
||||
@@ -143,8 +144,28 @@ function hasExtension(path: string, extensions: Set<string>): boolean {
|
||||
return !!ext && extensions.has(ext)
|
||||
}
|
||||
|
||||
// Render markdown, but turn <details>/<summary> blocks into real collapsible
|
||||
// elements. markdown-it runs with `html: false`, so raw HTML in the message
|
||||
// body would otherwise be escaped and shown as literal text. Every fragment
|
||||
// still goes through markdown-it, so nothing unsanitised reaches v-html.
|
||||
function renderWithDetails(content: string): string {
|
||||
const segments = splitDetails(content)
|
||||
if (segments.length === 1 && segments[0].kind === 'text') {
|
||||
return md.render(content)
|
||||
}
|
||||
|
||||
return segments.map((segment) => {
|
||||
if (segment.kind === 'text') {
|
||||
return md.render(segment.value)
|
||||
}
|
||||
const summaryHtml = md.renderInline(segment.block.summary.trim())
|
||||
const bodyHtml = renderWithDetails(segment.block.body)
|
||||
return `<details class="markdown-details"><summary class="markdown-details-summary">${summaryHtml}</summary><div class="markdown-details-body">${bodyHtml}</div></details>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
let html = md.render(repairNestedMarkdownFences(props.content))
|
||||
let html = renderWithDetails(repairNestedMarkdownFences(props.content))
|
||||
|
||||
// Add IDs to headings for anchor links
|
||||
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
|
||||
@@ -585,6 +606,46 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-details {
|
||||
margin: 8px 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $code-bg;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-details-summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
user-select: none;
|
||||
list-style: revert;
|
||||
display: list-item;
|
||||
|
||||
&::marker {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--bg-card-rgb), 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-details-body {
|
||||
padding: 8px 12px 4px;
|
||||
border-top: 1px solid $border-color;
|
||||
color: $text-primary;
|
||||
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-video-container {
|
||||
margin: 12px 0;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Collapsible <details>/<summary> blocks inside markdown messages.
|
||||
//
|
||||
// markdown-it runs with `html: false`, so raw HTML in a message body is
|
||||
// escaped and shown as literal text. That is the right default for LLM
|
||||
// output (no script injection), but it also kills the one HTML construct
|
||||
// agents legitimately use to hide the long tail of a reply.
|
||||
//
|
||||
// This module splits the raw message into plain text segments and
|
||||
// <details> blocks. The caller renders the text segments through
|
||||
// markdown-it as usual and substitutes the details blocks with real
|
||||
// <details> HTML built from markdown-rendered inner content. Everything
|
||||
// that ends up in the DOM still went through markdown-it, so no unsanitised
|
||||
// model output reaches v-html.
|
||||
|
||||
export interface DetailsBlock {
|
||||
summary: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type DetailsSegment =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'details'; block: DetailsBlock }
|
||||
|
||||
const OPEN_TAG = /<details\b[^>]*>/i
|
||||
const SUMMARY_TAG = /<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/i
|
||||
const TAG_SCAN = /<details\b[^>]*>|<\/details\s*>/gi
|
||||
|
||||
/** Marker that survives markdown rendering untouched, one per details block. */
|
||||
export function detailsToken(index: number): string {
|
||||
return `hermes-details-block-${index}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `src` into plain text and <details> segments.
|
||||
*
|
||||
* Nested <details> blocks are supported: the inner blocks end up inside the
|
||||
* outer block body and are split again when that body is rendered.
|
||||
*/
|
||||
export function splitDetails(src: string): DetailsSegment[] {
|
||||
const segments: DetailsSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < src.length) {
|
||||
const rest = src.slice(cursor)
|
||||
const open = OPEN_TAG.exec(rest)
|
||||
if (!open) {
|
||||
segments.push({ kind: 'text', value: src.slice(cursor) })
|
||||
break
|
||||
}
|
||||
|
||||
const openIndex = cursor + open.index
|
||||
if (openIndex > cursor) {
|
||||
segments.push({ kind: 'text', value: src.slice(cursor, openIndex) })
|
||||
}
|
||||
|
||||
const innerStart = openIndex + open[0].length
|
||||
const innerEnd = findMatchingClose(src, innerStart)
|
||||
|
||||
const inner = src.slice(innerStart, innerEnd)
|
||||
const summaryMatch = SUMMARY_TAG.exec(inner)
|
||||
const summary = summaryMatch ? summaryMatch[1] : ''
|
||||
const body = summaryMatch
|
||||
? inner.slice(0, summaryMatch.index) + inner.slice(summaryMatch.index + summaryMatch[0].length)
|
||||
: inner
|
||||
|
||||
segments.push({ kind: 'details', block: { summary, body } })
|
||||
|
||||
cursor = innerEnd + '</details>'.length
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `</details>` that closes the block opened before `from`.
|
||||
* Returns src.length when the tag is missing, so an unclosed block still
|
||||
* renders instead of swallowing the rest of the message.
|
||||
*/
|
||||
function findMatchingClose(src: string, from: number): number {
|
||||
let depth = 1
|
||||
TAG_SCAN.lastIndex = from
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = TAG_SCAN.exec(src)) !== null) {
|
||||
if (match[0][1] === '/') {
|
||||
depth -= 1
|
||||
if (depth === 0) return match.index
|
||||
} else {
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
return src.length
|
||||
}
|
||||
Reference in New Issue
Block a user