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