feat: add Apply Patch tool with patch input and diff view (#196)
* feat: add Apply Patch tool with patch input and diff view Introduce Apply Patch tool with a patch input field and diff preview Display target file path or file count for patch operations in tool UI Update status label to show applying patch during execution * fix: include apply_patch in editing tools detection
This commit is contained in:
committed by
GitHub
parent
ef4d37df09
commit
87175566b9
@@ -283,6 +283,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
|
void _streamPhase;
|
||||||
void _allowAnimation;
|
void _allowAnimation;
|
||||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const getToolIcon = (toolName: string) => {
|
|||||||
if (tool === 'image-preview') {
|
if (tool === 'image-preview') {
|
||||||
return <RiFileImageLine className={iconClass} />;
|
return <RiFileImageLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
return <RiPencilAiLine className={iconClass} />;
|
return <RiPencilAiLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
@@ -109,7 +109,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
|
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
|
||||||
Object.keys(popup.metadata.input).length > 0 &&
|
Object.keys(popup.metadata.input).length > 0 &&
|
||||||
popup.metadata?.tool !== 'todowrite' &&
|
popup.metadata?.tool !== 'todowrite' &&
|
||||||
popup.metadata?.tool !== 'todoread' ? (() => {
|
popup.metadata?.tool !== 'todoread' &&
|
||||||
|
popup.metadata?.tool !== 'apply_patch' ? (() => {
|
||||||
const meta = popup.metadata!;
|
const meta = popup.metadata!;
|
||||||
const input = meta.input as Record<string, unknown>;
|
const input = meta.input as Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const getToolIcon = (toolName: string) => {
|
|||||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||||
const tool = toolName.toLowerCase();
|
const tool = toolName.toLowerCase();
|
||||||
|
|
||||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
return <RiPencilLine className={iconClass} />;
|
return <RiPencilLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||||
@@ -175,6 +175,19 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
|
|||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
const input = stateWithData.input;
|
const input = stateWithData.input;
|
||||||
|
|
||||||
|
if (part.tool === 'apply_patch') {
|
||||||
|
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
|
||||||
|
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
|
||||||
|
const filePath = firstFile?.relativePath || firstFile?.filePath;
|
||||||
|
if (files.length > 1) {
|
||||||
|
return `${files.length} files`;
|
||||||
|
}
|
||||||
|
if (typeof filePath === 'string') {
|
||||||
|
return getRelativePath(filePath, currentDirectory, isMobile);
|
||||||
|
}
|
||||||
|
return 'Patch';
|
||||||
|
}
|
||||||
|
|
||||||
// Question tool: show "Asked N question(s)"
|
// Question tool: show "Asked N question(s)"
|
||||||
if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
|
if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
|
||||||
const count = input.questions.length;
|
const count = input.questions.length;
|
||||||
@@ -605,7 +618,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
|||||||
|
|
||||||
return formatInputForDisplay(input, part.tool);
|
return formatInputForDisplay(input, part.tool);
|
||||||
}, [input, part.tool]);
|
}, [input, part.tool]);
|
||||||
const hasInputText = inputTextContent.trim().length > 0;
|
const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0;
|
||||||
|
|
||||||
const renderScrollableBlock = (
|
const renderScrollableBlock = (
|
||||||
content: React.ReactNode,
|
content: React.ReactNode,
|
||||||
@@ -755,7 +768,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((part.tool === 'edit' || part.tool === 'multiedit') && diffContent) {
|
if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
|
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
|
||||||
{ className: 'p-1' }
|
{ className: 'p-1' }
|
||||||
@@ -1013,7 +1026,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
|
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
|
||||||
|
|
||||||
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
|
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null;
|
||||||
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
||||||
const displayName = getToolMetadata(part.tool).displayName;
|
const displayName = getToolMetadata(part.tool).displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -410,19 +410,22 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
|||||||
const newStart = match ? parseInt(match[2]) : 0;
|
const newStart = match ? parseInt(match[2]) : 0;
|
||||||
|
|
||||||
const unifiedLines: UnifiedDiffLine[] = [];
|
const unifiedLines: UnifiedDiffLine[] = [];
|
||||||
let lineNum = newStart;
|
let oldLineNum = oldStart;
|
||||||
|
let newLineNum = newStart;
|
||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
|
|
||||||
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
|
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
|
||||||
const contentLine = lines[j];
|
const contentLine = lines[j];
|
||||||
if (contentLine.startsWith('+')) {
|
if (contentLine.startsWith('+')) {
|
||||||
unifiedLines.push({ type: 'added', lineNumber: lineNum, content: contentLine.substring(1) });
|
unifiedLines.push({ type: 'added', lineNumber: newLineNum, content: contentLine.substring(1) });
|
||||||
lineNum++;
|
newLineNum++;
|
||||||
} else if (contentLine.startsWith('-')) {
|
} else if (contentLine.startsWith('-')) {
|
||||||
unifiedLines.push({ type: 'removed', lineNumber: null, content: contentLine.substring(1) });
|
unifiedLines.push({ type: 'removed', lineNumber: oldLineNum, content: contentLine.substring(1) });
|
||||||
|
oldLineNum++;
|
||||||
} else if (contentLine.startsWith(' ')) {
|
} else if (contentLine.startsWith(' ')) {
|
||||||
unifiedLines.push({ type: 'context', lineNumber: lineNum, content: contentLine.substring(1) });
|
unifiedLines.push({ type: 'context', lineNumber: newLineNum, content: contentLine.substring(1) });
|
||||||
lineNum++;
|
oldLineNum++;
|
||||||
|
newLineNum++;
|
||||||
}
|
}
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined = undefined;
|
let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined = undefined;
|
||||||
let activeToolName: string | undefined = undefined;
|
let activeToolName: string | undefined = undefined;
|
||||||
|
|
||||||
const editingTools = new Set(['edit', 'write']);
|
const editingTools = new Set(['edit', 'write', 'apply_patch']);
|
||||||
|
|
||||||
for (let i = (lastAssistant.parts ?? []).length - 1; i >= 0; i -= 1) {
|
for (let i = (lastAssistant.parts ?? []).length - 1; i >= 0; i -= 1) {
|
||||||
const part = lastAssistant.parts?.[i];
|
const part = lastAssistant.parts?.[i];
|
||||||
@@ -218,6 +218,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
write: 'writing file',
|
write: 'writing file',
|
||||||
edit: 'editing file',
|
edit: 'editing file',
|
||||||
multiedit: 'editing files',
|
multiedit: 'editing files',
|
||||||
|
apply_patch: 'applying patch',
|
||||||
bash: 'running command',
|
bash: 'running command',
|
||||||
grep: 'searching content',
|
grep: 'searching content',
|
||||||
glob: 'finding files',
|
glob: 'finding files',
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
|
|||||||
{ key: 'edits', label: 'Edits', type: 'code', language: 'json' }
|
{ key: 'edits', label: 'Edits', type: 'code', language: 'json' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
apply_patch: {
|
||||||
|
displayName: 'Apply Patch',
|
||||||
|
category: 'file',
|
||||||
|
outputLanguage: 'diff',
|
||||||
|
inputFields: [
|
||||||
|
{ key: 'patchText', label: 'Patch', type: 'code', language: 'diff' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
bash: {
|
bash: {
|
||||||
displayName: 'Shell Command',
|
displayName: 'Shell Command',
|
||||||
@@ -665,6 +673,13 @@ export function formatToolInput(input: Record<string, unknown>, toolName: string
|
|||||||
if (desc) return desc;
|
if (desc) return desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toolName === 'apply_patch' && typeof input === 'object') {
|
||||||
|
const patchText = getString('patchText') || getString('patch_text') || getString('patch');
|
||||||
|
if (patchText) {
|
||||||
|
return patchText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((toolName === 'edit' || toolName === 'multiedit') && typeof input === 'object') {
|
if ((toolName === 'edit' || toolName === 'multiedit') && typeof input === 'object') {
|
||||||
const filePath = getString('filePath') || getString('file_path') || getString('path');
|
const filePath = getString('filePath') || getString('file_path') || getString('path');
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
|
|||||||
Reference in New Issue
Block a user