feat: centralize tool display metadata

This commit is contained in:
Peter Steinberger
2026-01-03 13:17:58 +01:00
parent bf4ad295af
commit 6e16c0699a
19 changed files with 1850 additions and 142 deletions

View File

@@ -8,6 +8,7 @@
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
### Fixes
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.

View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

View File

@@ -361,10 +361,12 @@ class ChatController(
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
if (phase == "start") {
val args = data?.get("args").asObjectOrNull()
pendingToolCallsById[toolCallId] =
ChatPendingToolCall(
toolCallId = toolCallId,
name = name,
args = args,
startedAtMs = ts,
isError = null,
)

View File

@@ -18,6 +18,7 @@ data class ChatMessageContent(
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long,
val isError: Boolean? = null,
)

View File

@@ -0,0 +1,220 @@
package com.clawdis.android.tools
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
@Serializable
private data class ToolDisplayActionSpec(
val label: String? = null,
val detailKeys: List<String>? = null,
)
@Serializable
private data class ToolDisplaySpec(
val emoji: String? = null,
val title: String? = null,
val label: String? = null,
val detailKeys: List<String>? = null,
val actions: Map<String, ToolDisplayActionSpec>? = null,
)
@Serializable
private data class ToolDisplayConfig(
val version: Int? = null,
val fallback: ToolDisplaySpec? = null,
val tools: Map<String, ToolDisplaySpec>? = null,
)
data class ToolDisplaySummary(
val name: String,
val emoji: String,
val title: String,
val label: String,
val verb: String?,
val detail: String?,
) {
val detailLine: String?
get() {
val parts = mutableListOf<String>()
if (!verb.isNullOrBlank()) parts.add(verb)
if (!detail.isNullOrBlank()) parts.add(detail)
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
val summaryLine: String
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
}
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
private val json = Json { ignoreUnknownKeys = true }
@Volatile private var cachedConfig: ToolDisplayConfig? = null
fun resolve(
context: Context,
name: String?,
args: JsonObject?,
meta: String? = null,
): ToolDisplaySummary {
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
val key = trimmedName.lowercase()
val config = loadConfig(context)
val spec = config.tools?.get(key)
val fallback = config.fallback
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
val title = spec?.title ?: titleFromName(trimmedName)
val label = spec?.label ?: trimmedName
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
val action = actionRaw?.takeIf { it.isNotEmpty() }
val actionSpec = action?.let { spec?.actions?.get(it) }
val verb = normalizeVerb(actionSpec?.label ?: action)
var detail: String? = null
if (key == "read") {
detail = readDetail(args)
} else if (key == "write" || key == "edit" || key == "attach") {
detail = pathDetail(args)
}
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
}
if (detail == null) {
detail = meta
}
if (detail != null) {
detail = shortenHomeInString(detail)
}
return ToolDisplaySummary(
name = trimmedName,
emoji = emoji,
title = title,
label = label,
verb = verb,
detail = detail,
)
}
private fun loadConfig(context: Context): ToolDisplayConfig {
val existing = cachedConfig
if (existing != null) return existing
return try {
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
cachedConfig = decoded
decoded
} catch (_: Throwable) {
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback
}
}
private fun titleFromName(name: String): String {
val cleaned = name.replace("_", " ").trim()
if (cleaned.isEmpty()) return "Tool"
return cleaned
.split(Regex("\\s+"))
.joinToString(" ") { part ->
val upper = part.uppercase()
if (part.length <= 2 && part == upper) part
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
}
}
private fun normalizeVerb(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) return null
return trimmed.replace("_", " ")
}
private fun readDetail(args: JsonObject?): String? {
val path = args?.get("path")?.asStringOrNull() ?: return null
val offset = args["offset"].asNumberOrNull()
val limit = args["limit"].asNumberOrNull()
return if (offset != null && limit != null) {
val end = offset + limit
"${path}:${offset.toInt()}-${end.toInt()}"
} else {
path
}
}
private fun pathDetail(args: JsonObject?): String? {
return args?.get("path")?.asStringOrNull()
}
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
for (key in keys) {
val value = valueForPath(args, key)
val rendered = renderValue(value)
if (!rendered.isNullOrBlank()) return rendered
}
return null
}
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
var current: JsonElement? = args
for (segment in path.split(".")) {
if (segment.isBlank()) return null
val obj = current as? JsonObject ?: return null
current = obj[segment]
}
return current
}
private fun renderValue(value: JsonElement?): String? {
if (value == null) return null
if (value is JsonPrimitive) {
if (value.isString) {
val trimmed = value.contentOrNull?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
if (firstLine.isEmpty()) return null
return if (firstLine.length > 160) "${firstLine.take(157)}" else firstLine
}
value.booleanOrNull?.let { return it.toString() }
value.longOrNull?.let { return it.toString() }
value.doubleOrNull?.let { return it.toString() }
}
if (value is JsonArray) {
val items = value.mapNotNull { renderValue(it) }
if (items.isEmpty()) return null
val preview = items.take(3).joinToString(", ")
return if (items.size > 3) "${preview}" else preview
}
return null
}
private fun shortenHomeInString(value: String): String {
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
if (home.isNullOrEmpty()) return value
return value.replace(home, "~")
.replace(Regex("/Users/[^/]+"), "~")
.replace(Regex("/home/[^/]+"), "~")
}
private fun JsonElement?.asStringOrNull(): String? {
val primitive = this as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
}
private fun JsonElement?.asNumberOrNull(): Double? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.doubleOrNull
}
}

View File

@@ -34,8 +34,10 @@ import androidx.compose.foundation.Image
import com.clawdis.android.chat.ChatMessage
import com.clawdis.android.chat.ChatMessageContent
import com.clawdis.android.chat.ChatPendingToolCall
import com.clawdis.android.tools.ToolDisplayRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalContext
@Composable
fun ChatMessageBubble(message: ChatMessage) {
@@ -104,18 +106,42 @@ fun ChatTypingIndicatorBubble() {
@Composable
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
val context = LocalContext.current
val displays =
remember(toolCalls, context) {
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (t in toolCalls.take(6)) {
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Running tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
for (display in displays.take(6)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"${display.emoji} ${display.label}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
display.detailLine?.let { detail ->
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
}
}
if (toolCalls.size > 6) {
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"… +${toolCalls.size - 6} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -1,3 +1,4 @@
import ClawdisKit
import Foundation
import Observation
import SwiftUI
@@ -55,7 +56,7 @@ final class WorkActivityStore {
args: [String: AnyCodable]?)
{
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
let label = Self.buildLabel(name: name, meta: meta, args: args)
if phase.lowercased() == "start" {
self.lastToolLabel = label
self.lastToolUpdatedAt = Date()
@@ -208,41 +209,37 @@ final class WorkActivityStore {
}
private static func buildLabel(
kind: ToolKind,
name: String?,
meta: String?,
args: [String: AnyCodable]?) -> String
{
switch kind {
case .bash:
if let cmd = args?["command"]?.value as? String {
return "bash: \(cmd.split(separator: "\n").first ?? "")"
}
return "bash"
case .read, .write, .edit, .attach:
if let path = extractPath(args: args, meta: meta) {
return "\(kind.rawValue): \(path)"
}
return kind.rawValue
case .other:
if let name = args?["name"]?.value as? String {
return name
}
return "tool"
let wrappedArgs = wrapToolArgs(args)
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)"
}
return display.label
}
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
if let meta { return self.shortenHome(path: meta) }
return nil
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
guard let args else { return nil }
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) }
return ClawdisKit.AnyCodable(converted)
}
private static func shortenHome(path: String) -> String {
let home = NSHomeDirectory()
if path.hasPrefix(home) {
return "~" + path.dropFirst(home.count)
private static func unwrapJSONValue(_ value: Any) -> Any {
if let dict = value as? [String: AnyCodable] {
return dict.mapValues { unwrapJSONValue($0.value) }
}
return path
if let array = value as? [AnyCodable] {
return array.map { unwrapJSONValue($0.value) }
}
if let dict = value as? [String: Any] {
return dict.mapValues { unwrapJSONValue($0) }
}
if let array = value as? [Any] {
return array.map { unwrapJSONValue($0) }
}
return value
}
}

View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

View File

@@ -219,8 +219,9 @@ private struct ChatMessageBody: View {
if !self.inlineToolResults.isEmpty {
ForEach(self.inlineToolResults.indices, id: \.self) { idx in
let toolResult = self.inlineToolResults[idx]
let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil)
ToolResultCard(
title: toolResult.name ?? "Tool result",
title: "\(display.emoji) \(display.title)",
text: toolResult.text ?? "",
isUser: self.isUser)
}
@@ -282,9 +283,11 @@ private struct ChatMessageBody: View {
private var toolResultTitle: String {
if let name = self.message.toolName, !name.isEmpty {
return name
let display = ToolDisplayRegistry.resolve(name: name, args: nil)
return "\(display.emoji) \(display.title)"
}
return "Tool result"
let display = ToolDisplayRegistry.resolve(name: "tool", args: nil)
return "\(display.emoji) \(display.title)"
}
private var bubbleFillColor: Color {
@@ -377,8 +380,6 @@ private struct ToolCallCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "hammer")
.imageScale(.small)
Text(self.toolName)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
@@ -401,50 +402,15 @@ private struct ToolCallCard: View {
}
private var toolName: String {
self.content.name?.isEmpty == false ? (self.content.name ?? "Tool") : "Tool"
"\(self.display.emoji) \(self.display.title)"
}
private var summary: String? {
guard let args = self.content.arguments else { return nil }
if let dict = args.value as? [String: AnyCodable] {
if let command = dict["command"]?.value as? String { return command }
if let path = dict["path"]?.value as? String { return path }
if let pattern = dict["pattern"]?.value as? String { return pattern }
if let query = dict["query"]?.value as? String { return query }
if let url = dict["url"]?.value as? String { return url }
return Self.renderArgs(dict)
}
return Self.renderValue(args)
self.display.detailLine
}
private static func renderArgs(_ dict: [String: AnyCodable]) -> String? {
let keys = dict.keys.sorted()
let pairs = keys.prefix(6).compactMap { key -> String? in
guard let value = dict[key] else { return nil }
return "\(key)=\(self.renderValue(value) ?? "")"
}
guard !pairs.isEmpty else { return nil }
return pairs.joined(separator: " ")
}
private static func renderValue(_ value: AnyCodable) -> String? {
switch value.value {
case let str as String:
return str
case let num as Int:
return String(num)
case let num as Double:
return String(num)
case let bool as Bool:
return bool ? "true" : "false"
default:
if let data = try? JSONEncoder().encode(value),
let string = String(data: data, encoding: .utf8)
{
return string
}
return nil
}
private var display: ToolDisplaySummary {
ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments)
}
}
@@ -457,8 +423,6 @@ private struct ToolResultCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "terminal")
.imageScale(.small)
Text(self.title)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
@@ -567,12 +531,21 @@ struct ChatPendingToolsBubble: View {
.foregroundStyle(.secondary)
ForEach(self.toolCalls) { call in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(call.name)
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(display.emoji) \(display.label)")
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
}
if let detail = display.detailLine, !detail.isEmpty {
Text(detail)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(10)
.background(Color.white.opacity(0.06))

View File

@@ -0,0 +1,194 @@
import Foundation
public struct ToolDisplaySummary: Sendable, Equatable {
public let name: String
public let emoji: String
public let title: String
public let label: String
public let verb: String?
public let detail: String?
public var detailLine: String? {
var parts: [String] = []
if let verb, !verb.isEmpty { parts.append(verb) }
if let detail, !detail.isEmpty { parts.append(detail) }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
public var summaryLine: String {
if let detailLine {
return "\(emoji) \(label): \(detailLine)"
}
return "\(emoji) \(label)"
}
}
public enum ToolDisplayRegistry {
private struct ToolDisplayActionSpec: Decodable {
let label: String?
let detailKeys: [String]?
}
private struct ToolDisplaySpec: Decodable {
let emoji: String?
let title: String?
let label: String?
let detailKeys: [String]?
let actions: [String: ToolDisplayActionSpec]?
}
private struct ToolDisplayConfig: Decodable {
let version: Int?
let fallback: ToolDisplaySpec?
let tools: [String: ToolDisplaySpec]?
}
private static let config: ToolDisplayConfig = loadConfig()
public static func resolve(name: String?, args: AnyCodable?, meta: String? = nil) -> ToolDisplaySummary {
let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "tool"
let key = trimmedName.lowercased()
let spec = config.tools?[key]
let fallback = config.fallback
let emoji = spec?.emoji ?? fallback?.emoji ?? "🧩"
let title = spec?.title ?? titleFromName(trimmedName)
let label = spec?.label ?? trimmedName
let actionRaw = valueForKeyPath(args, path: "action") as? String
let action = actionRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
let actionSpec = action.flatMap { spec?.actions?[$0] }
let verb = normalizeVerb(actionSpec?.label ?? action)
var detail: String?
if key == "read" {
detail = readDetail(args)
} else if key == "write" || key == "edit" || key == "attach" {
detail = pathDetail(args)
}
let detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? fallback?.detailKeys ?? []
if detail == nil {
detail = firstValue(args, keys: detailKeys)
}
if detail == nil {
detail = meta
}
if let detailValue = detail {
detail = shortenHomeInString(detailValue)
}
return ToolDisplaySummary(
name: trimmedName,
emoji: emoji,
title: title,
label: label,
verb: verb,
detail: detail)
}
private static func loadConfig() -> ToolDisplayConfig {
guard let url = ClawdisKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
do {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
} catch {
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
}
}
private static func titleFromName(_ name: String) -> String {
let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return "Tool" }
return cleaned
.split(separator: " ")
.map { part in
let upper = part.uppercased()
if part.count <= 2 && part == upper { return String(part) }
return String(upper.prefix(1)) + String(part.lowercased().dropFirst())
}
.joined(separator: " ")
}
private static func normalizeVerb(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return trimmed.replacingOccurrences(of: "_", with: " ")
}
private static func readDetail(_ args: AnyCodable?) -> String? {
guard let path = valueForKeyPath(args, path: "path") as? String else { return nil }
let offset = valueForKeyPath(args, path: "offset") as? Double
let limit = valueForKeyPath(args, path: "limit") as? Double
if let offset, let limit {
let end = offset + limit
return "\(path):\(Int(offset))-\(Int(end))"
}
return path
}
private static func pathDetail(_ args: AnyCodable?) -> String? {
return valueForKeyPath(args, path: "path") as? String
}
private static func firstValue(_ args: AnyCodable?, keys: [String]) -> String? {
for key in keys {
if let value = valueForKeyPath(args, path: key),
let rendered = renderValue(value)
{
return rendered
}
}
return nil
}
private static func renderValue(_ value: Any) -> String? {
if let str = value as? String {
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let first = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
if first.count > 160 { return String(first.prefix(157)) + "" }
return first
}
if let num = value as? Int { return String(num) }
if let num = value as? Double { return String(num) }
if let bool = value as? Bool { return bool ? "true" : "false" }
if let array = value as? [Any] {
let items = array.compactMap { renderValue($0) }
guard !items.isEmpty else { return nil }
let preview = items.prefix(3).joined(separator: ", ")
return items.count > 3 ? "\(preview)" : preview
}
if let dict = value as? [String: Any] {
if let label = dict["name"].flatMap({ renderValue($0) }) { return label }
if let label = dict["id"].flatMap({ renderValue($0) }) { return label }
}
return nil
}
private static func valueForKeyPath(_ args: AnyCodable?, path: String) -> Any? {
guard let args else { return nil }
let parts = path.split(separator: ".").map(String.init)
var current: Any? = args.value
for part in parts {
if let dict = current as? [String: AnyCodable] {
current = dict[part]?.value
} else if let dict = current as? [String: Any] {
current = dict[part]
} else {
return nil
}
}
return current
}
private static func shortenHomeInString(_ value: String) -> String {
let home = NSHomeDirectory()
guard !home.isEmpty else { return value }
return value.replacingOccurrences(of: home, with: "~")
}
}

View File

@@ -1,4 +1,5 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
export function extractAssistantText(msg: AssistantMessage): string {
const isTextBlock = (
@@ -22,23 +23,6 @@ export function inferToolMetaFromArgs(
toolName: string,
args: unknown,
): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const p = typeof record.path === "string" ? record.path : undefined;
const command =
typeof record.command === "string" ? record.command : undefined;
if (toolName === "read" && p) {
const offset =
typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${p}:${offset}-${offset + limit}`;
}
return p;
}
if ((toolName === "edit" || toolName === "write") && p) return p;
if (toolName === "bash" && command) return command;
return p ?? command;
const display = resolveToolDisplay({ name: toolName, args });
return formatToolDetail(display);
}

View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

193
src/agents/tool-display.ts Normal file
View File

@@ -0,0 +1,193 @@
import { shortenHomeInString } from "../utils.js";
import rawConfig from "./tool-display.json" assert { type: "json" };
type ToolDisplayActionSpec = {
label?: string;
detailKeys?: string[];
};
type ToolDisplaySpec = {
emoji?: string;
title?: string;
label?: string;
detailKeys?: string[];
actions?: Record<string, ToolDisplayActionSpec>;
};
type ToolDisplayConfig = {
version?: number;
fallback?: ToolDisplaySpec;
tools?: Record<string, ToolDisplaySpec>;
};
export type ToolDisplay = {
name: string;
emoji: string;
title: string;
label: string;
verb?: string;
detail?: string;
};
const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
function normalizeToolName(name?: string): string {
return (name ?? "tool").trim();
}
function defaultTitle(name: string): string {
const cleaned = name.replace(/_/g, " ").trim();
if (!cleaned) return "Tool";
return cleaned
.split(/\s+/)
.map((part) =>
part.length <= 2 && part.toUpperCase() === part
? part
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
)
.join(" ");
}
function normalizeVerb(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/_/g, " ");
}
function coerceDisplayValue(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
const values = value
.map((item) => coerceDisplayValue(item))
.filter((item): item is string => Boolean(item));
if (values.length === 0) return undefined;
const preview = values.slice(0, 3).join(", ");
return values.length > 3 ? `${preview}` : preview;
}
return undefined;
}
function lookupValueByPath(args: unknown, path: string): unknown {
if (!args || typeof args !== "object") return undefined;
let current: unknown = args;
for (const segment of path.split(".")) {
if (!segment) return undefined;
if (!current || typeof current !== "object") return undefined;
const record = current as Record<string, unknown>;
current = record[segment];
}
return current;
}
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
for (const key of keys) {
const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value);
if (display) return display;
}
return undefined;
}
function resolveReadDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
if (!path) return undefined;
const offset = typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${path}:${offset}-${offset + limit}`;
}
return path;
}
function resolveWriteDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
return path;
}
function resolveActionSpec(
spec: ToolDisplaySpec | undefined,
action: string | undefined,
): ToolDisplayActionSpec | undefined {
if (!spec || !action) return undefined;
return spec.actions?.[action] ?? undefined;
}
export function resolveToolDisplay(params: {
name?: string;
args?: unknown;
meta?: string;
}): ToolDisplay {
const name = normalizeToolName(params.name);
const key = name.toLowerCase();
const spec = TOOL_MAP[key];
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? name;
const actionRaw =
params.args && typeof params.args === "object"
? ((params.args as Record<string, unknown>).action as string | undefined)
: undefined;
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
const actionSpec = resolveActionSpec(spec, action);
const verb = normalizeVerb(actionSpec?.label ?? action);
let detail: string | undefined;
if (key === "read") detail = resolveReadDetail(params.args);
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys);
}
if (!detail && params.meta) {
detail = params.meta;
}
if (detail) {
detail = shortenHomeInString(detail);
}
return {
name,
emoji,
title,
label,
verb,
detail,
};
}
export function formatToolDetail(display: ToolDisplay): string | undefined {
const parts: string[] = [];
if (display.verb) parts.push(display.verb);
if (display.detail) parts.push(display.detail);
if (parts.length === 0) return undefined;
return parts.join(" · ");
}
export function formatToolSummary(display: ToolDisplay): string {
const detail = formatToolDetail(display);
return detail
? `${display.emoji} ${display.label}: ${detail}`
: `${display.emoji} ${display.label}`;
}

View File

@@ -1,30 +1,9 @@
import { resolveToolDisplay, formatToolSummary } from "../agents/tool-display.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
export const TOOL_RESULT_DEBOUNCE_MS = 500;
export const TOOL_RESULT_FLUSH_COUNT = 5;
const TOOL_EMOJI_BY_NAME: Record<string, string> = {
bash: "🛠️",
process: "🧰",
read: "📖",
write: "✍️",
edit: "📝",
attach: "📎",
browser: "🌐",
canvas: "🖼️",
nodes: "📱",
cron: "⏰",
gateway: "🔌",
whatsapp_login: "🟢",
discord: "💬",
};
function resolveToolEmoji(toolName?: string): string {
const key = toolName?.trim().toLowerCase();
if (key && TOOL_EMOJI_BY_NAME[key]) return TOOL_EMOJI_BY_NAME[key];
return "🧩";
}
export function shortenPath(p: string): string {
return shortenHomePath(p);
}
@@ -43,14 +22,18 @@ export function formatToolAggregate(
metas?: string[],
): string {
const filtered = (metas ?? []).filter(Boolean).map(shortenMeta);
const label = toolName?.trim() || "tool";
const prefix = `${resolveToolEmoji(label)} ${label}`;
const display = resolveToolDisplay({ name: toolName });
const prefix = `${display.emoji} ${display.label}`;
if (!filtered.length) return prefix;
const rawSegments: string[] = [];
// Group by directory and brace-collapse filenames
const grouped: Record<string, string[]> = {};
for (const m of filtered) {
if (!isPathLike(m)) {
rawSegments.push(m);
continue;
}
if (m.includes("→")) {
rawSegments.push(m);
continue;
@@ -78,10 +61,18 @@ export function formatToolAggregate(
}
export function formatToolPrefix(toolName?: string, meta?: string) {
const label = toolName?.trim() || "tool";
const emoji = resolveToolEmoji(label);
const extra = meta?.trim() ? shortenMeta(meta) : undefined;
return extra ? `${emoji} ${label}: ${extra}` : `${emoji} ${label}`;
const display = resolveToolDisplay({ name: toolName, meta: extra });
return formatToolSummary(display);
}
function isPathLike(value: string): boolean {
if (!value) return false;
if (value.includes(" ")) return false;
if (value.includes("://")) return false;
if (value.includes("·")) return false;
if (value.includes("&&") || value.includes("||")) return false;
return /^~?(\\/[^\\s]+)+$/.test(value);
}
export function createToolDebouncer(

View File

@@ -1,4 +1,5 @@
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { formatToolDetail, resolveToolDisplay } from "../../agents/tool-display.js";
import { markdownTheme, theme } from "../theme/theme.js";
type ToolResultContent = {
@@ -17,13 +18,10 @@ type ToolResult = {
const PREVIEW_LINES = 12;
function formatArgs(toolName: string, args: unknown): string {
const display = resolveToolDisplay({ name: toolName, args });
const detail = formatToolDetail(display);
if (detail) return detail;
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
if (toolName === "bash" && typeof record.command === "string") {
return record.command;
}
const path = typeof record.path === "string" ? record.path : undefined;
if (path) return path;
try {
return JSON.stringify(args);
} catch {
@@ -108,7 +106,8 @@ export class ToolExecutionComponent extends Container {
: theme.toolSuccessBg;
this.box.setBgFn((line) => bg(line));
const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`;
const display = resolveToolDisplay({ name: this.toolName, args: this.args });
const title = `${display.emoji} ${display.label}${this.isPartial ? " (running)" : ""}`;
this.header.setText(theme.toolTitle(theme.bold(title)));
const argLine = formatArgs(this.toolName, this.args);

View File

@@ -575,6 +575,49 @@
color: var(--chat-text);
}
.chat-tool-card {
margin-top: 8px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.22);
display: grid;
gap: 4px;
}
:root[data-theme="light"] .chat-tool-card {
background: rgba(255, 255, 255, 0.7);
}
.chat-tool-card__title {
font-family: var(--font-mono);
font-size: 12px;
color: var(--chat-text);
}
.chat-tool-card__detail {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
}
.chat-tool-card__output {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
color: var(--chat-text);
padding: 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
:root[data-theme="light"] .chat-tool-card__output {
background: rgba(16, 24, 40, 0.05);
}
.chat-stamp {
font-size: 11px;
color: var(--muted);

197
ui/src/ui/tool-display.json Normal file
View File

@@ -0,0 +1,197 @@
{
"version": 1,
"fallback": {
"emoji": "🧩",
"detailKeys": [
"command",
"path",
"url",
"targetUrl",
"targetId",
"ref",
"element",
"node",
"nodeId",
"jobId",
"requestId",
"to",
"channelId",
"guildId",
"userId",
"name",
"query",
"pattern",
"messageId"
]
},
"tools": {
"bash": {
"emoji": "🛠️",
"title": "Bash",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",
"detailKeys": ["sessionId"]
},
"read": {
"emoji": "📖",
"title": "Read",
"detailKeys": ["path"]
},
"write": {
"emoji": "✍️",
"title": "Write",
"detailKeys": ["path"]
},
"edit": {
"emoji": "📝",
"title": "Edit",
"detailKeys": ["path"]
},
"attach": {
"emoji": "📎",
"title": "Attach",
"detailKeys": ["path", "url", "fileName"]
},
"browser": {
"emoji": "🌐",
"title": "Browser",
"actions": {
"status": { "label": "status" },
"start": { "label": "start" },
"stop": { "label": "stop" },
"tabs": { "label": "tabs" },
"open": { "label": "open", "detailKeys": ["targetUrl"] },
"focus": { "label": "focus", "detailKeys": ["targetId"] },
"close": { "label": "close", "detailKeys": ["targetId"] },
"snapshot": {
"label": "snapshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
},
"screenshot": {
"label": "screenshot",
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
},
"navigate": {
"label": "navigate",
"detailKeys": ["targetUrl", "targetId"]
},
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
"upload": {
"label": "upload",
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
},
"dialog": {
"label": "dialog",
"detailKeys": ["accept", "promptText", "targetId"]
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
}
}
},
"canvas": {
"emoji": "🖼️",
"title": "Canvas",
"actions": {
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
}
},
"nodes": {
"emoji": "📱",
"title": "Nodes",
"actions": {
"status": { "label": "status" },
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
"pending": { "label": "pending" },
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
}
}
},
"cron": {
"emoji": "⏰",
"title": "Cron",
"actions": {
"status": { "label": "status" },
"list": { "label": "list" },
"add": {
"label": "add",
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
},
"update": { "label": "update", "detailKeys": ["jobId"] },
"remove": { "label": "remove", "detailKeys": ["jobId"] },
"run": { "label": "run", "detailKeys": ["jobId"] },
"runs": { "label": "runs", "detailKeys": ["jobId"] },
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
}
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",
"actions": {
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
"actions": {
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
}
}
}

199
ui/src/ui/tool-display.ts Normal file
View File

@@ -0,0 +1,199 @@
import rawConfig from "./tool-display.json";
type ToolDisplayActionSpec = {
label?: string;
detailKeys?: string[];
};
type ToolDisplaySpec = {
emoji?: string;
title?: string;
label?: string;
detailKeys?: string[];
actions?: Record<string, ToolDisplayActionSpec>;
};
type ToolDisplayConfig = {
version?: number;
fallback?: ToolDisplaySpec;
tools?: Record<string, ToolDisplaySpec>;
};
export type ToolDisplay = {
name: string;
emoji: string;
title: string;
label: string;
verb?: string;
detail?: string;
};
const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig;
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { emoji: "🧩" };
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
function normalizeToolName(name?: string): string {
return (name ?? "tool").trim();
}
function defaultTitle(name: string): string {
const cleaned = name.replace(/_/g, " ").trim();
if (!cleaned) return "Tool";
return cleaned
.split(/\s+/)
.map((part) =>
part.length <= 2 && part.toUpperCase() === part
? part
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
)
.join(" ");
}
function normalizeVerb(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/_/g, " ");
}
function coerceDisplayValue(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) return undefined;
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
const values = value
.map((item) => coerceDisplayValue(item))
.filter((item): item is string => Boolean(item));
if (values.length === 0) return undefined;
const preview = values.slice(0, 3).join(", ");
return values.length > 3 ? `${preview}` : preview;
}
return undefined;
}
function lookupValueByPath(args: unknown, path: string): unknown {
if (!args || typeof args !== "object") return undefined;
let current: unknown = args;
for (const segment of path.split(".")) {
if (!segment) return undefined;
if (!current || typeof current !== "object") return undefined;
const record = current as Record<string, unknown>;
current = record[segment];
}
return current;
}
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
for (const key of keys) {
const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value);
if (display) return display;
}
return undefined;
}
function resolveReadDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
if (!path) return undefined;
const offset = typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) {
return `${path}:${offset}-${offset + limit}`;
}
return path;
}
function resolveWriteDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined;
return path;
}
function resolveActionSpec(
spec: ToolDisplaySpec | undefined,
action: string | undefined,
): ToolDisplayActionSpec | undefined {
if (!spec || !action) return undefined;
return spec.actions?.[action] ?? undefined;
}
export function resolveToolDisplay(params: {
name?: string;
args?: unknown;
meta?: string;
}): ToolDisplay {
const name = normalizeToolName(params.name);
const key = name.toLowerCase();
const spec = TOOL_MAP[key];
const emoji = spec?.emoji ?? FALLBACK.emoji ?? "🧩";
const title = spec?.title ?? defaultTitle(name);
const label = spec?.label ?? name;
const actionRaw =
params.args && typeof params.args === "object"
? ((params.args as Record<string, unknown>).action as string | undefined)
: undefined;
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
const actionSpec = resolveActionSpec(spec, action);
const verb = normalizeVerb(actionSpec?.label ?? action);
let detail: string | undefined;
if (key === "read") detail = resolveReadDetail(params.args);
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys);
}
if (!detail && params.meta) {
detail = params.meta;
}
if (detail) {
detail = shortenHomeInString(detail);
}
return {
name,
emoji,
title,
label,
verb,
detail,
};
}
export function formatToolDetail(display: ToolDisplay): string | undefined {
const parts: string[] = [];
if (display.verb) parts.push(display.verb);
if (display.detail) parts.push(display.detail);
if (parts.length === 0) return undefined;
return parts.join(" · ");
}
export function formatToolSummary(display: ToolDisplay): string {
const detail = formatToolDetail(display);
return detail
? `${display.emoji} ${display.label}: ${detail}`
: `${display.emoji} ${display.label}`;
}
function shortenHomeInString(input: string): string {
if (!input) return input;
return input
.replace(/\/Users\/[^/]+/g, "~")
.replace(/\/home\/[^/]+/g, "~");
}

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { SessionsListResult } from "../types";
import { resolveToolDisplay, formatToolDetail } from "../tool-display";
export type ChatProps = {
sessionKey: string;
@@ -168,11 +169,15 @@ function resolveSessionOptions(
function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message);
const isToolResult = isToolResultMessage(message);
const text =
extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2));
!isToolResult
? extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2))
: null;
const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
@@ -182,7 +187,8 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
<div class="chat-line ${klass}">
<div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
<div class="chat-text">${text}</div>
${text ? html`<div class="chat-text">${text}</div>` : nothing}
${toolCards.map((card) => renderToolCard(card))}
</div>
<div class="chat-stamp mono">
${who}${timestamp ? html` · ${timestamp}` : nothing}
@@ -209,3 +215,94 @@ function extractText(message: unknown): string | null {
if (typeof m.text === "string") return m.text;
return null;
}
type ToolCard = {
kind: "call" | "result";
name: string;
args?: unknown;
text?: string;
};
function extractToolCards(message: unknown): ToolCard[] {
const m = message as Record<string, unknown>;
const content = normalizeContent(m.content);
const cards: ToolCard[] = [];
for (const item of content) {
const kind = String(item.type ?? "").toLowerCase();
const isToolCall =
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
(typeof item.name === "string" && item.arguments != null);
if (isToolCall) {
cards.push({
kind: "call",
name: (item.name as string) ?? "tool",
args: coerceArgs(item.arguments ?? item.args),
});
}
}
for (const item of content) {
const kind = String(item.type ?? "").toLowerCase();
if (kind !== "toolresult" && kind !== "tool_result") continue;
const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text });
}
if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
"tool";
const text = extractText(message) ?? undefined;
cards.push({ kind: "result", name, text });
}
return cards;
}
function renderToolCard(card: ToolCard) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
return html`
<div class="chat-tool-card">
<div class="chat-tool-card__title">${display.emoji} ${display.label}</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${card.text
? html`<div class="chat-tool-card__output">${card.text}</div>`
: nothing}
</div>
`;
}
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(content)) return [];
return content.filter(Boolean) as Array<Record<string, unknown>>;
}
function coerceArgs(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed) return value;
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.text === "string") return item.text;
if (typeof item.content === "string") return item.content;
return undefined;
}
function isToolResultMessage(message: unknown): boolean {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
return role === "toolresult" || role === "tool_result";
}