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