package soy.iko.opencode.ui.chat import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Psychology import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import soy.iko.opencode.data.model.FilePart import soy.iko.opencode.data.model.Part import soy.iko.opencode.data.model.ReasoningPart import soy.iko.opencode.data.model.StepFinishPart import soy.iko.opencode.data.model.StepStartPart import soy.iko.opencode.data.model.TextPart import soy.iko.opencode.data.model.ToolCompleted import soy.iko.opencode.data.model.ToolError import soy.iko.opencode.data.model.ToolPart import soy.iko.opencode.data.model.ToolPending import soy.iko.opencode.data.model.ToolRunning import soy.iko.opencode.data.model.ToolState import soy.iko.opencode.data.model.ToolUnknown import soy.iko.opencode.data.model.UnknownPart import soy.iko.opencode.ui.components.DiffView import soy.iko.opencode.ui.components.MarkdownText import soy.iko.opencode.ui.components.copyToClipboard import soy.iko.opencode.ui.components.looksLikeDiff private const val COLLAPSED_LIMIT = 600 /** * Renders a single message [Part]. The exhaustive `when` over the sealed type gives * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible. */ @Composable fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier) { when (part) { is TextPart -> if (!part.ignored && part.text.isNotEmpty()) { MarkdownText(part.text, modifier = modifier) } is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier) is ToolPart -> ToolCallView(part, modifier) is FilePart -> FileChip(part, modifier) is StepStartPart -> {} // boundary marker — nothing to draw is StepFinishPart -> {} // metrics handled at message level is UnknownPart -> {} // forward-compat: unknown part types are ignored } } @Composable private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) { if (text.isBlank()) return val context = LocalContext.current var expanded by remember { mutableStateOf(false) } Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (streaming) { CircularProgressIndicator( Modifier.size(14.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Icon( Icons.Filled.Psychology, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( " ${if (streaming) "Thinking…" else "Thoughts"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } AnimatedVisibility(visible = expanded) { Column { Text( text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) TextButton( onClick = { copyToClipboard(context, "reasoning", text) }, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), ) { Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) Text(" Copy", style = MaterialTheme.typography.labelSmall) } } } } } @Composable private fun ToolCallView(part: ToolPart, modifier: Modifier) { val context = LocalContext.current Column( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(10.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { ToolStatusIcon(part.state) Text( " ${part.tool}", style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace, ) } val detail = when (val s = part.state) { is ToolCompleted -> s.output?.takeIf { it.isNotBlank() } is ToolError -> s.error ?: "error" else -> null } if (detail != null) { val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) } var expanded by remember(detail) { mutableStateOf(false) } val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail.take(5000) else collapsed if (looksLikeDiff(display)) { DiffView(display) } else { Text( display, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.padding(top = 6.dp), ) } if (detail.length > COLLAPSED_LIMIT) { TextButton( onClick = { expanded = !expanded }, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), ) { Icon( if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp), ) Text( " ${if (expanded) "Show less" else "Show more"}", style = MaterialTheme.typography.labelSmall, ) } } TextButton( onClick = { copyToClipboard(context, "output", detail) }, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), ) { Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp)) Text(" Copy", style = MaterialTheme.typography.labelSmall) } } } } @Composable private fun ToolStatusIcon(state: ToolState) { when (state) { is ToolPending, is ToolRunning -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) is ToolCompleted -> Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) is ToolError -> Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) is ToolUnknown -> Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp)) } } @Composable private fun FileChip(part: FilePart, modifier: Modifier) { Text( "📎 ${part.filename ?: part.url ?: "file"}", modifier = modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.bodyMedium, ) }