@@ -0,0 +1,165 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.heightIn
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.verticalScroll
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Autorenew
18+import androidx.compose.material.icons.filled.Cancel
19+import androidx.compose.material.icons.filled.CheckCircle
20+import androidx.compose.material.icons.filled.Checklist
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.RadioButtonUnchecked
24+import androidx.compose.material3.Icon
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.Surface
27+import androidx.compose.material3.Text
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableStateOf
31+import androidx.compose.runtime.remember
32+import androidx.compose.runtime.saveable.rememberSaveable
33+import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.semantics.stateDescription
40+import androidx.compose.ui.text.font.FontWeight
41+import androidx.compose.ui.text.style.TextDecoration
42+import androidx.compose.ui.unit.dp
43+import soy.iko.opencode.R
44+import soy.iko.opencode.data.model.TodoItem
45+import soy.iko.opencode.data.model.TodoStatus
46+import soy.iko.opencode.data.model.statusEnum
47+
48+/** Renders a task plan as a checklist: a status icon per row plus its description, with
49+ * completed/cancelled items struck through and the in-progress item emphasized. */
50+@Composable
51+fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
52+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
53+ for (todo in todos) TodoRow(todo)
54+ }
55+}
56+
57+@Composable
58+private fun TodoRow(todo: TodoItem) {
59+ val status = todo.statusEnum()
60+ val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
61+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
62+ TodoStatusIcon(status)
63+ Text(
64+ todo.content,
65+ style = MaterialTheme.typography.bodySmall,
66+ color = when {
67+ status == TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
68+ struck -> MaterialTheme.colorScheme.onSurfaceVariant
69+ else -> MaterialTheme.colorScheme.onSurface
70+ },
71+ fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
72+ textDecoration = if (struck) TextDecoration.LineThrough else null,
73+ modifier = Modifier.padding(start = 8.dp),
74+ )
75+ }
76+}
77+
78+@Composable
79+private fun TodoStatusIcon(status: TodoStatus) {
80+ val icon = when (status) {
81+ TodoStatus.COMPLETED -> Icons.Filled.CheckCircle
82+ TodoStatus.IN_PROGRESS -> Icons.Filled.Autorenew
83+ TodoStatus.CANCELLED -> Icons.Filled.Cancel
84+ else -> Icons.Filled.RadioButtonUnchecked
85+ }
86+ val tint = when (status) {
87+ TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
88+ else -> MaterialTheme.colorScheme.onSurfaceVariant
89+ }
90+ val desc = when (status) {
91+ TodoStatus.COMPLETED -> R.string.todo_completed
92+ TodoStatus.IN_PROGRESS -> R.string.todo_in_progress
93+ TodoStatus.CANCELLED -> R.string.todo_cancelled
94+ else -> R.string.todo_pending
95+ }
96+ Icon(
97+ icon,
98+ contentDescription = stringResource(desc),
99+ tint = tint,
100+ modifier = Modifier.size(16.dp),
101+ )
102+}
103+
104+/** A collapsible plan bar pinned above the composer: the header shows completed/total and
105+ * a chevron; expanding reveals the full (scrollable, height-capped) checklist. Renders
106+ * nothing when there's no plan, so the caller can place it unconditionally. */
107+@Composable
108+fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
109+ if (todos.isEmpty()) return
110+ var expanded by rememberSaveable { mutableStateOf(false) }
111+ val completed = remember(todos) { todos.count { it.statusEnum() == TodoStatus.COMPLETED } }
112+ val expandedState = stringResource(R.string.state_expanded)
113+ val collapsedState = stringResource(R.string.state_collapsed)
114+ Surface(
115+ color = MaterialTheme.colorScheme.surfaceVariant,
116+ tonalElevation = 2.dp,
117+ modifier = modifier.fillMaxWidth(),
118+ ) {
119+ Column {
120+ Row(
121+ modifier = Modifier
122+ .fillMaxWidth()
123+ .defaultMinSize(minHeight = 44.dp)
124+ .clickable(role = Role.Button) { expanded = !expanded }
125+ .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126+ .padding(horizontal = 12.dp, vertical = 6.dp),
127+ verticalAlignment = Alignment.CenterVertically,
128+ ) {
129+ Icon(
130+ Icons.Filled.Checklist,
131+ contentDescription = null,
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
133+ modifier = Modifier.size(18.dp),
134+ )
135+ Text(
136+ stringResource(R.string.plan),
137+ style = MaterialTheme.typography.labelLarge,
138+ modifier = Modifier.padding(start = 8.dp),
139+ )
140+ Text(
141+ stringResource(R.string.plan_progress, completed, todos.size),
142+ style = MaterialTheme.typography.labelMedium,
143+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ modifier = Modifier.padding(start = 8.dp),
145+ )
146+ Spacer(Modifier.weight(1f))
147+ Icon(
148+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
149+ contentDescription = null,
150+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
151+ modifier = Modifier.size(20.dp),
152+ )
153+ }
154+ AnimatedVisibility(visible = expanded) {
155+ TodoPlanChecklist(
156+ todos,
157+ modifier = Modifier
158+ .heightIn(max = 240.dp)
159+ .verticalScroll(rememberScrollState())
160+ .padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
161+ )
162+ }
163+ }
164+ }
165+}