Files
claudecodeui/src/components/chat/tools/configs/toolConfigs.ts
Haileyesus bb8db5815c fix: show Claude tool result errors
Claude stores some tool failures as errored tool_result rows. The UI either
attached those rows to hidden tool output or dropped them when no matching tool
call was rendered, which made validation failures disappear from chat history.

Render unattached errored tool results, unwrap Claude tool_use_error content,
and keep tool errors visible even for tools whose successful output is hidden.
Also remove the permission-grant recovery controls from rendered error history
so denied tool use stays a plain error message.
2026-06-05 16:16:34 +03:00

581 lines
16 KiB
TypeScript

/**
* Centralized tool configuration registry
* Defines display behavior for all tool types
*/
export interface ToolDisplayConfig {
input: {
type: 'one-line' | 'collapsible' | 'plan' | 'hidden';
// One-line config
icon?: string;
label?: string;
getValue?: (input: any) => string;
getSecondary?: (input: any) => string | undefined;
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
style?: string;
wrapText?: boolean;
colorScheme?: {
primary?: string;
secondary?: string;
background?: string;
border?: string;
icon?: string;
};
// Collapsible config
title?: string | ((input: any) => string);
defaultOpen?: boolean;
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';
getContentProps?: (input: any, helpers?: any) => any;
actionButton?: 'file-button' | 'none';
};
result?: {
hidden?: boolean;
hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'plan' | 'special';
title?: string | ((result: any) => string);
defaultOpen?: boolean;
// Special result handlers
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';
getMessage?: (result: any) => string;
getContentProps?: (result: any) => any;
};
}
export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
// ============================================================================
// COMMAND TOOLS
// ============================================================================
Bash: {
input: {
type: 'one-line',
icon: 'terminal',
getValue: (input) => input.command,
getSecondary: (input) => input.description,
action: 'copy',
style: 'terminal',
wrapText: true,
colorScheme: {
primary: 'text-green-400 font-mono',
secondary: 'text-gray-400',
background: '',
border: 'border-green-500 dark:border-green-400',
icon: 'text-green-500 dark:text-green-400'
}
},
result: {
hideOnSuccess: true,
type: 'special'
}
},
// ============================================================================
// FILE OPERATION TOOLS
// ============================================================================
Read: {
input: {
type: 'one-line',
label: 'Read',
getValue: (input) => input.file_path || '',
action: 'open-file',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
hidden: true
}
},
Edit: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: input.old_string,
newContent: input.new_string,
filePath: input.file_path,
badge: 'Edit',
badgeColor: 'gray'
})
},
result: {
hideOnSuccess: true
}
},
Write: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: '',
newContent: input.content,
filePath: input.file_path,
badge: 'New',
badgeColor: 'green'
})
},
result: {
hideOnSuccess: true
}
},
ApplyPatch: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: input.old_string,
newContent: input.new_string,
filePath: input.file_path,
badge: 'Patch',
badgeColor: 'gray'
})
},
result: {
hideOnSuccess: true
}
},
// ============================================================================
// SEARCH TOOLS
// ============================================================================
Grep: {
input: {
type: 'one-line',
label: 'Grep',
getValue: (input) => input.pattern,
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
action: 'jump-to-results',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list',
getContentProps: (result) => {
const toolData = result.toolUseResult || {};
return {
files: toolData.filenames || []
};
}
}
},
Glob: {
input: {
type: 'one-line',
label: 'Glob',
getValue: (input) => input.pattern,
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
action: 'jump-to-results',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list',
getContentProps: (result) => {
const toolData = result.toolUseResult || {};
return {
files: toolData.filenames || []
};
}
}
},
// ============================================================================
// TODO TOOLS
// ============================================================================
TodoWrite: {
input: {
type: 'collapsible',
title: 'Updating todo list',
defaultOpen: false,
contentType: 'todo-list',
getContentProps: (input) => ({
todos: input.todos
})
},
result: {
type: 'collapsible',
contentType: 'success-message',
getMessage: () => 'Todo list updated'
}
},
TodoRead: {
input: {
type: 'one-line',
label: 'TodoRead',
getValue: () => 'reading list',
action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500'
}
},
result: {
type: 'collapsible',
contentType: 'todo-list',
getContentProps: (result) => {
try {
const content = String(result.content || '');
let todos = null;
if (content.startsWith('[')) {
todos = JSON.parse(content);
}
return { todos, isResult: true };
} catch (e) {
console.warn('Failed to parse todo list content:', e);
return { todos: [], isResult: true };
}
}
}
},
// ============================================================================
// TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)
// ============================================================================
TaskCreate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.subject || 'Creating task',
getSecondary: (input) => input.status || undefined,
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskUpdate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => {
const parts = [];
if (input.taskId) parts.push(`#${input.taskId}`);
if (input.status) parts.push(input.status);
if (input.subject) parts.push(`"${input.subject}"`);
return parts.join(' → ') || 'updating';
},
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskList: {
input: {
type: 'one-line',
label: 'Tasks',
getValue: () => 'listing tasks',
action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task list',
contentType: 'task',
getContentProps: (result) => ({
content: String(result?.content || '')
})
}
},
TaskGet: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task details',
contentType: 'task',
getContentProps: (result) => ({
content: String(result?.content || '')
})
}
},
// ============================================================================
// SUBAGENT TASK TOOL
// ============================================================================
Task: {
input: {
type: 'collapsible',
title: (input) => {
const subagentType = input.subagent_type || 'Agent';
const description = input.description || 'Running task';
return `Subagent / ${subagentType}: ${description}`;
},
defaultOpen: false,
contentType: 'markdown',
getContentProps: (input) => {
// If only prompt exists (and required fields), show just the prompt
// Otherwise show all available fields
const hasOnlyPrompt = input.prompt &&
!input.model &&
!input.resume;
if (hasOnlyPrompt) {
return {
content: input.prompt || ''
};
}
// Format multiple fields
const parts = [];
if (input.model) {
parts.push(`**Model:** ${input.model}`);
}
if (input.prompt) {
parts.push(`**Prompt:**\n${input.prompt}`);
}
if (input.resume) {
parts.push(`**Resuming from:** ${input.resume}`);
}
return {
content: parts.join('\n\n')
};
},
colorScheme: {
border: 'border-purple-500 dark:border-purple-400',
icon: 'text-purple-500 dark:text-purple-400'
}
},
result: {
type: 'collapsible',
title: 'Subagent result',
defaultOpen: false,
contentType: 'markdown',
getContentProps: (result) => {
// Handle agent results which may have complex structure
if (result && result.content) {
let content = result.content;
// If content is a JSON string, try to parse it (agent results may arrive serialized)
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
content = parsed;
}
} catch {
// Not JSON — use as-is
return { content };
}
}
// If content is an array (typical for agent responses with multiple text blocks)
if (Array.isArray(content)) {
const textContent = content
.filter((item: any) => item.type === 'text')
.map((item: any) => item.text)
.join('\n\n');
return { content: textContent || 'No response text' };
}
return { content: String(content) };
}
// Fallback to string representation
return { content: String(result || 'No response') };
}
}
},
// ============================================================================
// INTERACTIVE TOOLS
// ============================================================================
AskUserQuestion: {
input: {
type: 'collapsible',
title: (input: any) => {
const count = input.questions?.length || 0;
const hasAnswers = input.answers && Object.keys(input.answers).length > 0;
if (count === 1) {
const header = input.questions[0]?.header || 'Question';
return hasAnswers ? `${header} — answered` : header;
}
return hasAnswers ? `${count} questions — answered` : `${count} questions`;
},
defaultOpen: true,
contentType: 'question-answer',
getContentProps: (input: any) => ({
questions: input.questions || [],
answers: input.answers || {}
}),
},
result: {
hideOnSuccess: true
}
},
// ============================================================================
// PLAN TOOLS
// ============================================================================
exit_plan_mode: {
input: {
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => ({
content: input.plan?.replace(/\\n/g, '\n') || input.plan
})
},
result: {
hidden: true
}
},
// Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: {
input: {
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => ({
content: input.plan?.replace(/\\n/g, '\n') || input.plan
})
},
result: {
hidden: true
}
},
// ============================================================================
// DEFAULT FALLBACK
// ============================================================================
Default: {
input: {
type: 'collapsible',
title: 'Parameters',
defaultOpen: false,
contentType: 'text',
getContentProps: (input) => ({
content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),
format: 'code'
})
},
result: {
type: 'collapsible',
contentType: 'text',
getContentProps: (result) => ({
content: String(result?.content || ''),
format: 'plain'
})
}
}
};
/**
* Get configuration for a tool, with fallback to default
*/
export function getToolConfig(toolName: string): ToolDisplayConfig {
return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;
}
/**
* Check if a tool result should be hidden
*/
export function shouldHideToolResult(toolName: string, toolResult: any): boolean {
const config = getToolConfig(toolName);
if (!config.result) return false;
// Hidden/success-only configs suppress noisy successful output, but errors
// still need to be visible so failed tool calls are diagnosable.
if (toolResult?.isError) return false;
// Always hidden
if (config.result.hidden) return true;
// Hide on success only
if (config.result.hideOnSuccess && toolResult) {
return true;
}
return false;
}