git.iko.soy/opencode-android
on 8bc2cce help sign in
480 lines 21 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.combinedClickable
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.text.selection.SelectionContainer
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.HelpOutline
16import androidx.compose.material.icons.filled.Bolt
17import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.Description
20import androidx.compose.material.icons.filled.Error
21import androidx.compose.material.icons.filled.ExpandLess
22import androidx.compose.material.icons.filled.ExpandMore
23import androidx.compose.material.icons.filled.Psychology
24import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Text
29import androidx.compose.material3.TextButton
30import androidx.compose.runtime.Composable
31import androidx.compose.runtime.getValue
32import androidx.compose.runtime.mutableStateOf
33import androidx.compose.runtime.remember
34import androidx.compose.runtime.saveable.rememberSaveable
35import androidx.compose.runtime.setValue
36import androidx.compose.ui.Alignment
37import androidx.compose.ui.Modifier
38import androidx.compose.ui.draw.clip
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.contentDescription
42import androidx.compose.ui.semantics.semantics
43import androidx.compose.ui.semantics.stateDescription
44import androidx.compose.ui.text.font.FontFamily
45import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.unit.dp
47import kotlinx.serialization.json.Json
48import kotlinx.serialization.json.JsonElement
49import soy.iko.opencode.data.model.FilePart
50import soy.iko.opencode.data.model.Part
51import soy.iko.opencode.data.model.ReasoningPart
52import soy.iko.opencode.data.model.StepFinishPart
53import soy.iko.opencode.data.model.StepStartPart
54import soy.iko.opencode.data.model.TextPart
55import soy.iko.opencode.data.model.ToolCompleted
56import soy.iko.opencode.data.model.ToolError
57import soy.iko.opencode.data.model.ToolPart
58import soy.iko.opencode.data.model.ToolPending
59import soy.iko.opencode.data.model.ToolRunning
60import soy.iko.opencode.data.model.ToolState
61import soy.iko.opencode.data.model.ToolUnknown
62import soy.iko.opencode.data.model.UnknownPart
63import soy.iko.opencode.ui.components.DiffView
64import soy.iko.opencode.ui.components.ImageLoadContext
65import soy.iko.opencode.ui.components.MarkdownText
66import soy.iko.opencode.ui.components.RemoteImage
67import soy.iko.opencode.ui.components.copyToClipboard
68import soy.iko.opencode.ui.components.isImage
69import soy.iko.opencode.ui.components.looksLikeDiff
70import soy.iko.opencode.ui.components.showToast
71import soy.iko.opencode.R
73private const val COLLAPSED_LIMIT = 600
75// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
76// resilient decoding) so this stays human-readable; constructed lazily and memoized.
77private val prettyJson: Json = Json { prettyPrint = true }
79/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
80private fun ToolState.titleText(): String? = when (this) {
81 is ToolRunning -> title
82 is ToolCompleted -> title
83 else -> null
86/** Extract the raw input arguments of a tool call, if any. */
87private fun ToolState.inputJson(): JsonElement? = when (this) {
88 is ToolRunning -> input
89 is ToolCompleted -> input
90 is ToolError -> input
91 else -> null
94/**
95 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
96 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
97 *
98 * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
99 * source path the viewer can open; null leaves the chip as a copy-path affordance
100 * (used when no file navigation is wired, e.g. in tests).
101 */
102@Composable
103fun PartView(
104 part: Part,
105 modifier: Modifier = Modifier,
106 isRunning: Boolean = false,
107 imageContext: ImageLoadContext? = null,
108 onOpenFile: ((path: String) -> Unit)? = null,
109) {
110 when (part) {
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
113 }
114 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
115 is ToolPart -> ToolCallView(part, modifier)
116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
117 RemoteImage(part, imageContext, modifier)
118 } else {
119 FileChip(part, modifier, onOpenFile)
120 }
121 is StepStartPart -> {} // boundary marker — nothing to draw
122 is StepFinishPart -> {} // metrics handled at message level
123 is UnknownPart -> UnknownPartNote(modifier)
124 }
127@Composable
128private fun UnknownPartNote(modifier: Modifier) {
129 // Forward-compat: a part type the client doesn't model. Render a muted note (matching
130 // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
131 // the part vanishing silently — important when a new server release adds a part type.
132 Row(
133 modifier = modifier.fillMaxWidth(),
134 verticalAlignment = Alignment.CenterVertically,
135 ) {
136 Icon(
137 Icons.AutoMirrored.Filled.HelpOutline,
138 contentDescription = null,
139 modifier = Modifier.size(14.dp),
140 tint = MaterialTheme.colorScheme.onSurfaceVariant,
141 )
142 Text(
143 stringResource(R.string.unknown_part),
144 style = MaterialTheme.typography.labelSmall,
145 color = MaterialTheme.colorScheme.onSurfaceVariant,
146 modifier = Modifier.padding(start = 6.dp),
147 )
148 }
151@Composable
152private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
153 if (text.isBlank()) return
154 val context = LocalContext.current
155 // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
156 // inherit the positional saveable key of a prior block. A content-prefix key would
157 // change on nearly every token while streaming and snap the block back to collapsed;
158 // fall back to a content prefix only when the part has no id.
159 val saveableKey = keyId.ifBlank { text.take(64) }
160 var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
161 val expandedState = stringResource(R.string.state_expanded)
162 val collapsedState = stringResource(R.string.state_collapsed)
163 val thinkingLabel = stringResource(R.string.thinking)
164 val thoughtsLabel = stringResource(R.string.thoughts)
165 Column(modifier = modifier.fillMaxWidth()) {
166 Row(
167 modifier = Modifier
168 .fillMaxWidth()
169 .defaultMinSize(minHeight = 48.dp)
170 .clickable(role = Role.Button) { expanded = !expanded }
171 .padding(vertical = 4.dp)
172 .semantics {
173 stateDescription = if (expanded) expandedState else collapsedState
174 },
175 verticalAlignment = Alignment.CenterVertically,
176 ) {
177 if (streaming) {
178 CircularProgressIndicator(
179 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
180 strokeWidth = 2.dp,
181 color = MaterialTheme.colorScheme.onSurfaceVariant,
182 )
183 } else {
184 Icon(
185 Icons.Filled.Psychology,
186 contentDescription = null,
187 modifier = Modifier.size(16.dp),
188 tint = MaterialTheme.colorScheme.onSurfaceVariant,
189 )
190 }
191 Text(
192 if (streaming) thinkingLabel else thoughtsLabel,
193 style = MaterialTheme.typography.labelMedium,
194 color = MaterialTheme.colorScheme.onSurfaceVariant,
195 modifier = Modifier.padding(start = 6.dp),
196 )
197 }
198 AnimatedVisibility(visible = expanded) {
199 Column {
200 // Wrap in SelectionContainer so the user can select a portion of the
201 // reasoning (e.g. a single step) instead of the all-or-nothing copy
202 // button below. Matches the markdown text's selectability.
203 SelectionContainer {
204 Text(
205 text,
206 style = MaterialTheme.typography.bodySmall,
207 color = MaterialTheme.colorScheme.onSurfaceVariant,
208 )
209 }
210 TextButton(
211 onClick = { copyToClipboard(context, "reasoning", text) },
212 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213 modifier = Modifier.semantics(mergeDescendants = true) {},
214 ) {
215 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
216 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
217 }
218 }
219 }
220 }
223@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
224@Composable
225private fun ToolCallView(part: ToolPart, modifier: Modifier) {
226 val context = LocalContext.current
227 Column(
228 modifier = modifier
229 .fillMaxWidth()
230 .clip(MaterialTheme.shapes.small)
231 .background(MaterialTheme.colorScheme.surfaceVariant)
232 .padding(10.dp),
233 ) {
234 Row(verticalAlignment = Alignment.CenterVertically) {
235 ToolStatusIcon(part.state)
236 Text(
237 part.tool,
238 style = MaterialTheme.typography.labelLarge,
239 fontFamily = FontFamily.Monospace,
240 modifier = Modifier.padding(start = 6.dp),
241 )
242 }
243 // A human-readable summary of what the tool was asked to do (e.g. "Reading
244 // src/main.kt"), when the server provides one. Sits directly under the tool
245 // name so the user understands the call before diving into input/output.
246 part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
247 Text(
248 title,
249 style = MaterialTheme.typography.bodySmall,
250 color = MaterialTheme.colorScheme.onSurfaceVariant,
251 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
252 )
253 }
254 // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
255 // output so the call reads top-to-bottom: name → what → input → result.
256 part.state.inputJson()?.let { input ->
257 val inputLabel = stringResource(R.string.tool_input)
258 val pretty = remember(input) {
259 runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
260 }
261 CollapsibleDetail(
262 label = inputLabel,
263 detail = pretty,
264 isDiff = false,
265 keySuffix = "input",
266 // The input block has a label row whose collapse toggle announces its state
267 // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
268 // rather than an empty state (the defaults are "").
269 expandedState = stringResource(R.string.state_expanded),
270 collapsedState = stringResource(R.string.state_collapsed),
271 )
272 }
273 val detail = when (val s = part.state) {
274 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
275 is ToolError -> s.error ?: stringResource(R.string.error_generic)
276 else -> null
277 }
278 if (detail != null) {
279 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
280 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
281 val expandedState = stringResource(R.string.state_expanded)
282 val collapsedState = stringResource(R.string.state_collapsed)
283 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
284 // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
285 // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
286 val isDiff = remember(display) { looksLikeDiff(display) }
287 CollapsibleDetail(
288 label = null,
289 detail = display,
290 isDiff = isDiff,
291 keySuffix = "output",
292 expanded = expanded || detail.length <= COLLAPSED_LIMIT,
293 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
294 { expanded = !expanded }
295 } else null,
296 expandedState = expandedState,
297 collapsedState = collapsedState,
298 onCopy = { copyToClipboard(context, "output", detail) },
299 )
300 }
301 }
304/**
305 * A collapsible monospace detail block shared by tool input and output. [label] is an
306 * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
307 * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
308 * with an expand/collapse affordance when [onToggleExpand] is supplied.
309 */
310@Composable
311private fun CollapsibleDetail(
312 label: String?,
313 detail: String,
314 isDiff: Boolean,
315 keySuffix: String,
316 expanded: Boolean = false,
317 onToggleExpand: (() -> Unit)? = null,
318 expandedState: String = "",
319 collapsedState: String = "",
320 onCopy: (() -> Unit)? = null,
321) {
322 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
323 if (label != null) {
324 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
325 Row(
326 modifier = Modifier
327 .fillMaxWidth()
328 .defaultMinSize(minHeight = 48.dp)
329 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
330 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
331 verticalAlignment = Alignment.CenterVertically,
332 ) {
333 Icon(
334 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
335 contentDescription = null,
336 modifier = Modifier.size(16.dp),
337 tint = MaterialTheme.colorScheme.onSurfaceVariant,
338 )
339 Text(
340 label,
341 style = MaterialTheme.typography.labelSmall,
342 color = MaterialTheme.colorScheme.onSurfaceVariant,
343 modifier = Modifier.padding(start = 4.dp),
344 )
345 }
346 if (!inputExpanded) return@Column
347 }
348 if (isDiff) {
349 DiffView(detail)
350 } else {
351 // SelectionContainer so the user can select a portion of the output (e.g.
352 // a single line of stdout) instead of only copy-all via the button below.
353 SelectionContainer {
354 Text(
355 detail,
356 style = MaterialTheme.typography.bodySmall,
357 fontFamily = FontFamily.Monospace,
358 )
359 }
360 }
361 if (onToggleExpand != null) {
362 TextButton(
363 onClick = onToggleExpand,
364 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
365 modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
366 ) {
367 Icon(
368 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
369 contentDescription = null,
370 modifier = Modifier.size(16.dp),
371 )
372 Text(
373 if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
374 style = MaterialTheme.typography.labelSmall,
375 modifier = Modifier.padding(start = 4.dp),
376 )
377 }
378 }
379 if (onCopy != null) {
380 TextButton(
381 onClick = onCopy,
382 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
383 modifier = Modifier.semantics(mergeDescendants = true) {},
384 ) {
385 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
386 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
387 }
388 }
389 }
392@Composable
393private fun ToolStatusIcon(state: ToolState) {
394 when (state) {
395 is ToolPending, is ToolRunning -> {
396 val label = stringResource(R.string.tool_running)
397 CircularProgressIndicator(
398 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
399 strokeWidth = 2.dp,
400 )
401 }
402 is ToolCompleted ->
403 Icon(
404 Icons.Filled.CheckCircle,
405 contentDescription = stringResource(R.string.tool_completed),
406 modifier = Modifier.size(16.dp),
407 tint = MaterialTheme.colorScheme.primary,
408 )
409 is ToolError ->
410 Icon(
411 Icons.Filled.Error,
412 contentDescription = stringResource(R.string.tool_error),
413 modifier = Modifier.size(16.dp),
414 tint = MaterialTheme.colorScheme.error,
415 )
416 is ToolUnknown ->
417 Icon(
418 Icons.Filled.Bolt,
419 contentDescription = stringResource(R.string.tool_unknown),
420 modifier = Modifier.size(16.dp),
421 )
422 }
425@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
426@Composable
427private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
428 val context = LocalContext.current
429 val path = part.source ?: part.url ?: part.filename
430 val copyLabel = stringResource(R.string.copy_path)
431 val openLabel = stringResource(R.string.open_file)
432 // When a source path is available and a navigation callback is wired, tapping the
433 // chip opens the file in the viewer (the action a user tapping a file reference
434 // most likely expects). Long-press still copies the path to the clipboard, so the
435 // copy affordance is preserved without being the default tap action. When no
436 // navigation is wired (e.g. tests), falls back to copy-path on tap.
437 val source = part.source
438 val opener = onOpenFile
439 val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
440 val canOpen = openPath != null
441 val semanticsLabel = if (canOpen) openLabel else copyLabel
442 Row(
443 modifier = modifier
444 .clip(MaterialTheme.shapes.small)
445 .background(MaterialTheme.colorScheme.surfaceVariant)
446 .combinedClickable(
447 role = Role.Button,
448 onClick = {
449 val toOpen = openPath
450 if (toOpen != null) {
451 opener?.invoke(toOpen)
452 } else if (!path.isNullOrBlank()) {
453 copyToClipboard(context, "path", path)
454 showToast(context, context.getString(R.string.path_copied))
455 }
456 },
457 onLongClick = {
458 if (!path.isNullOrBlank()) {
459 copyToClipboard(context, "path", path)
460 showToast(context, context.getString(R.string.path_copied))
461 }
462 },
463 )
464 .semantics { contentDescription = semanticsLabel }
465 .padding(horizontal = 12.dp, vertical = 6.dp),
466 verticalAlignment = Alignment.CenterVertically,
467 ) {
468 Icon(
469 Icons.Filled.Description,
470 contentDescription = null,
471 modifier = Modifier.size(16.dp),
472 tint = MaterialTheme.colorScheme.onSurfaceVariant,
473 )
474 Text(
475 part.filename ?: part.url ?: stringResource(R.string.file),
476 style = MaterialTheme.typography.bodyMedium,
477 modifier = Modifier.padding(start = 6.dp),
478 )
479 }