git.iko.soy/opencode-android
on 8865a77 help sign in
158 lines 6.1 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.Error
16import androidx.compose.material.icons.filled.Psychology
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.Icon
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Text
21import androidx.compose.runtime.Composable
22import androidx.compose.runtime.getValue
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.setValue
26import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
28import androidx.compose.ui.draw.clip
29import androidx.compose.ui.text.font.FontFamily
30import androidx.compose.ui.unit.dp
31import soy.iko.opencode.data.model.FilePart
32import soy.iko.opencode.data.model.Part
33import soy.iko.opencode.data.model.ReasoningPart
34import soy.iko.opencode.data.model.StepFinishPart
35import soy.iko.opencode.data.model.StepStartPart
36import soy.iko.opencode.data.model.TextPart
37import soy.iko.opencode.data.model.ToolCompleted
38import soy.iko.opencode.data.model.ToolError
39import soy.iko.opencode.data.model.ToolPart
40import soy.iko.opencode.data.model.ToolPending
41import soy.iko.opencode.data.model.ToolRunning
42import soy.iko.opencode.data.model.ToolState
43import soy.iko.opencode.data.model.ToolUnknown
44import soy.iko.opencode.data.model.UnknownPart
45import soy.iko.opencode.ui.components.DiffView
46import soy.iko.opencode.ui.components.MarkdownText
47import soy.iko.opencode.ui.components.looksLikeDiff
49/**
50 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
51 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
52 */
53@Composable
54fun PartView(part: Part, modifier: Modifier = Modifier) {
55 when (part) {
56 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
57 MarkdownText(part.text, modifier = modifier)
58 }
59 is ReasoningPart -> ReasoningBlock(part.text, modifier)
60 is ToolPart -> ToolCallView(part, modifier)
61 is FilePart -> FileChip(part, modifier)
62 is StepStartPart -> {} // boundary marker — nothing to draw
63 is StepFinishPart -> {} // metrics handled at message level
64 is UnknownPart -> {} // forward-compat: unknown part types are ignored
65 }
68@Composable
69private fun ReasoningBlock(text: String, modifier: Modifier) {
70 if (text.isBlank()) return
71 var expanded by remember { mutableStateOf(false) }
72 Column(modifier = modifier.fillMaxWidth()) {
73 Row(
74 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
75 verticalAlignment = Alignment.CenterVertically,
76 ) {
77 Icon(
78 Icons.Filled.Psychology,
79 contentDescription = null,
80 modifier = Modifier.size(16.dp),
81 tint = MaterialTheme.colorScheme.onSurfaceVariant,
82 )
83 Text(
84 if (expanded) " Thinking" else " Thinking…",
85 style = MaterialTheme.typography.labelMedium,
86 color = MaterialTheme.colorScheme.onSurfaceVariant,
87 )
88 }
89 AnimatedVisibility(visible = expanded) {
90 Text(
91 text,
92 style = MaterialTheme.typography.bodySmall,
93 color = MaterialTheme.colorScheme.onSurfaceVariant,
94 )
95 }
96 }
99@Composable
100private fun ToolCallView(part: ToolPart, modifier: Modifier) {
101 Column(
102 modifier = modifier
103 .fillMaxWidth()
104 .clip(RoundedCornerShape(8.dp))
105 .background(MaterialTheme.colorScheme.surfaceVariant)
106 .padding(10.dp),
107 ) {
108 Row(verticalAlignment = Alignment.CenterVertically) {
109 ToolStatusIcon(part.state)
110 Text(
111 " ${part.tool}",
112 style = MaterialTheme.typography.labelLarge,
113 fontFamily = FontFamily.Monospace,
114 )
115 }
116 val detail = when (val s = part.state) {
117 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
118 is ToolError -> s.error ?: "error"
119 else -> null
120 }
121 if (detail != null) {
122 val truncated = detail.take(5000)
123 if (looksLikeDiff(truncated)) {
124 DiffView(truncated)
125 } else {
126 Text(
127 truncated.take(2000),
128 style = MaterialTheme.typography.bodySmall,
129 fontFamily = FontFamily.Monospace,
130 modifier = Modifier.padding(top = 6.dp),
131 )
132 }
133 }
134 }
137@Composable
138private fun ToolStatusIcon(state: ToolState) {
139 when (state) {
140 is ToolPending, is ToolRunning ->
141 CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
142 is ToolCompleted ->
143 Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
144 is ToolError ->
145 Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
146 is ToolUnknown ->
147 Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
148 }
151@Composable
152private fun FileChip(part: FilePart, modifier: Modifier) {
153 Text(
154 "📎 ${part.filename ?: part.url ?: "file"}",
155 modifier = modifier.padding(vertical = 2.dp),
156 style = MaterialTheme.typography.bodyMedium,
157 )