diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt
index cc4d8e02..ee5d13c9 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt
@@ -68,7 +68,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -383,9 +382,10 @@ private fun AssistantBubble(
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
- RichText(
+ MarkdownText(
text = message.content,
textColor = textColor,
+ isStreaming = message.isStreaming,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
)
@@ -2182,121 +2182,6 @@ private fun CopyButton(
}
}
-/**
- * Simple rich text renderer supporting **bold**, `inline code`, ```code blocks```, and clickable URLs.
- */
-@Composable
-private fun RichText(
- text: String,
- textColor: Color,
- searchQuery: String = "",
- isCurrentMatch: Boolean = false,
-) {
- val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") }
- val linkColor = MaterialTheme.colorScheme.primary
- val searchHighlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
- val annotated =
- remember(text, searchQuery, isCurrentMatch) {
- buildAnnotatedString {
- var i = 0
- val src = text
- while (i < src.length) {
- when {
- // Code block: ```...```
- src.startsWith("```", i) -> {
- val end = src.indexOf("```", i + 3)
- if (end != -1) {
- val code = src.substring(i + 3, end).trimStart('\n').trimEnd('\n')
- withStyle(
- SpanStyle(
- fontFamily = FontFamily.Monospace,
- fontSize = 13.sp,
- background = textColor.copy(alpha = 0.08f),
- ),
- ) {
- append(code)
- }
- i = end + 3
- } else {
- append(src[i])
- i++
- }
- }
-
- // Inline code: `...`
- src[i] == '`' -> {
- val end = src.indexOf('`', i + 1)
- if (end != -1) {
- withStyle(
- SpanStyle(
- fontFamily = FontFamily.Monospace,
- fontSize = 13.sp,
- background = textColor.copy(alpha = 0.08f),
- ),
- ) {
- append(src.substring(i + 1, end))
- }
- i = end + 1
- } else {
- append(src[i])
- i++
- }
- }
-
- // Bold: **...**
- src.startsWith("**", i) -> {
- val end = src.indexOf("**", i + 2)
- if (end != -1) {
- withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
- append(src.substring(i + 2, end))
- }
- i = end + 2
- } else {
- append(src[i])
- i++
- }
- }
-
- // URL: https://...
- urlPattern.matchAt(src, i) != null -> {
- val match = urlPattern.matchAt(src, i)!!
- pushLink(LinkAnnotation.Url(match.value))
- withStyle(
- SpanStyle(
- color = linkColor,
- textDecoration = TextDecoration.Underline,
- ),
- ) {
- append(match.value)
- }
- pop()
- i = match.range.last + 1
- }
-
- // Search match highlight
- searchQuery.isNotEmpty() &&
- src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
- withStyle(SpanStyle(background = searchHighlightColor, color = Color(0xFF1A1A24))) {
- append(src.substring(i, i + searchQuery.length))
- }
- i += searchQuery.length
- }
-
- else -> {
- append(src[i])
- i++
- }
- }
- }
- }
- }
-
- Text(
- text = annotated,
- style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
- )
-}
-
private fun formatTimestamp(
timestamp: Long,
is24Hour: Boolean,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
new file mode 100644
index 00000000..940d4b19
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt
@@ -0,0 +1,949 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.ClipData
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.outlined.CheckBox
+import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.m57.hermescontrol.R
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"‘’]+""")
+private val HIGHLIGHT_BG = Color(0xFFFFFF00).copy(alpha = 0.3f)
+private val TABLE_COL_WIDTH = 160.dp
+private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
+
+/**
+ * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
+ * While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap
+ * to the formatted view on completion (and for all historical/restored messages).
+ *
+ * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*,
+ * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, keys, headings,
+ * bullet/ordered/task lists, > blockquotes, definition lists, tables, --- rules, footnotes, and
+ * [links](url) / bare URLs. (Math is intentionally out of scope for this hand-rolled renderer.)
+ */
+@Composable
+fun MarkdownText(
+ text: String,
+ textColor: Color,
+ isStreaming: Boolean = false,
+ searchQuery: String = "",
+ isCurrentMatch: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ if (isStreaming) {
+ Text(
+ text = text,
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = modifier,
+ )
+ return
+ }
+
+ val linkColor = MaterialTheme.colorScheme.primary
+ val blocks = remember(text) { parseBlocks(text) }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ for (block in blocks) {
+ when (block) {
+ is MdBlock.Code -> CodeBlockCard(code = block.code, textColor = textColor)
+
+ is MdBlock.Hr ->
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 8.dp),
+ color = textColor.copy(alpha = 0.25f),
+ )
+
+ is MdBlock.Heading -> {
+ val fontSize =
+ when (block.level) {
+ 1 -> 22.sp
+ 2 -> 20.sp
+ 3 -> 18.sp
+ 4 -> 16.sp
+ 5 -> 15.sp
+ else -> 14.sp
+ }
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style =
+ MaterialTheme.typography.bodyMedium
+ .copy(fontSize = fontSize, fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(vertical = 2.dp),
+ )
+ }
+
+ is MdBlock.Bullet -> {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = "•",
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(end = 6.dp),
+ )
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ is MdBlock.Task -> {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Icon(
+ imageVector =
+ if (block.checked) {
+ Icons.Outlined.CheckBox
+ } else {
+ Icons.Outlined.CheckBoxOutlineBlank
+ },
+ contentDescription = null,
+ tint =
+ if (block.checked) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ textColor.copy(
+ alpha = 0.6f,
+ )
+ },
+ modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
+ )
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ is MdBlock.Ordered -> {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ text = "${block.index}.",
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(end = 6.dp),
+ )
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ is MdBlock.Quote -> {
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(3.dp)
+ .fillMaxHeight()
+ .clip(RoundedCornerShape(2.dp))
+ .background(textColor.copy(alpha = 0.35f)),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ is MdBlock.DefList -> {
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
+ block.items.forEach { item ->
+ Text(
+ text = item.term,
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
+ )
+ item.definitions.forEach { def ->
+ Text(
+ text = def,
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
+ )
+ }
+ Spacer(modifier = Modifier.height(2.dp))
+ }
+ }
+ }
+
+ is MdBlock.Table -> MarkdownTable(block = block, textColor = textColor)
+
+ is MdBlock.Footnotes -> {
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 6.dp),
+ color = textColor.copy(alpha = 0.2f),
+ )
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "Footnotes",
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
+ modifier = Modifier.padding(bottom = 2.dp),
+ )
+ block.notes.forEach { note ->
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
+ Text(
+ text = "[${note.id}] ",
+ color = textColor,
+ style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
+ )
+ Text(
+ text =
+ remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(note.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ }
+
+ is MdBlock.Paragraph -> {
+ Text(
+ text =
+ remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
+ parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
+ },
+ color = textColor,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Fenced code block rendered as a card: monospaced text with horizontal scroll and a copy button.
+ */
+@Composable
+private fun CodeBlockCard(
+ code: String,
+ textColor: Color,
+) {
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ var copied by remember { mutableStateOf(false) }
+
+ // Auto-dismiss the "copied" checkmark after 2s
+ LaunchedEffect(copied) {
+ if (copied) {
+ delay(2000)
+ copied = false
+ }
+ }
+
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = textColor.copy(alpha = 0.06f),
+ border = BorderStroke(1.dp, textColor.copy(alpha = 0.15f)),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ ) {
+ Column {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End,
+ ) {
+ IconButton(
+ onClick = {
+ scope.launch {
+ clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, code)))
+ }
+ copied = true
+ },
+ modifier = Modifier.size(28.dp),
+ ) {
+ Icon(
+ imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
+ contentDescription = stringResource(R.string.content_desc_copy),
+ modifier = Modifier.size(15.dp),
+ tint = textColor.copy(alpha = 0.7f),
+ )
+ }
+ }
+ Text(
+ text = code,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 13.sp,
+ color = textColor,
+ softWrap = false,
+ modifier =
+ Modifier
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun MarkdownTable(
+ block: MdBlock.Table,
+ textColor: Color,
+) {
+ val headerBg = textColor.copy(alpha = 0.08f)
+ val alignments = block.alignments
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(vertical = 4.dp),
+ ) {
+ // Header row
+ Row(modifier = Modifier.background(headerBg)) {
+ block.header.forEachIndexed { idx, cell ->
+ Text(
+ text = cell,
+ textAlign = tableTextAlign(alignments.getOrNull(idx)),
+ color = textColor,
+ style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
+ modifier =
+ Modifier
+ .width(TABLE_COL_WIDTH)
+ .padding(6.dp),
+ )
+ }
+ }
+ HorizontalDivider(color = textColor.copy(alpha = 0.25f))
+ // Body rows
+ block.rows.forEach { row ->
+ Row {
+ row.forEachIndexed { idx, cell ->
+ Text(
+ text = cell,
+ textAlign = tableTextAlign(alignments.getOrNull(idx)),
+ color = textColor,
+ style = MaterialTheme.typography.bodySmall,
+ modifier =
+ Modifier
+ .width(TABLE_COL_WIDTH)
+ .padding(6.dp),
+ )
+ }
+ }
+ HorizontalDivider(color = textColor.copy(alpha = 0.1f))
+ }
+ }
+}
+
+private fun tableTextAlign(align: TableAlign?): TextAlign? =
+ when (align) {
+ TableAlign.CENTER -> TextAlign.Center
+ TableAlign.RIGHT -> TextAlign.End
+ else -> null
+ }
+
+/**
+ * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
+ * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
+ */
+internal fun parseBlocks(src: String): List {
+ val lines = src.lines()
+ val blocks = mutableListOf()
+ val footnotes = mutableListOf()
+ val bulletRe = Regex("""^\s*[-*+]\s+(.*)""")
+ val taskRe = Regex("""^\s*[-*+]\s+\[([ xX])\]\s+(.*)""")
+ val orderedRe = Regex("""^\s*\d+\.\s+(.*)""")
+ var i = 0
+
+ while (i < lines.size) {
+ val line = lines[i]
+
+ // Footnote definition: [^id]: text (collected, not rendered inline)
+ val fnMatch = FN_DEF_RE.matchAt(line, 0)
+ if (fnMatch != null) {
+ footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
+ i++
+ continue
+ }
+
+ when {
+ line.startsWith("```") -> {
+ val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
+ if (end != null) {
+ blocks.add(MdBlock.Code(lines.subList(i + 1, end).joinToString("\n")))
+ i = end + 1
+ } else {
+ blocks.add(MdBlock.Code(lines.subList(i + 1, lines.size).joinToString("\n")))
+ i = lines.size
+ }
+ }
+
+ line.isBlank() -> i++
+
+ isHorizontalRule(line) -> {
+ blocks.add(MdBlock.Hr)
+ i++
+ }
+
+ // Heading: requires a space after the '#' run so "#572" stays a paragraph
+ isValidHeading(line) -> {
+ val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
+ blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
+ i++
+ }
+
+ isTableStart(lines, i) -> {
+ val (header, alignments, body) = parseTable(lines, i)
+ blocks.add(MdBlock.Table(header, alignments, body))
+ i += body.size + 2
+ }
+
+ line.startsWith(">") -> {
+ val quote = mutableListOf()
+ while (i < lines.size && lines[i].startsWith(">")) {
+ quote.add(lines[i].removePrefix(">").trim())
+ i++
+ }
+ blocks.add(MdBlock.Quote(quote.joinToString("\n")))
+ }
+
+ // Definition list: term line followed by one+ ": definition" lines
+ isDefListStart(lines, i, bulletRe, orderedRe) -> {
+ val term = line.trim()
+ val defs = mutableListOf()
+ i++
+ while (i < lines.size && lines[i].trim().startsWith(":")) {
+ defs.add(lines[i].trim().removePrefix(":").trim())
+ i++
+ }
+ blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
+ }
+
+ taskRe.matches(line) -> {
+ val m = taskRe.find(line)!!
+ blocks.add(MdBlock.Task(m.groupValues[1].equals("x", ignoreCase = true), m.groupValues[2]))
+ i++
+ }
+
+ bulletRe.matches(line) -> {
+ val items = mutableListOf()
+ while (i < lines.size && bulletRe.matches(lines[i]) && !taskRe.matches(lines[i])) {
+ items.add(bulletRe.find(lines[i])!!.groupValues[1])
+ i++
+ }
+ items.forEach { blocks.add(MdBlock.Bullet(it)) }
+ }
+
+ orderedRe.matches(line) -> {
+ val items = mutableListOf()
+ while (i < lines.size && orderedRe.matches(lines[i])) {
+ items.add(orderedRe.find(lines[i])!!.groupValues[1])
+ i++
+ }
+ items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) }
+ }
+
+ else -> {
+ i = fallthroughToParagraph(lines, i, bulletRe, orderedRe, blocks)
+ }
+ }
+ }
+
+ if (footnotes.isNotEmpty()) {
+ blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
+ }
+ return blocks
+}
+
+private fun isHorizontalRule(line: String): Boolean {
+ val trimmed = line.trim()
+ if (trimmed.length < 3) return false
+ val c = trimmed[0]
+ return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
+}
+
+private fun isTableStart(
+ lines: List,
+ i: Int,
+): Boolean {
+ val l = lines[i]
+ if (!l.contains('|')) return false
+ if (i + 1 >= lines.size) return false
+ return isTableSeparator(lines[i + 1])
+}
+
+private fun isTableSeparator(line: String): Boolean {
+ val t = line.trim().trim('|')
+ if (t.isEmpty()) return false
+ // every cell must be only dashes/colons/spaces, with at least one dash
+ return t.split('|').all { cell ->
+ val c = cell.trim()
+ c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
+ }
+}
+
+private fun parseTable(
+ lines: List,
+ i: Int,
+): Triple, List, List>> {
+ val header = splitRow(lines[i])
+ val alignments =
+ splitRow(lines[i + 1]).map { cell ->
+ val c = cell.trim()
+ when {
+ c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
+ c.endsWith(":") -> TableAlign.RIGHT
+ c.startsWith(":") -> TableAlign.LEFT
+ else -> TableAlign.LEFT
+ }
+ }
+ val body = mutableListOf>()
+ var j = i + 2
+ while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
+ body.add(splitRow(lines[j]))
+ j++
+ }
+ return Triple(header, alignments, body)
+}
+
+private fun splitRow(line: String): List {
+ val trimmed = line.trim().trim('|')
+ return trimmed.split('|').map { it.trim() }
+}
+
+private fun isDefListStart(
+ lines: List,
+ i: Int,
+ bulletRe: Regex,
+ orderedRe: Regex,
+): Boolean {
+ val l = lines[i].trim()
+ if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || l.startsWith("```")) return false
+ if (bulletRe.matches(lines[i]) || orderedRe.matches(lines[i])) return false
+ if (i + 1 >= lines.size) return false
+ return lines[i + 1].trim().startsWith(":")
+}
+
+private fun isValidHeading(line: String): Boolean {
+ if (!line.startsWith("#")) return false
+ val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
+ return level < line.length && line[level] == ' '
+}
+
+private fun fallthroughToParagraph(
+ lines: List,
+ start: Int,
+ bulletRe: Regex,
+ orderedRe: Regex,
+ blocks: MutableList,
+): Int {
+ var i = start
+ val para = mutableListOf()
+ while (
+ i < lines.size &&
+ lines[i].isNotBlank() &&
+ !lines[i].startsWith("```") &&
+ !isValidHeading(lines[i]) &&
+ !lines[i].startsWith(">") &&
+ !bulletRe.matches(lines[i]) &&
+ !orderedRe.matches(lines[i]) &&
+ !isHorizontalRule(lines[i]) &&
+ !isTableStart(lines, i) &&
+ !isDefListStart(lines, i, bulletRe, orderedRe) &&
+ FN_DEF_RE.matchAt(lines[i], 0) == null
+ ) {
+ para.add(lines[i])
+ i++
+ }
+ if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
+ return i
+}
+
+/**
+ * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***,
+ * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and
+ * bare URLs, plus search-query highlighting.
+ */
+internal fun parseInline(
+ text: String,
+ textColor: Color,
+ searchQuery: String,
+ isCurrentMatch: Boolean,
+ linkColor: Color,
+): AnnotatedString {
+ val searchHighlightColor =
+ if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
+
+ return buildAnnotatedString {
+ var i = 0
+ val src = text
+ while (i < src.length) {
+ when {
+ // ***bold italic***
+ src.startsWith("***", i) -> {
+ val end = src.indexOf("***", i + 3)
+ if (end != -1) {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
+ append(src.substring(i + 3, end))
+ }
+ i = end + 3
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // **bold**
+ src.startsWith("**", i) -> {
+ val end = src.indexOf("**", i + 2)
+ if (end != -1) {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(src.substring(i + 2, end))
+ }
+ i = end + 2
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // ~~strike~~
+ src.startsWith("~~", i) -> {
+ val end = src.indexOf("~~", i + 2)
+ if (end != -1) {
+ withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
+ append(src.substring(i + 2, end))
+ }
+ i = end + 2
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // *italic*
+ src.startsWith("*", i) -> {
+ val end = src.indexOf('*', i + 1)
+ if (end != -1 && end > i + 1) {
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+ append(src.substring(i + 1, end))
+ }
+ i = end + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // ==highlight==
+ src.startsWith("==", i) -> {
+ val end = src.indexOf("==", i + 2)
+ if (end != -1) {
+ withStyle(SpanStyle(background = HIGHLIGHT_BG)) {
+ append(src.substring(i + 2, end))
+ }
+ i = end + 2
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // ^superscript^
+ src.startsWith("^", i) -> {
+ val end = src.indexOf('^', i + 1)
+ if (end != -1 && end > i + 1) {
+ withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
+ append(src.substring(i + 1, end))
+ }
+ i = end + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // ~subscript~ (single tilde; ~~ handled above)
+ src.startsWith("~", i) -> {
+ val end = src.indexOf('~', i + 1)
+ if (end != -1 && end > i + 1) {
+ withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
+ append(src.substring(i + 1, end))
+ }
+ i = end + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // key
+ src.startsWith("", i) -> {
+ val end = src.indexOf("", i)
+ if (end != -1) {
+ withStyle(
+ SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ background = textColor.copy(alpha = 0.12f),
+ ),
+ ) {
+ append(src.substring(i + 5, end))
+ }
+ i = end + 6
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // [^ref] footnote marker -> superscript
+ src.startsWith("[^", i) -> {
+ val close = src.indexOf(']', i)
+ if (close != -1) {
+ val id = src.substring(i + 2, close)
+ withStyle(
+ SpanStyle(
+ baselineShift = BaselineShift.Superscript,
+ color = linkColor,
+ fontWeight = FontWeight.Bold,
+ ),
+ ) {
+ append("[$id]")
+ }
+ i = close + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // [text](url)
+ src.startsWith("[", i) -> {
+ val close = src.indexOf(']', i)
+ if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
+ val urlEnd = src.indexOf(')', close + 2)
+ if (urlEnd != -1) {
+ val label = src.substring(i + 1, close)
+ val url = src.substring(close + 2, urlEnd)
+ pushLink(LinkAnnotation.Url(url))
+ withStyle(
+ SpanStyle(
+ color = linkColor,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ) {
+ append(label)
+ }
+ pop()
+ i = urlEnd + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // `inline code`
+ src.startsWith("`", i) -> {
+ val end = src.indexOf('`', i + 1)
+ if (end != -1) {
+ withStyle(
+ SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 13.sp,
+ background = textColor.copy(alpha = 0.08f),
+ ),
+ ) {
+ append(src.substring(i + 1, end))
+ }
+ i = end + 1
+ } else {
+ append(src[i])
+ i++
+ }
+ }
+
+ // bare URL
+ URL_PATTERN.matchAt(src, i) != null -> {
+ val match = URL_PATTERN.matchAt(src, i)!!
+ val url = match.value
+ pushLink(LinkAnnotation.Url(url))
+ withStyle(
+ SpanStyle(
+ color = linkColor,
+ textDecoration = TextDecoration.Underline,
+ ),
+ ) {
+ append(url)
+ }
+ pop()
+ i = match.range.last + 1
+ }
+
+ // search highlight
+ searchQuery.isNotEmpty() &&
+ src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
+ withStyle(
+ SpanStyle(
+ background = searchHighlightColor,
+ color = Color(0xFF1A1A24),
+ ),
+ ) {
+ append(src.substring(i, i + searchQuery.length))
+ }
+ i += searchQuery.length
+ }
+
+ else -> {
+ append(src[i])
+ i++
+ }
+ }
+ }
+ }
+}
+
+internal sealed interface MdBlock {
+ data class Code(val code: String) : MdBlock
+
+ data class Heading(val level: Int, val text: String) : MdBlock
+
+ data class Bullet(val text: String) : MdBlock
+
+ data class Task(val checked: Boolean, val text: String) : MdBlock
+
+ data class Ordered(val index: Int, val text: String) : MdBlock
+
+ data class Quote(val text: String) : MdBlock
+
+ data class Paragraph(val text: String) : MdBlock
+
+ data class Table(
+ val header: List,
+ val alignments: List,
+ val rows: List>,
+ ) : MdBlock
+
+ object Hr : MdBlock
+
+ data class DefList(val items: List) : MdBlock
+
+ data class Footnotes(val notes: List) : MdBlock
+}
+
+internal data class DefItem(
+ val term: String,
+ val definitions: List,
+)
+
+internal data class Footnote(
+ val id: String,
+ val text: String,
+)
+
+internal data class FnNote(
+ val id: String,
+ val text: String,
+)
+
+internal enum class TableAlign {
+ LEFT,
+ CENTER,
+ RIGHT,
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
new file mode 100644
index 00000000..b43b9c03
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt
@@ -0,0 +1,168 @@
+package com.m57.hermescontrol.ui.chat
+
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572
+ * (math excluded by decision). Each test asserts the block/inline structure actually parses —
+ * not just that it compiles. Inline assertions avoid referencing Compose text types (FontWeight,
+ * BaselineShift, etc.) that aren't on the unit-test classpath; we inspect SpanStyle fields
+ * structurally instead.
+ */
+class MarkdownTextFeatureTest {
+ // 1. TABLES
+ @Test
+ fun testTable_parsesHeaderAndRows() {
+ val md =
+ """
+ | Name | Age | Role |
+ |------|:---:|-----:|
+ | Alice | 30 | dev |
+ | Bob | 25 | ops |
+ """.trimIndent()
+ val blocks = parseBlocks(md)
+ val table = blocks.singleOrNull { it is MdBlock.Table } as MdBlock.Table?
+ assertTrue("table block expected", table != null)
+ assertEquals(listOf("Name", "Age", "Role"), table!!.header)
+ assertEquals(2, table.rows.size)
+ assertEquals("Alice", table.rows[0][0])
+ assertEquals("ops", table.rows[1][2])
+ // alignment inference: center, left, right
+ assertEquals(TableAlign.CENTER, table.alignments[1])
+ assertEquals(TableAlign.RIGHT, table.alignments[2])
+ }
+
+ // 2. MATH — excluded; ensure a math-looking string stays plain, not crashed
+ @Test
+ fun testMath_isTreatedAsPlainText() {
+ val md = "E = mc^2^ and `x = (-b ± √(b²-4ac)) / 2a`"
+ val block = parseBlocks(md).single() as MdBlock.Paragraph
+ assertEquals(md, block.text)
+ }
+
+ // 3. STRIKETHROUGH
+ @Test
+ fun testStrikethrough_parses() {
+ val an = parseInline("~~gone~~", Color.Black, "", false, Color.Blue)
+ assertEquals("gone", an.toString())
+ assertTrue(an.spanStyles.any { it.item.textDecoration != null })
+ }
+
+ // 4. BOLD + ITALIC in same word (***bolditalic***)
+ @Test
+ fun testBoldItalic_combined() {
+ val an = parseInline("***both***", Color.Black, "", false, Color.Blue)
+ assertEquals("both", an.toString())
+ assertTrue(an.spanStyles.isNotEmpty())
+ }
+
+ // 5. TASK LIST / CHECKBOX
+ @Test
+ fun testTaskList_parsesCheckedAndUnchecked() {
+ val md =
+ """
+ - [x] done
+ - [ ] todo
+ """.trimIndent()
+ val blocks = parseBlocks(md)
+ assertEquals(2, blocks.size)
+ val done = blocks[0] as MdBlock.Task
+ val todo = blocks[1] as MdBlock.Task
+ assertTrue(done.checked)
+ assertFalse(todo.checked)
+ assertEquals("done", done.text)
+ assertEquals("todo", todo.text)
+ }
+
+ // 6. HORIZONTAL RULE (---)
+ @Test
+ fun testHorizontalRule_parses() {
+ val md =
+ """
+ above
+
+ ---
+
+ below
+ """.trimIndent()
+ val blocks = parseBlocks(md)
+ assertTrue(blocks.any { it is MdBlock.Hr })
+ }
+
+ // 7. FOOTNOTES
+ @Test
+ fun testFootnotes_collectedAndRendered() {
+ val md =
+ """
+ Science is cool.[^1]
+
+ [^1]: A famous claim.
+ """.trimIndent()
+ val blocks = parseBlocks(md)
+ val fn = blocks.singleOrNull { it is MdBlock.Footnotes } as MdBlock.Footnotes?
+ assertTrue(fn != null)
+ assertEquals("1", fn!!.notes[0].id)
+ assertEquals("A famous claim.", fn.notes[0].text)
+ }
+
+ // 8. DEFINITION LIST
+ @Test
+ fun testDefinitionList_parses() {
+ val md =
+ """
+ Term
+ : first definition
+ : second definition
+ """.trimIndent()
+ val dl = parseBlocks(md).singleOrNull { it is MdBlock.DefList } as MdBlock.DefList?
+ assertTrue(dl != null)
+ assertEquals("Term", dl!!.items[0].term)
+ assertEquals(2, dl.items[0].definitions.size)
+ assertEquals("first definition", dl.items[0].definitions[0])
+ }
+
+ // 9. HIGHLIGHT
+ @Test
+ fun testHighlight_parses() {
+ val an = parseInline("==mark==", Color.Black, "", false, Color.Blue)
+ assertEquals("mark", an.toString())
+ assertTrue(an.spanStyles.any { it.item.background != null })
+ }
+
+ // 10. SUPERSCRIPT / SUBSCRIPT
+ @Test
+ fun testSuperscriptAndSubscript_parse() {
+ val sup = parseInline("x^2^", Color.Black, "", false, Color.Blue)
+ assertTrue(sup.spanStyles.any { it.item.baselineShift != null })
+ val sub = parseInline("H~2~O", Color.Black, "", false, Color.Blue)
+ assertTrue(sub.spanStyles.any { it.item.baselineShift != null })
+ }
+
+ // 11. KEYBOARD KEYS
+ @Test
+ fun testKbd_parses() {
+ val an = parseInline("Press Ctrl+C", Color.Black, "", false, Color.Blue)
+ assertTrue(an.toString().contains("Ctrl"))
+ assertTrue(an.toString().contains("C"))
+ assertTrue(an.spanStyles.any { it.item.fontFamily != null })
+ }
+
+ // --- regression: issue ref "#572" must NOT become a heading ---
+ @Test
+ fun testHashRef_notHeading() {
+ val blocks = parseBlocks("#572 should stay text")
+ assertTrue(blocks.single() is MdBlock.Paragraph)
+ }
+
+ // --- regression: streaming gate handled in composable, parser must not split inline code ---
+ @Test
+ fun testInlineCode_preserved() {
+ val an = parseInline("use `val x = 1` here", Color.Black, "", false, Color.Blue)
+ assertEquals("use val x = 1 here", an.toString())
+ assertTrue(an.spanStyles.any { it.item.fontFamily != null })
+ }
+}