diff --git a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx index f466b2ad2..8a85a86e7 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/UI.tsx +++ b/packages/builtin-tools/src/tools/FileEditTool/UI.tsx @@ -1,5 +1,7 @@ import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import type { StructuredPatchHunk } from 'diff'; import * as React from 'react'; +import { Suspense, use, useState } from 'react'; import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js'; import { MessageResponse } from 'src/components/MessageResponse.js'; import { extractTag } from 'src/utils/messages.js'; @@ -10,10 +12,15 @@ import { Text } from '@anthropic/ink'; import { FilePathLink } from 'src/components/FilePathLink.js'; import type { Tools } from 'src/Tool.js'; import type { Message, ProgressMessage } from 'src/types/message.js'; +import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js'; import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js'; +import { logError } from 'src/utils/log.js'; import { getPlansDirectory } from 'src/utils/plans.js'; +import { readEditContext } from 'src/utils/readEditContext.js'; +import { firstLineOf } from 'src/utils/stringUtils.js'; import type { ThemeName } from 'src/utils/theme.js'; import type { FileEditOutput } from './types.js'; +import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js'; export function userFacingName( input: @@ -84,6 +91,8 @@ export function renderToolResultMessage( + ); + } + + const isNewFile = oldString === ''; + + // For new file creation, show content preview instead of diff + if (isNewFile) { + return ( + + ); + } return ( - @@ -149,3 +184,103 @@ export function renderToolUseErrorMessage( } return ; } + +type RejectionDiffData = { + patch: StructuredPatchHunk[]; + firstLine: string | null; + fileContent: string | undefined; +}; + +function EditRejectionDiff({ + filePath, + oldString, + newString, + replaceAll, + style, + verbose, +}: { + filePath: string; + oldString: string; + newString: string; + replaceAll: boolean; + style?: 'condensed'; + verbose: boolean; +}): React.ReactNode { + const [dataPromise] = useState(() => loadRejectionDiff(filePath, oldString, newString, replaceAll)); + return ( + + } + > + + + ); +} + +function EditRejectionBody({ + promise, + filePath, + style, + verbose, +}: { + promise: Promise; + filePath: string; + style?: 'condensed'; + verbose: boolean; +}): React.ReactNode { + const { patch, firstLine, fileContent } = use(promise); + return ( + + ); +} + +async function loadRejectionDiff( + filePath: string, + oldString: string, + newString: string, + replaceAll: boolean, +): Promise { + try { + // Chunked read — context window around the first occurrence. replaceAll + // still shows matches *within* the window via getPatchForEdit; we accept + // losing the all-occurrences view to keep the read bounded. + const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES); + if (ctx === null || ctx.truncated || ctx.content === '') { + // ENOENT / not found / truncated — diff just the tool inputs. + const { patch } = getPatchForEdit({ + filePath, + fileContents: oldString, + oldString, + newString, + }); + return { patch, firstLine: null, fileContent: undefined }; + } + const actualOld = findActualString(ctx.content, oldString) || oldString; + const actualNew = preserveQuoteStyle(oldString, actualOld, newString); + const { patch } = getPatchForEdit({ + filePath, + fileContents: ctx.content, + oldString: actualOld, + newString: actualNew, + replaceAll, + }); + return { + patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1), + firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null, + fileContent: ctx.content, + }; + } catch (e) { + // User may have manually applied the change while the diff was shown. + logError(e as Error); + return { patch: [], firstLine: null, fileContent: undefined }; + } +} diff --git a/packages/builtin-tools/src/tools/FileWriteTool/UI.tsx b/packages/builtin-tools/src/tools/FileWriteTool/UI.tsx index 5a946be65..c875fdf57 100644 --- a/packages/builtin-tools/src/tools/FileWriteTool/UI.tsx +++ b/packages/builtin-tools/src/tools/FileWriteTool/UI.tsx @@ -1,6 +1,8 @@ import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import { relative } from 'path'; +import type { StructuredPatchHunk } from 'diff'; +import { isAbsolute, relative, resolve } from 'path'; import * as React from 'react'; +import { Suspense, use, useState } from 'react'; import { MessageResponse } from 'src/components/MessageResponse.js'; import { extractTag } from 'src/utils/messages.js'; import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js'; @@ -15,8 +17,11 @@ import { FilePathLink } from 'src/components/FilePathLink.js'; import type { ToolProgressData } from 'src/Tool.js'; import type { ProgressMessage } from 'src/types/message.js'; import { getCwd } from 'src/utils/cwd.js'; +import { getPatchForDisplay } from 'src/utils/diff.js'; import { getDisplayPath } from 'src/utils/file.js'; +import { logError } from 'src/utils/log.js'; import { getPlansDirectory } from 'src/utils/plans.js'; +import { openForScan, readCapped } from 'src/utils/readEditContext.js'; import type { Output } from './FileWriteTool.js'; const MAX_LINES_TO_RENDER = 10; @@ -122,10 +127,115 @@ export function renderToolUseMessage( } export function renderToolUseRejectedMessage( - { file_path }: { file_path: string; content: string }, + { file_path, content }: { file_path: string; content: string }, { style, verbose }: { style?: 'condensed'; verbose: boolean }, ): React.ReactNode { - return ; + return ; +} + +type RejectionDiffData = + | { type: 'create' } + | { type: 'update'; patch: StructuredPatchHunk[]; oldContent: string } + | { type: 'error' }; + +function WriteRejectionDiff({ + filePath, + content, + style, + verbose, +}: { + filePath: string; + content: string; + style?: 'condensed'; + verbose: boolean; +}): React.ReactNode { + const [dataPromise] = useState(() => loadRejectionDiff(filePath, content)); + const firstLine = content.split('\n')[0] ?? null; + const createFallback = ( + + ); + return ( + + + + ); +} + +function WriteRejectionBody({ + promise, + filePath, + firstLine, + createFallback, + style, + verbose, +}: { + promise: Promise; + filePath: string; + firstLine: string | null; + createFallback: React.ReactNode; + style?: 'condensed'; + verbose: boolean; +}): React.ReactNode { + const data = use(promise); + if (data.type === 'create') return createFallback; + if (data.type === 'error') { + return ( + + (No changes) + + ); + } + return ( + + ); +} + +async function loadRejectionDiff(filePath: string, content: string): Promise { + try { + const fullFilePath = isAbsolute(filePath) ? filePath : resolve(getCwd(), filePath); + const handle = await openForScan(fullFilePath); + if (handle === null) return { type: 'create' }; + let oldContent: string | null; + try { + oldContent = await readCapped(handle); + } finally { + await handle.close(); + } + // File exceeds MAX_SCAN_BYTES — fall back to the create view rather than + // OOMing on a diff of a multi-GB file. + if (oldContent === null) return { type: 'create' }; + const patch = getPatchForDisplay({ + filePath, + fileContents: oldContent, + edits: [{ old_string: oldContent, new_string: content, replace_all: false }], + }); + return { type: 'update', patch, oldContent }; + } catch (e) { + // User may have manually applied the change while the diff was shown. + logError(e as Error); + return { type: 'error' }; + } } export function renderToolUseErrorMessage( @@ -179,6 +289,8 @@ export function renderToolResultMessage( acc + count(hunk.lines, _ => _.startsWith('+')), 0); const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0); @@ -39,7 +47,7 @@ export function FileEditToolUpdatedMessage({ // Plan files: invert condensed behavior // - Regular mode: just show the hint (user can type /plan to see full content) - // - Condensed mode (subagent view): show the text + // - Condensed mode (subagent view): show the diff if (previewHint) { if (style !== 'condensed' && !verbose) { return ( @@ -52,5 +60,19 @@ export function FileEditToolUpdatedMessage({ return text; } - return {text}; + return ( + + + {text} + + + + ); } diff --git a/src/components/FileEditToolUseRejectedMessage.tsx b/src/components/FileEditToolUseRejectedMessage.tsx index 1929651c9..bc09f5d57 100644 --- a/src/components/FileEditToolUseRejectedMessage.tsx +++ b/src/components/FileEditToolUseRejectedMessage.tsx @@ -1,17 +1,39 @@ +import type { StructuredPatchHunk } from 'diff'; import { relative } from 'path'; import * as React from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; import { getCwd } from 'src/utils/cwd.js'; import { Box, Text } from '@anthropic/ink'; +import { HighlightedCode } from './HighlightedCode.js'; import { MessageResponse } from './MessageResponse.js'; +import { StructuredDiffList } from './StructuredDiffList.js'; + +const MAX_LINES_TO_RENDER = 10; type Props = { file_path: string; operation: 'write' | 'update'; + // For updates - show diff + patch?: StructuredPatchHunk[]; + firstLine: string | null; + fileContent?: string; + // For new file creation - show content preview + content?: string; style?: 'condensed'; verbose: boolean; }; -export function FileEditToolUseRejectedMessage({ file_path, operation, style, verbose }: Props): React.ReactNode { +export function FileEditToolUseRejectedMessage({ + file_path, + operation, + patch, + firstLine, + fileContent, + content, + style, + verbose, +}: Props): React.ReactNode { + const { columns } = useTerminalSize(); const text = ( User rejected {operation} to @@ -26,5 +48,42 @@ export function FileEditToolUseRejectedMessage({ file_path, operation, style, ve return {text}; } - return {text}; + // For new file creation, show content preview (dimmed) + if (operation === 'write' && content !== undefined) { + const lines = content.split('\n'); + const numLines = lines.length; + const plusLines = numLines - MAX_LINES_TO_RENDER; + const truncatedContent = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join('\n'); + + return ( + + + {text} + + {!verbose && plusLines > 0 && … +{plusLines} lines} + + + ); + } + + // For updates, show diff + if (!patch || patch.length === 0) { + return {text}; + } + + return ( + + + {text} + + + + ); }