* 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.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")
|
||||
}
|
||||
Reference in new issue
Block a user