feat: custom tool renderers

This commit is contained in:
Zoe
2026-09-25 19:46:00 -05:00
parent 3f81f96bda
commit bb5795e00d
18 changed files with 1294 additions and 65 deletions
@@ -0,0 +1,324 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed, onUnmounted, ref, useId, watch } from "vue";
import CodeViewer from "./CodeViewer.vue";
import {
formatDuration,
formatToolValue,
getToolDisplayInfo,
parseToolValue,
} from "~/utils/tool-rendering";
export interface CustomToolTab {
id: string;
label: string;
count?: string;
copyContent?: () => string;
}
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
title?: string;
subtitle?: string;
icon?: string;
customTab?: CustomToolTab;
duration?: string;
}>();
const emit = defineEmits<{
retry: [];
}>();
const isOpen = ref(false);
const contentId = useId();
const copied = ref(false);
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
const displayInfo = computed(() => getToolDisplayInfo(props.toolCall));
const displayTitle = computed(() => props.title || displayInfo.value.verb);
const displaySubtitle = computed(() => props.subtitle !== undefined ? props.subtitle : displayInfo.value.target);
const displayIcon = computed(() => props.icon || displayInfo.value.icon);
const calculatedDuration = computed(() => {
if (props.duration) {
return props.duration;
}
if (props.toolCall.status === "pending") {
return null;
}
const start = props.part?.createdAt || props.toolCall.createdAt;
const end = props.part?.lastUpdatedAt;
return formatDuration(start, end);
});
interface TabItem {
id: string;
label: string;
}
const availableTabs = computed<TabItem[]>(() => {
const tabs: TabItem[] = [];
if (props.customTab) {
tabs.push({ id: props.customTab.id, label: props.customTab.label });
}
if (props.toolCall.status === "failed" || props.toolCall.error) {
tabs.push({ id: "error", label: "Error" });
}
if (props.toolCall.input) {
tabs.push({ id: "input", label: "Input" });
}
if (props.toolCall.output) {
tabs.push({ id: "output", label: "Output" });
}
tabs.push({ id: "trace", label: "Trace" });
return tabs;
});
const resolveDefaultTab = (): string => {
if (props.toolCall.status === "failed" || props.toolCall.error) {
return "error";
}
if (props.customTab && props.toolCall.status === "completed") {
return props.customTab.id;
}
if (props.toolCall.output) {
return "output";
}
if (props.toolCall.input) {
return "input";
}
return "trace";
};
const activeTab = ref(resolveDefaultTab());
watch(
() => props.toolCall.status,
(newStatus) => {
if (newStatus === "failed") {
activeTab.value = "error";
} else if (newStatus === "completed" && props.customTab) {
activeTab.value = props.customTab.id;
}
},
);
const formattedError = computed(() => {
if (!props.toolCall.error) {
return "";
}
const parsed = parseToolValue(props.toolCall.error);
if (typeof parsed === "string") {
return parsed;
}
return JSON.stringify(parsed, null, 2);
});
const currentTabCode = computed(() => {
switch (activeTab.value) {
case "input":
return formatToolValue(props.toolCall.input);
case "output":
return formatToolValue(props.toolCall.output);
case "error":
return formattedError.value;
case "trace": {
const traceObj = {
id: props.toolCall.id,
toolName: props.toolCall.toolName,
status: props.toolCall.status,
input: parseToolValue(props.toolCall.input),
output: parseToolValue(props.toolCall.output),
error: parseToolValue(props.toolCall.error),
createdAt: props.toolCall.createdAt,
};
return JSON.stringify(traceObj, null, 2);
}
default:
return "";
}
});
const currentTabLang = computed(() => {
if (activeTab.value === "trace") {
return "json";
}
if (activeTab.value === "input") {
return props.toolCall.input?.type === "json" ? "json" : "text";
}
if (activeTab.value === "output") {
return props.toolCall.output?.type === "json" ? "json" : "text";
}
return "text";
});
const footerInfo = computed(() => {
if (props.customTab && activeTab.value === props.customTab.id) {
return props.customTab.count || "";
}
const lines = currentTabCode.value ? currentTabCode.value.split("\n").length : 0;
const lineLabel = lines === 1 ? "line" : "lines";
const lang = currentTabLang.value;
return `${lines} ${lineLabel} · ${lang}`;
});
const canRetry = computed(() => props.toolCall.status === "failed");
const copyActiveTab = async () => {
let textToCopy = "";
if (props.customTab && activeTab.value === props.customTab.id && props.customTab.copyContent) {
textToCopy = props.customTab.copyContent();
} else {
textToCopy = currentTabCode.value;
}
if (!textToCopy) {
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
copied.value = true;
if (copyTimeout) {
clearTimeout(copyTimeout);
}
copyTimeout = setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy tool call content:", err);
}
};
const toggle = () => {
isOpen.value = !isOpen.value;
};
onUnmounted(() => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
});
</script>
<template>
<div class="tool-disclosure flex flex-col min-w-0 text-[var(--text-tertiary)]">
<button
type="button"
class="w-full @hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] cursor-pointer select-none text-left"
:aria-expanded="isOpen"
:aria-controls="contentId"
@click="toggle"
>
<span class="flex items-center gap-1.5 min-w-0 overflow-hidden">
<span class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span
:class="[
'w-3 h-3 text-3 transition-colors',
displayIcon,
toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'
]"
aria-hidden="true"
></span>
</span>
<span class="text-sm truncate">
<span :class="toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'">
{{ displayTitle }}
</span>
<span v-if="displaySubtitle" class="text-[var(--text-tertiary)] ml-1.5 font-normal truncate">
{{ displaySubtitle }}
</span>
</span>
</span>
<span class="flex items-center gap-2 shrink-0 ml-2">
<slot name="summary"></slot>
<span
v-if="toolCall.status === 'pending'"
class="i-svg-spinners-180-ring-with-bg text-3 text-[var(--color-accent)]"
aria-hidden="true"
></span>
<span v-else-if="toolCall.status === 'failed'" class="text-xs text-red-400 font-medium">Failed</span>
<span v-else-if="toolCall.status === 'cancelled'" class="text-xs text-[var(--text-tertiary)]">Cancelled</span>
<span v-else-if="calculatedDuration" class="text-xs text-[var(--text-tertiary)] font-mono">{{ calculatedDuration }}</span>
<span
class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)] transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="isOpen ? '' : '-rotate-90'"
aria-hidden="true"
></span>
</span>
</button>
<div
v-show="isOpen"
:id="contentId"
class="mt-1 border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] overflow-hidden text-xs"
>
<div class="flex items-center gap-1 px-3 pt-2.5 pb-2 overflow-x-auto scrollbar-none border-b border-[var(--color-border)]/40">
<button
v-for="tab in availableTabs"
:key="tab.id"
type="button"
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer shrink-0"
:class="[
activeTab === tab.id
? (tab.id === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-[var(--color-hover)] text-[var(--text-primary)]')
: 'text-[var(--text-tertiary)] @hover:text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]/50'
]"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="min-h-0">
<div v-if="customTab && activeTab === customTab.id">
<slot :name="customTab.id">
<slot></slot>
</slot>
</div>
<div v-else-if="activeTab === 'error'">
<CodeViewer :code="formattedError" lang="json"></CodeViewer>
</div>
<div v-else-if="['input', 'output', 'trace'].includes(activeTab)">
<CodeViewer :code="currentTabCode" :lang="currentTabLang"></CodeViewer>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2 border-t border-[var(--color-border)]/40 text-xs text-[var(--text-tertiary)] bg-[var(--color-hover)]/20">
<div>
<span class="font-mono text-[11px] text-[var(--text-tertiary)]">{{ footerInfo }}</span>
</div>
<div class="flex items-center gap-2">
<slot name="actions"></slot>
<button
v-if="canRetry"
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-red-400 @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="emit('retry')"
>
<span class="i-mynaui-refresh w-3.5 h-3.5" aria-hidden="true"></span>
<span>Retry</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="copyActiveTab"
>
<span :class="copied ? 'i-mynaui-check text-emerald-400' : 'i-mynaui-copy'" class="w-3.5 h-3.5" aria-hidden="true"></span>
<span>{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
</div>
</div>
</div>
</template>