feat(apps): render collapsible details natively (#115641)

* feat(apps): render collapsible details natively in chat

* fix(apps): preserve reference definitions when folding disclosures

* fix(apps): keep authored details summaries unlocalized

* fix(apps): keep raw-HTML disclosure examples literal

* refactor(apps): isolate summaries and raw HTML contexts

* fix(apps): honor all CommonMark raw HTML contexts in disclosures

* chore(apps): refresh native i18n inventory for disclosure strings
This commit is contained in:
Peter Steinberger
2026-07-29 02:52:54 -04:00
committed by GitHub
parent 9daa60961c
commit 138675aeeb
7 changed files with 1802 additions and 170 deletions
+30 -14
View File
@@ -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": "<redacted secret>",
"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",
@@ -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<ChatMarkdownRenderBlock>,
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>,
) : ChatMarkdownRenderBlock
}
internal fun chatMarkdownDisclosureSummarySource(
authoredSummary: String?,
localizedDefault: () -> String,
): String = authoredSummary ?: localizedDefault()
internal fun parseChatMarkdownBlocks(text: String): List<ChatMarkdownRenderBlock> {
val document = parseChatMarkdown(text)
val tokenizer = DisclosureTokenizer()
val tokens = mutableListOf<DisclosureToken>()
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<ChatMarkdownRenderBlock> {
val blocks = mutableListOf<ChatMarkdownRenderBlock>()
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("</$tag>")
}
companion object {
fun opening(line: String): RawHtmlContext? {
val trimmed = line.trimStart()
val lowercased = trimmed.lowercase(Locale.US)
if (trimmed.startsWith("<!--")) return Comment
if (trimmed.startsWith("<?")) return ProcessingInstruction
if (trimmed.startsWith("<![CDATA[")) return Cdata
if (trimmed.length > 2 && trimmed.startsWith("<!") && trimmed[2] in 'A'..'Z') return Declaration
for (tag in listOf("pre", "script", "style", "textarea")) {
val prefix = "<$tag"
if (!lowercased.startsWith(prefix)) continue
val boundary = lowercased.getOrNull(prefix.length)
if (boundary == null || boundary.isWhitespace() || boundary == '>') return Element(tag)
}
return null
}
}
}
private val balanceStack = mutableListOf<BalanceFrame>()
fun tokenize(source: String): List<DisclosureToken> {
val lines = source.split('\n')
val tokens = mutableListOf<DisclosureToken>()
val pendingSource = StringBuilder()
var rawHtmlContext: RawHtmlContext? = null
fun flushSource() {
val markdown = pendingSource.toString()
pendingSource.clear()
if (markdown.isBlank()) return
val document = parseChatMarkdown(markdown)
var child = document.firstChild
while (child != null) {
tokens += DisclosureToken.Block(ChatMarkdownRenderBlock.CommonMark(child))
child = child.next
}
}
fun appendLiteral(raw: String) {
flushSource()
tokens += DisclosureToken.Block(ChatMarkdownRenderBlock.LiteralHtml(raw))
}
fun appendSourceLine(
line: String,
index: Int,
) {
pendingSource.append(line)
if (index < lines.lastIndex) pendingSource.append('\n')
}
lines.forEachIndexed { lineIndex, line ->
rawHtmlContext?.let { context ->
appendSourceLine(line, lineIndex)
if (context.closes(line)) rawHtmlContext = null
return@forEachIndexed
}
RawHtmlContext.opening(line)?.let { context ->
appendSourceLine(line, lineIndex)
if (!context.closes(line)) rawHtmlContext = context
return@forEachIndexed
}
val tags = tags(line)
if (tags == null) {
appendSourceLine(line, lineIndex)
return@forEachIndexed
}
var cursor = 0
var index = 0
while (index < tags.size) {
val tag = tags[index]
pendingSource.append(line, cursor, tag.range.first)
when (tag.kind) {
TagKind.DETAILS_OPEN,
TagKind.DETAILS_OPEN_EXPANDED,
-> {
flushSource()
val isStructural = balanceStack.size < CHAT_MARKDOWN_DISCLOSURE_MAX_DEPTH
if (isStructural) {
tokens += DisclosureToken.Open(tag.kind == TagKind.DETAILS_OPEN_EXPANDED)
} else {
appendLiteral(tag.raw)
}
balanceStack += BalanceFrame(isStructural = isStructural)
}
TagKind.UNSUPPORTED_DETAILS_OPEN -> {
appendLiteral(tag.raw)
balanceStack += BalanceFrame(isStructural = false)
}
TagKind.DETAILS_CLOSE,
TagKind.UNSUPPORTED_DETAILS_CLOSE,
-> {
val frame = balanceStack.removeLastOrNull()
if (frame == null) {
appendLiteral(tag.raw)
} else {
flushSource()
if (frame.isStructural && tag.kind == TagKind.DETAILS_CLOSE) {
tokens += DisclosureToken.Close
} else {
appendLiteral(tag.raw)
}
}
}
TagKind.SUMMARY_OPEN -> {
// The web block rule also pairs summary tags within one line;
// multiline summaries deliberately remain literal on every surface.
// Reference definitions resolve in bodies, not standalone summaries;
// keeping fragments isolated avoids cross-document splicing.
val closeIndex = ((index + 1) until tags.size).firstOrNull { tags[it].kind == TagKind.SUMMARY_CLOSE }
val frame = balanceStack.lastOrNull()
if (closeIndex != null && frame?.isStructural == true && !frame.hasSummary) {
flushSource()
val close = tags[closeIndex]
val summary = line.substring(tag.range.last + 1, close.range.first)
if (summary.isNotBlank()) tokens += DisclosureToken.Summary(summary)
frame.hasSummary = true
cursor = close.range.last + 1
index = closeIndex + 1
continue
}
appendLiteral(tag.raw)
}
TagKind.SUMMARY_CLOSE,
TagKind.UNSUPPORTED_SUMMARY,
-> appendLiteral(tag.raw)
}
cursor = tag.range.last + 1
index += 1
}
pendingSource.append(line, cursor, line.length)
if (lineIndex < lines.lastIndex) pendingSource.append('\n')
}
flushSource()
return tokens
}
companion object {
private val candidateLineRegex = Regex("^ {0,3}</?(?:details|summary)(?=[\\s>])", RegexOption.IGNORE_CASE)
private val tagRegex = Regex("</?(?:details|summary)(?=[\\s>])[^>]*>", RegexOption.IGNORE_CASE)
fun startsWithCandidateLine(source: String): Boolean = source.lineSequence().firstOrNull { it.isNotBlank() }?.let { tags(it) != null } ?: false
private fun tags(line: String): List<Tag>? {
if (!candidateLineRegex.containsMatchIn(line)) return null
val codeRanges = inlineCodeRanges(line)
val tags =
tagRegex
.findAll(line)
.mapNotNull { match ->
if (isEscaped(line, match.range.first) || codeRanges.any { match.range.first in it }) {
null
} else {
Tag(range = match.range, raw = match.value, kind = kind(match.value))
}
}.toList()
return tags.ifEmpty { null }
}
private fun kind(raw: String): TagKind =
when (raw.lowercase(Locale.US)) {
"<details>" -> TagKind.DETAILS_OPEN
"<details open>" -> TagKind.DETAILS_OPEN_EXPANDED
"</details>" -> TagKind.DETAILS_CLOSE
"<summary>" -> TagKind.SUMMARY_OPEN
"</summary>" -> TagKind.SUMMARY_CLOSE
else -> {
val lower = raw.lowercase(Locale.US)
when {
lower.startsWith("</details") -> TagKind.UNSUPPORTED_DETAILS_CLOSE
lower.startsWith("<details") -> TagKind.UNSUPPORTED_DETAILS_OPEN
else -> TagKind.UNSUPPORTED_SUMMARY
}
}
}
private fun isEscaped(
line: String,
index: Int,
): Boolean {
var cursor = index - 1
var count = 0
while (cursor >= 0 && line[cursor] == '\\') {
count += 1
cursor -= 1
}
return count % 2 == 1
}
private fun inlineCodeRanges(line: String): List<IntRange> {
val ranges = mutableListOf<IntRange>()
var cursor = 0
while (cursor < line.length) {
if (line[cursor] != '`') {
cursor += 1
continue
}
val openerStart = cursor
while (cursor < line.length && line[cursor] == '`') cursor += 1
val runLength = cursor - openerStart
var search = cursor
var closeEnd = -1
while (search < line.length) {
if (line[search] != '`') {
search += 1
continue
}
val closeStart = search
while (search < line.length && line[search] == '`') search += 1
if (search - closeStart == runLength) {
closeEnd = search
break
}
}
if (closeEnd >= 0) {
ranges += openerStart until closeEnd
cursor = closeEnd
}
}
return ranges
}
}
}
private fun foldDisclosureTokens(tokens: List<DisclosureToken>): List<ChatMarkdownRenderBlock> {
data class Frame(
var summary: String? = null,
val isExpanded: Boolean,
val blocks: MutableList<ChatMarkdownRenderBlock> = mutableListOf(),
)
val result = mutableListOf<ChatMarkdownRenderBlock>()
val stack = mutableListOf<Frame>()
fun append(block: ChatMarkdownRenderBlock) {
stack.lastOrNull()?.blocks?.add(block) ?: result.add(block)
}
fun closeTopFrame() {
val frame = stack.removeLastOrNull() ?: return
append(
ChatMarkdownRenderBlock.Disclosure(
summary = frame.summary,
isExpanded = frame.isExpanded,
blocks = frame.blocks,
),
)
}
tokens.forEach { token ->
when (token) {
is DisclosureToken.Block -> append(token.block)
is DisclosureToken.Open -> stack += Frame(isExpanded = token.isExpanded)
is DisclosureToken.Summary -> stack.lastOrNull()?.summary = token.source
DisclosureToken.Close -> closeTopFrame()
}
}
// Streaming commonly ends before the closing tag; fold every open frame at
// EOF so the partial body stays inside a stable expandable section.
while (stack.isNotEmpty()) closeTopFrame()
return result
}
private fun buildPlainText(start: Node?): String {
val sb = StringBuilder()
var node = start
@@ -4,10 +4,12 @@ import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import org.commonmark.node.BlockQuote
import org.commonmark.node.BulletList
import org.commonmark.node.Emphasis
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.HtmlBlock
import org.commonmark.node.Paragraph
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -16,6 +18,231 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ChatMarkdownTest {
@Test
fun detailsFoldCollapsedAndExpandedBlocks() {
val collapsed =
parseChatMarkdownBlocks(
"""
<details>
<summary>**Why**</summary>
Body
</details>
""".trimIndent(),
).single() as ChatMarkdownRenderBlock.Disclosure
val expanded =
parseChatMarkdownBlocks(
"""
<details open>
<summary>More</summary>
Body
</details>
""".trimIndent(),
).single() as ChatMarkdownRenderBlock.Disclosure
assertEquals("**Why**", collapsed.summary)
assertEquals(false, collapsed.isExpanded)
assertTrue((collapsed.blocks.single() as ChatMarkdownRenderBlock.CommonMark).node is Paragraph)
val renderedSummary = buildChatInlineMarkdown(checkNotNull(collapsed.summary))
assertEquals("Why", renderedSummary.text)
assertTrue(renderedSummary.spanStyles.any { it.item.fontWeight == FontWeight.SemiBold })
assertEquals("More", expanded.summary)
assertEquals(true, expanded.isExpanded)
}
@Test
fun authoredDetailsSummaryDoesNotUseLocalizedFallback() {
val disclosure =
parseChatMarkdownBlocks("<details>\n<summary>Details</summary>\n\nBody\n\n</details>")
.single() as ChatMarkdownRenderBlock.Disclosure
var fallbackEvaluated = false
val rendered =
chatMarkdownDisclosureSummarySource(disclosure.summary) {
fallbackEvaluated = true
"Localized details"
}
assertEquals("Details", disclosure.summary)
assertEquals("Details", rendered)
assertEquals(false, fallbackEvaluated)
}
@Test
fun detailsWithoutSummaryUseLocalizedDefaultLabel() {
val disclosure =
parseChatMarkdownBlocks("<details>\n\nBody\n\n</details>").single() as ChatMarkdownRenderBlock.Disclosure
assertNull(disclosure.summary)
assertEquals(
"Localized details",
chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" },
)
}
@Test
fun emptyDetailsSummaryAfterProseUsesLocalizedDefault() {
val blocks =
parseChatMarkdownBlocks(
"Intro\n\n<details>\n<summary></summary>\n\nBody\n\n</details>",
)
val intro = (blocks[0] as ChatMarkdownRenderBlock.CommonMark).node as Paragraph
val disclosure = blocks[1] as ChatMarkdownRenderBlock.Disclosure
assertEquals("Intro", (intro.firstChild as org.commonmark.node.Text).literal)
assertNull(disclosure.summary)
assertEquals(
"Localized details",
chatMarkdownDisclosureSummarySource(disclosure.summary) { "Localized details" },
)
}
@Test
fun detailsBodyKeepsNativeListAndFenceBlocks() {
val disclosure =
parseChatMarkdownBlocks(
"""
<details open>
<summary>Why</summary>
- first
- second
```kotlin
val value = 1
```
</details>
""".trimIndent(),
).single() as ChatMarkdownRenderBlock.Disclosure
assertTrue((disclosure.blocks[0] as ChatMarkdownRenderBlock.CommonMark).node is BulletList)
assertTrue((disclosure.blocks[1] as ChatMarkdownRenderBlock.CommonMark).node is FencedCodeBlock)
}
@Test
fun unsupportedNestedDetailsBalanceWithoutClosingOuterDisclosure() {
val blocks =
parseChatMarkdownBlocks(
"""
<details>
<summary>Outer</summary>
<details class="legacy">
unsupported body
</details>
still outer
</details>
""".trimIndent(),
)
val outer = blocks.single() as ChatMarkdownRenderBlock.Disclosure
val literals = outer.blocks.filterIsInstance<ChatMarkdownRenderBlock.LiteralHtml>().map { it.source }
assertTrue(literals.contains("<details class=\"legacy\">"))
assertTrue(literals.contains("</details>"))
val paragraphText =
outer.blocks
.filterIsInstance<ChatMarkdownRenderBlock.CommonMark>()
.mapNotNull { it.node as? Paragraph }
.mapNotNull { it.firstChild as? org.commonmark.node.Text }
.mapNotNull { it.literal }
assertTrue(paragraphText.contains("still outer"))
}
@Test
fun detailsTagsInFencedAndInlineCodeStayLiteral() {
val fenced = parseChatMarkdownBlocks("```html\n<details>\n</details>\n```").single()
val inline = parseChatMarkdownBlocks("`<details>`").single()
assertTrue((fenced as ChatMarkdownRenderBlock.CommonMark).node is FencedCodeBlock)
assertTrue((inline as ChatMarkdownRenderBlock.CommonMark).node is Paragraph)
}
@Test
fun detailsInRawHtmlContextsStayLiteral() {
val commented = "<!--\n<details>\n<summary>Example</summary>\n</details>\n-->"
val preformatted = "<pre>\n<details>\n<summary>Example</summary>\n</details>\n</pre>"
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 = "<!--\n<details>\n</details>\n-->"
val blocks =
parseChatMarkdownBlocks(
"$comment\n<details>\n<summary>After</summary>\n\nBody\n\n</details>",
)
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(
"<details>\n<summary>X</summary>\n<pre>\n</details>\n</pre>\n</details>" to "</details>",
"<details>\n<summary>X</summary>\n<!--\n</details>\n-->\n</details>" to "</details>",
"<details>\n<summary>X</summary>\n<?pi\n<details>\n?>\n</details>" to "<details>",
"<details>\n<summary>X</summary>\n<![CDATA[\n<details>\n]]>\n</details>" to "<details>",
"<details>\n<summary>X</summary>\n<!DOCTYPE\n<details>\n</details>" to "<details>",
).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 <details> after").single()
val indented = parseChatMarkdownBlocks(" <details>\n body\n </details>").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("<details open>\n<summary>Progress</summary>\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) { "<details>" }.joinToString("\n") +
"\nbody\n" +
List(depth) { "</details>" }.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<ChatMarkdownRenderBlock.LiteralHtml>().any { it.source == "<details>" })
assertTrue(blocks.filterIsInstance<ChatMarkdownRenderBlock.LiteralHtml>().any { it.source == "</details>" })
}
@Test
fun displayMathSegmentsOwnLineAndSameLineDollarBlocks() {
val sameLine = segmentChatMarkdown("before\n$$ x^2 + y^2 $$\nafter", isStreaming = false)
@@ -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
}
}
@@ -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("<!--") { return .comment }
if trimmed.hasPrefix("<?") { return .processingInstruction }
if trimmed.hasPrefix("<![CDATA[") { return .cdata }
if trimmed.count > 2,
trimmed.hasPrefix("<!"),
("A"..."Z").contains(trimmed[trimmed.index(trimmed.startIndex, offsetBy: 2)])
{
return .declaration
}
for tag in ["pre", "script", "style", "textarea"] {
let prefix = "<\(tag)"
guard lowercased.hasPrefix(prefix) else { continue }
let boundary = lowercased.index(lowercased.startIndex, offsetBy: prefix.count)
if boundary == lowercased.endIndex || lowercased[boundary].isWhitespace || lowercased[boundary] == ">" {
return .element(tag)
}
}
return nil
}
func closes(in line: String) -> Bool {
switch self {
case .comment:
line.contains("-->")
case .processingInstruction:
line.contains("?>")
case .declaration:
line.contains(">")
case .cdata:
line.contains("]]>")
case let .element(tag):
line.lowercased().contains("</\(tag)>")
}
}
}
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..<end])))
unfolded.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..<end])).map(UnfoldedBlock.block))
}
for extraction in extractions where extraction.lineRange.lowerBound >= 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<Int>) -> 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<Int>
let block: ChatMarkdownBlock
let content: Content
enum Content {
case block(ChatMarkdownBlock)
case html(String)
}
init(lineRange: Range<Int>, block: ChatMarkdownBlock) {
self.lineRange = lineRange
self.content = .block(block)
}
init(lineRange: Range<Int>, 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<String.Index>
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..<tag.range.lowerBound]
switch tag.kind {
case let .detailsOpen(isExpanded):
flushSource()
let isStructural = self.balanceStack.count < Self.maxDepth
if isStructural {
tokens.append(.disclosureOpen(isExpanded: isExpanded))
} else {
appendLiteral(tag.raw)
}
self.balanceStack.append(BalanceFrame(
isStructural: isStructural,
hasSummary: false))
case .unsupportedDetailsOpen:
appendLiteral(tag.raw)
self.balanceStack.append(BalanceFrame(
isStructural: false,
hasSummary: false))
case .detailsClose, .unsupportedDetailsClose:
guard let frame = self.balanceStack.popLast() else {
appendLiteral(tag.raw)
cursor = tag.range.upperBound
index += 1
continue
}
flushSource()
if frame.isStructural, case .detailsClose = tag.kind {
tokens.append(.disclosureClose)
} else {
appendLiteral(tag.raw)
}
case .summaryOpen:
// The web block rule also pairs summary tags within one line;
// multiline summaries deliberately remain literal on every surface.
// Reference definitions resolve in bodies, not standalone summaries;
// keeping fragments isolated avoids cross-document splicing.
let closeIndex = tags[(index + 1)...].firstIndex { candidate in
if case .summaryClose = candidate.kind { return true }
return false
}
if let closeIndex,
!self.balanceStack.isEmpty,
self.balanceStack[self.balanceStack.count - 1].isStructural,
!self.balanceStack[self.balanceStack.count - 1].hasSummary
{
flushSource()
let close = tags[closeIndex]
let summary = String(line[tag.range.upperBound..<close.range.lowerBound])
if !summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
tokens.append(.disclosureSummary(summary))
}
self.balanceStack[self.balanceStack.count - 1].hasSummary = true
cursor = close.range.upperBound
index = closeIndex + 1
continue
}
appendLiteral(tag.raw)
case .summaryClose, .unsupportedSummary:
appendLiteral(tag.raw)
}
cursor = tag.range.upperBound
index += 1
}
pendingSource += line[cursor...]
if lineIndex < lines.count - 1 { pendingSource += "\n" }
}
flushSource()
return tokens
}
private static let maxDepth = ChatMarkdownBlockSegmenter.maxDisclosureDepth
private static let tagExpression: NSRegularExpression = {
do {
return try NSRegularExpression(
pattern: #"</?(?:details|summary)(?=[\s>])[^>]*>"#,
options: [.caseInsensitive])
} catch {
preconditionFailure("invalid disclosure tag expression: \(error)")
}
}()
private static func tags(in line: String) -> [Tag]? {
guard line.range(
of: #"^ {0,3}</?(?:details|summary)(?=[\s>])"#,
options: [.regularExpression, .caseInsensitive]) != nil
else { return nil }
let codeRanges = self.inlineCodeRanges(in: line)
let fullRange = NSRange(line.startIndex..<line.endIndex, in: line)
let matches = self.tagExpression.matches(in: line, range: fullRange)
let tags = matches.compactMap { match -> 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 "<details>": return .detailsOpen(isExpanded: false)
case "<details open>": return .detailsOpen(isExpanded: true)
case "</details>": return .detailsClose
case "<summary>": return .summaryOpen
case "</summary>": return .summaryClose
default:
if lower.hasPrefix("</details") { return .unsupportedDetailsClose }
if lower.hasPrefix("<details") { return .unsupportedDetailsOpen }
return .unsupportedSummary
}
}
private static func isEscaped(_ line: String, at index: String.Index) -> 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<String.Index>] {
var ranges: [Range<String.Index>] = []
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..<closeEnd)
cursor = closeEnd
} else {
cursor = openerEnd
}
}
return ranges
}
}
private struct MathExtractionResult {
@@ -352,6 +800,11 @@ enum ChatMarkdownBlockSegmenter {
let protectedRanges: [Range<Int>]
}
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<Int>, [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..<upper, Array(replacement.markdown.utf8))
}
guard byteReplacements.count == replacements.count else { return nil }
for replacement in byteReplacements.sorted(by: { $0.0.lowerBound > $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[..<lineIndex].reduce(0) { $0 + $1.utf8.count + 1 } + columnOffset
}
func dedentedText(in range: SourceRange) -> String? {
guard let raw = self.text(in: range) else { return nil }
let startLine = range.lowerBound.line - 1
@@ -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 {
@@ -37,6 +37,324 @@ struct ChatMarkdownBlockSegmenterTests {
])
}
// MARK: - Disclosures
@Test @MainActor func `details fold collapsed and expanded blocks`() throws {
let collapsed = try #require(self.segments("""
<details>
<summary>**Why**</summary>
Body
</details>
""").first)
let expanded = try #require(self.segments("""
<details open>
<summary>More</summary>
Body
</details>
""").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: "<details>\n<summary>**Why**</summary>\n\nBody\n\n</details>",
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(
"<details>\n<summary>Details</summary>\n\nBody\n\n</details>").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("<details>\n\nBody\n\n</details>").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
<details>
<summary></summary>
Body
</details>
""",
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("""
<details open>
<summary>Why</summary>
- first
- second
```swift
let value = 1
```
</details>
""")
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]
<details>
<summary>More</summary>
Body
</details>
[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: """
<details>
<summary>More</summary>
[docs][id]
[id]: https://example.com
</details>
""",
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("""
<details>
<summary>Outer</summary>
<details class="legacy">
unsupported body
</details>
still outer
</details>
""")
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(#"<details class="legacy">"#)))
#expect(outer.blocks.contains(.prose("</details>")))
#expect(outer.blocks.contains(.prose("still outer")))
}
@Test func `details tags in code stay literal`() {
let fenced = "```html\n<details>\n<summary>Code</summary>\n</details>\n```"
#expect(self.segments(fenced) == [
.code(ChatCodeBlock(
language: "html",
code: "<details>\n<summary>Code</summary>\n</details>",
isComplete: true)),
])
#expect(self.segments("`<details>`") == [.prose("`<details>`")])
}
@Test func `details in raw HTML contexts stay literal`() {
let commented = """
<!--
<details>
<summary>Example</summary>
</details>
-->
"""
let preformatted = """
<pre>
<details>
<summary>Example</summary>
</details>
</pre>
"""
#expect(self.segments(commented) == [.prose(commented)])
#expect(self.segments(preformatted) == [.prose(preformatted)])
}
@Test func `details after an HTML comment still fold`() {
let comment = """
<!--
<details>
</details>
-->
"""
#expect(self.segments("""
\(comment)
<details>
<summary>After</summary>
Body
</details>
""") == [
.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 [
(
"<details>\n<summary>X</summary>\n<pre>\n</details>\n</pre>\n</details>",
"<pre>\n</details>\n</pre>"),
(
"<details>\n<summary>X</summary>\n<!--\n</details>\n-->\n</details>",
"<!--\n</details>\n-->"),
(
"<details>\n<summary>X</summary>\n<?pi\n<details>\n?>\n</details>",
"<?pi\n<details>\n?>"),
(
"<details>\n<summary>X</summary>\n<![CDATA[\n<details>\n]]>\n</details>",
"<![CDATA[\n<details>\n]]>"),
(
"<details>\n<summary>X</summary>\n<!DOCTYPE\n<details>\n</details>",
"<!DOCTYPE\n<details>"),
] {
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 <details> after") == [.prose("before <details> after")])
#expect(self.segments(" <details>\n body\n </details>") == [
.prose(" <details>\n body\n </details>"),
])
}
@Test func `unclosed streaming details fold available body`() {
#expect(self.segments(
"<details open>\n<summary>Progress</summary>\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: "<details>", count: depth).joined(separator: "\n")
+ "\nbody\n"
+ Array(repeating: "</details>", 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("<details>")))
#expect(blocks.contains(.prose("</details>")))
}
// MARK: - Headings
@Test func `all ATX heading levels become native blocks`() {