git.iko.soy/opencode-android
on 66d239c help sign in
269 lines 12 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.Description
17import androidx.compose.material.icons.filled.Error
18import androidx.compose.material.icons.filled.ExpandLess
19import androidx.compose.material.icons.filled.ExpandMore
20import androidx.compose.material.icons.filled.Psychology
21import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.Icon
23import androidx.compose.material3.IconButton
24import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.Text
26import androidx.compose.material3.TextButton
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.getValue
29import androidx.compose.runtime.mutableStateOf
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.platform.LocalContext
36import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.role
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.semantics.stateDescription
41import androidx.compose.ui.text.font.FontFamily
42import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.unit.dp
44import soy.iko.opencode.data.model.FilePart
45import soy.iko.opencode.data.model.Part
46import soy.iko.opencode.data.model.ReasoningPart
47import soy.iko.opencode.data.model.StepFinishPart
48import soy.iko.opencode.data.model.StepStartPart
49import soy.iko.opencode.data.model.TextPart
50import soy.iko.opencode.data.model.ToolCompleted
51import soy.iko.opencode.data.model.ToolError
52import soy.iko.opencode.data.model.ToolPart
53import soy.iko.opencode.data.model.ToolPending
54import soy.iko.opencode.data.model.ToolRunning
55import soy.iko.opencode.data.model.ToolState
56import soy.iko.opencode.data.model.ToolUnknown
57import soy.iko.opencode.data.model.UnknownPart
58import soy.iko.opencode.ui.components.DiffView
59import soy.iko.opencode.ui.components.ImageLoadContext
60import soy.iko.opencode.ui.components.MarkdownText
61import soy.iko.opencode.ui.components.RemoteImage
62import soy.iko.opencode.ui.components.copyToClipboard
63import soy.iko.opencode.ui.components.isImage
64import soy.iko.opencode.ui.components.looksLikeDiff
65import soy.iko.opencode.R
67private const val COLLAPSED_LIMIT = 600
69/**
70 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
71 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
72 */
73@Composable
74fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
75 when (part) {
76 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
77 MarkdownText(part.text, modifier = modifier)
78 }
79 is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
80 is ToolPart -> ToolCallView(part, modifier)
81 is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
82 RemoteImage(part, imageContext, modifier)
83 } else {
84 FileChip(part, modifier)
85 }
86 is StepStartPart -> {} // boundary marker — nothing to draw
87 is StepFinishPart -> {} // metrics handled at message level
88 is UnknownPart -> {} // forward-compat: unknown part types are ignored
89 }
92@Composable
93private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
94 if (text.isBlank()) return
95 val context = LocalContext.current
96 var expanded by remember { mutableStateOf(false) }
97 val expandedState = stringResource(R.string.state_expanded)
98 val collapsedState = stringResource(R.string.state_collapsed)
99 Column(modifier = modifier.fillMaxWidth()) {
100 Row(
101 modifier = Modifier
102 .clickable { expanded = !expanded }
103 .padding(vertical = 4.dp)
104 .semantics {
105 role = Role.Button
106 stateDescription = if (expanded) expandedState else collapsedState
107 },
108 verticalAlignment = Alignment.CenterVertically,
109 ) {
110 if (streaming) {
111 CircularProgressIndicator(
112 Modifier.size(14.dp),
113 strokeWidth = 2.dp,
114 color = MaterialTheme.colorScheme.onSurfaceVariant,
115 )
116 } else {
117 Icon(
118 Icons.Filled.Psychology,
119 contentDescription = null,
120 modifier = Modifier.size(16.dp),
121 tint = MaterialTheme.colorScheme.onSurfaceVariant,
122 )
123 }
124 Text(
125 if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts),
126 style = MaterialTheme.typography.labelMedium,
127 color = MaterialTheme.colorScheme.onSurfaceVariant,
128 modifier = Modifier.padding(start = 6.dp),
129 )
130 }
131 AnimatedVisibility(visible = expanded) {
132 Column {
133 Text(
134 text,
135 style = MaterialTheme.typography.bodySmall,
136 color = MaterialTheme.colorScheme.onSurfaceVariant,
137 )
138 TextButton(
139 onClick = { copyToClipboard(context, "reasoning", text) },
140 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
141 ) {
142 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
143 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
144 }
145 }
146 }
147 }
150@Composable
151private fun ToolCallView(part: ToolPart, modifier: Modifier) {
152 val context = LocalContext.current
153 Column(
154 modifier = modifier
155 .fillMaxWidth()
156 .clip(RoundedCornerShape(8.dp))
157 .background(MaterialTheme.colorScheme.surfaceVariant)
158 .padding(10.dp),
159 ) {
160 Row(verticalAlignment = Alignment.CenterVertically) {
161 ToolStatusIcon(part.state)
162 Text(
163 part.tool,
164 style = MaterialTheme.typography.labelLarge,
165 fontFamily = FontFamily.Monospace,
166 modifier = Modifier.padding(start = 6.dp),
167 )
168 }
169 val detail = when (val s = part.state) {
170 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
171 is ToolError -> s.error ?: stringResource(R.string.error_generic)
172 else -> null
173 }
174 if (detail != null) {
175 val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
176 var expanded by remember(detail) { mutableStateOf(false) }
177 val expandedState = stringResource(R.string.state_expanded)
178 val collapsedState = stringResource(R.string.state_collapsed)
179 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
180 if (looksLikeDiff(display)) {
181 DiffView(display)
182 } else {
183 Text(
184 display,
185 style = MaterialTheme.typography.bodySmall,
186 fontFamily = FontFamily.Monospace,
187 modifier = Modifier.padding(top = 6.dp),
188 )
189 }
190 if (detail.length > COLLAPSED_LIMIT) {
191 TextButton(
192 onClick = { expanded = !expanded },
193 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
194 modifier = Modifier.semantics {
195 stateDescription = if (expanded) expandedState else collapsedState
196 },
197 ) {
198 Icon(
199 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
200 contentDescription = null,
201 modifier = Modifier.size(16.dp),
202 )
203 Text(
204 if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
205 style = MaterialTheme.typography.labelSmall,
206 modifier = Modifier.padding(start = 4.dp),
207 )
208 }
209 }
210 TextButton(
211 onClick = { copyToClipboard(context, "output", detail) },
212 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213 ) {
214 Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
215 Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
216 }
217 }
218 }
221@Composable
222private fun ToolStatusIcon(state: ToolState) {
223 when (state) {
224 is ToolPending, is ToolRunning ->
225 CircularProgressIndicator(
226 modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
227 strokeWidth = 2.dp,
228 )
229 is ToolCompleted ->
230 Icon(
231 Icons.Filled.CheckCircle,
232 contentDescription = stringResource(R.string.tool_completed),
233 modifier = Modifier.size(16.dp),
234 tint = MaterialTheme.colorScheme.primary,
235 )
236 is ToolError ->
237 Icon(
238 Icons.Filled.Error,
239 contentDescription = stringResource(R.string.tool_error),
240 modifier = Modifier.size(16.dp),
241 tint = MaterialTheme.colorScheme.error,
242 )
243 is ToolUnknown ->
244 Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
245 }
248@Composable
249private fun FileChip(part: FilePart, modifier: Modifier) {
250 Row(
251 modifier = modifier
252 .clip(RoundedCornerShape(12.dp))
253 .background(MaterialTheme.colorScheme.surfaceVariant)
254 .padding(horizontal = 12.dp, vertical = 6.dp),
255 verticalAlignment = Alignment.CenterVertically,
256 ) {
257 Icon(
258 Icons.Filled.Description,
259 contentDescription = null,
260 modifier = Modifier.size(16.dp),
261 tint = MaterialTheme.colorScheme.onSurfaceVariant,
262 )
263 Text(
264 part.filename ?: part.url ?: stringResource(R.string.file),
265 style = MaterialTheme.typography.bodyMedium,
266 modifier = Modifier.padding(start = 6.dp),
267 )
268 }