mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 09:02:08 +08:00
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.
581 lines
16 KiB
TypeScript
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;
|
|
}
|