git.iko.soy/opencode-android
on 29df003 help sign in
371 lines 15 kB history raw
1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.content.Intent
7import android.net.Uri
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)
66@Composable
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(
73 topStart = 16.dp,
74 topEnd = 16.dp,
75 bottomStart = if (isUser) 16.dp else 4.dp,
76 bottomEnd = if (isUser) 4.dp else 16.dp,
77 )
79 val context = LocalContext.current
80 var showMenu by remember { mutableStateOf(false) }
82 Row(
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,
85 ) {
86 if (!isUser) {
87 Icon(
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),
92 )
93 }
94 Column(
95 modifier = Modifier
96 .fillMaxWidth(0.85f)
97 .widthIn(max = 320.dp)
98 .clip(shape)
99 .background(bubbleColor)
100 .combinedClickable(
101 onClick = {},
102 onLongClick = {
103 if (message.text.isNotEmpty()) {
104 showMenu = true
105 }
106 },
107 )
108 .padding(horizontal = 14.dp, vertical = 10.dp),
109 ) {
110 DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
111 DropdownMenuItem(
112 text = { Text("Copy") },
113 onClick = {
114 copyToClipboard(context, message.text)
115 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
116 showMenu = false
117 },
118 )
119 DropdownMenuItem(
120 text = { Text("Share") },
121 onClick = {
122 shareText(context, message.text)
123 showMenu = false
124 },
125 )
126 }
128 message.reasoning?.let { ReasoningBlock(it, context) }
130 message.tools.forEach { ToolRow(it) }
132 SelectionContainer {
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)
138 }
139 Text(
140 text = annotatedText,
141 color = textColor,
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
146 } else {
147 MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
148 },
149 )
150 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
151 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
152 }
153 }
155 if (message.time > 0) {
156 Text(
157 text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
158 style = MaterialTheme.typography.labelSmall,
159 color = mutedColor,
160 modifier = Modifier.padding(top = 4.dp),
161 )
162 }
164 if (isUser && message.status != MessageStatus.Sent) {
165 StatusLine(message.status, onRetry = onRetry)
166 }
167 }
168 }
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]+""")
178/**
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.
183 */
184private fun buildLinkedText(
185 text: String,
186 streaming: Boolean,
187 linkColor: Color,
188 codeBackground: Color,
189 context: Context,
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)) {
197 append(segment)
198 }
199 } else {
200 appendLinkedText(segment, linkColor, context)
201 }
202 }
203 if (streaming) append(" ▌")
206/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
207private fun AnnotatedString.Builder.appendLinkedText(
208 text: String,
209 linkColor: Color,
210 context: Context,
211) {
212 val matches = urlRegex.findAll(text).toList()
213 var cursor = 0
214 for (match in matches) {
215 append(text.substring(cursor, match.range.first))
216 val url = match.value
217 withLink(
218 LinkAnnotation.Clickable(
219 tag = url,
220 styles = TextLinkStyles(
221 SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
222 ),
223 linkInteractionListener = {
224 runCatching {
225 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
226 }
227 },
228 ),
229 ) {
230 append(url)
231 }
232 cursor = match.range.last + 1
233 }
234 append(text.substring(cursor))
237private fun shareText(context: Context, text: String) {
238 val intent = Intent(Intent.ACTION_SEND).apply {
239 type = "text/plain"
240 putExtra(Intent.EXTRA_TEXT, text)
241 }
242 context.startActivity(Intent.createChooser(intent, null))
245@Composable
246private fun ReasoningBlock(text: String, context: Context) {
247 var expanded by remember { mutableStateOf(false) }
248 Column(modifier = Modifier.padding(bottom = 6.dp)) {
249 Row(
250 verticalAlignment = Alignment.CenterVertically,
251 modifier = Modifier.clickable { expanded = !expanded },
252 ) {
253 Text(
254 text = if (expanded) "Show less" else "Reasoning",
255 style = MaterialTheme.typography.bodySmall,
256 color = MaterialTheme.colorScheme.onSurfaceVariant,
257 )
258 Icon(
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),
263 )
264 }
265 if (expanded) {
266 Text(
267 text = text,
268 style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
269 color = MaterialTheme.colorScheme.onSurfaceVariant,
270 modifier = Modifier.padding(top = 4.dp),
271 )
272 Text(
273 text = "Copy",
274 style = MaterialTheme.typography.labelSmall,
275 color = MaterialTheme.colorScheme.primary,
276 modifier = Modifier
277 .padding(top = 4.dp)
278 .clickable {
279 copyToClipboard(context, text)
280 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
281 },
282 )
283 }
284 }
287@Composable
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
296 }
297 Row(
298 verticalAlignment = Alignment.CenterVertically,
299 modifier = Modifier
300 .fillMaxWidth()
301 .clickable { expanded = !expanded }
302 .padding(bottom = 6.dp),
303 ) {
304 Icon(
305 Icons.Filled.Build,
306 contentDescription = null,
307 tint = MaterialTheme.colorScheme.primary,
308 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
309 )
310 Text(
311 text = buildAnnotatedString {
312 withStyle(SpanStyle(color = baseColor)) {
313 append(tool.name)
314 append(" · ")
315 }
316 withStyle(SpanStyle(color = statusColor)) {
317 append(tool.status)
318 }
319 if (expanded) tool.title?.let {
320 withStyle(SpanStyle(color = baseColor)) {
321 append(" — ")
322 append(it)
323 }
324 }
325 },
326 style = MonoStyle,
327 color = baseColor,
328 maxLines = if (expanded) Int.MAX_VALUE else 1,
329 overflow = TextOverflow.Ellipsis,
330 modifier = Modifier.weight(1f),
331 )
332 Icon(
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),
337 )
338 }
341@Composable
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)
347 }
348 Row(
349 verticalAlignment = Alignment.CenterVertically,
350 horizontalArrangement = Arrangement.End,
351 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
352 ) {
353 Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
354 Text(
355 text = label,
356 style = MaterialTheme.typography.labelSmall,
357 color = tint,
358 modifier = Modifier.padding(start = 4.dp),
359 )
360 if (status == MessageStatus.Failed) {
361 Text(
362 text = "Retry",
363 style = MaterialTheme.typography.labelSmall,
364 color = MaterialTheme.colorScheme.primary,
365 modifier = Modifier
366 .padding(start = 12.dp)
367 .clickable { onRetry() },
368 )
369 }
370 }