Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc
This commit is contained in:
1 parent
a8920d8d26
commit
f8effb4152
6 files changed
+330
-177
No files matched your search
@@ -10,6 +10,10 @@ val composeVersion = "1.7.3"
|
||||
val coroutinesVersion = "1.9.0"
|
||||
val serializationVersion = "1.7.3"
|
||||
val okhttpVersion = "4.12.0"
|
||||
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
|
||||
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
|
||||
// and the -code module for language-aware syntax highlighting (Highlights).
|
||||
val markdownVersion = "0.33.0"
|
||||
|
||||
kotlin {
|
||||
androidTarget()
|
||||
@@ -27,11 +31,23 @@ kotlin {
|
||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
|
||||
// Full Material icon set (Icons.Filled.ContentCopy for the code
|
||||
// copy buttons); -core only ships a small subset.
|
||||
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
||||
// OkHttp is a JVM library; both targets here are JVM-based
|
||||
// (androidTarget + jvm("desktop")), so it resolves on both.
|
||||
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
|
||||
// Markdown rendering for chat bubbles (M8): core parser/renderer,
|
||||
// Material 3 themed defaults, and the -code module for syntax
|
||||
// highlighting of fenced code blocks (Highlights).
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||
androidMain.dependencies {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import iris.ui.theme.IrisColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
|
||||
/**
|
||||
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
||||
* tables, task-list checkboxes, and fenced code blocks with language-aware
|
||||
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||
* rendered text blends in.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkdownText(
|
||||
text: String,
|
||||
color: Color,
|
||||
fontSize: TextUnit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberMarkdownState(text)
|
||||
// The app is always dark-themed, so force the dark highlight palette
|
||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||
val highlights = remember {
|
||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||
}
|
||||
val base = TextStyle(color = color, fontSize = fontSize)
|
||||
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
|
||||
// Inline-code span style (mirrors the library's codeSpanStyle): the
|
||||
// monospace inlineCode typography plus the chip background.
|
||||
val codeSpanStyle =
|
||||
base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle()
|
||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||
// visible feedback (the clipboard write itself is silent).
|
||||
val copiedCodeSpanStyle =
|
||||
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||
.toSpanStyle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// The text of the inline code most recently copied (null when idle). Read
|
||||
// inside the annotator so a change re-renders the chip with the flash.
|
||||
val copiedCode = remember { mutableStateOf<String?>(null) }
|
||||
// The default CODE_SPAN handler pads inline code with a leading and a
|
||||
// trailing space, which doubled the whitespace around `code` mid-line.
|
||||
// Re-render it without the padding, and wrap it in a link so tapping the
|
||||
// inline code copies it to the clipboard (Telegram-style). The
|
||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||
val annotator = markdownAnnotator(
|
||||
annotate = { content, child ->
|
||||
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
||||
val children = child.children
|
||||
// Drop the surrounding backtick tokens (present as first/last child).
|
||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
|
||||
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
|
||||
pushStyle(spanStyle)
|
||||
withLink(
|
||||
LinkAnnotation.Url(
|
||||
url = "iris:copy-code",
|
||||
// Keep every interaction state identical to the chip so
|
||||
// hover/press never restyles the inline code.
|
||||
styles = TextLinkStyles(
|
||||
style = spanStyle,
|
||||
focusedStyle = spanStyle,
|
||||
hoveredStyle = spanStyle,
|
||||
pressedStyle = spanStyle,
|
||||
),
|
||||
linkInteractionListener = LinkInteractionListener {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copiedCode.value = code
|
||||
scope.launch {
|
||||
delay(600)
|
||||
copiedCode.value = null
|
||||
}
|
||||
},
|
||||
),
|
||||
) {
|
||||
append(code)
|
||||
}
|
||||
pop()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
Markdown(
|
||||
markdownState = state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = markdownColor(
|
||||
text = color,
|
||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||
inlineCodeBackground = inlineCodeBackground,
|
||||
dividerColor = IrisColors.divider,
|
||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||
),
|
||||
typography = markdownTypography(
|
||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||
h4 = base.copy(fontWeight = FontWeight.Bold),
|
||||
h5 = base.copy(fontWeight = FontWeight.Bold),
|
||||
h6 = base.copy(fontWeight = FontWeight.Bold),
|
||||
text = base,
|
||||
code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace),
|
||||
inlineCode = base.copy(fontFamily = FontFamily.Monospace),
|
||||
quote = base.copy(fontStyle = FontStyle.Italic),
|
||||
paragraph = base,
|
||||
ordered = base,
|
||||
bullet = base,
|
||||
list = base,
|
||||
link = base.copy(textDecoration = TextDecoration.Underline),
|
||||
table = base.copy(fontSize = fontSize * 0.95f),
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
},
|
||||
codeBlock = { model ->
|
||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
},
|
||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||
// Material 3 checkbox instead.
|
||||
checkbox = {
|
||||
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||
},
|
||||
),
|
||||
loading = { m ->
|
||||
// While (re)parsing — e.g. on each streaming update — show the raw
|
||||
// text so the bubble never goes blank between updates.
|
||||
Text(text, modifier = m, color = color, fontSize = fontSize)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a highlighted code block with a copy button in the top-right corner
|
||||
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeBlockWithCopy(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
highlights: Highlights.Builder,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Box {
|
||||
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
|
||||
if (code.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||
// match that so the button sits inside the block, not above it.
|
||||
.padding(top = 8.dp, end = 4.dp)
|
||||
.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = Color.White.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ import iris.protocol.ROLE_USER
|
||||
import iris.protocol.SearchHit
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.MarkdownText
|
||||
import iris.ui.rightClick
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
@@ -131,6 +132,8 @@ import iris.ui.theme.contrastText
|
||||
import iris.util.formatDayLabel
|
||||
import iris.util.formatTime
|
||||
import iris.util.localDayKey
|
||||
import iris.util.prepareForMarkdown
|
||||
import iris.util.preserveNewlinesAsHardBreaks
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -1910,11 +1913,14 @@ private fun MessageBubble(
|
||||
}
|
||||
if (isUser) {
|
||||
// M7: text + inline timestamp / delivery status at the end
|
||||
// (reference look); the bubble hugs its content.
|
||||
// (reference look); the bubble hugs its content. User text is
|
||||
// rendered as markdown too (bold / italic / inline code), so a
|
||||
// pasted snippet or emphasis shows up the same as in replies.
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
if (msg.text.isNotBlank() || msg.streaming) {
|
||||
Text(
|
||||
msg.text + if (msg.streaming) " ▉" else "",
|
||||
MarkdownText(
|
||||
text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||
if (msg.streaming) " ▉" else "",
|
||||
color = textColor,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
@@ -1945,10 +1951,16 @@ private fun MessageBubble(
|
||||
}
|
||||
} else {
|
||||
if (msg.text.isNotBlank() || msg.streaming) {
|
||||
Text(
|
||||
msg.text + if (msg.streaming) " ▉" else "",
|
||||
// M8: render the agent's reply as markdown (bold / italic /
|
||||
// underscore, tables, highlighted code blocks). Leading
|
||||
// newlines are stripped so the text hugs the top of the
|
||||
// bubble; the ▉ cursor is kept while streaming.
|
||||
val displayText = msg.text.prepareForMarkdown() + if (msg.streaming) " ▉" else ""
|
||||
MarkdownText(
|
||||
text = displayText,
|
||||
color = textColor,
|
||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* Drops the leading newlines / blank lines a model reply often starts with, so
|
||||
* the rendered text hugs the top of the bubble instead of leaving a blank gap.
|
||||
* Leading indentation on the first real line is preserved, so an indented code
|
||||
* block at the very start of a message still parses as code.
|
||||
*/
|
||||
fun String.prepareForMarkdown(): String {
|
||||
val lines = split("\n")
|
||||
var i = 0
|
||||
while (i < lines.size && lines[i].isBlank()) i++
|
||||
return lines.drop(i).joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the single newlines a user typed into markdown hard breaks (two
|
||||
* trailing spaces) so they render as line breaks instead of being collapsed
|
||||
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
|
||||
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
|
||||
* code content is not altered; blank lines are kept as-is since they already
|
||||
* produce paragraph breaks.
|
||||
*/
|
||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||
val lines = split("\n")
|
||||
var inCodeBlock = false
|
||||
val out = lines.map { line ->
|
||||
val trimmed = line.trimStart()
|
||||
when {
|
||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||
inCodeBlock = !inCodeBlock
|
||||
line
|
||||
}
|
||||
inCodeBlock || line.isBlank() -> line
|
||||
else -> line + " "
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MarkdownTest {
|
||||
|
||||
@Test
|
||||
fun stripsLeadingNewlines() {
|
||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||
assertEquals("Hello", "\nHello".prepareForMarkdown())
|
||||
assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsLeadingBlankLines() {
|
||||
assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown())
|
||||
assertEquals("a\nb", "\n\na\nb".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesLeadingIndentationOnFirstLine() {
|
||||
// An indented code block at the very start must still parse as code.
|
||||
assertEquals(" code", "\n\n code".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesInteriorWhitespaceUntouched() {
|
||||
val md = "Line one\n\nLine two\n\n- item"
|
||||
assertEquals(md, md.prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLeadingNewlinesIsUnchanged() {
|
||||
assertEquals("Hello world", "Hello world".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allBlankBecomesEmpty() {
|
||||
assertEquals("", "\n\n \n".prepareForMarkdown())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user