@@ -3,6 +3,8 @@ 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
68import android.widget.Toast
79import androidx.compose.foundation.ExperimentalFoundationApi
810import androidx.compose.foundation.background
@@ -25,6 +27,8 @@ import androidx.compose.material.icons.filled.ExpandLess
2527import androidx.compose.material.icons.filled.ExpandMore
2628import androidx.compose.material.icons.filled.Schedule
2729import androidx.compose.material.icons.filled.SmartToy
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.DropdownMenuItem
2832import androidx.compose.material3.Icon
2933import androidx.compose.material3.MaterialTheme
3034import androidx.compose.material3.Text
@@ -38,10 +42,17 @@ import androidx.compose.ui.Modifier
3842import androidx.compose.ui.draw.clip
3943import androidx.compose.ui.graphics.Color
4044import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
4147import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
4249import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
4351import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.withLink
4454import androidx.compose.ui.text.withStyle
55+import androidx.compose.ui.text.style.TextDecoration
4556import androidx.compose.ui.text.style.TextOverflow
4657import androidx.compose.ui.unit.dp
4758import androidx.compose.ui.unit.sp
@@ -67,6 +78,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
6778 )
6879
6980 val context = LocalContext.current
81+ var showMenu by remember { mutableStateOf(false) }
7082
7183 Row(
7284 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
@@ -90,21 +102,55 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry:
90102 onClick = {},
91103 onLongClick = {
92104 if (message.text.isNotEmpty()) {
93− copyToClipboard(context, message.text)
94− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
105+ showMenu = true
95106 }
96107 },
97108 )
98109 .padding(horizontal = 14.dp, vertical = 10.dp),
99110 ) {
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+
100129 message.reasoning?.let { ReasoningBlock(it, context) }
101130
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+
102141 message.tools.forEach { ToolRow(it) }
103142
104143 SelectionContainer {
105144 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+ }
106152 Text(
107− text = message.text + if (message.streaming) " ▌" else "",
153+ text = annotatedText,
108154 color = textColor,
109155 // Give assistant replies (often long, code-heavy) extra line
110156 // spacing for readability; user bubbles keep the default.
@@ -140,6 +186,170 @@ private fun copyToClipboard(context: Context, text: String) {
140186 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
141187}
142188
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+
143353@Composable
144354private fun ReasoningBlock(text: String, context: Context) {
145355 var expanded by remember { mutableStateOf(false) }