diff --git a/CHANGELOG.md b/CHANGELOG.md index 7962f8276..07a77e411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/android/app/src/main/assets/tool-display.json b/apps/android/app/src/main/assets/tool-display.json new file mode 100644 index 000000000..b6a28f60f --- /dev/null +++ b/apps/android/app/src/main/assets/tool-display.json @@ -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"] } + } + } + } +} diff --git a/apps/android/app/src/main/java/com/clawdis/android/chat/ChatController.kt b/apps/android/app/src/main/java/com/clawdis/android/chat/ChatController.kt index 2a01527a0..921c6e7b3 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/chat/ChatController.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/chat/ChatController.kt @@ -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, ) diff --git a/apps/android/app/src/main/java/com/clawdis/android/chat/ChatModels.kt b/apps/android/app/src/main/java/com/clawdis/android/chat/ChatModels.kt index e9cfcbaec..7f0af0cea 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/chat/ChatModels.kt @@ -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, ) diff --git a/apps/android/app/src/main/java/com/clawdis/android/tools/ToolDisplay.kt b/apps/android/app/src/main/java/com/clawdis/android/tools/ToolDisplay.kt new file mode 100644 index 000000000..55d8d5bd7 --- /dev/null +++ b/apps/android/app/src/main/java/com/clawdis/android/tools/ToolDisplay.kt @@ -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? = null, +) + +@Serializable +private data class ToolDisplaySpec( + val emoji: String? = null, + val title: String? = null, + val label: String? = null, + val detailKeys: List? = null, + val actions: Map? = null, +) + +@Serializable +private data class ToolDisplayConfig( + val version: Int? = null, + val fallback: ToolDisplaySpec? = null, + val tools: Map? = 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() + 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? { + 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 + } +} diff --git a/apps/android/app/src/main/java/com/clawdis/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/com/clawdis/android/ui/chat/ChatMessageViews.kt index a4a1cf2d2..9f93e9cd0 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/ui/chat/ChatMessageViews.kt @@ -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) { + 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, + ) } } } diff --git a/apps/macos/Sources/Clawdis/WorkActivityStore.swift b/apps/macos/Sources/Clawdis/WorkActivityStore.swift index e0a1bdc07..d2c195af8 100644 --- a/apps/macos/Sources/Clawdis/WorkActivityStore.swift +++ b/apps/macos/Sources/Clawdis/WorkActivityStore.swift @@ -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 } } diff --git a/apps/shared/ClawdisKit/Resources/tool-display.json b/apps/shared/ClawdisKit/Resources/tool-display.json new file mode 100644 index 000000000..b6a28f60f --- /dev/null +++ b/apps/shared/ClawdisKit/Resources/tool-display.json @@ -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"] } + } + } + } +} diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift index bd8e97c52..dcb2fee23 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift @@ -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)) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/ToolDisplay.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/ToolDisplay.swift new file mode 100644 index 000000000..211ab51d3 --- /dev/null +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/ToolDisplay.swift @@ -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: "~") + } +} diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index a571a9047..a68956c7b 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -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; - - 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); } diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json new file mode 100644 index 000000000..b6a28f60f --- /dev/null +++ b/src/agents/tool-display.json @@ -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"] } + } + } + } +} diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts new file mode 100644 index 000000000..12c727b33 --- /dev/null +++ b/src/agents/tool-display.ts @@ -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; +}; + +type ToolDisplayConfig = { + version?: number; + fallback?: ToolDisplaySpec; + tools?: Record; +}; + +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; + 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; + 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; + 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).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}`; +} diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 8932aa166..7c482d91e 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -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 = { - 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 = {}; 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( diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index 47cab661e..671973929 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -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; - 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); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0b3aabdc2..98586e949 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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); diff --git a/ui/src/ui/tool-display.json b/ui/src/ui/tool-display.json new file mode 100644 index 000000000..b6a28f60f --- /dev/null +++ b/ui/src/ui/tool-display.json @@ -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"] } + } + } + } +} diff --git a/ui/src/ui/tool-display.ts b/ui/src/ui/tool-display.ts new file mode 100644 index 000000000..02c54b457 --- /dev/null +++ b/ui/src/ui/tool-display.ts @@ -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; +}; + +type ToolDisplayConfig = { + version?: number; + fallback?: ToolDisplaySpec; + tools?: Record; +}; + +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; + 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; + 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; + 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).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, "~"); +} diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 70812b683..e2fbc1282 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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; 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 }) {
-
${text}
+ ${text ? html`
${text}
` : nothing} + ${toolCards.map((card) => renderToolCard(card))}
${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; + 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` +
+
${display.emoji} ${display.label}
+ ${detail + ? html`
${detail}
` + : nothing} + ${card.text + ? html`
${card.text}
` + : nothing} +
+ `; +} + +function normalizeContent(content: unknown): Array> { + if (!Array.isArray(content)) return []; + return content.filter(Boolean) as Array>; +} + +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 | 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; + const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; + return role === "toolresult" || role === "tool_result"; +}