diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index db39394..6578038 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -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, + ) } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt new file mode 100644 index 0000000..9193343 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -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) : 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 { + val blocks = mutableListOf() + 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() + 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() + 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() + 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 -> {} + } + } + } +}