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

@@ -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: "~")
}
}