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
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
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.
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).
105 modifier: Modifier = Modifier,
106 isRunning: Boolean = false,
107 imageContext: ImageLoadContext? = null,
108 onOpenFile: ((path: String) -> Unit)? = null,
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
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)
119 FileChip(part, modifier, onOpenFile)
121 is StepStartPart -> {} // boundary marker — nothing to draw
122 is StepFinishPart -> {} // metrics handled at message level
123 is UnknownPart -> UnknownPartNote(modifier)
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.
133 modifier = modifier.fillMaxWidth(),
134 verticalAlignment = Alignment.CenterVertically,
137 Icons.AutoMirrored.Filled.HelpOutline,
138 contentDescription = null,
139 modifier = Modifier.size(14.dp),
140 tint = MaterialTheme.colorScheme.onSurfaceVariant,
143 stringResource(R.string.unknown_part),
144 style = MaterialTheme.typography.labelSmall,
145 color = MaterialTheme.colorScheme.onSurfaceVariant,
146 modifier = Modifier.padding(start = 6.dp),
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()) {
169 .defaultMinSize(minHeight = 48.dp)
170 .clickable(role = Role.Button) { expanded = !expanded }
171 .padding(vertical = 4.dp)
173 stateDescription = if (expanded) expandedState else collapsedState
175 verticalAlignment = Alignment.CenterVertically,
178 CircularProgressIndicator(
179 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
181 color = MaterialTheme.colorScheme.onSurfaceVariant,
185 Icons.Filled.Psychology,
186 contentDescription = null,
187 modifier = Modifier.size(16.dp),
188 tint = MaterialTheme.colorScheme.onSurfaceVariant,
192 if (streaming) thinkingLabel else thoughtsLabel,
193 style = MaterialTheme.typography.labelMedium,
194 color = MaterialTheme.colorScheme.onSurfaceVariant,
195 modifier = Modifier.padding(start = 6.dp),
198 AnimatedVisibility(visible = expanded) {
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.
206 style = MaterialTheme.typography.bodySmall,
207 color = MaterialTheme.colorScheme.onSurfaceVariant,
211 onClick = { copyToClipboard(context, "reasoning", text) },
212 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213 modifier = Modifier.semantics(mergeDescendants = true) {},
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))
223@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
225private fun ToolCallView(part: ToolPart, modifier: Modifier) {
226 val context = LocalContext.current
230 .clip(MaterialTheme.shapes.small)
231 .background(MaterialTheme.colorScheme.surfaceVariant)
234 Row(verticalAlignment = Alignment.CenterVertically) {
235 ToolStatusIcon(part.state)
238 style = MaterialTheme.typography.labelLarge,
239 fontFamily = FontFamily.Monospace,
240 modifier = Modifier.padding(start = 6.dp),
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 ->
249 style = MaterialTheme.typography.bodySmall,
250 color = MaterialTheme.colorScheme.onSurfaceVariant,
251 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
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())
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),
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)
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) }
291 keySuffix = "output",
292 expanded = expanded || detail.length <= COLLAPSED_LIMIT,
293 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
294 { expanded = !expanded }
296 expandedState = expandedState,
297 collapsedState = collapsedState,
298 onCopy = { copyToClipboard(context, "output", detail) },
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.
311private fun CollapsibleDetail(
316 expanded: Boolean = false,
317 onToggleExpand: (() -> Unit)? = null,
318 expandedState: String = "",
319 collapsedState: String = "",
320 onCopy: (() -> Unit)? = null,
322 Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
324 var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
328 .defaultMinSize(minHeight = 48.dp)
329 .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
330 .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
331 verticalAlignment = Alignment.CenterVertically,
334 if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
335 contentDescription = null,
336 modifier = Modifier.size(16.dp),
337 tint = MaterialTheme.colorScheme.onSurfaceVariant,
341 style = MaterialTheme.typography.labelSmall,
342 color = MaterialTheme.colorScheme.onSurfaceVariant,
343 modifier = Modifier.padding(start = 4.dp),
346 if (!inputExpanded) return@Column
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.
356 style = MaterialTheme.typography.bodySmall,
357 fontFamily = FontFamily.Monospace,
361 if (onToggleExpand != null) {
363 onClick = onToggleExpand,
364 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
365 modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
368 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
369 contentDescription = null,
370 modifier = Modifier.size(16.dp),
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),
379 if (onCopy != null) {
382 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
383 modifier = Modifier.semantics(mergeDescendants = true) {},
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))
393private fun ToolStatusIcon(state: ToolState) {
395 is ToolPending, is ToolRunning -> {
396 val label = stringResource(R.string.tool_running)
397 CircularProgressIndicator(
398 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
404 Icons.Filled.CheckCircle,
405 contentDescription = stringResource(R.string.tool_completed),
406 modifier = Modifier.size(16.dp),
407 tint = MaterialTheme.colorScheme.primary,
412 contentDescription = stringResource(R.string.tool_error),
413 modifier = Modifier.size(16.dp),
414 tint = MaterialTheme.colorScheme.error,
419 contentDescription = stringResource(R.string.tool_unknown),
420 modifier = Modifier.size(16.dp),
425@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
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
444 .clip(MaterialTheme.shapes.small)
445 .background(MaterialTheme.colorScheme.surfaceVariant)
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))
458 if (!path.isNullOrBlank()) {
459 copyToClipboard(context, "path", path)
460 showToast(context, context.getString(R.string.path_copied))
464 .semantics { contentDescription = semanticsLabel }
465 .padding(horizontal = 12.dp, vertical = 6.dp),
466 verticalAlignment = Alignment.CenterVertically,
469 Icons.Filled.Description,
470 contentDescription = null,
471 modifier = Modifier.size(16.dp),
472 tint = MaterialTheme.colorScheme.onSurfaceVariant,
475 part.filename ?: part.url ?: stringResource(R.string.file),
476 style = MaterialTheme.typography.bodyMedium,
477 modifier = Modifier.padding(start = 6.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