git.iko.soy/opencode-android
on 8bc2cce help sign in
283 lines 13 kB history raw
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
38/**
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.
42 */
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
49 ?: id
52/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
53@Composable
54fun MessageBubble(
55 message: MessageWithParts,
56 modifier: Modifier = Modifier,
57 isRunning: Boolean = false,
58 imageContext: ImageLoadContext? = null,
59 modelLabel: String? = null,
60 onOpenFile: ((String) -> Unit)? = null,
61) {
62 when (message.info) {
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)
66 }
69@Composable
70private fun UnknownMessageBlock(
71 message: MessageWithParts,
72 imageContext: ImageLoadContext?,
73 modifier: Modifier,
74 onOpenFile: ((String) -> Unit)? = null,
75) {
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.
79 Column(
80 modifier = modifier.fillMaxWidth(),
81 verticalArrangement = Arrangement.spacedBy(8.dp),
82 ) {
83 Row(verticalAlignment = Alignment.CenterVertically) {
84 Icon(
85 Icons.AutoMirrored.Filled.HelpOutline,
86 contentDescription = null,
87 modifier = Modifier.size(14.dp),
88 tint = MaterialTheme.colorScheme.onSurfaceVariant,
89 )
90 Text(
91 stringResource(R.string.unknown_message),
92 style = MaterialTheme.typography.labelSmall,
93 color = MaterialTheme.colorScheme.onSurfaceVariant,
94 modifier = Modifier.padding(start = 6.dp),
95 )
96 }
97 for (part in message.parts) {
98 PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
99 }
100 MessageTimestampText(message.info)
101 }
104@Composable
105private fun UserBubble(
106 message: MessageWithParts,
107 imageContext: ImageLoadContext?,
108 modifier: Modifier,
109 onOpenFile: ((String) -> Unit)? = null,
110) {
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) {
116 message.parts
117 .filterIsInstance<TextPart>()
118 .joinToString("\n\n") { it.text }
119 .takeIf { it.isNotBlank() }
120 }
121 Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
122 Column(
123 modifier = Modifier
124 .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
125 .clip(MaterialTheme.shapes.medium)
126 .background(MaterialTheme.colorScheme.primaryContainer)
127 .padding(12.dp),
128 verticalArrangement = Arrangement.spacedBy(6.dp),
129 ) {
130 for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
131 Row(
132 modifier = Modifier.fillMaxWidth(),
133 horizontalArrangement = Arrangement.SpaceBetween,
134 verticalAlignment = Alignment.CenterVertically,
135 ) {
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) {
141 IconButton(
142 onClick = { copyToClipboard(context, "message", textToCopy) },
143 ) {
144 Icon(
145 Icons.Filled.ContentCopy,
146 contentDescription = copyLabel,
147 modifier = Modifier.size(18.dp),
148 tint = MaterialTheme.colorScheme.onSurfaceVariant,
149 )
150 }
151 }
152 }
153 }
154 }
157@Composable
158private fun AssistantBlock(
159 message: MessageWithParts,
160 isRunning: Boolean,
161 imageContext: ImageLoadContext?,
162 modifier: Modifier,
163 modelLabel: String? = null,
164 onOpenFile: ((String) -> Unit)? = null,
165) {
166 Column(
167 modifier = modifier.fillMaxWidth(),
168 verticalArrangement = Arrangement.spacedBy(8.dp),
169 ) {
170 val info = message.info
171 if (info is AssistantMessage) {
172 val label = modelLabel ?: info.modelID
173 if (label != null) {
174 Text(
175 label,
176 style = MaterialTheme.typography.labelSmall,
177 color = MaterialTheme.colorScheme.onSurfaceVariant,
178 )
179 }
180 }
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)
188 }
189 if (info is AssistantMessage) {
190 val cost = info.cost
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
200 else buildList {
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(" • ")
206 }
207 Row(
208 modifier = Modifier.fillMaxWidth(),
209 horizontalArrangement = Arrangement.SpaceBetween,
210 verticalAlignment = Alignment.CenterVertically,
211 ) {
212 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
213 costSummary?.let {
214 Text(
215 it,
216 style = MaterialTheme.typography.labelSmall,
217 color = MaterialTheme.colorScheme.onSurfaceVariant,
218 )
219 }
220 MessageTimestampText(message.info)
221 }
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) {
227 message.parts
228 .filterIsInstance<TextPart>()
229 .joinToString("\n\n") { it.text }
230 .takeIf { it.isNotBlank() }
231 }
232 if (textToCopy != null) {
233 IconButton(
234 onClick = { copyToClipboard(context, "message", textToCopy) },
235 ) {
236 Icon(
237 Icons.Filled.ContentCopy,
238 contentDescription = copyLabel,
239 modifier = Modifier.size(18.dp),
240 tint = MaterialTheme.colorScheme.onSurfaceVariant,
241 )
242 }
243 }
244 }
245 } else {
246 MessageTimestampText(message.info)
247 }
248 }
251@Composable
252private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
253 val t = rememberRelativeTime(info.time?.created)
254 if (t.isNotEmpty()) {
255 Text(
256 t,
257 style = MaterialTheme.typography.labelSmall,
258 color = MaterialTheme.colorScheme.onSurfaceVariant,
259 )
260 }
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
267// not thread-safe.
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)