@@ -3,13 +3,14 @@ package soy.iko.opencode.ui.chat
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.text.selection.SelectionContainer
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.HelpOutline
15import androidx.compose.material.icons.automirrored.filled.HelpOutline
15import androidx.compose.material.icons.filled.Bolt
16import androidx.compose.material.icons.filled.Bolt
@@ -93,9 +94,19 @@ private fun ToolState.inputJson(): JsonElement? = when (this) {
94 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
95 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
95 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
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).
97@Composable
102@Composable
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
98−fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
99 when (part) {
110 when (part) {
100 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
111 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
101 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
112 MarkdownText(part.text, modifier = modifier, streaming = isRunning)
@@ -105,7 +116,7 @@ fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = fal
105 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
116 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
106 RemoteImage(part, imageContext, modifier)
117 RemoteImage(part, imageContext, modifier)
108− FileChip(part, modifier)
119+ FileChip(part, modifier, onOpenFile)
110 is StepStartPart -> {} // boundary marker — nothing to draw
121 is StepStartPart -> {} // boundary marker — nothing to draw
111 is StepFinishPart -> {} // metrics handled at message level
122 is StepFinishPart -> {} // metrics handled at message level
@@ -184,11 +195,16 @@ private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier)
185 AnimatedVisibility(visible = expanded) {
196 AnimatedVisibility(visible = expanded) {
198+ // Wrap in SelectionContainer so the user can select a portion of the
199+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
200+ // button below. Matches the markdown text's selectability.
201+ SelectionContainer {
189− style = MaterialTheme.typography.bodySmall,
204+ style = MaterialTheme.typography.bodySmall,
190− color = MaterialTheme.colorScheme.onSurfaceVariant,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
192 TextButton(
208 TextButton(
193 onClick = { copyToClipboard(context, "reasoning", text) },
209 onClick = { copyToClipboard(context, "reasoning", text) },
194 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
210 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
@@ -209,7 +225,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
210 modifier = modifier
226 modifier = modifier
211 .fillMaxWidth()
227 .fillMaxWidth()
212− .clip(RoundedCornerShape(8.dp))
228+ .clip(MaterialTheme.shapes.small)
213 .background(MaterialTheme.colorScheme.surfaceVariant)
229 .background(MaterialTheme.colorScheme.surfaceVariant)
214 .padding(10.dp),
230 .padding(10.dp),
@@ -325,11 +341,15 @@ private fun CollapsibleDetail(
325 if (isDiff) {
341 if (isDiff) {
326 DiffView(detail)
342 DiffView(detail)
344+ // SelectionContainer so the user can select a portion of the output (e.g.
345+ // a single line of stdout) instead of only copy-all via the button below.
346+ SelectionContainer {
330− style = MaterialTheme.typography.bodySmall,
349+ style = MaterialTheme.typography.bodySmall,
331− fontFamily = FontFamily.Monospace,
350+ fontFamily = FontFamily.Monospace,
334 if (onToggleExpand != null) {
354 if (onToggleExpand != null) {
335 TextButton(
355 TextButton(
@@ -395,22 +415,46 @@ private fun ToolStatusIcon(state: ToolState) {
418+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
398@Composable
419@Composable
399−private fun FileChip(part: FilePart, modifier: Modifier) {
420+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
400 val context = LocalContext.current
421 val context = LocalContext.current
401 val path = part.source ?: part.url ?: part.filename
422 val path = part.source ?: part.url ?: part.filename
402 val copyLabel = stringResource(R.string.copy_path)
423 val copyLabel = stringResource(R.string.copy_path)
424+ val openLabel = stringResource(R.string.open_file)
425+ // When a source path is available and a navigation callback is wired, tapping the
426+ // chip opens the file in the viewer (the action a user tapping a file reference
427+ // most likely expects). Long-press still copies the path to the clipboard, so the
428+ // copy affordance is preserved without being the default tap action. When no
429+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
430+ val source = part.source
431+ val opener = onOpenFile
432+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
433+ val canOpen = openPath != null
434+ val semanticsLabel = if (canOpen) openLabel else copyLabel
404 modifier = modifier
436 modifier = modifier
405− .clip(RoundedCornerShape(12.dp))
437+ .clip(MaterialTheme.shapes.small)
406 .background(MaterialTheme.colorScheme.surfaceVariant)
438 .background(MaterialTheme.colorScheme.surfaceVariant)
407− .clickable(role = Role.Button) {
440+ role = Role.Button,
442+ val toOpen = openPath
443+ if (toOpen != null) {
444+ opener?.invoke(toOpen)
445+ } else if (!path.isNullOrBlank()) {
446+ copyToClipboard(context, "path", path)
447+ showToast(context, context.getString(R.string.path_copied))
408− if (!path.isNullOrBlank()) {
451+ if (!path.isNullOrBlank()) {
409− copyToClipboard(context, "path", path)
452+ copyToClipboard(context, "path", path)
410− showToast(context, context.getString(R.string.path_copied))
453+ showToast(context, context.getString(R.string.path_copied))
413− .semantics { contentDescription = copyLabel }
457+ .semantics { contentDescription = semanticsLabel }
414 .padding(horizontal = 12.dp, vertical = 6.dp),
458 .padding(horizontal = 12.dp, vertical = 6.dp),
415 verticalAlignment = Alignment.CenterVertically,
459 verticalAlignment = Alignment.CenterVertically,