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
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.
75fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
77 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
78 MarkdownText(part.text, modifier = modifier)
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)
85 FileChip(part, modifier)
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
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()) {
104 .defaultMinSize(minHeight = 48.dp)
105 .clickable(role = Role.Button) { expanded = !expanded }
106 .padding(vertical = 4.dp)
108 stateDescription = if (expanded) expandedState else collapsedState
110 verticalAlignment = Alignment.CenterVertically,
113 val thinkingLabel = stringResource(R.string.thinking)
114 CircularProgressIndicator(
115 Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
117 color = MaterialTheme.colorScheme.onSurfaceVariant,
121 Icons.Filled.Psychology,
122 contentDescription = null,
123 modifier = Modifier.size(16.dp),
124 tint = MaterialTheme.colorScheme.onSurfaceVariant,
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),
134 AnimatedVisibility(visible = expanded) {
138 style = MaterialTheme.typography.bodySmall,
139 color = MaterialTheme.colorScheme.onSurfaceVariant,
142 onClick = { copyToClipboard(context, "reasoning", text) },
143 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
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))
154private fun ToolCallView(part: ToolPart, modifier: Modifier) {
155 val context = LocalContext.current
159 .clip(RoundedCornerShape(8.dp))
160 .background(MaterialTheme.colorScheme.surfaceVariant)
163 Row(verticalAlignment = Alignment.CenterVertically) {
164 ToolStatusIcon(part.state)
167 style = MaterialTheme.typography.labelLarge,
168 fontFamily = FontFamily.Monospace,
169 modifier = Modifier.padding(start = 6.dp),
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)
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)) {
188 style = MaterialTheme.typography.bodySmall,
189 fontFamily = FontFamily.Monospace,
190 modifier = Modifier.padding(top = 6.dp),
193 if (detail.length > COLLAPSED_LIMIT) {
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
202 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
203 contentDescription = null,
204 modifier = Modifier.size(16.dp),
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),
214 onClick = { copyToClipboard(context, "output", detail) },
215 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
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))
225private fun ToolStatusIcon(state: ToolState) {
227 is ToolPending, is ToolRunning -> {
228 val label = stringResource(R.string.tool_running)
229 CircularProgressIndicator(
230 modifier = Modifier.size(16.dp).semantics { contentDescription = label },
236 Icons.Filled.CheckCircle,
237 contentDescription = stringResource(R.string.tool_completed),
238 modifier = Modifier.size(16.dp),
239 tint = MaterialTheme.colorScheme.primary,
244 contentDescription = stringResource(R.string.tool_error),
245 modifier = Modifier.size(16.dp),
246 tint = MaterialTheme.colorScheme.error,
251 contentDescription = stringResource(R.string.tool_unknown),
252 modifier = Modifier.size(16.dp),
258private fun FileChip(part: FilePart, modifier: Modifier) {
261 .clip(RoundedCornerShape(12.dp))
262 .background(MaterialTheme.colorScheme.surfaceVariant)
263 .padding(horizontal = 12.dp, vertical = 6.dp),
264 verticalAlignment = Alignment.CenterVertically,
267 Icons.Filled.Description,
268 contentDescription = null,
269 modifier = Modifier.size(16.dp),
270 tint = MaterialTheme.colorScheme.onSurfaceVariant,
273 part.filename ?: part.url ?: stringResource(R.string.file),
274 style = MaterialTheme.typography.bodyMedium,
275 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