feat(#210): schema-driven tool bubble redesign with summary line and copy support (#356)

* feat(#210): add ToolSchemaRegistry with display config for each tool

* feat(#210): three-state ToolBubble with summary line, clean expanded view, copy support

* test(#210): add ToolBubbleParsingTest for schema-aware parsing

* fix(#210): CopyButton align modifier moved to call sites

* fix(#210): null-safety on durationSec in test

* fix(#210): backward compat for old session tool data formats

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 00:27:49 +03:00
1 parent 1bd56270b3
commit 067f4500aa
3 files changed
+677 -138

No files matched your search

@@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -423,88 +424,134 @@ private fun SystemBubble(
}
}
private data class ParsedToolOutput(
/**
* Cleanly parsed representation of a tool call, extracted from the
* tool.complete JSON payload (which contains both args and result).
*/
data class ParsedToolData(
val toolName: String = "",
val args: Map<String, Any?> = emptyMap(),
val result: Map<String, Any?> = emptyMap(),
val isTerminal: Boolean = false,
val stdout: String? = null,
val stderr: String? = null,
val exitCode: Int? = null,
val error: String? = null,
val summaryText: String? = null,
val durationSec: Double? = null,
val mainOutput: String? = null,
val genericFields: Map<String, String> = emptyMap(),
val extraFields: Map<String, String> = emptyMap(),
val isRunning: Boolean = false,
)
private fun parseToolOutput(content: String): ParsedToolOutput? {
/**
* Parses the tool.complete JSON payload into a [ParsedToolData].
* The gateway sends a payload with `args` and `result` sub-objects
* (see tui_gateway/server.py `_on_tool_complete`). We extract both
* and use the tool name to build a one-line summary from the relevant arg.
*/
fun parseToolOutput(
content: String,
toolName: String?,
isRunning: Boolean,
): ParsedToolData? {
val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try {
val element =
com.google.gson.JsonParser
.parseString(trimmed)
if (element.isJsonObject) {
val obj = element.asJsonObject
val element = com.google.gson.JsonParser.parseString(trimmed)
if (!element.isJsonObject) return null
val obj = element.asJsonObject
val hasStdout = obj.has("stdout")
val hasStderr = obj.has("stderr")
val hasExitCode = obj.has("exit_code") || obj.has("exitCode")
// Resolve tool name — from parameter first, then from payload (old sessions)
val resolvedToolName =
toolName
?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString
if (hasStdout || hasStderr || hasExitCode) {
val stdout =
obj
.get("stdout")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
val stderr =
obj
.get("stderr")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt
val error =
obj
.get("error")
?.takeIf { !it.isJsonNull }
?.asString
?.takeIf { it.isNotEmpty() }
val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName)
ParsedToolOutput(
isTerminal = true,
stdout = stdout,
stderr = stderr,
exitCode = exitCode,
error = error,
)
// Extract args sub-object if present (new tool.complete format)
val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
val args: Map<String, Any?> =
argsObj?.entrySet()?.associate { entry ->
entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString())
} ?: emptyMap()
// Extract result sub-object if present, fall back to top-level (old format)
val resultObj = obj.get("result")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
val dataSource = resultObj ?: obj
// Build summary line from args (new format) or context field (old tool.start)
val summaryText =
if (config.summaryArgKey != null) {
val raw = args[config.summaryArgKey]?.toString() ?: ""
val truncated = if (raw.length > 100) raw.take(100) + "…" else raw
if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null
} else {
val mainOutput =
(obj.get("output") ?: obj.get("result") ?: obj.get("content") ?: obj.get("text"))
?.takeIf { !it.isJsonNull }
?.let {
if (it.isJsonPrimitive) it.asString else it.toString()
}?.takeIf { it.isNotEmpty() }
val genericFields = mutableMapOf<String, String>()
obj.entrySet().forEach { (key, value) ->
if (key != "output" && key != "result" && key != "content" && key != "text" && !value.isJsonNull) {
val valStr = if (value.isJsonPrimitive) value.asString else value.toString()
if (valStr.isNotEmpty() && valStr != "null") {
genericFields[key] = valStr
}
}
}
ParsedToolOutput(
mainOutput = mainOutput,
genericFields = genericFields,
)
null ?: obj.get("context")?.takeIf { !it.isJsonNull }?.asString
?.takeIf { it.isNotEmpty() }
?.let { "${config.summaryPrefix}$it" }
}
} else if (element.isJsonArray) {
val arr = element.asJsonArray
ParsedToolOutput(
mainOutput = arr.toString(),
// Check if this looks like a terminal result (check dataSource for backward compat)
val hasStdout = dataSource.has("stdout")
val hasStderr = dataSource.has("stderr")
val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode")
if (hasStdout || hasStderr || hasExitCode) {
val stdout = dataSource.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val stderr = dataSource.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt
val error = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
ParsedToolData(
toolName = resolvedToolName ?: "",
args = args,
result = dataSource.entrySet().associate { it.key to it.value.toString() },
isTerminal = true,
stdout = stdout,
stderr = stderr,
exitCode = exitCode,
error = error,
summaryText = summaryText,
durationSec = duration,
isRunning = isRunning,
)
} else {
null
// Generic tool display
val mainOutput =
dataSource
.let { r ->
(r.get("output") ?: r.get("result") ?: r.get("content") ?: r.get("text"))
?.takeIf { !it.isJsonNull }
?.let { if (it.isJsonPrimitive) it.asString else it.toString() }
?.takeIf { it.isNotEmpty() }
}
val extraFields = mutableMapOf<String, String>()
dataSource.entrySet().forEach { (key, value) ->
if (key != "output" && key != "result" && key != "content" && key != "text" &&
key != "name" && key != "tool_id" && key != "context" && !value.isJsonNull
) {
val valStr = if (value.isJsonPrimitive) value.asString else value.toString()
if (valStr.isNotEmpty() && valStr != "null") {
extraFields[key] = valStr
}
}
}
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
ParsedToolData(
toolName = resolvedToolName ?: "",
args = args,
result = dataSource.entrySet().associate { it.key to it.value.toString() },
summaryText = summaryText,
mainOutput = mainOutput,
extraFields = extraFields,
durationSec = duration,
isRunning = isRunning,
)
}
} catch (e: Exception) {
null
@@ -512,12 +559,27 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
}
@Composable
private fun ParsedToolContent(
parsed: ParsedToolOutput,
private fun ExpandedToolContent(
parsed: ParsedToolData,
contentColor: Color,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Summary line at top of expanded view
if (parsed.summaryText != null) {
Text(
text = parsed.summaryText,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
Spacer(modifier = Modifier.height(2.dp))
}
if (parsed.isTerminal) {
// ── Terminal output ──
parsed.stdout?.let {
Text(
text = it,
@@ -564,7 +626,19 @@ private fun ParsedToolContent(
)
}
}
// Duration footer for terminal
parsed.durationSec?.let { dur ->
Text(
text = "Duration: ${"%.1f".format(dur)}s",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
),
)
}
} else {
// ── Generic tool output ──
parsed.mainOutput?.let {
Text(
text = it,
@@ -575,7 +649,7 @@ private fun ParsedToolContent(
),
)
}
parsed.genericFields.forEach { (key, value) ->
parsed.extraFields.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -598,6 +672,17 @@ private fun ParsedToolContent(
)
}
}
// Duration footer
parsed.durationSec?.let { dur ->
Text(
text = "Duration: ${"%.1f".format(dur)}s",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
),
)
}
}
}
}
@@ -613,7 +698,22 @@ private fun ToolBubble(
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black
val parsedOutput = remember(message.content) { parseToolOutput(message.content) }
val parsed =
remember(message.content, message.toolName, message.toolStatus) {
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
}
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
val clipboardManager = LocalClipboardManager.current
var showCopyButton by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds
LaunchedEffect(showCopyButton) {
if (showCopyButton) {
delay(4000)
showCopyButton = false
}
}
Box(
modifier =
@@ -633,96 +733,131 @@ private fun ToolBubble(
.animateContentSize()
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val (icon, tint) =
when (message.toolStatus) {
ToolStatus.RUNNING -> Icons.Filled.Build to MaterialTheme.colorScheme.secondary
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle to StatusGreen
ToolStatus.FAILED -> Icons.Filled.Error to StatusRed
null -> Icons.Filled.Build to contentColor.copy(alpha = 0.6f)
}
if (message.toolStatus == ToolStatus.RUNNING) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.secondary,
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = tint,
)
}
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor)
// ── Summary line (always visible when collapsed) ──
if (!expanded && parsed?.summaryText != null) {
Text(
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
text = parsed.summaryText,
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
)
}
if (expanded && message.content.isNotBlank()) {
Column(modifier = Modifier.padding(top = 6.dp)) {
if (parsedOutput != null && !showRawJson) {
ParsedToolContent(parsedOutput, contentColor)
// ── Expanded content ──
if (expanded) {
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_tool_show_raw),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("chat_tool_show_raw")
.clickable(role = Role.Button) { showRawJson = true },
)
} else {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
if (parsedOutput != null) {
Spacer(modifier = Modifier.height(8.dp))
if (showRawJson) {
// Raw JSON view — selectable + copy button
Box {
SelectionContainer {
Text(
text = stringResource(R.string.chat_tool_show_parsed),
text = message.content,
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
modifier =
Modifier
.testTag("chat_tool_show_parsed")
.clickable(role = Role.Button) { showRawJson = false },
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
clipboardManager = clipboardManager,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_tool_show_parsed),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("chat_tool_show_parsed")
.clickable(role = Role.Button) { showRawJson = false },
)
} else if (parsed != null) {
// Clean structured expanded view
Box {
SelectionContainer {
ExpandedToolContent(parsed, contentColor)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
clipboardManager = clipboardManager,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_tool_show_raw),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("chat_tool_show_raw")
.clickable(role = Role.Button) { showRawJson = true },
)
} else {
// Unparseable content — show raw JSON, selectable
Box {
SelectionContainer {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
clipboardManager = clipboardManager,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
}
}
// Timestamp — consistent with UserBubble/AssistantBubble
val textColor = if (isDarkTheme) Color.White else Color.Black
// ── Timestamp ──
Text(
text = formatTimestamp(message.timestamp),
color = textColor.copy(alpha = 0.5f),
color = contentColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
)
@@ -731,6 +866,92 @@ private fun ToolBubble(
}
}
@Composable
private fun HeaderRow(
message: ChatMessage,
config: ToolDisplayConfig,
contentColor: Color,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Status icon or spinner
if (message.toolStatus == ToolStatus.RUNNING) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.secondary,
)
} else {
val icon =
when (message.toolStatus) {
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle
ToolStatus.FAILED -> Icons.Filled.Error
else -> Icons.Filled.Build
}
val tint =
when (message.toolStatus) {
ToolStatus.COMPLETED -> StatusGreen
ToolStatus.FAILED -> StatusRed
else -> contentColor.copy(alpha = 0.6f)
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = tint,
)
}
Text(
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
fontFamily = FontFamily.Monospace,
),
)
}
}
@Composable
private fun CopyButton(
visible: Boolean,
textToCopy: String,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
onCopy: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = modifier,
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(textToCopy))
onCopy()
},
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
/**
* Simple rich text renderer supporting **bold**, `inline code`, ```code blocks```, and clickable URLs.
*/
@@ -0,0 +1,111 @@
package com.m57.hermescontrol.ui.chat
/**
* Display configuration for a Hermes tool — controls the summary line,
* icon, and which arg field is shown when the bubble is collapsed.
*
* Based on the canonical tool schemas defined in the Hermes Agent source
* at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict).
*/
data class ToolDisplayConfig(
val name: String,
/** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */
val summaryArgKey: String? = null,
/** Prefix for the summary line (e.g. "$ " for terminal, "📄 " for read_file). */
val summaryPrefix: String = "",
/** Emoji icon for this tool type. */
val iconEmoji: String = "🔧",
)
/** Maps tool names to their display config. Ordered by expected frequency of use. */
object ToolSchemaRegistry {
private val knownTools: Map<String, ToolDisplayConfig> =
mapOf(
"terminal" to
ToolDisplayConfig(
name = "terminal",
summaryArgKey = "command",
summaryPrefix = "$ ",
iconEmoji = "💻",
),
"read_file" to
ToolDisplayConfig(
name = "read_file",
summaryArgKey = "path",
summaryPrefix = "📄 ",
iconEmoji = "📄",
),
"write_file" to
ToolDisplayConfig(
name = "write_file",
summaryArgKey = "path",
summaryPrefix = "✏️ ",
iconEmoji = "✏️",
),
"patch" to
ToolDisplayConfig(
name = "patch",
summaryArgKey = "path",
summaryPrefix = "🔧 ",
iconEmoji = "🔧",
),
"search_files" to
ToolDisplayConfig(
name = "search_files",
summaryArgKey = "pattern",
summaryPrefix = "🔍 ",
iconEmoji = "🔍",
),
"web_search" to
ToolDisplayConfig(
name = "web_search",
summaryArgKey = "query",
summaryPrefix = "🌐 ",
iconEmoji = "🌐",
),
"browser_navigate" to
ToolDisplayConfig(
name = "browser_navigate",
summaryArgKey = "url",
summaryPrefix = "🌍 ",
iconEmoji = "🌍",
),
"browser_click" to
ToolDisplayConfig(
name = "browser_click",
summaryArgKey = "ref",
summaryPrefix = "🖱 ",
iconEmoji = "🖱",
),
"browser_snapshot" to
ToolDisplayConfig(
name = "browser_snapshot",
summaryArgKey = null,
iconEmoji = "📋",
),
"clarify" to
ToolDisplayConfig(
name = "clarify",
summaryArgKey = "question",
summaryPrefix = "💬 ",
iconEmoji = "💬",
),
"delegate_task" to
ToolDisplayConfig(
name = "delegate_task",
summaryArgKey = "goal",
summaryPrefix = "🔄 ",
iconEmoji = "🔄",
),
"execute_code" to
ToolDisplayConfig(
name = "execute_code",
summaryArgKey = "code",
summaryPrefix = "▶️ ",
iconEmoji = "▶️",
),
)
fun getDisplayConfig(toolName: String?): ToolDisplayConfig =
toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool")
}