import { useState } from 'react'; import type { ReactNode } from 'react'; import { Check, ChevronRight, Loader2, X } from 'lucide-react'; import type { ToolCall, ToolResult } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; // v1.8.2: cap on the inline arg-summary length. Expanded view shows full // args + full result, so this is purely a single-line render budget. const ARG_SUMMARY_MAX = 60; export interface ToolRun { call: ToolCall; // null while the call is in flight or the matching tool result hasn't // arrived yet on the WS stream. result: ToolResult | null; } function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n - 1) + '…' : s; } // Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single // scannable line that surfaces the *what* (path / pattern) without // overwhelming the chat with full JSON. export function formatToolArgs(name: string, args: Record): string { if (name === 'view_file') { const path = String(args.path ?? ''); const start = args.start_line; const end = args.end_line; if (typeof start === 'number' && typeof end === 'number') { return truncate(`${path}:${start}-${end}`, ARG_SUMMARY_MAX); } if (typeof start === 'number') { return truncate(`${path}:${start}`, ARG_SUMMARY_MAX); } return truncate(path, ARG_SUMMARY_MAX); } if (name === 'list_dir') { return truncate(String(args.path ?? '.'), ARG_SUMMARY_MAX); } if (name === 'grep') { const pattern = String(args.pattern ?? ''); const path = args.path ? ` ${String(args.path)}` : ''; return truncate(`"${pattern}"${path}`, ARG_SUMMARY_MAX); } if (name === 'find_files') { return truncate(String(args.pattern ?? ''), ARG_SUMMARY_MAX); } if (name === 'git_status') { return ''; } if (name === 'skill_use') { // Schema (apps/server/src/services/tools.ts SkillUseInput) uses `name`; // fall back to `skill_name` defensively in case a model emits that key. return truncate( String(args.name ?? (args as { skill_name?: unknown }).skill_name ?? ''), ARG_SUMMARY_MAX, ); } // v1.12 Track B.2: codecontext tool pills. Format is "most-identifying-arg", // matching view_file/grep precedent — surface the path/symbol/query that // makes the call meaningful at a glance. if (name === 'get_codebase_overview') { return ''; } if (name === 'get_file_analysis') { return truncate(String(args.file_path ?? ''), ARG_SUMMARY_MAX); } if (name === 'get_symbol_info') { return truncate(String(args.symbol_name ?? ''), ARG_SUMMARY_MAX); } if (name === 'search_symbols') { return truncate(`"${String(args.query ?? '')}"`, ARG_SUMMARY_MAX); } if (name === 'get_dependencies') { return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); } if (name === 'watch_changes') { return args.enable ? 'enable' : 'disable'; } if (name === 'get_semantic_neighborhoods') { return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); } if (name === 'get_framework_analysis') { return truncate(String(args.framework ?? '(auto-detect)'), ARG_SUMMARY_MAX); } // Unknown tool — surface first arg value or the literal {} so the user can // see something happened. Forward-compatible with future tools. const keys = Object.keys(args); if (keys.length === 0) return '{}'; const first = keys[0]!; return truncate(`${first}: ${String(args[first])}`, ARG_SUMMARY_MAX); } export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' { if (run.result === null) return 'pending'; if (run.result.error) return 'error'; return 'success'; } // Path-shaped paths in tool output text get a click handler so users can // jump to the file. Same heuristic as MessageBubble.linkifyPaths. const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g; function linkifyOutput(text: string): ReactNode[] { const out: ReactNode[] = []; let lastIdx = 0; let idx = 0; for (const match of text.matchAll(PATH_REGEX)) { const matchedText = match[0]; const start = match.index ?? 0; if (!matchedText.includes('/')) continue; if (start > lastIdx) out.push(text.slice(lastIdx, start)); out.push( ); lastIdx = start + matchedText.length; idx += 1; } if (lastIdx < text.length) out.push(text.slice(lastIdx)); return out.length > 0 ? out : [text]; } interface Props { run: ToolRun; // When rendered inside a ToolCallGroup the line is already nested under a // shared header, so the leading arrow is dropped to avoid double indent. insideGroup?: boolean; } export function ToolCallLine({ run, insideGroup }: Props) { const [open, setOpen] = useState(false); const status = runStatus(run); const args = run.call.args ?? {}; const summary = formatToolArgs(run.call.name, args); return (
{open && (
            {JSON.stringify(args, null, 2)}
          
{run.result && (
              {run.result.error ? (
                {run.result.error}
              ) : (
                linkifyOutput(
                  typeof run.result.output === 'string'
                    ? run.result.output
                    : JSON.stringify(run.result.output, null, 2)
                )
              )}
              {run.result.truncated && (
                
— output truncated —
)}
)}
)}
); }