Files
hermes-agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
T
2026-05-02 03:19:39 -05:00

231 lines
7.7 KiB
TypeScript

'use client'
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { ChevronRight } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const TOOL_SPINNER_INTERVAL_MS = 80
function titleForTool(name: string): string {
return (
name
.split('_')
.filter(Boolean)
.map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
.join(' ') || name
)
}
function toolLabel(name: string, isPending: boolean): string {
const labels: Record<string, { done: string; pending: string }> = {
edit_file: { done: 'Edited file', pending: 'Editing file' },
execute_code: { done: 'Ran code', pending: 'Running code' },
image_generate: { done: 'Generated image', pending: 'Generating image' },
list_files: { done: 'Listed files', pending: 'Listing files' },
read_file: { done: 'Read file', pending: 'Reading file' },
search_files: { done: 'Searched files', pending: 'Searching files' },
session_search_recall: { done: 'Searched session history', pending: 'Searching session history' },
terminal: { done: 'Ran command', pending: 'Running command' },
todo: { done: 'Updated todos', pending: 'Updating todos' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage' },
web_search: { done: 'Searched the web', pending: 'Searching the web' },
write_file: { done: 'Edited file', pending: 'Editing file' }
}
if (labels[name]) {
return isPending ? labels[name].pending : labels[name].done
}
return `${isPending ? 'Using' : 'Used'} ${titleForTool(name)}`
}
function compactPreview(value: unknown, max = 72): string {
const text =
typeof value === 'string'
? value
: value && typeof value === 'object' && 'context' in value
? String((value as { context?: unknown }).context ?? '')
: ''
const oneLine = text.replace(/\s+/g, ' ').trim()
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine
}
function shouldShowInlinePreview(toolName: string): boolean {
return !['image_generate', 'terminal', 'execute_code'].includes(toolName)
}
function contextValue(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value && typeof value === 'object' && 'context' in value) {
return String((value as { context?: unknown }).context ?? '')
}
return ''
}
function prettyJson(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
function recordValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function stripInlineDiffChrome(value: string): string {
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
}
function inlineDiffFromResult(result: unknown): string {
const value = recordValue(result).inline_diff
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
}
function detailLabel(toolName: string): string {
if (toolName === 'image_generate') {
return 'Prompt'
}
if (toolName === 'web_search') {
return 'Query'
}
if (toolName === 'web_extract') {
return 'URL'
}
if (toolName === 'terminal') {
return 'Command'
}
if (toolName === 'execute_code') {
return 'Code'
}
return 'Input'
}
function detailText(args: unknown, result: unknown): string {
const argContext = contextValue(args)
const resultContext = contextValue(result)
if (resultContext && resultContext !== argContext) {
return resultContext
}
if (argContext) {
return argContext
}
if (result !== undefined) {
return prettyJson(result)
}
return prettyJson(args)
}
export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => {
const [open, setOpen] = useState(false)
const isPending = result === undefined
const [tick, setTick] = useState(0)
const elapsed = useElapsedSeconds(isPending)
const preview = compactPreview(args) || compactPreview(result)
const label = toolLabel(toolName, isPending)
const detail = detailText(args, result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
useEffect(() => {
if (!isPending) {
return
}
const id = window.setInterval(() => setTick(value => value + 1), TOOL_SPINNER_INTERVAL_MS)
return () => window.clearInterval(id)
}, [isPending])
return (
<div className="mb-3 mt-1 text-sm text-muted-foreground">
<button
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(v => !v)}
type="button"
>
<ChevronRight
className={cn('shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
size={12}
/>
<span
className={cn('shrink-0 text-xs font-medium text-foreground/70', isPending && 'shimmer text-foreground/55')}
>
{label}
</span>
{preview && shouldShowInlinePreview(toolName) && (
<span className="min-w-0 truncate text-xs text-muted-foreground/80">{preview}</span>
)}
{isPending ? (
<span aria-label="Running" className="ml-1 w-3 shrink-0 text-center text-xs text-muted-foreground/80">
{spinnerFrame}
</span>
) : null}
{isPending && <ActivityTimerText seconds={elapsed} />}
</button>
{open && (
<div className="ml-4 mt-1 max-w-full whitespace-pre-wrap wrap-anywhere border-l border-border pl-3 text-xs leading-relaxed text-muted-foreground/85">
<span className="mr-1 font-medium text-muted-foreground/70">{detailLabel(toolName)}:</span>
{detail}
</div>
)}
{inlineDiff && <InlineDiff text={inlineDiff} />}
</div>
)
}
function InlineDiff({ text }: { text: string }) {
return (
<pre className="ml-4 mt-2 max-h-96 max-w-full overflow-auto rounded-lg border border-border/60 bg-background/70 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
{text.split('\n').map((line, index) => {
const added = line.startsWith('+') && !line.startsWith('+++')
const removed = line.startsWith('-') && !line.startsWith('---')
const hunk = line.startsWith('@@')
const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
return (
<span
className={cn(
'block min-w-max whitespace-pre',
added && 'text-emerald-700 dark:text-emerald-300',
removed && 'text-rose-700 dark:text-rose-300',
hunk && 'text-sky-700 dark:text-sky-300',
!added && !removed && !hunk && fileHeader && 'text-muted-foreground/80'
)}
key={`${index}-${line}`}
>
{line || ' '}
</span>
)
})}
</pre>
)
}