mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
9daa60961c
commit
138675aeeb
@@ -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`() {
|
||||
|
||||
Reference in New Issue
Block a user