1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
8import androidx.compose.foundation.background
9import androidx.compose.foundation.clickable
10import androidx.compose.foundation.combinedClickable
11import androidx.compose.foundation.layout.Arrangement
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.shape.RoundedCornerShape
18import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.Build
21import androidx.compose.material.icons.filled.Check
22import androidx.compose.material.icons.filled.ErrorOutline
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Schedule
26import androidx.compose.material3.Icon
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Text
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.draw.clip
37import androidx.compose.ui.graphics.Color
38import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.text.SpanStyle
40import androidx.compose.ui.text.buildAnnotatedString
41import androidx.compose.ui.text.font.FontStyle
42import androidx.compose.ui.text.withStyle
43import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.unit.dp
45import soy.iko.opencode.core.MessageStatus
46import soy.iko.opencode.core.MessageView
47import soy.iko.opencode.core.ToolView
48import soy.iko.opencode.ui.theme.MonoStyle
50private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
52@OptIn(ExperimentalFoundationApi::class)
54fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
55 val isUser = message.isUser
56 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
57 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
58 val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
59 val shape = RoundedCornerShape(
62 bottomStart = if (isUser) 16.dp else 4.dp,
63 bottomEnd = if (isUser) 4.dp else 16.dp,
66 val context = LocalContext.current
69 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
70 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
75 .widthIn(max = 320.dp)
77 .background(bubbleColor)
81 if (message.text.isNotEmpty()) {
82 copyToClipboard(context, message.text)
83 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
87 .padding(horizontal = 14.dp, vertical = 10.dp),
89 message.reasoning?.let { ReasoningBlock(it, context) }
91 message.tools.forEach { ToolRow(it) }
94 if (message.text.isNotEmpty()) {
96 text = message.text + if (message.streaming) " ▌" else "",
98 style = MaterialTheme.typography.bodyLarge,
100 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
101 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
105 if (message.time > 0) {
107 text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
108 style = MaterialTheme.typography.labelSmall,
110 modifier = Modifier.padding(top = 4.dp),
114 if (isUser && message.status != MessageStatus.Sent) {
115 StatusLine(message.status)
121private fun copyToClipboard(context: Context, text: String) {
122 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
123 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
127private fun ReasoningBlock(text: String, context: Context) {
128 var expanded by remember { mutableStateOf(false) }
129 Column(modifier = Modifier.padding(bottom = 6.dp)) {
131 verticalAlignment = Alignment.CenterVertically,
132 modifier = Modifier.clickable { expanded = !expanded },
135 text = if (expanded) "Show less" else "Reasoning",
136 style = MaterialTheme.typography.bodySmall,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
140 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
141 contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
142 tint = MaterialTheme.colorScheme.onSurfaceVariant,
143 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
149 style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
150 color = MaterialTheme.colorScheme.onSurfaceVariant,
151 modifier = Modifier.padding(top = 4.dp),
155 style = MaterialTheme.typography.labelSmall,
156 color = MaterialTheme.colorScheme.primary,
160 copyToClipboard(context, text)
161 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
169private fun ToolRow(tool: ToolView) {
170 var expanded by remember { mutableStateOf(false) }
171 val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
172 val statusColor = when (tool.status) {
173 "completed", "success" -> MaterialTheme.colorScheme.primary
174 "error", "failed" -> MaterialTheme.colorScheme.error
175 "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
176 else -> MaterialTheme.colorScheme.onSurfaceVariant
179 verticalAlignment = Alignment.CenterVertically,
182 .clickable { expanded = !expanded }
183 .padding(bottom = 6.dp),
187 contentDescription = null,
188 tint = MaterialTheme.colorScheme.primary,
189 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
192 text = buildAnnotatedString {
193 withStyle(SpanStyle(color = baseColor)) {
197 withStyle(SpanStyle(color = statusColor)) {
200 if (expanded) tool.title?.let {
201 withStyle(SpanStyle(color = baseColor)) {
209 maxLines = if (expanded) Int.MAX_VALUE else 1,
210 overflow = TextOverflow.Ellipsis,
211 modifier = Modifier.weight(1f),
214 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
215 contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
216 tint = MaterialTheme.colorScheme.onSurfaceVariant,
217 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
223private fun StatusLine(status: MessageStatus) {
224 val (icon, label, tint) = when (status) {
225 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
226 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
227 MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
230 verticalAlignment = Alignment.CenterVertically,
231 horizontalArrangement = Arrangement.End,
232 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
234 Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
237 style = MaterialTheme.typography.labelSmall,
239 modifier = Modifier.padding(start = 4.dp),
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