@@ -3,8 +3,6 @@ package soy.iko.opencode.ui.components
33import android.content.ClipData
44import android.content.ClipboardManager
55import android.content.Context
6−import android.content.Intent
7−import android.net.Uri
86import android.widget.Toast
97import androidx.compose.foundation.ExperimentalFoundationApi
108import androidx.compose.foundation.background
@@ -15,7 +13,6 @@ import androidx.compose.foundation.layout.Column
1513import androidx.compose.foundation.layout.Row
1614import androidx.compose.foundation.layout.fillMaxWidth
1715import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
1916import androidx.compose.foundation.layout.widthIn
2017import androidx.compose.foundation.shape.RoundedCornerShape
2118import androidx.compose.foundation.text.selection.SelectionContainer
@@ -26,9 +23,6 @@ import androidx.compose.material.icons.filled.ErrorOutline
2623import androidx.compose.material.icons.filled.ExpandLess
2724import androidx.compose.material.icons.filled.ExpandMore
2825import androidx.compose.material.icons.filled.Schedule
29−import androidx.compose.material.icons.filled.SmartToy
30−import androidx.compose.material3.DropdownMenu
31−import androidx.compose.material3.DropdownMenuItem
3226import androidx.compose.material3.Icon
3327import androidx.compose.material3.MaterialTheme
3428import androidx.compose.material3.Text
@@ -42,17 +36,10 @@ import androidx.compose.ui.Modifier
4236import androidx.compose.ui.draw.clip
4337import androidx.compose.ui.graphics.Color
4438import androidx.compose.ui.platform.LocalContext
45−import androidx.compose.ui.text.AnnotatedString
46−import androidx.compose.ui.text.LinkAnnotation
4739import androidx.compose.ui.text.SpanStyle
48−import androidx.compose.ui.text.TextLinkStyles
4940import androidx.compose.ui.text.buildAnnotatedString
50−import androidx.compose.ui.text.font.FontFamily
5141import androidx.compose.ui.text.font.FontStyle
52−import androidx.compose.ui.text.font.FontWeight
53−import androidx.compose.ui.text.withLink
5442import androidx.compose.ui.text.withStyle
55−import androidx.compose.ui.text.style.TextDecoration
5643import androidx.compose.ui.text.style.TextOverflow
5744import androidx.compose.ui.unit.dp
5845import androidx.compose.ui.unit.sp
@@ -65,7 +52,7 @@ private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
6552
6653@OptIn(ExperimentalFoundationApi::class)
6754@Composable
68−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
55+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}) {
6956 val isUser = message.isUser
7057 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
7158 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
@@ -78,20 +65,11 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
7865 )
7966
8067 val context = LocalContext.current
81− var showMenu by remember { mutableStateOf(false) }
8268
8369 Row(
84− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
70+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
8571 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
8672 ) {
87− if (!isUser) {
88− Icon(
89− Icons.Filled.SmartToy,
90− contentDescription = null,
91− tint = MaterialTheme.colorScheme.primary,
92− modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93− )
94− }
9573 Column(
9674 modifier = Modifier
9775 .fillMaxWidth(0.85f)
@@ -102,55 +80,21 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
10280 onClick = {},
10381 onLongClick = {
10482 if (message.text.isNotEmpty()) {
105− showMenu = true
83+ copyToClipboard(context, message.text)
84+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
10685 }
10786 },
10887 )
10988 .padding(horizontal = 14.dp, vertical = 10.dp),
11089 ) {
111− DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112− DropdownMenuItem(
113− text = { Text("Copy") },
114− onClick = {
115− copyToClipboard(context, message.text)
116− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117− showMenu = false
118− },
119− )
120− DropdownMenuItem(
121− text = { Text("Share") },
122− onClick = {
123− shareText(context, message.text)
124− showMenu = false
125− },
126− )
127− }
128−
12990 message.reasoning?.let { ReasoningBlock(it, context) }
13091
131− // Give a quick overview of how many tools ran before listing them.
132− if (message.tools.size > 1) {
133− Text(
134− text = "${message.tools.size} tool calls",
135− style = MaterialTheme.typography.labelSmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− modifier = Modifier.padding(bottom = 4.dp),
138− )
139− }
140−
14192 message.tools.forEach { ToolRow(it) }
14293
14394 SelectionContainer {
14495 if (message.text.isNotEmpty()) {
145− val linkColor = MaterialTheme.colorScheme.primary
146− // Tint the code background off the bubble's own foreground colour so it
147− // contrasts on both bubble types (user bubbles use primaryContainer).
148− val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149− val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150− buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151− }
15296 Text(
153− text = annotatedText,
97+ text = message.text + if (message.streaming) " ▌" else "",
15498 color = textColor,
15599 // Give assistant replies (often long, code-heavy) extra line
156100 // spacing for readability; user bubbles keep the default.
@@ -186,170 +130,6 @@ private fun copyToClipboard(context: Context, text: String) {
186130 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187131}
188132
189−private val urlRegex = Regex("""https?://[^\s]+""")
190−
191−/**
192− * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193− * prose and code segments: code segments are rendered monospace on a tinted
194− * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195− * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196− */
197−private fun buildLinkedText(
198− text: String,
199− streaming: Boolean,
200− linkColor: Color,
201− codeBackground: Color,
202− context: Context,
203−): AnnotatedString = buildAnnotatedString {
204− // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205− // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206− val segments = text.split("```")
207− segments.forEachIndexed { index, segment ->
208− if (index % 2 == 1) {
209− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210− append(segment)
211− }
212− } else {
213− appendLinkedText(segment, linkColor, codeBackground, context)
214− }
215− }
216− if (streaming) append(" ▌")
217−}
218−
219−/**
220− * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221− * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222− * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223− * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224− * inline spans reach here.
225− */
226−private fun AnnotatedString.Builder.appendLinkedText(
227− text: String,
228− linkColor: Color,
229− codeBackground: Color,
230− context: Context,
231−) {
232− // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233− // inline code and links inside it. Odd-indexed segments sit between a pair of **
234− // markers, so they're bold (markers dropped); even segments are ordinary prose.
235− // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236− // segment on "*" would misfire on the leftover asterisks from the ** markers.
237− val segments = text.split("**")
238− // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239− // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240− val unclosed = segments.size % 2 == 0
241− segments.forEachIndexed { index, segment ->
242− val isUnclosedTail = unclosed && index == segments.size - 1
243− if (index % 2 == 1 && !isUnclosedTail) {
244− withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245− appendInlineCode(segment, linkColor, codeBackground, context)
246− }
247− } else {
248− if (isUnclosedTail) append("**")
249− appendItalicText(segment, linkColor, codeBackground, context)
250− }
251− }
252−}
253−
254−/**
255− * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256− * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257− * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258− */
259−private fun AnnotatedString.Builder.appendItalicText(
260− text: String,
261− linkColor: Color,
262− codeBackground: Color,
263− context: Context,
264−) {
265− // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266− // (markers dropped); even segments are ordinary prose.
267− val segments = text.split("*")
268− // An even segment count means an odd number of * markers, so the final * is unclosed:
269− // its trailing segment isn't italic. Restore the literal * and render it as prose.
270− val unclosed = segments.size % 2 == 0
271− segments.forEachIndexed { index, segment ->
272− val isUnclosedTail = unclosed && index == segments.size - 1
273− if (index % 2 == 1 && !isUnclosedTail) {
274− withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275− appendInlineCode(segment, linkColor, codeBackground, context)
276− }
277− } else {
278− if (isUnclosedTail) append("*")
279− appendInlineCode(segment, linkColor, codeBackground, context)
280− }
281− }
282−}
283−
284−/**
285− * Append [text] to the builder, rendering single-backtick pairs as inline code
286− * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287− * into a clickable link.
288− */
289−private fun AnnotatedString.Builder.appendInlineCode(
290− text: String,
291− linkColor: Color,
292− codeBackground: Color,
293− context: Context,
294−) {
295− // Odd-indexed segments sit between a pair of single backticks, so they're inline
296− // code. Even segments are ordinary prose and get URL linking applied.
297− val segments = text.split("`")
298− // An even segment count means an odd number of backtick markers, so the final backtick
299− // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300− val unclosed = segments.size % 2 == 0
301− segments.forEachIndexed { index, segment ->
302− val isUnclosedTail = unclosed && index == segments.size - 1
303− if (index % 2 == 1 && !isUnclosedTail) {
304− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305− append(segment)
306− }
307− } else {
308− if (isUnclosedTail) append("`")
309− appendUrls(segment, linkColor, context)
310− }
311− }
312−}
313−
314−/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315−private fun AnnotatedString.Builder.appendUrls(
316− text: String,
317− linkColor: Color,
318− context: Context,
319−) {
320− val matches = urlRegex.findAll(text).toList()
321− var cursor = 0
322− for (match in matches) {
323− append(text.substring(cursor, match.range.first))
324− val url = match.value
325− withLink(
326− LinkAnnotation.Clickable(
327− tag = url,
328− styles = TextLinkStyles(
329− SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330− ),
331− linkInteractionListener = {
332− runCatching {
333− context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334− }
335− },
336− ),
337− ) {
338− append(url)
339− }
340− cursor = match.range.last + 1
341− }
342− append(text.substring(cursor))
343−}
344−
345−private fun shareText(context: Context, text: String) {
346− val intent = Intent(Intent.ACTION_SEND).apply {
347− type = "text/plain"
348− putExtra(Intent.EXTRA_TEXT, text)
349− }
350− context.startActivity(Intent.createChooser(intent, null))
351−}
352−
353133@Composable
354134private fun ReasoningBlock(text: String, context: Context) {
355135 var expanded by remember { mutableStateOf(false) }