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.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.shape.RoundedCornerShape
12import androidx.compose.material.icons.Icons
13import androidx.compose.material.icons.filled.Bolt
14import androidx.compose.material.icons.filled.CheckCircle
15import androidx.compose.material.icons.filled.Error
16import androidx.compose.material.icons.filled.Psychology
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.Icon
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.getValue
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.setValue
26import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
28import androidx.compose.ui.draw.clip
29import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.dp
31import soy.iko.opencode.data.model.FilePart
32import soy.iko.opencode.data.model.Part
33import soy.iko.opencode.data.model.ReasoningPart
34import soy.iko.opencode.data.model.StepFinishPart
35import soy.iko.opencode.data.model.StepStartPart
36import soy.iko.opencode.data.model.TextPart
37import soy.iko.opencode.data.model.ToolCompleted
38import soy.iko.opencode.data.model.ToolError
39import soy.iko.opencode.data.model.ToolPart
40import soy.iko.opencode.data.model.ToolPending
41import soy.iko.opencode.data.model.ToolRunning
42import soy.iko.opencode.data.model.ToolState
43import soy.iko.opencode.data.model.ToolUnknown
44import soy.iko.opencode.data.model.UnknownPart
45import soy.iko.opencode.ui.components.DiffView
46import soy.iko.opencode.ui.components.MarkdownText
47import soy.iko.opencode.ui.components.looksLikeDiff
50 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
51 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
54fun PartView(part: Part, modifier: Modifier = Modifier) {
56 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
57 MarkdownText(part.text, modifier = modifier)
59 is ReasoningPart -> ReasoningBlock(part.text, modifier)
60 is ToolPart -> ToolCallView(part, modifier)
61 is FilePart -> FileChip(part, modifier)
62 is StepStartPart -> {} // boundary marker — nothing to draw
63 is StepFinishPart -> {} // metrics handled at message level
64 is UnknownPart -> {} // forward-compat: unknown part types are ignored
69private fun ReasoningBlock(text: String, modifier: Modifier) {
70 if (text.isBlank()) return
71 var expanded by remember { mutableStateOf(false) }
72 Column(modifier = modifier.fillMaxWidth()) {
74 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
75 verticalAlignment = Alignment.CenterVertically,
78 Icons.Filled.Psychology,
79 contentDescription = null,
80 modifier = Modifier.size(16.dp),
81 tint = MaterialTheme.colorScheme.onSurfaceVariant,
84 if (expanded) " Thinking" else " Thinking…",
85 style = MaterialTheme.typography.labelMedium,
86 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 AnimatedVisibility(visible = expanded) {
92 style = MaterialTheme.typography.bodySmall,
93 color = MaterialTheme.colorScheme.onSurfaceVariant,
100private fun ToolCallView(part: ToolPart, modifier: Modifier) {
104 .clip(RoundedCornerShape(8.dp))
105 .background(MaterialTheme.colorScheme.surfaceVariant)
108 Row(verticalAlignment = Alignment.CenterVertically) {
109 ToolStatusIcon(part.state)
112 style = MaterialTheme.typography.labelLarge,
113 fontFamily = FontFamily.Monospace,
116 val detail = when (val s = part.state) {
117 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
118 is ToolError -> s.error ?: "error"
121 if (detail != null) {
122 val truncated = detail.take(5000)
123 if (looksLikeDiff(truncated)) {
127 truncated.take(2000),
128 style = MaterialTheme.typography.bodySmall,
129 fontFamily = FontFamily.Monospace,
130 modifier = Modifier.padding(top = 6.dp),
138private fun ToolStatusIcon(state: ToolState) {
140 is ToolPending, is ToolRunning ->
141 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
143 Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
145 Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
147 Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
152private fun FileChip(part: FilePart, modifier: Modifier) {
154 "📎 ${part.filename ?: part.url ?: "file"}",
155 modifier = modifier.padding(vertical = 2.dp),
156 style = MaterialTheme.typography.bodyMedium,
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