* 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:
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.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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 isTerminal: Boolean = false,
|
||||||
val stdout: String? = null,
|
val stdout: String? = null,
|
||||||
val stderr: String? = null,
|
val stderr: String? = null,
|
||||||
val exitCode: Int? = null,
|
val exitCode: Int? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val summaryText: String? = null,
|
||||||
|
val durationSec: Double? = null,
|
||||||
val mainOutput: String? = 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()
|
val trimmed = content.trim()
|
||||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
|
||||||
return try {
|
return try {
|
||||||
val element =
|
val element = com.google.gson.JsonParser.parseString(trimmed)
|
||||||
com.google.gson.JsonParser
|
if (!element.isJsonObject) return null
|
||||||
.parseString(trimmed)
|
val obj = element.asJsonObject
|
||||||
if (element.isJsonObject) {
|
|
||||||
val obj = element.asJsonObject
|
|
||||||
|
|
||||||
val hasStdout = obj.has("stdout")
|
// Resolve tool name — from parameter first, then from payload (old sessions)
|
||||||
val hasStderr = obj.has("stderr")
|
val resolvedToolName =
|
||||||
val hasExitCode = obj.has("exit_code") || obj.has("exitCode")
|
toolName
|
||||||
|
?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString
|
||||||
|
|
||||||
if (hasStdout || hasStderr || hasExitCode) {
|
val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName)
|
||||||
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() }
|
|
||||||
|
|
||||||
ParsedToolOutput(
|
// Extract args sub-object if present (new tool.complete format)
|
||||||
isTerminal = true,
|
val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
|
||||||
stdout = stdout,
|
val args: Map<String, Any?> =
|
||||||
stderr = stderr,
|
argsObj?.entrySet()?.associate { entry ->
|
||||||
exitCode = exitCode,
|
entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString())
|
||||||
error = error,
|
} ?: 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 {
|
} else {
|
||||||
val mainOutput =
|
null ?: obj.get("context")?.takeIf { !it.isJsonNull }?.asString
|
||||||
(obj.get("output") ?: obj.get("result") ?: obj.get("content") ?: obj.get("text"))
|
?.takeIf { it.isNotEmpty() }
|
||||||
?.takeIf { !it.isJsonNull }
|
?.let { "${config.summaryPrefix}$it" }
|
||||||
?.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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if (element.isJsonArray) {
|
|
||||||
val arr = element.asJsonArray
|
// Check if this looks like a terminal result (check dataSource for backward compat)
|
||||||
ParsedToolOutput(
|
val hasStdout = dataSource.has("stdout")
|
||||||
mainOutput = arr.toString(),
|
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 {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
@@ -512,12 +559,27 @@ private fun parseToolOutput(content: String): ParsedToolOutput? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ParsedToolContent(
|
private fun ExpandedToolContent(
|
||||||
parsed: ParsedToolOutput,
|
parsed: ParsedToolData,
|
||||||
contentColor: Color,
|
contentColor: Color,
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
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) {
|
if (parsed.isTerminal) {
|
||||||
|
// ── Terminal output ──
|
||||||
parsed.stdout?.let {
|
parsed.stdout?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
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 {
|
} else {
|
||||||
|
// ── Generic tool output ──
|
||||||
parsed.mainOutput?.let {
|
parsed.mainOutput?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
@@ -575,7 +649,7 @@ private fun ParsedToolContent(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
parsed.genericFields.forEach { (key, value) ->
|
parsed.extraFields.forEach { (key, value) ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
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 chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
|
||||||
val contentColor = if (isDarkTheme) Color.White else Color.Black
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -633,96 +733,131 @@ private fun ToolBubble(
|
|||||||
.animateContentSize()
|
.animateContentSize()
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
// ── Header row: icon + tool name ──
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
HeaderRow(message, config, contentColor)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── Summary line (always visible when collapsed) ──
|
||||||
|
if (!expanded && parsed?.summaryText != null) {
|
||||||
Text(
|
Text(
|
||||||
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
text = parsed.summaryText,
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelMedium.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = contentColor,
|
color = contentColor.copy(alpha = 0.7f),
|
||||||
fontFamily = FontFamily.Monospace,
|
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()) {
|
// ── Expanded content ──
|
||||||
Column(modifier = Modifier.padding(top = 6.dp)) {
|
if (expanded) {
|
||||||
if (parsedOutput != null && !showRawJson) {
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
ParsedToolContent(parsedOutput, contentColor)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
if (showRawJson) {
|
||||||
Text(
|
// Raw JSON view — selectable + copy button
|
||||||
text = stringResource(R.string.chat_tool_show_raw),
|
Box {
|
||||||
style =
|
SelectionContainer {
|
||||||
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))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.chat_tool_show_parsed),
|
text = message.content,
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelSmall.copy(
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = contentColor.copy(alpha = 0.8f),
|
||||||
textDecoration = TextDecoration.Underline,
|
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
|
// ── Timestamp ──
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
Text(
|
Text(
|
||||||
text = formatTimestamp(message.timestamp),
|
text = formatTimestamp(message.timestamp),
|
||||||
color = textColor.copy(alpha = 0.5f),
|
color = contentColor.copy(alpha = 0.5f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
|
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.
|
* 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ToolBubbleParsingTest {
|
||||||
|
// ── Terminal ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseTerminal_withStdout() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_001",
|
||||||
|
"name": "terminal",
|
||||||
|
"args": {"command": "npm run build"},
|
||||||
|
"result": {"stdout": "Build succeeded!", "exit_code": 0},
|
||||||
|
"duration_s": 5.2
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "terminal", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("terminal", parsed!!.toolName)
|
||||||
|
assertTrue(parsed.isTerminal)
|
||||||
|
assertEquals("Build succeeded!", parsed.stdout)
|
||||||
|
assertEquals(0, parsed.exitCode)
|
||||||
|
assertEquals(5.2, parsed.durationSec!!, 0.01)
|
||||||
|
assertEquals("$ npm run build", parsed.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseTerminal_withStderr() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_002",
|
||||||
|
"name": "terminal",
|
||||||
|
"args": {"command": "rm -rf /"},
|
||||||
|
"result": {"stderr": "Permission denied", "exit_code": 1}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "terminal", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertTrue(parsed!!.isTerminal)
|
||||||
|
assertEquals("Permission denied", parsed.stderr)
|
||||||
|
assertEquals(1, parsed.exitCode)
|
||||||
|
assertEquals("$ rm -rf /", parsed.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File tools ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseReadFile() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_003",
|
||||||
|
"name": "read_file",
|
||||||
|
"args": {"path": "/opt/hermes/config.yaml"},
|
||||||
|
"result": {"content": "port: 8080\nhost: 0.0.0.0"}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "read_file", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertFalse(parsed!!.isTerminal)
|
||||||
|
assertEquals("📄 /opt/hermes/config.yaml", parsed.summaryText)
|
||||||
|
assertEquals("port: 8080\nhost: 0.0.0.0", parsed.mainOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseWriteFile() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_004",
|
||||||
|
"name": "write_file",
|
||||||
|
"args": {"path": "/tmp/output.txt"},
|
||||||
|
"result": {"content": "File written successfully"}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "write_file", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("✏️ /tmp/output.txt", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParsePatch() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_005",
|
||||||
|
"name": "patch",
|
||||||
|
"args": {"path": "src/main.kt"},
|
||||||
|
"result": {"success": true}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "patch", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("🔧 src/main.kt", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseSearchFiles() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_006",
|
||||||
|
"name": "search_files",
|
||||||
|
"args": {"pattern": "*.kt", "path": "/opt/hermes-mobile"},
|
||||||
|
"result": {"matches_data": ["file1.kt", "file2.kt"]}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "search_files", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("🔍 *.kt", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web / Browser ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseWebSearch() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_007",
|
||||||
|
"name": "web_search",
|
||||||
|
"args": {"query": "Kotlin Coroutines guide"},
|
||||||
|
"result": {"results_data": ["..."]}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "web_search", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("🌐 Kotlin Coroutines guide", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseBrowserNavigate() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_008",
|
||||||
|
"name": "browser_navigate",
|
||||||
|
"args": {"url": "https://example.com"},
|
||||||
|
"result": {"title": "Example Domain"}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "browser_navigate", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("🌍 https://example.com", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseClarify() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_009",
|
||||||
|
"name": "clarify",
|
||||||
|
"args": {"question": "Which environment?"},
|
||||||
|
"result": {"user_response": "staging"}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "clarify", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("💬 Which environment?", parsed!!.summaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Running state ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseRunningState() {
|
||||||
|
val json =
|
||||||
|
"""{"tool_id": "call_010", "name": "terminal", "args": {"command": "sleep 10"}}"""
|
||||||
|
val parsed = parseToolOutput(json, "terminal", true)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertTrue(parsed!!.isRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unknown tool fallback ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseUnknownTool_noSummary() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_011",
|
||||||
|
"name": "weird_tool",
|
||||||
|
"args": {"input": "data"},
|
||||||
|
"result": {"output": "processed"}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "weird_tool", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertNull(parsed!!.summaryText) // no known config → no summary
|
||||||
|
assertEquals("processed", parsed.mainOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Non-JSON content fallback ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseNonJson_returnsNull() {
|
||||||
|
val parsed = parseToolOutput("just plain text", "terminal", false)
|
||||||
|
assertNull(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty args ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseTerminal_withoutArgs() {
|
||||||
|
val json =
|
||||||
|
"""{
|
||||||
|
"tool_id": "call_012",
|
||||||
|
"name": "terminal",
|
||||||
|
"result": {"stdout": "done", "exit_code": 0}
|
||||||
|
}"""
|
||||||
|
val parsed = parseToolOutput(json, "terminal", false)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertTrue(parsed!!.isTerminal)
|
||||||
|
assertEquals("done", parsed.stdout)
|
||||||
|
assertEquals(0, parsed.exitCode)
|
||||||
|
assertNull(parsed.summaryText) // no args → no summary
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user