git.iko.soy/opencode-android
on e37d1bb help sign in
211 lines 8.8 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.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.ContentCopy
16import androidx.compose.material.icons.filled.Error
17import androidx.compose.material.icons.filled.ExpandLess
18import androidx.compose.material.icons.filled.ExpandMore
19import androidx.compose.material.icons.filled.Psychology
20import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Text
25import androidx.compose.material3.TextButton
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.unit.dp
37import soy.iko.opencode.data.model.FilePart
38import soy.iko.opencode.data.model.Part
39import soy.iko.opencode.data.model.ReasoningPart
40import soy.iko.opencode.data.model.StepFinishPart
41import soy.iko.opencode.data.model.StepStartPart
42import soy.iko.opencode.data.model.TextPart
43import soy.iko.opencode.data.model.ToolCompleted
44import soy.iko.opencode.data.model.ToolError
45import soy.iko.opencode.data.model.ToolPart
46import soy.iko.opencode.data.model.ToolPending
47import soy.iko.opencode.data.model.ToolRunning
48import soy.iko.opencode.data.model.ToolState
49import soy.iko.opencode.data.model.ToolUnknown
50import soy.iko.opencode.data.model.UnknownPart
51import soy.iko.opencode.ui.components.DiffView
52import soy.iko.opencode.ui.components.MarkdownText
53import soy.iko.opencode.ui.components.copyToClipboard
54import soy.iko.opencode.ui.components.looksLikeDiff
56private const val COLLAPSED_LIMIT = 600
58/**
59 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
60 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
61 */
62@Composable
63fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier) {
64 when (part) {
65 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
66 MarkdownText(part.text, modifier = modifier)
67 }
68 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
69 is ToolPart -> ToolCallView(part, modifier)
70 is FilePart -> FileChip(part, modifier)
71 is StepStartPart -> {} // boundary marker — nothing to draw
72 is StepFinishPart -> {} // metrics handled at message level
73 is UnknownPart -> {} // forward-compat: unknown part types are ignored
74 }
77@Composable
78private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
79 if (text.isBlank()) return
80 val context = LocalContext.current
81 var expanded by remember { mutableStateOf(false) }
82 Column(modifier = modifier.fillMaxWidth()) {
83 Row(
84 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
85 verticalAlignment = Alignment.CenterVertically,
86 ) {
87 if (streaming) {
88 CircularProgressIndicator(
89 Modifier.size(14.dp),
90 strokeWidth = 2.dp,
91 color = MaterialTheme.colorScheme.onSurfaceVariant,
92 )
93 } else {
94 Icon(
95 Icons.Filled.Psychology,
96 contentDescription = null,
97 modifier = Modifier.size(16.dp),
98 tint = MaterialTheme.colorScheme.onSurfaceVariant,
99 )
100 }
101 Text(
102 " ${if (streaming) "Thinking…" else "Thoughts"}",
103 style = MaterialTheme.typography.labelMedium,
104 color = MaterialTheme.colorScheme.onSurfaceVariant,
105 )
106 }
107 AnimatedVisibility(visible = expanded) {
108 Column {
109 Text(
110 text,
111 style = MaterialTheme.typography.bodySmall,
112 color = MaterialTheme.colorScheme.onSurfaceVariant,
113 )
114 TextButton(
115 onClick = { copyToClipboard(context, "reasoning", text) },
116 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
117 ) {
118 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
119 Text(" Copy", style = MaterialTheme.typography.labelSmall)
120 }
121 }
122 }
123 }
126@Composable
127private fun ToolCallView(part: ToolPart, modifier: Modifier) {
128 val context = LocalContext.current
129 Column(
130 modifier = modifier
131 .fillMaxWidth()
132 .clip(RoundedCornerShape(8.dp))
133 .background(MaterialTheme.colorScheme.surfaceVariant)
134 .padding(10.dp),
135 ) {
136 Row(verticalAlignment = Alignment.CenterVertically) {
137 ToolStatusIcon(part.state)
138 Text(
139 " ${part.tool}",
140 style = MaterialTheme.typography.labelLarge,
141 fontFamily = FontFamily.Monospace,
142 )
143 }
144 val detail = when (val s = part.state) {
145 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
146 is ToolError -> s.error ?: "error"
147 else -> null
148 }
149 if (detail != null) {
150 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
151 var expanded by remember(detail) { mutableStateOf(false) }
152 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail.take(5000) else collapsed
153 if (looksLikeDiff(display)) {
154 DiffView(display)
155 } else {
156 Text(
157 display,
158 style = MaterialTheme.typography.bodySmall,
159 fontFamily = FontFamily.Monospace,
160 modifier = Modifier.padding(top = 6.dp),
161 )
162 }
163 if (detail.length > COLLAPSED_LIMIT) {
164 TextButton(
165 onClick = { expanded = !expanded },
166 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
167 ) {
168 Icon(
169 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
170 contentDescription = null,
171 modifier = Modifier.size(16.dp),
172 )
173 Text(
174 " ${if (expanded) "Show less" else "Show more"}",
175 style = MaterialTheme.typography.labelSmall,
176 )
177 }
178 }
179 TextButton(
180 onClick = { copyToClipboard(context, "output", detail) },
181 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
182 ) {
183 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
184 Text(" Copy", style = MaterialTheme.typography.labelSmall)
185 }
186 }
187 }
190@Composable
191private fun ToolStatusIcon(state: ToolState) {
192 when (state) {
193 is ToolPending, is ToolRunning ->
194 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
195 is ToolCompleted ->
196 Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
197 is ToolError ->
198 Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
199 is ToolUnknown ->
200 Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
201 }
204@Composable
205private fun FileChip(part: FilePart, modifier: Modifier) {
206 Text(
207 "📎 ${part.filename ?: part.url ?: "file"}",
208 modifier = modifier.padding(vertical = 2.dp),
209 style = MaterialTheme.typography.bodyMedium,
210 )