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

@@ -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";
}