From 4b421b4817fc137cd3e41412211e7521b66e9355 Mon Sep 17 00:00:00 2001 From: "mihail.olshevsky" Date: Wed, 12 Aug 2026 21:20:32 +0300 Subject: [PATCH] Improve todo and reasoning presentation --- .../p4oc/ui/components/chat/ChatMessage.kt | 35 ++++- .../components/toolwidgets/TodoToolWidget.kt | 136 ++++++++++++++++++ .../components/toolwidgets/ToolCallWidget.kt | 6 + app/src/main/res/values/strings.xml | 1 + .../ui/components/chat/ReasoningTitleTest.kt | 45 ++++++ .../toolwidgets/TodoToolWidgetTest.kt | 63 ++++++++ 6 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidget.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/chat/ReasoningTitleTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidgetTest.kt diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt index ac4f517..383f521 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.* @@ -31,6 +32,8 @@ import dev.blazelight.p4oc.ui.components.toolwidgets.ToolWidgetState import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive @Composable @Suppress("LongParameterList", "FunctionNaming") @@ -401,11 +404,16 @@ private fun ReasoningPart(part: Part.Reasoning) { } } + val detailTitle = reasoningDetailTitle(part) Text( - text = stringResource(R.string.models_reasoning), + text = detailTitle?.let { + stringResource(R.string.reasoning_with_title, it) + } ?: stringResource(R.string.models_reasoning), style = MaterialTheme.typography.labelSmall, color = theme.warning, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Icon( @@ -442,6 +450,29 @@ private fun ReasoningPart(part: Part.Reasoning) { } } +internal fun reasoningDetailTitle(part: Part.Reasoning): String? { + val metadataTitle = listOf("title", "summary", "subject", "heading") + .firstNotNullOfOrNull { key -> part.metadata?.get(key)?.jsonPrimitive?.contentOrNull } + ?.trim() + ?.takeIf { it.isNotBlank() } + val source = metadataTitle ?: part.text.lineSequence() + .map(String::trim) + .firstOrNull { it.isNotBlank() } + ?.trimStart('#') + ?.trim() + return source + ?.stripReasoningTitleMarkdown() + ?.replace(Regex("\\s+"), " ") + ?.take(REASONING_TITLE_MAX_CHARS) + ?.takeIf { it.isNotBlank() && !it.equals("reasoning", ignoreCase = true) } +} + +private fun String.stripReasoningTitleMarkdown(): String = + replace("**", "") + .replace("__", "") + +private const val REASONING_TITLE_MAX_CHARS = 80 + @Composable private fun FilePart(part: Part.File) { val theme = LocalOpenCodeTheme.current diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidget.kt new file mode 100644 index 0000000..c5b17a5 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidget.kt @@ -0,0 +1,136 @@ +package dev.blazelight.p4oc.ui.components.toolwidgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDecoration +import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.Todo +import dev.blazelight.p4oc.ui.components.todo.TODO_STATUS_CANCELLED +import dev.blazelight.p4oc.ui.components.todo.TODO_STATUS_COMPLETED +import dev.blazelight.p4oc.ui.components.todo.TODO_STATUS_IN_PROGRESS +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.SemanticColors +import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiCodeFontSize +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +internal fun todosFromToolInput(input: JsonObject): List? { + val values = input["todos"] as? JsonArray ?: return null + return values.mapIndexedNotNull { index, element -> + val item = element as? JsonObject ?: return@mapIndexedNotNull null + val content = item["content"]?.jsonPrimitive?.contentOrNull + ?.takeIf { it.isNotBlank() } + ?: return@mapIndexedNotNull null + Todo( + id = item["id"]?.jsonPrimitive?.contentOrNull ?: "tool-todo-$index", + content = content, + status = item["status"]?.jsonPrimitive?.contentOrNull ?: "pending", + priority = item["priority"]?.jsonPrimitive?.contentOrNull ?: "medium", + ) + } +} + +internal fun todoCompactDescription(tool: Part.Tool): String { + val todos = todosFromToolInput(tool.state.input) ?: return tool.toolName + val completed = todos.count { it.status == TODO_STATUS_COMPLETED || it.status == TODO_STATUS_CANCELLED } + return "Todos $completed/${todos.size}" +} + +@Composable +@Suppress("FunctionNaming") +internal fun TodoWidgetExpanded( + tool: Part.Tool, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val todos = todosFromToolInput(tool.state.input) + if (todos == null) { + DefaultWidgetExpanded( + tool = tool, + onClick = onClick, + showApprovalActions = false, + onToolApprove = {}, + onToolDeny = {}, + modifier = modifier, + ) + return + } + + val theme = LocalOpenCodeTheme.current + Column( + modifier = modifier + .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier) + .background(theme.backgroundPanel.copy(alpha = 0.5f)) + .padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = todoCompactDescription(tool), + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = TuiCodeFontSize.lg, + ), + color = theme.text, + ) + todos.forEach { todo -> TodoToolRow(todo) } + } +} + +@Composable +@Suppress("FunctionNaming") +private fun TodoToolRow(todo: Todo) { + val theme = LocalOpenCodeTheme.current + val completed = todo.status == TODO_STATUS_COMPLETED + val cancelled = todo.status == TODO_STATUS_CANCELLED + val statusColor = when (todo.status) { + TODO_STATUS_IN_PROGRESS -> SemanticColors.Todo.inProgress + TODO_STATUS_COMPLETED -> SemanticColors.Todo.completed + TODO_STATUS_CANCELLED -> SemanticColors.Todo.cancelled + else -> SemanticColors.Todo.pending + } + val marker = when (todo.status) { + TODO_STATUS_IN_PROGRESS -> "▶" + TODO_STATUS_COMPLETED -> "✓" + TODO_STATUS_CANCELLED -> "×" + else -> "○" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(theme.backgroundElement) + .padding(Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.Top, + ) { + Text(marker, color = statusColor, style = MaterialTheme.typography.labelMedium) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + text = todo.content, + style = MaterialTheme.typography.bodySmall, + color = if (completed || cancelled) theme.textMuted else theme.text, + textDecoration = if (completed || cancelled) TextDecoration.LineThrough else null, + ) + Text( + text = "[${todo.priority.uppercase()}]", + style = MaterialTheme.typography.labelSmall, + color = SemanticColors.Todo.forPriority(todo.priority), + ) + } + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt index 6e56bc3..a725855 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolCallWidget.kt @@ -242,6 +242,11 @@ fun ToolCallExpanded( onOpenSubSession = onOpenSubSession, modifier = modifier ) + "todowrite", "todoread" -> TodoWidgetExpanded( + tool = tool, + onClick = onClick, + modifier = modifier, + ) else -> DefaultWidgetExpanded( tool = tool, onClick = onClick, @@ -303,6 +308,7 @@ private fun getToolCompactDescription(tool: Part.Tool): String { val pattern = extractParam(input, "pattern") ?: extractParam(input, "substring_pattern") pattern?.take(40)?.let { "Search: $it" } ?: tool.toolName } + name in listOf("todowrite", "todoread") -> todoCompactDescription(tool) else -> tool.toolName } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b12345..73ee209 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -486,6 +486,7 @@ Context: %s Tools Reasoning + Reasoning · %1$s Could not load models Check the server connection, then try again. No models available diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/ReasoningTitleTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/ReasoningTitleTest.kt new file mode 100644 index 0000000..97a0f2f --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/ReasoningTitleTest.kt @@ -0,0 +1,45 @@ +package dev.blazelight.p4oc.ui.components.chat + +import dev.blazelight.p4oc.domain.model.Part +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReasoningTitleTest { + @Test + fun `prefers server metadata title`() { + val part = reasoning( + text = "Fallback first line", + metadata = buildJsonObject { put("title", "Checking session persistence") }, + ) + + assertEquals("Checking session persistence", reasoningDetailTitle(part)) + } + + @Test + fun `uses cleaned first meaningful reasoning line as fallback`() { + val part = reasoning(text = "\n## Comparing server and local state\nMore detail") + + assertEquals("Comparing server and local state", reasoningDetailTitle(part)) + } + + @Test + fun `removes bold markers from collapsed title only`() { + val part = reasoning(text = "**Adding full tests and formatting fixes**\nMore detail") + + assertEquals("Adding full tests and formatting fixes", reasoningDetailTitle(part)) + assertEquals("**Adding full tests and formatting fixes**\nMore detail", part.text) + } + + private fun reasoning( + text: String, + metadata: kotlinx.serialization.json.JsonObject? = null, + ) = Part.Reasoning( + id = "part", + sessionID = "session", + messageID = "message", + text = text, + metadata = metadata, + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidgetTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidgetTest.kt new file mode 100644 index 0000000..23c0449 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/toolwidgets/TodoToolWidgetTest.kt @@ -0,0 +1,63 @@ +package dev.blazelight.p4oc.ui.components.toolwidgets + +import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.ToolState +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TodoToolWidgetTest { + @Test + fun `parses todo write input without ids and applies safe defaults`() { + val input = buildJsonObject { + put( + "todos", + buildJsonArray { + add( + buildJsonObject { + put("content", "Check persistence") + put("status", "in_progress") + put("priority", "high") + } + ) + add( + buildJsonObject { + put("content", "Run tests") + put("status", "completed") + } + ) + } + ) + } + + val todos = todosFromToolInput(input).orEmpty() + + assertEquals(2, todos.size) + assertEquals("tool-todo-0", todos.first().id) + assertEquals("medium", todos.last().priority) + assertEquals("Todos 1/2", todoCompactDescription(tool(input))) + } + + @Test + fun `returns null when input does not contain todo array`() { + assertNull(todosFromToolInput(buildJsonObject { put("content", "not a list") })) + } + + private fun tool(input: kotlinx.serialization.json.JsonObject) = Part.Tool( + id = "part", + sessionID = "session", + messageID = "message", + callID = "call", + toolName = "todowrite", + state = ToolState.Completed( + input = input, + output = "[]", + title = "Updated todo list", + startedAt = 1, + endedAt = 2, + ), + ) +}