1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.background
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
12import androidx.compose.material.icons.filled.ContentCopy
13import androidx.compose.material3.Icon
14import androidx.compose.material3.IconButton
15import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.Text
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.remember
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.draw.clip
22import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.unit.dp
25import soy.iko.opencode.R
26import soy.iko.opencode.data.model.AssistantMessage
27import soy.iko.opencode.data.model.MessageWithParts
28import soy.iko.opencode.data.model.ModelOption
29import soy.iko.opencode.data.model.TextPart
30import soy.iko.opencode.data.model.Tokens
31import soy.iko.opencode.data.model.UnknownMessage
32import soy.iko.opencode.data.model.UserMessage
33import soy.iko.opencode.data.network.NetworkConfig
34import soy.iko.opencode.ui.components.ImageLoadContext
35import soy.iko.opencode.ui.components.copyToClipboard
36import soy.iko.opencode.ui.components.rememberRelativeTime
39 * Resolve an assistant message's model id to a friendly label from the loaded catalog,
40 * falling back to the raw id (so a model not in the catalog still shows something
41 * meaningful instead of being hidden). Returns null only when the message has no model.
43fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
44 val id = info.modelID ?: return null
45 if (models.isEmpty()) return id
46 val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
47 return byPair?.modelLabel
48 ?: models.firstOrNull { it.modelID == id }?.modelLabel
52/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
55 message: MessageWithParts,
56 modifier: Modifier = Modifier,
57 isRunning: Boolean = false,
58 imageContext: ImageLoadContext? = null,
59 modelLabel: String? = null,
60 onOpenFile: ((String) -> Unit)? = null,
63 is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
64 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
65 else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
70private fun UnknownMessageBlock(
71 message: MessageWithParts,
72 imageContext: ImageLoadContext?,
74 onOpenFile: ((String) -> Unit)? = null,
76 // Forward-compat: a role the client doesn't model. Render a muted note so the user
77 // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
78 // server attached, so content isn't silently dropped.
80 modifier = modifier.fillMaxWidth(),
81 verticalArrangement = Arrangement.spacedBy(8.dp),
83 Row(verticalAlignment = Alignment.CenterVertically) {
85 Icons.AutoMirrored.Filled.HelpOutline,
86 contentDescription = null,
87 modifier = Modifier.size(14.dp),
88 tint = MaterialTheme.colorScheme.onSurfaceVariant,
91 stringResource(R.string.unknown_message),
92 style = MaterialTheme.typography.labelSmall,
93 color = MaterialTheme.colorScheme.onSurfaceVariant,
94 modifier = Modifier.padding(start = 6.dp),
97 for (part in message.parts) {
98 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
100 MessageTimestampText(message.info)
105private fun UserBubble(
106 message: MessageWithParts,
107 imageContext: ImageLoadContext?,
109 onOpenFile: ((String) -> Unit)? = null,
111 val context = LocalContext.current
112 val copyLabel = stringResource(R.string.copy)
113 // Collect text from all TextParts for copying, so a user can reuse/repost their
114 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
115 val textToCopy = remember(message.parts) {
117 .filterIsInstance<TextPart>()
118 .joinToString("\n\n") { it.text }
119 .takeIf { it.isNotBlank() }
121 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
124 .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
125 .clip(MaterialTheme.shapes.medium)
126 .background(MaterialTheme.colorScheme.primaryContainer)
128 verticalArrangement = Arrangement.spacedBy(6.dp),
130 for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
132 modifier = Modifier.fillMaxWidth(),
133 horizontalArrangement = Arrangement.SpaceBetween,
134 verticalAlignment = Alignment.CenterVertically,
136 MessageTimestampText(message.info)
137 // Copy affordance for user prompts, mirroring the assistant block.
138 // Without it, reusing a prior prompt requires discovering the
139 // long-press on the markdown text (and only TextParts support that).
140 if (textToCopy != null) {
142 onClick = { copyToClipboard(context, "message", textToCopy) },
145 Icons.Filled.ContentCopy,
146 contentDescription = copyLabel,
147 modifier = Modifier.size(18.dp),
148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
158private fun AssistantBlock(
159 message: MessageWithParts,
161 imageContext: ImageLoadContext?,
163 modelLabel: String? = null,
164 onOpenFile: ((String) -> Unit)? = null,
167 modifier = modifier.fillMaxWidth(),
168 verticalArrangement = Arrangement.spacedBy(8.dp),
170 val info = message.info
171 if (info is AssistantMessage) {
172 val label = modelLabel ?: info.modelID
176 style = MaterialTheme.typography.labelSmall,
177 color = MaterialTheme.colorScheme.onSurfaceVariant,
181 message.parts.forEachIndexed { index, part ->
182 // Only the final part of a running message is actively streaming; earlier parts
183 // (a finished reasoning block, a completed tool call) are already done, so passing
184 // isRunning to all of them would keep a completed reasoning block showing the
185 // "Thinking…" spinner until the whole message finishes.
186 val partStreaming = isRunning && index == message.parts.lastIndex
187 PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
189 if (info is AssistantMessage) {
191 val tokens = info.tokens
192 val tokenFormat = stringResource(R.string.tokens_in_out)
193 val costShort = stringResource(R.string.cost_format_short)
194 val costLong = stringResource(R.string.cost_format_long)
195 // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
196 // state-flip recomposition doesn't re-run NumberFormat + buildList +
197 // joinToString for every visible assistant bubble.
198 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
199 if (!info.isComplete || (cost == null && tokens == null)) null
201 // Skip an all-zero token count (e.g. a completed message that reported no
202 // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
203 tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
204 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
205 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
208 modifier = Modifier.fillMaxWidth(),
209 horizontalArrangement = Arrangement.SpaceBetween,
210 verticalAlignment = Alignment.CenterVertically,
212 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
216 style = MaterialTheme.typography.labelSmall,
217 color = MaterialTheme.colorScheme.onSurfaceVariant,
220 MessageTimestampText(message.info)
222 val context = LocalContext.current
223 val copyLabel = stringResource(R.string.copy)
224 // Collect text from all TextParts for copying. Memoized so a
225 // scroll-induced recomposition doesn't re-scan the parts list.
226 val textToCopy = remember(message.parts) {
228 .filterIsInstance<TextPart>()
229 .joinToString("\n\n") { it.text }
230 .takeIf { it.isNotBlank() }
232 if (textToCopy != null) {
234 onClick = { copyToClipboard(context, "message", textToCopy) },
237 Icons.Filled.ContentCopy,
238 contentDescription = copyLabel,
239 modifier = Modifier.size(18.dp),
240 tint = MaterialTheme.colorScheme.onSurfaceVariant,
246 MessageTimestampText(message.info)
252private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
253 val t = rememberRelativeTime(info.time?.created)
254 if (t.isNotEmpty()) {
257 style = MaterialTheme.typography.labelSmall,
258 color = MaterialTheme.colorScheme.onSurfaceVariant,
263// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
264// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
265// when the message list re-seeds after a reconnect and every visible assistant bubble
266// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
268private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
269 java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
272private fun formatTokens(tokens: Tokens, format: String): String {
273 val nf = tokenNumberFormat.get()!!
274 return format.format(nf.format(tokens.input), nf.format(tokens.output))
277private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
278 // Locale.US so the formatting is stable regardless of device locale (avoids
279 // non-ASCII digits or comma decimal separators in a dollar amount). The format
280 // string itself is localized via strings.xml so the currency symbol can be
281 // adapted by translators.
282 if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
283 else String.format(java.util.Locale.US, shortFormat, cost)
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