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
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.
72fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
74 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
75 MarkdownText(part.text, modifier = modifier)
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)
82 FileChip(part, modifier)
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
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()) {
98 .clickable { expanded = !expanded }
99 .padding(vertical = 4.dp)
100 .semantics { role = Role.Button },
101 verticalAlignment = Alignment.CenterVertically,
104 CircularProgressIndicator(
105 Modifier.size(14.dp),
107 color = MaterialTheme.colorScheme.onSurfaceVariant,
111 Icons.Filled.Psychology,
112 contentDescription = null,
113 modifier = Modifier.size(16.dp),
114 tint = MaterialTheme.colorScheme.onSurfaceVariant,
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),
124 AnimatedVisibility(visible = expanded) {
128 style = MaterialTheme.typography.bodySmall,
129 color = MaterialTheme.colorScheme.onSurfaceVariant,
132 onClick = { copyToClipboard(context, "reasoning", text) },
133 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
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))
144private fun ToolCallView(part: ToolPart, modifier: Modifier) {
145 val context = LocalContext.current
149 .clip(RoundedCornerShape(8.dp))
150 .background(MaterialTheme.colorScheme.surfaceVariant)
153 Row(verticalAlignment = Alignment.CenterVertically) {
154 ToolStatusIcon(part.state)
157 style = MaterialTheme.typography.labelLarge,
158 fontFamily = FontFamily.Monospace,
159 modifier = Modifier.padding(start = 6.dp),
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)
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)) {
176 style = MaterialTheme.typography.bodySmall,
177 fontFamily = FontFamily.Monospace,
178 modifier = Modifier.padding(top = 6.dp),
181 if (detail.length > COLLAPSED_LIMIT) {
183 onClick = { expanded = !expanded },
184 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
187 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
188 contentDescription = null,
189 modifier = Modifier.size(16.dp),
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),
199 onClick = { copyToClipboard(context, "output", detail) },
200 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
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))
210private fun ToolStatusIcon(state: ToolState) {
212 is ToolPending, is ToolRunning ->
213 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
215 Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
217 Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
219 Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
224private fun FileChip(part: FilePart, modifier: Modifier) {
227 .clip(RoundedCornerShape(12.dp))
228 .background(MaterialTheme.colorScheme.surfaceVariant)
229 .padding(horizontal = 12.dp, vertical = 6.dp),
230 verticalAlignment = Alignment.CenterVertically,
233 Icons.Filled.Description,
234 contentDescription = null,
235 modifier = Modifier.size(16.dp),
236 tint = MaterialTheme.colorScheme.onSurfaceVariant,
239 part.filename ?: part.url ?: stringResource(R.string.file),
240 style = MaterialTheme.typography.bodyMedium,
241 modifier = Modifier.padding(start = 6.dp),
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