@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
99import androidx.compose.foundation.layout.Column
1010import androidx.compose.foundation.layout.Row
1111import androidx.compose.foundation.layout.fillMaxWidth
12+ import androidx.compose.foundation.layout.height
1213import androidx.compose.foundation.layout.heightIn
1314import androidx.compose.foundation.layout.padding
1415import androidx.compose.foundation.layout.size
@@ -31,6 +32,7 @@ import androidx.compose.ui.res.painterResource
3132import androidx.compose.ui.res.stringResource
3233import androidx.compose.ui.text.style.TextOverflow
3334import androidx.compose.ui.unit.dp
35+ import org.json.JSONObject
3436import com.openbitfun.mobile.app.R
3537import com.openbitfun.mobile.core.feature.session.ToolAction
3638import com.openbitfun.mobile.core.feature.session.ToolCard
@@ -212,6 +214,11 @@ internal fun ToolStatusRow(
212214 }
213215 return
214216 }
217+ if (tool.kind == com.openbitfun.mobile.core.feature.session.ToolKind .TODO ||
218+ tool.operation == ToolOperation .UPDATE_TODOS || tool.name.equals(" TodoWrite" , ignoreCase = true )) {
219+ TodoToolCard (tool = tool, enabled = enabled, modifier = modifier)
220+ return
221+ }
215222 var localExpanded by rememberSaveable(tool.id) { mutableStateOf(false ) }
216223 val disclosure = LocalToolDisclosure .current
217224 val expanded = disclosure?.let { it.selected == tool.id } ? : localExpanded
@@ -358,6 +365,79 @@ internal fun ToolStatusRow(
358365 }
359366}
360367
368+
369+ private data class TodoItem (val content : String , val status : String )
370+
371+ private fun parseTodoItems (value : String ): List <TodoItem > = runCatching {
372+ val root = JSONObject (value)
373+ val todos = root.optJSONArray(" todos" ) ? : return @runCatching emptyList()
374+ buildList {
375+ for (index in 0 until todos.length()) {
376+ val item = todos.optJSONObject(index) ? : continue
377+ val content = item.optString(" content" ).trim()
378+ if (content.isNotEmpty()) add(TodoItem (content, item.optString(" status" , " pending" )))
379+ }
380+ }
381+ }.getOrDefault(emptyList())
382+
383+ @Composable
384+ private fun TodoToolCard (tool : ToolCard , enabled : Boolean , modifier : Modifier ) {
385+ var expanded by rememberSaveable(tool.id) { mutableStateOf(tool.phase != ToolPhase .COMPLETED ) }
386+ val items = remember(tool.input, tool.output) {
387+ parseTodoItems(tool.input).ifEmpty { parseTodoItems(tool.output) }
388+ }
389+ val done = items.count { it.status.equals(" completed" , true ) }
390+ val title = stringResource(R .string.tool_op_update_todos)
391+ val active = items.firstOrNull { it.status.equals(" in_progress" , true ) }
392+ ? : items.firstOrNull { it.status.equals(" pending" , true ) }
393+ val summary = when {
394+ items.isEmpty() -> tool.target.ifBlank { stringResource(R .string.tool_todo_fallback) }
395+ done == items.size -> stringResource(R .string.tool_todo_all_completed)
396+ else -> active?.content ? : items.first().content
397+ }
398+ Column (
399+ modifier = modifier.fillMaxWidth()
400+ .background(MaterialTheme .colorScheme.surfaceVariant, RoundedCornerShape (16 .dp))
401+ .border(1 .dp, MaterialTheme .colorScheme.outlineVariant, RoundedCornerShape (16 .dp))
402+ .padding(horizontal = 12 .dp, vertical = 10 .dp),
403+ verticalArrangement = Arrangement .spacedBy(8 .dp),
404+ ) {
405+ Row (
406+ modifier = Modifier .fillMaxWidth().clickable { expanded = ! expanded },
407+ verticalAlignment = Alignment .CenterVertically ,
408+ horizontalArrangement = Arrangement .spacedBy(9 .dp),
409+ ) {
410+ ToolStatusIcon (tool, Modifier )
411+ Column (Modifier .weight(1f ), verticalArrangement = Arrangement .spacedBy(2 .dp)) {
412+ Text (title, style = MaterialTheme .typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight .SemiBold )
413+ Text (summary, style = MaterialTheme .typography.bodySmall, color = MaterialTheme .colorScheme.onSurfaceVariant,
414+ maxLines = 1 , overflow = TextOverflow .Ellipsis )
415+ }
416+ if (items.isNotEmpty()) Text (" $done /${items.size} " , style = MaterialTheme .typography.labelSmall,
417+ color = MaterialTheme .colorScheme.onSurfaceVariant)
418+ Chevron (if (expanded) R .drawable.ic_symbol_chevron_down else R .drawable.ic_symbol_chevron_right)
419+ }
420+ if (expanded && items.isNotEmpty()) {
421+ Column (Modifier .fillMaxWidth().padding(start = 29 .dp), verticalArrangement = Arrangement .spacedBy(8 .dp)) {
422+ Box (Modifier .fillMaxWidth().height(2 .dp).background(MaterialTheme .colorScheme.outlineVariant, RoundedCornerShape (2 .dp))) {
423+ Box (Modifier .fillMaxWidth(done.toFloat() / items.size.toFloat()).height(2 .dp).background(MaterialTheme .colorScheme.onSurfaceVariant, RoundedCornerShape (2 .dp)))
424+ }
425+ items.forEach { item ->
426+ Row (horizontalArrangement = Arrangement .spacedBy(8 .dp), verticalAlignment = Alignment .Top ) {
427+ Text (if (item.status.equals(" completed" , true )) " ✓" else if (item.status.equals(" cancelled" , true )) " ×" else if (item.status.equals(" in_progress" , true )) " •" else " ○" ,
428+ color = MaterialTheme .colorScheme.onSurfaceVariant)
429+ Text (item.content, style = MaterialTheme .typography.bodySmall,
430+ color = if (item.status.equals(" in_progress" , true )) MaterialTheme .colorScheme.onSurface else MaterialTheme .colorScheme.onSurfaceVariant)
431+ }
432+ }
433+ }
434+ }
435+ if (expanded && items.isEmpty() && tool.output.isNotBlank()) {
436+ ToolDetail (stringResource(R .string.tool_output), tool.output, tool.phase == ToolPhase .FAILED )
437+ }
438+ }
439+ }
440+
361441/* *
362442 * The row's one line, ported from `toolLineLabel`.
363443 *
0 commit comments