feat: Markdown rendering with syntax-highlighted code blocks

Native Compose Markdown renderer replaces raw Text() for assistant
messages. Supports headings, bold/italic, inline code, fenced code
blocks with keyword highlighting (15+ languages), ordered/unordered
lists. Streaming cursor integrates seamlessly into the last block.
This commit is contained in:
Bruno Deanoz 2026-06-19 17:21:01 +02:00
parent 423abc03af
commit 4616ef2ba1
2 changed files with 396 additions and 2 deletions

View file

@ -325,8 +325,10 @@ fun MessageRow(message: Message) {
if (message.thinking) {
TypingDots()
} else {
val display = if (message.streaming) message.content + "" else message.content
Text(display, color = cs.onBackground, fontSize = 16.sp, lineHeight = 23.sp)
MarkdownText(
text = message.content,
streaming = message.streaming,
)
}
}
}

View file

@ -0,0 +1,392 @@
package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
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.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private sealed interface MdBlock {
data class Paragraph(val text: String) : MdBlock
data class Heading(val level: Int, val text: String) : MdBlock
data class CodeFence(val lang: String, val code: String) : MdBlock
data class ListBlock(val items: List<ListItem>) : MdBlock
data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock
}
private data class ListItem(val text: String, val ordered: Boolean, val index: Int)
private fun parseBlocks(src: String): List<MdBlock> {
val blocks = mutableListOf<MdBlock>()
val lines = src.lines()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Code fence
if (line.trimStart().startsWith("```")) {
val lang = line.trimStart().removePrefix("```").trim()
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
if (i < lines.size) i++ // skip closing ```
blocks.add(MdBlock.CodeFence(lang, codeLines.joinToString("\n")))
continue
}
// Heading
val headingMatch = Regex("^(#{1,3})\\s+(.+)").matchEntire(line)
if (headingMatch != null) {
val level = headingMatch.groupValues[1].length
blocks.add(MdBlock.Heading(level, headingMatch.groupValues[2]))
i++
continue
}
// Unordered list item
val ulMatch = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(line)
if (ulMatch != null) {
val items = mutableListOf<ListItem>()
while (i < lines.size) {
val m = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(lines[i])
if (m != null) {
items.add(ListItem(m.groupValues[1], ordered = false, index = items.size + 1))
i++
} else break
}
blocks.add(MdBlock.ListBlock(items))
continue
}
// Ordered list item
val olMatch = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(line)
if (olMatch != null) {
val items = mutableListOf<ListItem>()
while (i < lines.size) {
val m = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(lines[i])
if (m != null) {
items.add(ListItem(m.groupValues[2], ordered = true, index = m.groupValues[1].toInt()))
i++
} else break
}
blocks.add(MdBlock.ListBlock(items))
continue
}
// Empty line — skip
if (line.isBlank()) {
i++
continue
}
// Paragraph: collect consecutive non-blank, non-special lines
val paraLines = mutableListOf(line)
i++
while (i < lines.size) {
val next = lines[i]
if (next.isBlank()
|| next.trimStart().startsWith("```")
|| Regex("^#{1,3}\\s+").containsMatchIn(next)
|| Regex("^\\s*[-*+]\\s+").containsMatchIn(next)
|| Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next)
) break
paraLines.add(next)
i++
}
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
}
return blocks
}
private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color): AnnotatedString {
return buildAnnotatedString {
var pos = 0
val src = text
while (pos < src.length) {
// Inline code: `...`
if (src[pos] == '`' && pos + 1 < src.length) {
val end = src.indexOf('`', pos + 1)
if (end > pos) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBg, fontSize = 14.sp)) {
append(src.substring(pos + 1, end))
}
pos = end + 1
continue
}
}
// Bold+italic: ***...*** or ___...___
if (pos + 2 < src.length && src.substring(pos, pos + 3).let { it == "***" || it == "___" }) {
val marker = src.substring(pos, pos + 3)
val end = src.indexOf(marker, pos + 3)
if (end > pos) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(src.substring(pos + 3, end))
}
pos = end + 3
continue
}
}
// Bold: **...** or __...__
if (pos + 1 < src.length && src.substring(pos, pos + 2).let { it == "**" || it == "__" }) {
val marker = src.substring(pos, pos + 2)
val end = src.indexOf(marker, pos + 2)
if (end > pos) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(src.substring(pos + 2, end))
}
pos = end + 2
continue
}
}
// Italic: *...* or _..._
if ((src[pos] == '*' || src[pos] == '_') && pos + 1 < src.length && src[pos + 1] != ' ') {
val marker = src[pos]
val end = src.indexOf(marker, pos + 1)
if (end > pos && end > pos + 1) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(src.substring(pos + 1, end))
}
pos = end + 1
continue
}
}
append(src[pos])
pos++
}
}
}
// Syntax-highlighting keywords per language
private val KEYWORDS = mapOf(
"kotlin" to setOf("fun", "val", "var", "class", "object", "interface", "if", "else", "when", "for", "while", "return", "import", "package", "private", "public", "internal", "protected", "override", "suspend", "data", "sealed", "enum", "companion", "null", "true", "false", "is", "in", "as", "this", "super", "try", "catch", "finally", "throw", "by", "init", "constructor", "typealias", "abstract", "open", "annotation", "inline", "operator", "infix", "tailrec", "reified", "crossinline", "noinline", "const", "lateinit", "break", "continue", "do"),
"java" to setOf("class", "public", "private", "protected", "static", "final", "void", "int", "long", "double", "float", "boolean", "char", "byte", "short", "if", "else", "for", "while", "return", "new", "import", "package", "try", "catch", "throw", "throws", "extends", "implements", "interface", "abstract", "null", "true", "false", "this", "super", "switch", "case", "default", "break", "continue", "do", "enum", "instanceof", "synchronized", "volatile", "transient", "native", "strictfp"),
"javascript" to setOf("function", "const", "let", "var", "if", "else", "for", "while", "return", "import", "export", "default", "class", "new", "this", "try", "catch", "throw", "async", "await", "null", "undefined", "true", "false", "switch", "case", "break", "continue", "do", "typeof", "instanceof", "in", "of", "from", "yield", "delete", "void", "with", "debugger", "finally", "extends", "super", "static", "get", "set"),
"typescript" to setOf("function", "const", "let", "var", "if", "else", "for", "while", "return", "import", "export", "default", "class", "new", "this", "try", "catch", "throw", "async", "await", "null", "undefined", "true", "false", "switch", "case", "break", "continue", "do", "typeof", "instanceof", "in", "of", "from", "yield", "type", "interface", "enum", "namespace", "module", "declare", "as", "is", "keyof", "readonly", "extends", "implements", "super", "static", "get", "set", "abstract", "private", "protected", "public", "override"),
"python" to setOf("def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "try", "except", "raise", "with", "pass", "break", "continue", "lambda", "yield", "del", "global", "nonlocal", "assert", "in", "not", "and", "or", "is", "None", "True", "False", "async", "await", "finally", "self"),
"bash" to setOf("if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "echo", "export", "source", "local", "readonly", "declare", "unset", "shift", "set", "true", "false", "in", "select", "until", "trap", "break", "continue"),
"sh" to setOf("if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "echo", "export", "source", "local", "readonly", "declare", "unset", "shift", "set", "true", "false", "in", "select", "until", "trap", "break", "continue"),
"sql" to setOf("SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "INTO", "VALUES", "SET", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "NULL", "IS", "IN", "AS", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", "DISTINCT", "EXISTS", "BETWEEN", "LIKE", "CASE", "WHEN", "THEN", "ELSE", "END", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CASCADE", "DEFAULT", "CONSTRAINT", "UNIQUE", "CHECK", "TRUE", "FALSE", "BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "WITH"),
"css" to setOf("color", "background", "border", "margin", "padding", "display", "position", "width", "height", "font", "text", "flex", "grid", "align", "justify", "overflow", "opacity", "transform", "transition", "animation", "content", "cursor", "visibility", "z-index"),
"html" to setOf("div", "span", "p", "a", "img", "input", "button", "form", "table", "tr", "td", "th", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "head", "body", "html", "link", "meta", "script", "style", "section", "header", "footer", "nav", "main", "article", "aside"),
"json" to setOf("true", "false", "null"),
"yaml" to setOf("true", "false", "null", "yes", "no"),
"xml" to emptySet(),
"rust" to setOf("fn", "let", "mut", "const", "if", "else", "match", "for", "while", "loop", "return", "use", "mod", "pub", "crate", "self", "super", "struct", "enum", "impl", "trait", "type", "where", "as", "in", "ref", "move", "async", "await", "true", "false", "Some", "None", "Ok", "Err", "unsafe", "extern", "dyn", "static", "macro_rules"),
"go" to setOf("func", "var", "const", "if", "else", "for", "range", "return", "import", "package", "type", "struct", "interface", "map", "chan", "go", "defer", "select", "case", "switch", "break", "continue", "default", "nil", "true", "false", "fallthrough"),
"swift" to setOf("func", "let", "var", "class", "struct", "enum", "protocol", "if", "else", "for", "while", "return", "import", "switch", "case", "break", "continue", "guard", "defer", "do", "try", "catch", "throw", "throws", "async", "await", "nil", "true", "false", "self", "Self", "init", "deinit", "extension", "subscript", "where", "in", "is", "as", "typealias", "associatedtype", "private", "public", "internal", "open", "fileprivate", "static", "override", "mutating", "weak", "unowned", "lazy", "final", "required", "convenience", "optional", "some", "any"),
)
private fun highlightCode(code: String, lang: String, isDark: Boolean): AnnotatedString {
val keywords = KEYWORDS[lang.lowercase()] ?: KEYWORDS["javascript"] ?: emptySet()
val kwColor = if (isDark) Color(0xFFFF79C6) else Color(0xFFD63384)
val strColor = if (isDark) Color(0xFFF1FA8C) else Color(0xFF22863A)
val commentColor = if (isDark) Color(0xFF6272A4) else Color(0xFF6A737D)
val numColor = if (isDark) Color(0xFFBD93F9) else Color(0xFF005CC5)
val baseColor = if (isDark) Color(0xFFF8F8F2) else Color(0xFF24292E)
return buildAnnotatedString {
var pos = 0
while (pos < code.length) {
// Line comment
if (pos + 1 < code.length && code[pos] == '/' && code[pos + 1] == '/') {
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// Hash comment
if (code[pos] == '#' && (lang.lowercase() in setOf("python", "bash", "sh", "yaml", "ruby"))) {
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// String literals
if (code[pos] == '"' || code[pos] == '\'') {
val quote = code[pos]
var end = pos + 1
while (end < code.length && code[end] != quote) {
if (code[end] == '\\' && end + 1 < code.length) end++
end++
}
if (end < code.length) end++
withStyle(SpanStyle(color = strColor)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// Numbers
if (code[pos].isDigit() && (pos == 0 || !code[pos - 1].isLetterOrDigit())) {
var end = pos
while (end < code.length && (code[end].isDigit() || code[end] == '.' || code[end] == 'x' || code[end] == 'f' || code[end] == 'L')) end++
withStyle(SpanStyle(color = numColor)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// Words (potential keywords)
if (code[pos].isLetter() || code[pos] == '_') {
var end = pos
while (end < code.length && (code[end].isLetterOrDigit() || code[end] == '_')) end++
val word = code.substring(pos, end)
if (word in keywords) {
withStyle(SpanStyle(color = kwColor, fontWeight = FontWeight.SemiBold)) {
append(word)
}
} else {
withStyle(SpanStyle(color = baseColor)) {
append(word)
}
}
pos = end
continue
}
withStyle(SpanStyle(color = baseColor)) {
append(code[pos])
}
pos++
}
}
}
@Composable
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val blocks = remember(text) { parseBlocks(text) }
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6)
val codeBorder = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
val inlineCodeBg = if (isDark) Color(0xFF1E2433) else Color(0xFFEBEDF0)
val inlineCodeColor = if (isDark) Color(0xFFE06C75) else Color(0xFFD63384)
val langBadgeColor = cs.onSurfaceVariant.copy(alpha = 0.5f)
val cursor = if (streaming) "" else ""
Column(modifier) {
blocks.forEachIndexed { idx, block ->
val isLast = idx == blocks.lastIndex
when (block) {
is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text(
text = rendered,
color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold,
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
)
Spacer(Modifier.height(4.dp))
}
is MdBlock.CodeFence -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(codeBg)
.border(1.dp, codeBorder, RoundedCornerShape(10.dp))
) {
Column {
if (block.lang.isNotBlank()) {
Text(
block.lang,
color = langBadgeColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
)
}
Box(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = if (block.lang.isNotBlank()) 6.dp else 10.dp)
) {
Text(
text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 19.sp,
)
}
}
}
Spacer(Modifier.height(6.dp))
}
is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(4.dp))
block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
val bullet = if (item.ordered) "${item.index}." else ""
Text(
bullet,
color = cs.onSurfaceVariant,
fontSize = 16.sp,
modifier = Modifier.width(if (item.ordered) 24.dp else 16.dp),
)
val appendCursor = isLast && itemIdx == block.items.lastIndex
Text(
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 23.sp,
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(4.dp))
}
is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 23.sp,
)
}
is MdBlock.Item -> {}
}
}
}
}