diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 555f35fc4bda..00718765f515 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -12201,9 +12201,17 @@ "surface": "android", "id": "native.android.d1712183ebf86197" }, + { + "kind": "ui-call", + "line": 308, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt", + "source": "Details", + "surface": "android", + "id": "native.android.64997e4b7298eb49" + }, { "kind": "ui-named-argument", - "line": 328, + "line": 428, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt", "source": "$index.", "surface": "android", @@ -12211,7 +12219,7 @@ }, { "kind": "ui-call", - "line": 725, + "line": 1206, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt", "source": "Image", "surface": "android", @@ -12219,7 +12227,7 @@ }, { "kind": "ui-call", - "line": 731, + "line": 1212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt", "source": "Image unavailable", "surface": "android", @@ -35187,7 +35195,7 @@ }, { "kind": "ui-localized-call", - "line": 11, + "line": 12, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Wake up, my friend!", "surface": "apple", @@ -35195,7 +35203,7 @@ }, { "kind": "conditional-branch", - "line": 229, + "line": 230, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "", "surface": "apple", @@ -35203,7 +35211,7 @@ }, { "kind": "conditional-branch", - "line": 288, + "line": 289, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "OpenClaw was interrupted. Restart to try again.", "surface": "apple", @@ -35211,7 +35219,7 @@ }, { "kind": "conditional-branch", - "line": 289, + "line": 290, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "The Gateway connection changed. Restart OpenClaw to reconnect.", "surface": "apple", @@ -35219,7 +35227,7 @@ }, { "kind": "ui-call", - "line": 331, + "line": 332, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "OpenClaw is working…", "surface": "apple", @@ -35227,7 +35235,7 @@ }, { "kind": "ui-call", - "line": 356, + "line": 357, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Restart", "surface": "apple", @@ -35235,7 +35243,7 @@ }, { "kind": "ui-call", - "line": 367, + "line": 368, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Enter secret…", "surface": "apple", @@ -35243,7 +35251,7 @@ }, { "kind": "ui-call", - "line": 369, + "line": 370, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Reply to OpenClaw… (yes sets everything up)", "surface": "apple", @@ -35251,7 +35259,7 @@ }, { "kind": "ui-call", - "line": 409, + "line": 410, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Skip for now", "surface": "apple", @@ -35259,7 +35267,7 @@ }, { "kind": "ui-call", - "line": 444, + "line": 445, "path": "apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift", "source": "Recommended", "surface": "apple", @@ -40209,9 +40217,17 @@ "surface": "apple", "id": "native.apple.2a03f71557a5b659" }, + { + "kind": "ui-localized-call", + "line": 284, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift", + "source": "Details", + "surface": "apple", + "id": "native.apple.15a9bdea318fdc21" + }, { "kind": "conditional-branch", - "line": 603, + "line": 735, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift", "source": "Image", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt index af114f1949ee..3c63cdded6b7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMarkdown.kt @@ -2,12 +2,14 @@ package ai.openclaw.app.ui.chat import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS import ai.openclaw.app.i18n.nativeString +import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.mobileAccent import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCodeBg import ai.openclaw.app.ui.mobileCodeText import ai.openclaw.app.ui.mobileTextSecondary +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -23,10 +25,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -85,6 +93,7 @@ import org.commonmark.node.Text as MarkdownTextNode private const val LIST_INDENT_DP = 14 private const val DATA_IMAGE_HEADER_MAX_CHARS = 64 +internal const val CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH = 32 private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$") private val markdownParser: Parser by lazy { @@ -117,9 +126,9 @@ fun ChatMarkdown( for (block in blocks) { when (block) { is ChatMarkdownSourceBlock.Markdown -> { - val document = remember(block.source) { parseChatMarkdown(block.source) } + val documentBlocks = remember(block.source) { parseChatMarkdownBlocks(block.source) } RenderMarkdownBlocks( - start = document.firstChild, + blocks = documentBlocks, textColor = textColor, inlineStyles = inlineStyles, listDepth = 0, @@ -135,121 +144,212 @@ fun ChatMarkdown( @Composable private fun RenderMarkdownBlocks( - start: Node?, + blocks: List, textColor: Color, inlineStyles: InlineStyles, listDepth: Int, isStreaming: Boolean, ) { - var node = start - while (node != null) { - val current = node - when (current) { - is Paragraph -> { - RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) - } - is Heading -> { - val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } - Text( - text = headingText, - style = headingStyle(current.level, inlineStyles.baseCallout), - color = textColor, - ) - } - is FencedCodeBlock -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock( - code = current.literal.orEmpty(), - language = current.info?.trim()?.ifEmpty { null }, - // Streaming: an unclosed fence grows on every delta, so keep it plain until the - // closing marker arrives. Finalized messages may validly end at EOF without a - // closing fence (CommonMark), so completeness comes from stream state, not syntax. - isComplete = !isStreaming || current.closingFenceLength != null, - ) - } - } - is IndentedCodeBlock -> { - SelectionContainer(modifier = Modifier.fillMaxWidth()) { - ChatCodeBlock(code = current.literal.orEmpty(), language = null) - } - } - is BlockQuote -> { - Row( - modifier = - Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top, - ) { - Box( - modifier = - Modifier - .width(2.dp) - .fillMaxHeight() - .background(mobileTextSecondary.copy(alpha = 0.35f)), - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - RenderMarkdownBlocks( - start = current.firstChild, - textColor = textColor, - inlineStyles = inlineStyles, - listDepth = listDepth, - isStreaming = isStreaming, - ) - } - } - } - is BulletList -> { - RenderBulletList( - list = current, + for (block in blocks) { + when (block) { + is ChatMarkdownRenderBlock.CommonMark -> + RenderCommonMarkBlock( + current = block.node, textColor = textColor, inlineStyles = inlineStyles, listDepth = listDepth, isStreaming = isStreaming, ) - } - is OrderedList -> { - RenderOrderedList( - list = current, + is ChatMarkdownRenderBlock.LiteralHtml -> RenderLiteralHtml(block.source, textColor) + is ChatMarkdownRenderBlock.Disclosure -> + RenderMarkdownDisclosure( + disclosure = block, textColor = textColor, inlineStyles = inlineStyles, listDepth = listDepth, isStreaming = isStreaming, ) - } - is TableBlock -> { - RenderTableBlock( - table = current, - textColor = textColor, - inlineStyles = inlineStyles, + } + } +} + +@Composable +private fun RenderCommonMarkBlock( + current: Node, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, + isStreaming: Boolean, +) { + when (current) { + is Paragraph -> { + RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) + } + is Heading -> { + val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } + Text( + text = headingText, + style = headingStyle(current.level, inlineStyles.baseCallout), + color = textColor, + ) + } + is FencedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock( + code = current.literal.orEmpty(), + language = current.info?.trim()?.ifEmpty { null }, + // Streaming: an unclosed fence grows on every delta, so keep it plain until the + // closing marker arrives. Finalized messages may validly end at EOF without a + // closing fence (CommonMark), so completeness comes from stream state, not syntax. + isComplete = !isStreaming || current.closingFenceLength != null, ) } - is ThematicBreak -> { + } + is IndentedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = null) + } + } + is BlockQuote -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { Box( modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(mobileTextSecondary.copy(alpha = 0.25f)), + .width(2.dp) + .fillMaxHeight() + .background(mobileTextSecondary.copy(alpha = 0.35f)), ) - } - is HtmlBlock -> { - val literal = current.literal.orEmpty().trim() - if (literal.isNotEmpty()) { - Text( - text = literal, - style = mobileCallout.copy(fontFamily = FontFamily.Monospace), - color = textColor, + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RenderMarkdownBlocks( + blocks = commonMarkBlocks(current.firstChild), + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + isStreaming = isStreaming, ) } } } - node = current.next + is BulletList -> { + RenderBulletList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + isStreaming = isStreaming, + ) + } + is OrderedList -> { + RenderOrderedList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + isStreaming = isStreaming, + ) + } + is TableBlock -> { + RenderTableBlock( + table = current, + textColor = textColor, + inlineStyles = inlineStyles, + ) + } + is ThematicBreak -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(mobileTextSecondary.copy(alpha = 0.25f)), + ) + } + is HtmlBlock -> { + RenderLiteralHtml(current.literal.orEmpty(), textColor) + } + } +} + +@Composable +private fun RenderLiteralHtml( + source: String, + textColor: Color, +) { + val literal = source.trim() + if (literal.isNotEmpty()) { + Text( + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) + } +} + +@Composable +private fun RenderMarkdownDisclosure( + disclosure: ChatMarkdownRenderBlock.Disclosure, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, + isStreaming: Boolean, +) { + var isExpanded by rememberSaveable { mutableStateOf(disclosure.isExpanded) } + val summarySource = chatMarkdownDisclosureSummarySource(disclosure.summary) { nativeString("Details") } + val summary = + remember(summarySource, inlineStyles.linkColor) { + buildChatInlineMarkdown(summarySource, linkColor = inlineStyles.linkColor) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Surface( + onClick = { isExpanded = !isExpanded }, + shape = RoundedCornerShape(8.dp), + color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f), + contentColor = ClawTheme.colors.textMuted, + border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.6f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isExpanded) "▾" else "▸", + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + text = summary, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + ) + } + } + + if (isExpanded) { + Column( + modifier = Modifier.padding(start = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RenderMarkdownBlocks( + blocks = disclosure.blocks, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + isStreaming = isStreaming, + ) + } + } } } @@ -372,7 +472,7 @@ private fun RenderListItem( verticalArrangement = Arrangement.spacedBy(4.dp), ) { RenderMarkdownBlocks( - start = contentStart, + blocks = commonMarkBlocks(contentStart), textColor = textColor, inlineStyles = inlineStyles, listDepth = listDepth + 1, @@ -635,6 +735,387 @@ internal fun buildChatInlineMarkdown( internal fun parseChatMarkdown(text: String): Document = markdownParser.parse(text) as Document +internal sealed interface ChatMarkdownRenderBlock { + data class CommonMark( + val node: Node, + ) : ChatMarkdownRenderBlock + + data class LiteralHtml( + val source: String, + ) : ChatMarkdownRenderBlock + + data class Disclosure( + val summary: String?, + val isExpanded: Boolean, + val blocks: List, + ) : ChatMarkdownRenderBlock +} + +internal fun chatMarkdownDisclosureSummarySource( + authoredSummary: String?, + localizedDefault: () -> String, +): String = authoredSummary ?: localizedDefault() + +internal fun parseChatMarkdownBlocks(text: String): List { + val document = parseChatMarkdown(text) + val tokenizer = DisclosureTokenizer() + val tokens = mutableListOf() + var node = document.firstChild + while (node != null) { + val current = node + if (current is HtmlBlock && DisclosureTokenizer.startsWithCandidateLine(current.literal.orEmpty())) { + tokens += tokenizer.tokenize(current.literal.orEmpty()) + } else { + tokens += DisclosureToken.Block(ChatMarkdownRenderBlock.CommonMark(current)) + } + node = current.next + } + return foldDisclosureTokens(tokens) +} + +private fun commonMarkBlocks(start: Node?): List { + val blocks = mutableListOf() + var node = start + while (node != null) { + blocks += ChatMarkdownRenderBlock.CommonMark(node) + node = node.next + } + return blocks +} + +private sealed interface DisclosureToken { + data class Block( + val block: ChatMarkdownRenderBlock, + ) : DisclosureToken + + data class Open( + val isExpanded: Boolean, + ) : DisclosureToken + + data class Summary( + val source: String, + ) : DisclosureToken + + data object Close : DisclosureToken +} + +private class DisclosureTokenizer { + private data class BalanceFrame( + val isStructural: Boolean, + var hasSummary: Boolean = false, + ) + + private enum class TagKind { + DETAILS_OPEN, + DETAILS_OPEN_EXPANDED, + DETAILS_CLOSE, + SUMMARY_OPEN, + SUMMARY_CLOSE, + UNSUPPORTED_DETAILS_OPEN, + UNSUPPORTED_DETAILS_CLOSE, + UNSUPPORTED_SUMMARY, + } + + private data class Tag( + val range: IntRange, + val raw: String, + val kind: TagKind, + ) + + // Disclosure scanning pauses inside CommonMark raw HTML block types 1-5; + // their contents stay literal until the matching terminator. + private sealed interface RawHtmlContext { + fun closes(line: String): Boolean + + data object Comment : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains("-->") + } + + data object ProcessingInstruction : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains("?>") + } + + data object Declaration : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains('>') + } + + data object Cdata : RawHtmlContext { + override fun closes(line: String): Boolean = line.contains("]]>") + } + + data class Element( + val tag: String, + ) : RawHtmlContext { + override fun closes(line: String): Boolean = line.lowercase(Locale.US).contains("") + } + + companion object { + fun opening(line: String): RawHtmlContext? { + val trimmed = line.trimStart() + val lowercased = trimmed.lowercase(Locale.US) + if (trimmed.startsWith("" + val preformatted = "
\n
\nExample\n
\n
" + + assertTrue((parseChatMarkdownBlocks(commented).single() as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + assertTrue((parseChatMarkdownBlocks(preformatted).single() as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + } + + @Test + fun detailsAfterHtmlCommentStillFold() { + val comment = "" + val blocks = + parseChatMarkdownBlocks( + "$comment\n
\nAfter\n\nBody\n\n
", + ) + + assertEquals(2, blocks.size) + assertTrue((blocks[0] as ChatMarkdownRenderBlock.CommonMark).node is HtmlBlock) + assertEquals("After", (blocks[1] as ChatMarkdownRenderBlock.Disclosure).summary) + } + + @Test + fun rawHtmlCloseMarkersInsideDetailsStayLiteral() { + listOf( + "
\nX\n
\n
\n\n" to "", + "
\nX\n\n
" to "", + "
\nX\n\n?>\n
" to "
", + "
\nX\n\n]]>\n
" to "
", + "
\nX\n\n
" to "
", + ).forEach { (source, literalClose) -> + val blocks = parseChatMarkdownBlocks(source) + val disclosure = blocks.single() as ChatMarkdownRenderBlock.Disclosure + val rawBlock = (disclosure.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node as HtmlBlock + + assertTrue(rawBlock.literal.contains(literalClose)) + } + } + + @Test + fun midLineAndOverIndentedDetailsStayLiteral() { + val midLine = parseChatMarkdownBlocks("before
after").single() + val indented = parseChatMarkdownBlocks("
\n body\n
").single() + + assertTrue((midLine as ChatMarkdownRenderBlock.CommonMark).node is Paragraph) + val indentedNode = (indented as ChatMarkdownRenderBlock.CommonMark).node + assertTrue(indentedNode is org.commonmark.node.IndentedCodeBlock) + } + + @Test + fun unclosedStreamingDetailsFoldAvailableBody() { + val disclosure = + parseChatMarkdownBlocks("
\nProgress\n\n- first") + .single() as ChatMarkdownRenderBlock.Disclosure + + assertEquals("Progress", disclosure.summary) + assertEquals(true, disclosure.isExpanded) + assertTrue((disclosure.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node is BulletList) + } + + @Test + fun detailsNestingStopsAtDepthCap() { + val depth = CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH + 1 + val markdown = + List(depth) { "
" }.joinToString("\n") + + "\nbody\n" + + List(depth) { "
" }.joinToString("\n") + var blocks = parseChatMarkdownBlocks(markdown) + var structuralDepth = 0 + while (blocks.singleOrNull() is ChatMarkdownRenderBlock.Disclosure) { + structuralDepth += 1 + blocks = (blocks.single() as ChatMarkdownRenderBlock.Disclosure).blocks + } + + assertEquals(CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH, structuralDepth) + assertTrue(blocks.filterIsInstance().any { it.source == "
" }) + assertTrue(blocks.filterIsInstance().any { it.source == "
" }) + } + @Test fun displayMathSegmentsOwnLineAndSameLineDollarBlocks() { val sameLine = segmentChatMarkdown("before\n$$ x^2 + y^2 $$\nafter", isStreaming = false) diff --git a/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift b/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift index 66dff3025603..99c7b9f21ad8 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingSystemAgentChat.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import OpenClawChatUI import OpenClawKit import SwiftUI @@ -474,9 +475,11 @@ private struct SystemAgentChatBubble: View { if self.message.role == .user { Spacer(minLength: 40) } - Text(self.attributedText) - .font(.callout) - .textSelection(.enabled) + OpenClawChatMarkdownView( + text: self.message.text, + isUserMessage: self.message.role == .user, + variant: .compact, + textColor: .primary) .padding(.horizontal, 10) .padding(.vertical, 7) .background( @@ -489,28 +492,4 @@ private struct SystemAgentChatBubble: View { } } } - - private var attributedText: AttributedString { - // OpenClaw replies use light markdown (headings, bold, backticks). - // Parse per line so multi-line replies keep their structure. - var result = AttributedString() - let lines = self.message.text.split(separator: "\n", omittingEmptySubsequences: false) - for (index, line) in lines.enumerated() { - var text = String(line) - var isHeading = false - if text.hasPrefix("## ") { - text = String(text.dropFirst(3)) - isHeading = true - } - var piece = (try? AttributedString(markdown: text)) ?? AttributedString(text) - if isHeading { - piece.font = .headline - } - result.append(piece) - if index < lines.count - 1 { - result.append(AttributedString("\n")) - } - } - return result - } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift index 9d647f3b4db2..027619ac46d9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift @@ -10,9 +10,16 @@ enum ChatMarkdownBlock: Equatable { case math(ChatMathBlock) case table(ChatMarkdownTable) case list(ChatMarkdownList) + case disclosure(ChatMarkdownDisclosure) case thematicBreak } +struct ChatMarkdownDisclosure: Equatable { + let summary: String? + let isExpanded: Bool + let blocks: [ChatMarkdownBlock] +} + struct ChatMarkdownHeading: Equatable { let level: Int /// Keep the complete source so Swift's Markdown parser continues to own @@ -92,6 +99,54 @@ enum ChatMarkdownBlockSyntax { } } +/// Disclosure scanning pauses inside CommonMark raw HTML block types 1-5; +/// their contents stay literal until the matching terminator. +private enum ChatMarkdownRawHTMLContext { + case comment + case processingInstruction + case declaration + case cdata + case element(String) + + static func opening(in line: String) -> ChatMarkdownRawHTMLContext? { + let trimmed = line.drop(while: \.isWhitespace) + let lowercased = trimmed.lowercased() + if trimmed.hasPrefix("") + case .processingInstruction: + line.contains("?>") + case .declaration: + line.contains(">") + case .cdata: + line.contains("]]>") + case let .element(tag): + line.lowercased().contains("") + } + } +} + enum ChatMarkdownBlockSegmenter { static let maxMathBytes = 5000 static let maxTableBytes = 20000 @@ -101,13 +156,13 @@ enum ChatMarkdownBlockSegmenter { static let maxListBytes = 20000 static let maxListItems = 100 static let maxListDepth = 6 + static let maxDisclosureDepth = 32 /// Extracts top-level structural blocks. The parser owns CommonMark /// container and reference semantics; nested list content stays attached /// to its owning list item. static func segments(markdown: String, isComplete: Bool) -> [ChatMarkdownBlock] { - let source = SourceBuffer(markdown) - let document = Document(parsing: source.markdown) + let (source, document) = self.parsedSourcePreservingDisclosureReferences(markdown) let mathResult = self.mathExtractions( source: source, document: document, @@ -120,6 +175,11 @@ enum ChatMarkdownBlockSegmenter { continue } + if let html = self.disclosureHTML(child, source: source, lineRange: lineRange) { + extractions.append(Extraction(lineRange: lineRange, html: html)) + continue + } + if let heading = child as? Markdown.Heading { extractions.append(Extraction( lineRange: lineRange, @@ -199,21 +259,32 @@ enum ChatMarkdownBlockSegmenter { return left.lineRange.upperBound > right.lineRange.upperBound } - var blocks: [ChatMarkdownBlock] = [] + var unfolded: [UnfoldedBlock] = [] + var disclosureTokenizer = DisclosureTokenizer() var proseStart = 0 func appendProse(until end: Int) { guard proseStart < end else { return } - blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..= proseStart { appendProse(until: extraction.lineRange.lowerBound) - blocks.append(extraction.block) + switch extraction.content { + case let .block(block): + unfolded.append(.block(block)) + case let .html(html): + unfolded.append(contentsOf: disclosureTokenizer.tokenize( + html, + parseMarkdown: { markdown in + self.segments(markdown: markdown, isComplete: isComplete).map(UnfoldedBlock.block) + })) + } proseStart = extraction.lineRange.upperBound } appendProse(until: source.lines.count) + let blocks = self.foldDisclosures(unfolded) let reparsesListContent = blocks.contains { block in if case .list = block { return true } return false @@ -325,11 +396,7 @@ enum ChatMarkdownBlockSegmenter { private static func containsReferenceLink(_ document: Document, source: SourceBuffer) -> Bool { func search(_ markup: any Markup) -> Bool { - if markup is Markdown.Link || markup is Markdown.Image, - let range = markup.range, - let raw = source.text(in: range)?.trimmingCharacters(in: .whitespacesAndNewlines), - raw.hasSuffix("]") - { + if self.isReferenceLink(markup, source: source) { return true } return markup.children.contains(where: search) @@ -337,13 +404,394 @@ enum ChatMarkdownBlockSegmenter { return search(document) } + private static func parsedSourcePreservingDisclosureReferences( + _ markdown: String) -> (SourceBuffer, Document) + { + let source = SourceBuffer(markdown) + let document = Document(parsing: source.markdown) + guard self.containsDisclosure(in: document, source: source), + let resolved = self.resolvingReferenceLinks(in: document, source: source) + else { return (source, document) } + + let resolvedSource = SourceBuffer(resolved) + return (resolvedSource, Document(parsing: resolvedSource.markdown)) + } + + private static func containsDisclosure(in document: Document, source: SourceBuffer) -> Bool { + document.children.contains { child in + guard let lineRange = source.lineRange(for: child.range) else { return false } + return self.disclosureHTML(child, source: source, lineRange: lineRange) != nil + } + } + + private static func resolvingReferenceLinks(in document: Document, source: SourceBuffer) -> String? { + var replacements: [SourceReplacement] = [] + + func collect(_ markup: any Markup) { + if self.isReferenceLink(markup, source: source), + let range = markup.range + { + replacements.append(SourceReplacement(range: range, markdown: markup.format())) + return + } + for child in markup.children { + collect(child) + } + } + + collect(document) + guard !replacements.isEmpty else { return nil } + return source.replacing(replacements) + } + + private static func isReferenceLink(_ markup: any Markup, source: SourceBuffer) -> Bool { + guard markup is Markdown.Link || markup is Markdown.Image, + let range = markup.range, + let raw = source.text(in: range)?.trimmingCharacters(in: .whitespacesAndNewlines) + else { return false } + return raw.hasSuffix("]") + } + private static func dropStructuralCodeNewline(_ code: String) -> String { code.hasSuffix("\n") ? String(code.dropLast()) : code } + private static func disclosureHTML( + _ child: any Markup, + source: SourceBuffer, + lineRange: Range) -> String? + { + guard child is Markdown.HTMLBlock else { return nil } + let html = source.text(in: lineRange) + return DisclosureTokenizer.startsWithCandidateLine(html) ? html : nil + } + + private static func foldDisclosures(_ unfolded: [UnfoldedBlock]) -> [ChatMarkdownBlock] { + struct Frame { + var summary: String? + let isExpanded: Bool + var blocks: [ChatMarkdownBlock] = [] + } + + var result: [ChatMarkdownBlock] = [] + var stack: [Frame] = [] + + func append(_ block: ChatMarkdownBlock) { + if stack.isEmpty { + result.append(block) + } else { + stack[stack.count - 1].blocks.append(block) + } + } + + func closeTopFrame() { + guard let frame = stack.popLast() else { return } + append(.disclosure(ChatMarkdownDisclosure( + summary: frame.summary, + isExpanded: frame.isExpanded, + blocks: frame.blocks))) + } + + for token in unfolded { + switch token { + case let .block(block): + append(block) + case let .disclosureOpen(isExpanded): + stack.append(Frame(summary: nil, isExpanded: isExpanded)) + case let .disclosureSummary(summary): + if !stack.isEmpty { + stack[stack.count - 1].summary = summary + } + case .disclosureClose: + closeTopFrame() + } + } + + // The model streams the opener before the closer. Treat EOF as an + // implicit close so each delta remains a usable disclosure hierarchy. + while !stack.isEmpty { + closeTopFrame() + } + return result + } + private struct Extraction { let lineRange: Range - let block: ChatMarkdownBlock + let content: Content + + enum Content { + case block(ChatMarkdownBlock) + case html(String) + } + + init(lineRange: Range, block: ChatMarkdownBlock) { + self.lineRange = lineRange + self.content = .block(block) + } + + init(lineRange: Range, html: String) { + self.lineRange = lineRange + self.content = .html(html) + } + } + + private enum UnfoldedBlock { + case block(ChatMarkdownBlock) + case disclosureOpen(isExpanded: Bool) + case disclosureSummary(String) + case disclosureClose + } + + private struct DisclosureTokenizer { + private struct BalanceFrame { + let isStructural: Bool + var hasSummary: Bool + } + + private enum TagKind { + case detailsOpen(isExpanded: Bool) + case detailsClose + case summaryOpen + case summaryClose + case unsupportedDetailsOpen + case unsupportedDetailsClose + case unsupportedSummary + } + + private struct Tag { + let range: Range + let raw: String + let kind: TagKind + } + + private var balanceStack: [BalanceFrame] = [] + + static func startsWithCandidateLine(_ source: String) -> Bool { + let firstContentLine = source + .split(separator: "\n", omittingEmptySubsequences: false) + .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + return firstContentLine.map { Self.tags(in: String($0)) != nil } ?? false + } + + mutating func tokenize( + _ source: String, + parseMarkdown: (String) -> [UnfoldedBlock]) -> [UnfoldedBlock] + { + let lines = source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var tokens: [UnfoldedBlock] = [] + var pendingSource = "" + var rawHTMLContext: ChatMarkdownRawHTMLContext? + + func flushSource() { + let trimmed = pendingSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + pendingSource = "" + return + } + tokens.append(contentsOf: parseMarkdown(pendingSource)) + pendingSource = "" + } + + func appendLiteral(_ raw: String) { + flushSource() + tokens.append(.block(.prose(raw))) + } + + func appendSourceLine(_ line: String, at index: Int) { + pendingSource += line + if index < lines.count - 1 { pendingSource += "\n" } + } + + for (lineIndex, line) in lines.enumerated() { + if let context = rawHTMLContext { + appendSourceLine(line, at: lineIndex) + if context.closes(in: line) { rawHTMLContext = nil } + continue + } + if let context = ChatMarkdownRawHTMLContext.opening(in: line) { + appendSourceLine(line, at: lineIndex) + if !context.closes(in: line) { rawHTMLContext = context } + continue + } + guard let tags = Self.tags(in: line) else { + appendSourceLine(line, at: lineIndex) + continue + } + + var cursor = line.startIndex + var index = 0 + while index < tags.count { + let tag = tags[index] + pendingSource += line[cursor..])[^>]*>"#, + options: [.caseInsensitive]) + } catch { + preconditionFailure("invalid disclosure tag expression: \(error)") + } + }() + + private static func tags(in line: String) -> [Tag]? { + guard line.range( + of: #"^ {0,3}])"#, + options: [.regularExpression, .caseInsensitive]) != nil + else { return nil } + + let codeRanges = self.inlineCodeRanges(in: line) + let fullRange = NSRange(line.startIndex.. Tag? in + guard let range = Range(match.range, in: line), + !self.isEscaped(line, at: range.lowerBound), + !codeRanges.contains(where: { $0.contains(range.lowerBound) }) + else { return nil } + let raw = String(line[range]) + return Tag(range: range, raw: raw, kind: self.kind(of: raw)) + } + return tags.isEmpty ? nil : tags + } + + private static func kind(of raw: String) -> TagKind { + let lower = raw.lowercased() + switch lower { + case "
": return .detailsOpen(isExpanded: false) + case "
": return .detailsOpen(isExpanded: true) + case "
": return .detailsClose + case "": return .summaryOpen + case "": return .summaryClose + default: + if lower.hasPrefix(" Bool { + var cursor = index + var count = 0 + while cursor > line.startIndex { + let previous = line.index(before: cursor) + guard line[previous] == "\\" else { break } + count += 1 + cursor = previous + } + return count.isMultiple(of: 2) == false + } + + private static func inlineCodeRanges(in line: String) -> [Range] { + var ranges: [Range] = [] + var cursor = line.startIndex + while cursor < line.endIndex { + guard line[cursor] == "`" else { + cursor = line.index(after: cursor) + continue + } + let openerStart = cursor + var openerEnd = cursor + while openerEnd < line.endIndex, line[openerEnd] == "`" { + openerEnd = line.index(after: openerEnd) + } + let runLength = line.distance(from: openerStart, to: openerEnd) + var search = openerEnd + var closeEnd: String.Index? + while search < line.endIndex { + guard line[search] == "`" else { + search = line.index(after: search) + continue + } + let closeStart = search + while search < line.endIndex, line[search] == "`" { + search = line.index(after: search) + } + if line.distance(from: closeStart, to: search) == runLength { + closeEnd = search + break + } + } + if let closeEnd { + ranges.append(openerStart..] } + private struct SourceReplacement { + let range: SourceRange + let markdown: String + } + private struct MathDelimiter { let close: String let sameLineLatex: String? @@ -526,6 +979,32 @@ enum ChatMarkdownBlockSegmenter { return ([first] + middle + [last]).joined(separator: "\n") } + func replacing(_ replacements: [SourceReplacement]) -> String? { + var bytes = Array(self.markdown.utf8) + let byteReplacements = replacements.compactMap { replacement -> (Range, [UInt8])? in + guard let lower = self.utf8Offset(for: replacement.range.lowerBound), + let upper = self.utf8Offset(for: replacement.range.upperBound), + lower <= upper + else { return nil } + return (lower.. $1.0.lowerBound }) { + bytes.replaceSubrange(replacement.0, with: replacement.1) + } + return String(bytes: bytes, encoding: .utf8) + } + + private func utf8Offset(for location: SourceLocation) -> Int? { + let lineIndex = location.line - 1 + let columnOffset = location.column - 1 + guard self.lines.indices.contains(lineIndex), + columnOffset >= 0, + columnOffset <= self.lines[lineIndex].utf8.count + else { return nil } + return self.lines[.. String? { guard let raw = self.text(in: range) else { return nil } let startLine = range.lowerBound.line - 1 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift index 5585d014faad..1a631758a0d8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift @@ -7,6 +7,47 @@ public enum ChatMarkdownVariant: String, CaseIterable, Sendable { case compact } +func chatMarkdownDisclosureSummarySource( + _ authoredSummary: String?, + localizedDefault: () -> String) -> String +{ + authoredSummary ?? localizedDefault() +} + +/// Shared native Markdown rendering for app-owned chat surfaces outside the +/// full OpenClaw chat transcript. +@MainActor +public struct OpenClawChatMarkdownView: View { + private let text: String + private let isUserMessage: Bool + private let variant: ChatMarkdownVariant + private let textColor: Color + private let isComplete: Bool + + public init( + text: String, + isUserMessage: Bool, + variant: ChatMarkdownVariant = .standard, + textColor: Color, + isComplete: Bool = true) + { + self.text = text + self.isUserMessage = isUserMessage + self.variant = variant + self.textColor = textColor + self.isComplete = isComplete + } + + public var body: some View { + ChatMarkdownRenderer( + text: self.text, + context: self.isUserMessage ? .user : .assistant, + variant: self.variant, + textColor: self.textColor, + isComplete: self.isComplete) + } +} + @MainActor struct ChatMarkdownRenderer: View { enum Context { @@ -145,6 +186,13 @@ struct ChatMarkdownRenderer: View { ChatMarkdownTableView(table: table) case let .list(list): self.listView(list) + case let .disclosure(disclosure): + ChatMarkdownDisclosureView( + disclosure: disclosure, + context: self.context, + variant: self.variant, + typography: self.typography, + textColor: self.textColor) case .thematicBreak: Divider() .accessibilityHidden(true) @@ -192,35 +240,63 @@ struct ChatMarkdownRenderSnapshot { let processed = ChatMarkdownPreprocessor.preprocess(markdown: text) self.blocks = ChatMarkdownBlockSegmenter.segments( markdown: processed.cleaned, - isComplete: isComplete).map { block in - switch block { - case let .prose(markdown): - .prose(ChatMarkdownProse( - markdown: markdown, - isComplete: isComplete, - preparesReveal: preparesReveal)) - case let .heading(heading): - .heading( - level: heading.level, - prose: ChatMarkdownProse( - markdown: heading.markdown, - isComplete: isComplete, - preparesReveal: false)) - case let .code(code): - .code(code) - case let .math(math): - .math(math) - case let .table(table): - .table(table) - case let .list(list): - .list(list) - case .thematicBreak: - .thematicBreak - } + isComplete: isComplete).map { + Self.renderedBlock($0, isComplete: isComplete, preparesReveal: preparesReveal) } self.images = processed.images } + init(blocks: [ChatMarkdownRenderedBlock], images: [ChatMarkdownPreprocessor.InlineImage] = []) { + self.blocks = blocks + self.images = images + } + + private static func renderedBlock( + _ block: ChatMarkdownBlock, + isComplete: Bool, + preparesReveal: Bool) -> ChatMarkdownRenderedBlock + { + switch block { + case let .prose(markdown): + .prose(ChatMarkdownProse( + markdown: markdown, + isComplete: isComplete, + preparesReveal: preparesReveal)) + case let .heading(heading): + .heading( + level: heading.level, + prose: ChatMarkdownProse( + markdown: heading.markdown, + isComplete: isComplete, + preparesReveal: false)) + case let .code(code): + .code(code) + case let .math(math): + .math(math) + case let .table(table): + .table(table) + case let .list(list): + .list(list) + case let .disclosure(disclosure): + .disclosure(ChatMarkdownRenderedDisclosure( + summary: ChatMarkdownProse( + markdown: chatMarkdownDisclosureSummarySource(disclosure.summary) { + String(localized: "Details") + }, + isComplete: true, + preparesReveal: false), + isExpanded: disclosure.isExpanded, + blocks: disclosure.blocks.map { + Self.renderedBlock( + $0, + isComplete: isComplete, + preparesReveal: preparesReveal) + })) + case .thematicBreak: + .thematicBreak + } + } + var lastProseIndex: Int? { self.blocks.lastIndex { if case .prose = $0 { @@ -238,9 +314,65 @@ enum ChatMarkdownRenderedBlock { case math(ChatMathBlock) case table(ChatMarkdownTable) case list(ChatMarkdownList) + case disclosure(ChatMarkdownRenderedDisclosure) case thematicBreak } +struct ChatMarkdownRenderedDisclosure { + let summary: ChatMarkdownProse + let isExpanded: Bool + let blocks: [ChatMarkdownRenderedBlock] +} + +@MainActor +private struct ChatMarkdownDisclosureView: View { + let disclosure: ChatMarkdownRenderedDisclosure + let context: ChatMarkdownRenderer.Context + let variant: ChatMarkdownVariant + let typography: ChatMarkdownRenderer.Typography + let textColor: Color + + @State private var isExpanded: Bool + + init( + disclosure: ChatMarkdownRenderedDisclosure, + context: ChatMarkdownRenderer.Context, + variant: ChatMarkdownVariant, + typography: ChatMarkdownRenderer.Typography, + textColor: Color) + { + self.disclosure = disclosure + self.context = context + self.variant = variant + self.typography = typography + self.textColor = textColor + self._isExpanded = State(initialValue: disclosure.isExpanded) + } + + var body: some View { + DisclosureGroup(isExpanded: self.$isExpanded) { + ChatMarkdownRenderer( + snapshot: ChatMarkdownRenderSnapshot(blocks: self.disclosure.blocks), + context: self.context, + variant: self.variant, + typography: self.typography, + textColor: self.textColor) + .padding(.top, self.variant == .compact ? 4 : 6) + } label: { + ChatMarkdownRenderer( + snapshot: ChatMarkdownRenderSnapshot(blocks: [.prose(self.disclosure.summary)]), + context: self.context, + variant: self.variant, + typography: self.typography, + textColor: self.textColor) + .accessibilityLabel( + self.disclosure.summary.inlineAccessibilityText + ?? String(self.disclosure.summary.attributed.characters)) + } + .tint(self.context == .user ? self.textColor : OpenClawChatTheme.accent) + } +} + @MainActor struct ChatMarkdownProse { struct TailPiece { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift index 7f5467071b56..56b8402e3007 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift @@ -37,6 +37,324 @@ struct ChatMarkdownBlockSegmenterTests { ]) } + // MARK: - Disclosures + + @Test @MainActor func `details fold collapsed and expanded blocks`() throws { + let collapsed = try #require(self.segments(""" +
+ **Why** + + Body + +
+ """).first) + let expanded = try #require(self.segments(""" +
+ More + + Body + +
+ """).first) + + #expect(collapsed == .disclosure(ChatMarkdownDisclosure( + summary: "**Why**", + isExpanded: false, + blocks: [.prose("Body")]))) + #expect(expanded == .disclosure(ChatMarkdownDisclosure( + summary: "More", + isExpanded: true, + blocks: [.prose("Body")]))) + + let snapshot = ChatMarkdownRenderSnapshot( + text: "
\n**Why**\n\nBody\n\n
", + isComplete: true) + guard case let .disclosure(rendered) = try #require(snapshot.blocks.first) else { + Issue.record("expected rendered disclosure") + return + } + #expect(String(rendered.summary.attributed.characters) == "Why") + #expect(rendered.summary.attributed.runs.contains { + $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true + }) + } + + @Test func `authored Details summary does not use localized fallback`() throws { + guard case let .disclosure(disclosure) = try #require(self.segments( + "
\nDetails\n\nBody\n\n
").first) + else { + Issue.record("expected disclosure") + return + } + var fallbackEvaluated = false + let rendered = chatMarkdownDisclosureSummarySource(disclosure.summary) { + fallbackEvaluated = true + return "Localized details" + } + + #expect(disclosure.summary == "Details") + #expect(rendered == "Details") + #expect(!fallbackEvaluated) + } + + @Test func `details without summary use localized default label`() throws { + guard case let .disclosure(disclosure) = try #require( + self.segments("
\n\nBody\n\n
").first) + else { + Issue.record("expected disclosure") + return + } + + #expect(disclosure.summary == nil) + #expect(chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" } + == "Localized details") + } + + @Test @MainActor func `empty details summary after prose uses localized default`() throws { + let snapshot = ChatMarkdownRenderSnapshot( + text: """ + Intro + +
+ + + Body + +
+ """, + isComplete: true) + guard case let .prose(intro) = try #require(snapshot.blocks.first), + case let .disclosure(disclosure) = try #require(snapshot.blocks.dropFirst().first) + else { + Issue.record("expected intro followed by disclosure") + return + } + + #expect(String(intro.attributed.characters) == "Intro") + #expect(String(disclosure.summary.attributed.characters) == String(localized: "Details")) + } + + @Test func `details body keeps native list and fence blocks`() throws { + let blocks = self.segments(""" +
+ Why + + - first + - second + + ```swift + let value = 1 + ``` + +
+ """) + guard case let .disclosure(disclosure) = try #require(blocks.first) else { + Issue.record("expected disclosure block") + return + } + + #expect(disclosure.blocks.count == 2) + guard case let .list(list) = disclosure.blocks[0] else { + Issue.record("expected native list in disclosure") + return + } + #expect(list.items.count == 2) + #expect(disclosure.blocks[1] == .code(ChatCodeBlock( + language: "swift", + code: "let value = 1", + isComplete: true))) + } + + @Test @MainActor func `details preserve document scoped reference links`() throws { + let destination = try #require(URL(string: "https://example.com")) + let snapshot = ChatMarkdownRenderSnapshot( + text: """ + [docs][id] + +
+ More + + Body + +
+ + [id]: https://example.com + """, + isComplete: true) + guard case let .prose(prose) = try #require(snapshot.blocks.first), + case .disclosure = try #require(snapshot.blocks.dropFirst().first) + else { + Issue.record("expected resolved prose followed by a disclosure") + return + } + #expect(prose.attributed.runs.contains { $0.link == destination }) + + let nestedSnapshot = ChatMarkdownRenderSnapshot( + text: """ +
+ More + + [docs][id] + + [id]: https://example.com + +
+ """, + isComplete: true) + guard case let .disclosure(disclosure) = try #require(nestedSnapshot.blocks.first), + case let .prose(nestedProse) = try #require(disclosure.blocks.first) + else { + Issue.record("expected resolved prose inside the disclosure") + return + } + #expect(nestedProse.attributed.runs.contains { $0.link == destination }) + } + + @Test func `unsupported nested details balance without closing outer disclosure`() throws { + let blocks = self.segments(""" +
+ Outer + +
+ + unsupported body + +
+ + still outer + +
+ """) + guard case let .disclosure(outer) = try #require(blocks.first) else { + Issue.record("expected outer disclosure") + return + } + + #expect(blocks.count == 1) + #expect(outer.blocks.contains(.prose(#"
"#))) + #expect(outer.blocks.contains(.prose("
"))) + #expect(outer.blocks.contains(.prose("still outer"))) + } + + @Test func `details tags in code stay literal`() { + let fenced = "```html\n
\nCode\n
\n```" + #expect(self.segments(fenced) == [ + .code(ChatCodeBlock( + language: "html", + code: "
\nCode\n
", + isComplete: true)), + ]) + #expect(self.segments("`
`") == [.prose("`
`")]) + } + + @Test func `details in raw HTML contexts stay literal`() { + let commented = """ + + """ + let preformatted = """ +
+        
+ Example +
+
+ """ + + #expect(self.segments(commented) == [.prose(commented)]) + #expect(self.segments(preformatted) == [.prose(preformatted)]) + } + + @Test func `details after an HTML comment still fold`() { + let comment = """ + + """ + #expect(self.segments(""" + \(comment) +
+ After + + Body + +
+ """) == [ + .prose(comment), + .disclosure(ChatMarkdownDisclosure( + summary: "After", + isExpanded: false, + blocks: [.prose("Body")])), + ]) + } + + @Test func `raw HTML close markers inside details stay literal`() throws { + for (markdown, literal) in [ + ( + "
\nX\n
\n
\n\n
", + "
\n
\n"), + ( + "
\nX\n\n
", + ""), + ( + "
\nX\n\n?>\n
", + "\n?>"), + ( + "
\nX\n\n]]>\n
", + "\n]]>"), + ( + "
\nX\n\n
", + ""), + ] { + let blocks = self.segments(markdown) + guard blocks.count == 1, + case let .disclosure(disclosure) = try #require(blocks.first) + else { + Issue.record("expected one outer disclosure") + continue + } + #expect(disclosure.blocks == [.prose(literal)]) + } + } + + @Test func `mid line and over indented details stay literal`() { + #expect(self.segments("before
after") == [.prose("before
after")]) + #expect(self.segments("
\n body\n
") == [ + .prose("
\n body\n
"), + ]) + } + + @Test func `unclosed streaming details fold available body`() { + #expect(self.segments( + "
\nProgress\n\n- first", + isComplete: false) == [ + .disclosure(ChatMarkdownDisclosure( + summary: "Progress", + isExpanded: true, + blocks: [.prose("- first")])), + ]) + } + + @Test func `details nesting stops at depth cap`() { + let depth = ChatMarkdownBlockSegmenter.maxDisclosureDepth + 1 + let markdown = Array(repeating: "
", count: depth).joined(separator: "\n") + + "\nbody\n" + + Array(repeating: "
", count: depth).joined(separator: "\n") + var blocks = self.segments(markdown) + var structuralDepth = 0 + while case let .disclosure(disclosure)? = blocks.first { + structuralDepth += 1 + blocks = disclosure.blocks + } + + #expect(structuralDepth == ChatMarkdownBlockSegmenter.maxDisclosureDepth) + #expect(blocks.contains(.prose("
"))) + #expect(blocks.contains(.prose("
"))) + } + // MARK: - Headings @Test func `all ATX heading levels become native blocks`() {