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:
Bohdan Triapitsyn
2026-01-22 19:09:07 +02:00
committed by GitHub
parent ef4d37df09
commit 87175566b9
6 changed files with 47 additions and 13 deletions
@@ -283,6 +283,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
errorMessage,
}) => {
void _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -39,7 +39,7 @@ const getToolIcon = (toolName: string) => {
if (tool === 'image-preview') {
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} />;
}
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' &&
Object.keys(popup.metadata.input).length > 0 &&
popup.metadata?.tool !== 'todowrite' &&
popup.metadata?.tool !== 'todoread' ? (() => {
popup.metadata?.tool !== 'todoread' &&
popup.metadata?.tool !== 'apply_patch' ? (() => {
const meta = popup.metadata!;
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 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} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
@@ -175,6 +175,19 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile:
const metadata = stateWithData.metadata;
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)"
if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
const count = input.questions.length;
@@ -605,7 +618,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
return formatInputForDisplay(input, part.tool);
}, [input, part.tool]);
const hasInputText = inputTextContent.trim().length > 0;
const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0;
const renderScrollableBlock = (
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(
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
{ className: 'p-1' }
@@ -1013,7 +1026,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
onContentChange?.('structural');
}, [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 displayName = getToolMetadata(part.tool).displayName;
@@ -410,19 +410,22 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
const newStart = match ? parseInt(match[2]) : 0;
const unifiedLines: UnifiedDiffLine[] = [];
let lineNum = newStart;
let oldLineNum = oldStart;
let newLineNum = newStart;
let j = i + 1;
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
const contentLine = lines[j];
if (contentLine.startsWith('+')) {
unifiedLines.push({ type: 'added', lineNumber: lineNum, content: contentLine.substring(1) });
lineNum++;
unifiedLines.push({ type: 'added', lineNumber: newLineNum, content: contentLine.substring(1) });
newLineNum++;
} 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(' ')) {
unifiedLines.push({ type: 'context', lineNumber: lineNum, content: contentLine.substring(1) });
lineNum++;
unifiedLines.push({ type: 'context', lineNumber: newLineNum, content: contentLine.substring(1) });
oldLineNum++;
newLineNum++;
}
j++;
}
+2 -1
View File
@@ -169,7 +169,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | 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) {
const part = lastAssistant.parts?.[i];
@@ -218,6 +218,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
write: 'writing file',
edit: 'editing file',
multiedit: 'editing files',
apply_patch: 'applying patch',
bash: 'running command',
grep: 'searching content',
glob: 'finding files',
+15
View File
@@ -52,6 +52,14 @@ export const TOOL_METADATA: Record<string, ToolMetadata> = {
{ 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: {
displayName: 'Shell Command',
@@ -665,6 +673,13 @@ export function formatToolInput(input: Record<string, unknown>, toolName: string
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') {
const filePath = getString('filePath') || getString('file_path') || getString('path');
if (filePath) {