Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Todo>? {
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),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@
<string name="models_context_format">Context: %s</string>
<string name="models_tools">Tools</string>
<string name="models_reasoning">Reasoning</string>
<string name="reasoning_with_title">Reasoning · %1$s</string>
<string name="models_load_failed_title">Could not load models</string>
<string name="models_load_failed_description">Check the server connection, then try again.</string>
<string name="models_empty_title">No models available</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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,
),
)
}