// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer'; import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common'; import { LabelProvider } from '@theia/core/lib/browser'; import { URI } from '@theia/core/lib/common/uri'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; import { ReactNode } from '@theia/core/shared/react'; import { EditorManager } from '@theia/editor/lib/browser'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content'; import { CollapsibleToolRenderer } from './collapsible-tool-renderer'; import { nls, ILogger } from '@theia/core'; interface EditOperation { old_string: string; new_string: string; replace_all?: boolean; } interface MultiEditToolInput { file_path: string; edits: EditOperation[]; } @injectable() export class MultiEditToolRenderer implements ChatResponsePartRenderer { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(ILogger) @named('ai-claude-code:MultiEditToolRenderer') protected readonly logger: ILogger; canHandle(response: ChatResponseContent): number { if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'MultiEdit') { return 15; // Higher than default ToolCallPartRenderer (10) } return -1; } render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode { try { const input = JSON.parse(response.arguments || '{}') as MultiEditToolInput; return ; } catch (error) { this.logger.warn('Failed to parse MultiEdit tool input:', error); return
{nls.localize('theia/ai/claude-code/failedToParseMultiEditToolData', 'Failed to parse MultiEdit tool data')}
; } } } const MultiEditToolComponent: React.FC<{ input: MultiEditToolInput; workspaceService: WorkspaceService; labelProvider: LabelProvider; editorManager: EditorManager; }> = ({ input, workspaceService, labelProvider, editorManager }) => { const getFileName = (filePath: string): string => filePath.split('/').pop() || filePath; const getWorkspaceRelativePath = async (filePath: string): Promise => { try { const absoluteUri = new URI(filePath).parent; const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri); return workspaceRelativePath || ''; } catch { return ''; } }; const getIcon = (filePath: string): string => { try { const uri = new URI(filePath); return labelProvider.getIcon(uri) || 'codicon-file'; } catch { return 'codicon-file'; } }; const handleOpenFile = async () => { try { const uri = new URI(input.file_path); await editorManager.open(uri); } catch (error) { console.error('Failed to open file:', error); } }; const [relativePath, setRelativePath] = React.useState(''); React.useEffect(() => { getWorkspaceRelativePath(input.file_path).then(setRelativePath); }, [input.file_path]); const getChangeInfo = () => { let totalOldLines = 0; let totalNewLines = 0; input.edits.forEach(edit => { totalOldLines += edit.old_string.split('\n').length; totalNewLines += edit.new_string.split('\n').length; }); return { totalOldLines, totalNewLines }; }; const replaceAllCount = input.edits.filter(edit => edit.replace_all).length; const totalEdits = input.edits.length; const compactHeader = ( <>
{nls.localize('theia/ai/claude-code/multiEditing', 'Multi-editing')} {getFileName(input.file_path)} {relativePath && {relativePath}}
-{getChangeInfo().totalOldLines} +{getChangeInfo().totalNewLines} {totalEdits === 1 ? nls.localize('theia/ai/claude-code/oneEdit', '1 edit') : nls.localize('theia/ai/claude-code/editsCount', '{0} edits', totalEdits)} {replaceAllCount > 0 && ( {nls.localize('theia/ai/claude-code/replaceAllCount', '{0} replace-all', replaceAllCount)} )}
); const expandedContent = (
{nls.localize('theia/ai/claude-code/filePath', 'File Path')} {input.file_path}
{nls.localize('theia/ai/claude-code/totalEdits', 'Total Edits')} {totalEdits}
{input.edits.map((edit, index) => (
{nls.localize('theia/ai/claude-code/edit', 'Edit {0}', index + 1)} {edit.replace_all && ( {nls.localizeByDefault('Replace All')} )}
{nls.localize('theia/ai/claude-code/from', 'From')}
                            {edit.old_string.length > 100
                                ? edit.old_string.substring(0, 100) + '...'
                                : edit.old_string}
                        
{nls.localize('theia/ai/claude-code/to', 'To')}
                            {edit.new_string.length > 100
                                ? edit.new_string.substring(0, 100) + '...'
                                : edit.new_string}
                        
))}
); return ( ); };