1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.content.Intent
8import android.widget.Toast
9import androidx.compose.foundation.ExperimentalFoundationApi
10import androidx.compose.foundation.background
11import androidx.compose.foundation.clickable
12import androidx.compose.foundation.combinedClickable
13import androidx.compose.foundation.layout.Arrangement
14import androidx.compose.foundation.layout.Column
15import androidx.compose.foundation.layout.Row
16import androidx.compose.foundation.layout.fillMaxWidth
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.shape.RoundedCornerShape
21import androidx.compose.foundation.text.selection.SelectionContainer
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.filled.Build
24import androidx.compose.material.icons.filled.Check
25import androidx.compose.material.icons.filled.ErrorOutline
26import androidx.compose.material.icons.filled.ExpandLess
27import androidx.compose.material.icons.filled.ExpandMore
28import androidx.compose.material.icons.filled.Schedule
29import androidx.compose.material.icons.filled.SmartToy
30import androidx.compose.material3.DropdownMenu
31import androidx.compose.material3.DropdownMenuItem
32import androidx.compose.material3.Icon
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.Text
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.getValue
37import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.remember
39import androidx.compose.runtime.setValue
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.draw.clip
43import androidx.compose.ui.graphics.Color
44import androidx.compose.ui.platform.LocalContext
45import androidx.compose.ui.text.AnnotatedString
46import androidx.compose.ui.text.LinkAnnotation
47import androidx.compose.ui.text.SpanStyle
48import androidx.compose.ui.text.TextLinkStyles
49import androidx.compose.ui.text.buildAnnotatedString
50import androidx.compose.ui.text.font.FontFamily
51import androidx.compose.ui.text.font.FontStyle
52import androidx.compose.ui.text.withLink
53import androidx.compose.ui.text.withStyle
54import androidx.compose.ui.text.style.TextDecoration
55import androidx.compose.ui.text.style.TextOverflow
56import androidx.compose.ui.unit.dp
57import androidx.compose.ui.unit.sp
58import soy.iko.opencode.core.MessageStatus
59import soy.iko.opencode.core.MessageView
60import soy.iko.opencode.core.ToolView
61import soy.iko.opencode.ui.theme.MonoStyle
63private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65@OptIn(ExperimentalFoundationApi::class)
67fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
68 val isUser = message.isUser
69 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
70 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
71 val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
72 val shape = RoundedCornerShape(
75 bottomStart = if (isUser) 16.dp else 4.dp,
76 bottomEnd = if (isUser) 4.dp else 16.dp,
79 val context = LocalContext.current
80 var showMenu by remember { mutableStateOf(false) }
83 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
84 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
88 Icons.Filled.SmartToy,
89 contentDescription = null,
90 tint = MaterialTheme.colorScheme.primary,
91 modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
97 .widthIn(max = 320.dp)
99 .background(bubbleColor)
103 if (message.text.isNotEmpty()) {
108 .padding(horizontal = 14.dp, vertical = 10.dp),
110 DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112 text = { Text("Copy") },
114 copyToClipboard(context, message.text)
115 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
120 text = { Text("Share") },
122 shareText(context, message.text)
128 message.reasoning?.let { ReasoningBlock(it, context) }
130 message.tools.forEach { ToolRow(it) }
133 if (message.text.isNotEmpty()) {
134 val linkColor = MaterialTheme.colorScheme.primary
135 val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
136 val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
137 buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
140 text = annotatedText,
142 // Give assistant replies (often long, code-heavy) extra line
143 // spacing for readability; user bubbles keep the default.
144 style = if (isUser) {
145 MaterialTheme.typography.bodyLarge
147 MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
150 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
151 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
155 if (message.time > 0) {
157 text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
158 style = MaterialTheme.typography.labelSmall,
160 modifier = Modifier.padding(top = 4.dp),
164 if (isUser && message.status != MessageStatus.Sent) {
165 StatusLine(message.status, onRetry = onRetry)
171private fun copyToClipboard(context: Context, text: String) {
172 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
173 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
176private val urlRegex = Regex("""https?://[^\s]+""")
179 * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
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
182 * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
184private fun buildLinkedText(
188 codeBackground: Color,
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. */
207private fun AnnotatedString.Builder.appendLinkedText(
212 val matches = urlRegex.findAll(text).toList()
214 for (match in matches) {
215 append(text.substring(cursor, match.range.first))
216 val url = match.value
218 LinkAnnotation.Clickable(
220 styles = TextLinkStyles(
221 SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
223 linkInteractionListener = {
225 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
232 cursor = match.range.last + 1
234 append(text.substring(cursor))
237private fun shareText(context: Context, text: String) {
238 val intent = Intent(Intent.ACTION_SEND).apply {
240 putExtra(Intent.EXTRA_TEXT, text)
242 context.startActivity(Intent.createChooser(intent, null))
246private fun ReasoningBlock(text: String, context: Context) {
247 var expanded by remember { mutableStateOf(false) }
248 Column(modifier = Modifier.padding(bottom = 6.dp)) {
250 verticalAlignment = Alignment.CenterVertically,
251 modifier = Modifier.clickable { expanded = !expanded },
254 text = if (expanded) "Show less" else "Reasoning",
255 style = MaterialTheme.typography.bodySmall,
256 color = MaterialTheme.colorScheme.onSurfaceVariant,
259 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
260 contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
261 tint = MaterialTheme.colorScheme.onSurfaceVariant,
262 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
268 style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
269 color = MaterialTheme.colorScheme.onSurfaceVariant,
270 modifier = Modifier.padding(top = 4.dp),
274 style = MaterialTheme.typography.labelSmall,
275 color = MaterialTheme.colorScheme.primary,
279 copyToClipboard(context, text)
280 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
288private fun ToolRow(tool: ToolView) {
289 var expanded by remember { mutableStateOf(false) }
290 val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
291 val statusColor = when (tool.status) {
292 "completed", "success" -> MaterialTheme.colorScheme.primary
293 "error", "failed" -> MaterialTheme.colorScheme.error
294 "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
295 else -> MaterialTheme.colorScheme.onSurfaceVariant
298 verticalAlignment = Alignment.CenterVertically,
301 .clickable { expanded = !expanded }
302 .padding(bottom = 6.dp),
306 contentDescription = null,
307 tint = MaterialTheme.colorScheme.primary,
308 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
311 text = buildAnnotatedString {
312 withStyle(SpanStyle(color = baseColor)) {
316 withStyle(SpanStyle(color = statusColor)) {
319 if (expanded) tool.title?.let {
320 withStyle(SpanStyle(color = baseColor)) {
328 maxLines = if (expanded) Int.MAX_VALUE else 1,
329 overflow = TextOverflow.Ellipsis,
330 modifier = Modifier.weight(1f),
333 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
334 contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
335 tint = MaterialTheme.colorScheme.onSurfaceVariant,
336 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
342private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
343 val (icon, label, tint) = when (status) {
344 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
345 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
346 MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
349 verticalAlignment = Alignment.CenterVertically,
350 horizontalArrangement = Arrangement.End,
351 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
353 Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
356 style = MaterialTheme.typography.labelSmall,
358 modifier = Modifier.padding(start = 4.dp),
360 if (status == MessageStatus.Failed) {
363 style = MaterialTheme.typography.labelSmall,
364 color = MaterialTheme.colorScheme.primary,
366 .padding(start = 12.dp)
367 .clickable { onRetry() },
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list