@@ -47,6 +47,7 @@ import androidx.compose.ui.text.LinkAnnotation
47import androidx.compose.ui.text.SpanStyle
47import androidx.compose.ui.text.SpanStyle
48import androidx.compose.ui.text.TextLinkStyles
48import androidx.compose.ui.text.TextLinkStyles
49import androidx.compose.ui.text.buildAnnotatedString
49import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
50import androidx.compose.ui.text.font.FontStyle
51import androidx.compose.ui.text.font.FontStyle
51import androidx.compose.ui.text.withLink
52import androidx.compose.ui.text.withLink
52import androidx.compose.ui.text.withStyle
53import androidx.compose.ui.text.withStyle
@@ -131,8 +132,9 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
131 SelectionContainer {
132 SelectionContainer {
132 if (message.text.isNotEmpty()) {
133 if (message.text.isNotEmpty()) {
133 val linkColor = MaterialTheme.colorScheme.primary
134 val linkColor = MaterialTheme.colorScheme.primary
135+ val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
134− val annotatedText = remember(message.text, message.streaming, linkColor) {
136+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
135− buildLinkedText(message.text, message.streaming, linkColor, context)
137+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
138 text = annotatedText,
140 text = annotatedText,
@@ -174,16 +176,39 @@ private fun copyToClipboard(context: Context, text: String) {
174private val urlRegex = Regex("""https?://[^\s]+""")
176private val urlRegex = Regex("""https?://[^\s]+""")
177− * Render [text] as an AnnotatedString where each http(s) URL becomes a clickable link
179+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
178− * that opens in the browser. Falls back to plain text when no URLs are present. The
180+ * prose and code segments: code segments are rendered monospace on a tinted
181+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
179− * streaming cursor (" ▌") is appended outside any link annotation.
182+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
181private fun buildLinkedText(
184private fun buildLinkedText(
182 text: String,
185 text: String,
183 streaming: Boolean,
186 streaming: Boolean,
184 linkColor: Color,
187 linkColor: Color,
188+ codeBackground: Color,
185 context: Context,
189 context: Context,
186): AnnotatedString = buildAnnotatedString {
190): AnnotatedString = buildAnnotatedString {
191+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
192+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
193+ val segments = text.split("```")
194+ segments.forEachIndexed { index, segment ->
195+ if (index % 2 == 1) {
196+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
200+ appendLinkedText(segment, linkColor, context)
203+ if (streaming) append(" ▌")
206+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
207+private fun AnnotatedString.Builder.appendLinkedText(
187 val matches = urlRegex.findAll(text).toList()
212 val matches = urlRegex.findAll(text).toList()
188 var cursor = 0
213 var cursor = 0
189 for (match in matches) {
214 for (match in matches) {
@@ -207,7 +232,6 @@ private fun buildLinkedText(
207 cursor = match.range.last + 1
232 cursor = match.range.last + 1
209 append(text.substring(cursor))
234 append(text.substring(cursor))
210− if (streaming) append(" ▌")
213private fun shareText(context: Context, text: String) {
237private fun shareText(context: Context, text: String) {