08-文件工具族
8. 文件工具族
所属分组:工具系统
概述
文件操作是 Claude Code 与代码库交互最频繁的场景,因此文件工具族被设计得最为细致:FileEditTool 负责精确字符串替换,FileWriteTool 负责整文件创建/覆盖,FileReadTool 负责读取(文本、图片、PDF、notebook 等多形态),BriefTool 则承担向用户发送富文本消息并附带附件的职能。这四个工具共享一套"diff 生成 + 拒绝回退 + 路径展示"的 UI 哲学,但各自的实现细节折射出不同的关注点。
本文以四个工具目录下的 UI 与 prompt/upload 文件为切入点,剖析它们如何在统一的 Tool 接口下演化出各自的渲染策略与执行机制。重点关注 diff 生成的懒加载、计划文件的特殊处理、Brief 附件上传的 graceful degradation 等设计。
源码位置
- [tools/FileEditTool/UI.tsx](file:///e:/2026plan/AI_Lab/claude-code-sourcemap-main/restored-src/src/tools/FileEditTool/UI.tsx)
- [tools/FileWriteTool/UI.tsx](file:///e:/2026plan/AI_Lab/claude-code-sourcemap-main/restored-src/src/tools/FileWriteTool/UI.tsx)
- [tools/FileReadTool/UI.tsx](file:///e:/2026plan/AI_Lab/claude-code-sourcemap-main/restored-src/src/tools/FileReadTool/UI.tsx)
- [tools/BriefTool/prompt.ts](file:///e:/2026plan/AI_Lab/claude-code-sourcemap-main/restored-src/src/tools/BriefTool/prompt.ts)
- [tools/BriefTool/upload.ts](file:///e:/2026plan/AI_Lab/claude-code-sourcemap-main/restored-src/src/tools/BriefTool/upload.ts)
核心实现分析
1. FileEditTool:从输入推断意图的 userFacingName
FileEditTool 的 userFacingName() 不是简单返回固定字符串,而是根据输入推断模型意图:
export function userFacingName(input): string {
if (!input) return 'Update'
if (input.file_path?.startsWith(getPlansDirectory())) return 'Updated plan'
if (input.edits != null) return 'Update' // Hashline 多点编辑
if (input.old_string === '') return 'Create' // 空字符串=新建文件
return 'Update'
}
old_string === '' 被解释为"新建文件",UI 显示 Create 而非 Update——这是工具用输入字段语义反推操作类型的范例。edits 字段则代表 hashline 多点编辑模式(基于行号引用的批量编辑)。
2. FileEditTool 的拒绝 diff 懒加载
renderToolUseRejectedMessage 是用户拒绝编辑后看到的 diff 视图。它使用 React Suspense + useState 懒加载 diff:
function EditRejectionDiff({ filePath, oldString, newString, replaceAll, style, verbose }) {
const [dataPromise] = useState(() => loadRejectionDiff(filePath, oldString, newString, replaceAll))
// ...
return <Suspense fallback={t2}>{t3}</Suspense>
}
loadRejectionDiff 调用 readEditContext 做分块读取——只读 old_string 第一次出现位置周围的窗口(CONTEXT_LINES 行),而不是整个文件:
async function loadRejectionDiff(filePath, oldString, newString, replaceAll) {
// 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)
// ...
return { patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1), /* ... */ }
}
注释里"accept losing the all-occurrences view to keep the read bounded"揭示了取舍:当 replace_all: true 时,理论上应该展示所有匹配位置,但为了保证读取有界,只展示第一个匹配窗口内的所有匹配——这样既不 OOM,又保留了大部分信息。preserveQuoteStyle 处理 old_string 中的引号风格被模型改写的情况(如直引号变弯引号),让 diff 显示用户文件里的真实旧字符串。
3. FileEditTool 的错误降级
renderToolUseErrorMessage 把底层的 <tool_use_error> 标签转换为更友好的提示:
if (errorMessage?.includes('File has not been read yet')) {
return <MessageResponse><Text dimColor>File must be read first</Text></MessageResponse>
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return <MessageResponse><Text color="error">File not found</Text></MessageResponse>
}
return <MessageResponse><Text color="error">Error editing file</Text></MessageResponse>
"File must be read first"用 dimColor 表示这是预期行为而非错误(模型忘了先 Read),"File not found"才用 error 红色。这种"颜色编码错误严重性"的细节让 UI 更准确传达状态。
4. FileWriteTool:countLines 与 EOL 处理
FileWriteTool 中有个独立的 countLines 函数:
const EOL = '\n'
export function countLines(content: string): number {
const parts = content.split(EOL)
return content.endsWith(EOL) ? parts.length - 1 : parts.length
}
注释说明原因:“Model output uses \n regardless of platform, so always split on \n. os.EOL is \r\n on Windows, which would give numLines=1 for all files.” 这是跨平台一致性的硬性要求——文件内容来自模型,永远是 \n 分隔,不能用 os.EOL。
5. FileWriteTool 的 isResultTruncated 早退出
isResultTruncated() 决定是否显示"点击展开"提示。它做了一个性能优化——只数前 MAX_LINES_TO_RENDER+1 行就退出,不切分整个内容:
export function isResultTruncated({ type, content }: Output): boolean {
if (type !== 'create') return false
let pos = 0
for (let i = 0; i < MAX_LINES_TO_RENDER; i++) {
pos = content.indexOf(EOL, pos)
if (pos === -1) return false
pos++
}
return pos < content.length
}
注释提到这是"Called per visible message on hover/scroll, so early-exit after finding the (MAX+1)th line instead of splitting the whole (possibly huge) content." 在大文件创建时这条路径会被频繁调用,早退出避免了无意义的字符串拷贝。
6. FileWriteTool 的 create vs update 双形态
renderToolResultMessage 根据 type 字段分两路渲染:
switch (type) {
case 'create':
// Plan files 特殊处理:常规模式只显示 /plan hint;condensed 模式显示全内容
if (isPlanFile && !verbose) {
if (style !== 'condensed') {
return <MessageResponse><Text dimColor>/plan to preview</Text></MessageResponse>
}
} else if (style === 'condensed' && !verbose) {
return <Text>Wrote <Text bold>{numLines}</Text> lines to{' '}<Text bold>{relative(getCwd(), filePath)}</Text></Text>
}
return <FileWriteToolCreatedMessage filePath={filePath} content={content} verbose={verbose} />
case 'update':
return <FileEditToolUpdatedMessage filePath={filePath} structuredPatch={structuredPatch} /* ... */ />
}
create 渲染文件预览(前 MAX_LINES_TO_RENDER=10 行 + “+N lines” 提示),update 渲染 diff。Plan files 又是一种特例——它把 condensed/verbose 行为反转了:常规模式只显示提示(因为 plan 内容较长,用户可以用 /plan 命令展开),condensed 模式(subagent 视图)反而显示全内容。这种"上下文反向"的设计反映了 plan 文件在不同视图下的语义差异。
7. FileWriteTool 的 RejectionDiff 三态
loadRejectionDiff 返回三种状态:
type RejectionDiffData =
| { type: 'create' } // 文件原本不存在,回退到 create 视图
| { type: 'update'; patch; oldContent } // 正常 update diff
| { type: 'error' } // 异常(用户可能手动改了文件)
openForScan 返回 null 表示文件不存在 → type: 'create';readCapped 返回 null 表示文件超过 MAX_SCAN_BYTES → 同样回退到 create 视图(“rather than OOMing on a diff of a multi-GB file”)。error 分支显示"(No changes)“——这是注释里说的"User may have manually applied the change while the diff was shown”。
8. FileReadTool:多类型输出渲染
renderToolResultMessage 根据 output.type 分支:
switch (output.type) {
case 'image': return <Text>Read image ({formattedSize})</Text>
case 'notebook': return <Text>Read <Text bold>{cells.length}</Text> cells</Text>
case 'pdf': return <Text>Read PDF ({formattedSize})</Text>
case 'parts': return <Text>Read <Text bold>{output.file.count}</Text>{' '}{... ? 'page' : 'pages'} ({formatFileSize(...)})</Text>
case 'text': return <Text>Read <Text bold>{numLines}</Text>{' '}{numLines === 1 ? 'line' : 'lines'}</Text>
case 'file_unchanged': return <Text dimColor>Unchanged since last read</Text>
}
注意 file_unchanged 用 dimColor 表示"未变化"——这是 FileStateCache 的应用:模型重复读同一文件时,如果文件未被外部修改,UI 上提示"Unchanged since last read",让用户知道模型没在浪费 token。getAgentOutputTaskId 又是一个特殊路径——当读取 {projectTempDir}/tasks/{taskId}.output 时,UI 隐藏括号内容,task ID 通过 renderToolUseTag 单独显示。
9. BriefTool 的 prompt:把"消息通道"讲清楚
BriefTool(工具名 SendUserMessage,旧名 Brief)的 prompt 短小但语义密集:
export const BRIEF_TOOL_PROMPT = `Send a message the user will read. Text outside this tool is visible in the detail view, but most won't open it — the answer lives here.
\`message\` supports markdown. \`attachments\` takes file paths (absolute or cwd-relative) for images, diffs, logs.
\`status\` labels intent: 'normal' when replying to what they just asked; 'proactive' when you're initiating — a scheduled task finished, a blocker surfaced during background work, you need input on something they haven't asked about. Set it honestly; downstream routing uses it.`
它讲清了三件事:① 工具外文本用户多半看不到,重要内容必须走 BriefTool;② attachments 接受文件路径(图片、diff、日志);③ status 字段会被下游路由使用,必须如实标注。
BRIEF_PROACTIVE_SECTION 进一步强调"every time the user says something, the reply they actually read comes through BriefTool. Even for ‘hi’. Even for ‘thanks’."——这是为主动消息场景训练模型习惯的强约束。
10. BriefTool upload.ts:graceful degradation 的范本
uploadBriefAttachment 是附件上传的 best-effort 实现,文件头注释把它说得很清楚:
/**
* Upload BriefTool attachments to private_api so web viewers can preview them.
* ...
* Best-effort: any failure (no token, bridge off, network error, 4xx) logs
* debug and returns undefined. The attachment still carries {path, size,
* isImage}, so local-terminal and same-machine-desktop render unaffected.
*/
每一个早返回都是有意为之的降级:
if (feature('BRIDGE_MODE')) {
if (!ctx.replBridgeEnabled) return undefined // bridge 未启用
if (size > MAX_UPLOAD_BYTES) { /* skip */ } // 超过 30MB
const token = getBridgeAccessToken()
if (!token) { debug('skip: no oauth token'); return undefined } // 无 token
let content: Buffer
try { content = await readFile(fullPath) }
catch (e) { debug(`read failed`); return undefined } // 读文件失败
// ...axios.post...
if (response.status !== 201) { /* upload failed */ return undefined }
// ...
}
return undefined
注释提到一个微妙的部署细节:“Subprocess hosts (cowork) pass ANTHROPIC_BASE_URL alongside CLAUDE_CODE_OAUTH_TOKEN — prefer that since getOauthConfig() only returns staging when USE_STAGING_OAUTH is set, which such hosts don’t set. Without this a staging token hits api.anthropic.com → 401 → silent skip → web viewer sees inert cards with no file_uuid.” 这是真实部署踩过的坑——staging token 打到生产域名导致 401,所有附件静默失败。
11. MIME 类型白名单的取舍
MIME_BY_EXT 只列出四种图片格式:
const MIME_BY_EXT: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
注释解释了为什么 svg/bmp/ico/pdf 都不在内:“svg/bmp/ico risk a 400, and pdf routes to upload_pdf_file_wrapped which also skips ORIGINAL. Dispatch viewers use /preview for images and /contents for everything else, so images go image/* and the rest go octet-stream.” 这是对后端 dispatch 行为的精确适配——非白名单格式统一用 application/octet-stream,由后端走 generic file 路径。
关键设计要点
- 意图反推:FileEditTool 用
old_string === ''推断Create,BriefTool 用status字段区分normal/proactive,UI 标签随意图变化,避免"一刀切"的 Update/Write 表述。 - diff 懒加载与有界读取:拒绝 diff 用 Suspense +
readEditContext只读窗口上下文,超大文件回退到 create 视图,保证 UI 永不 OOM。 - 跨平台 EOL 一致性:FileWriteTool 强制
\n分割,注释明确反对os.EOL,保证模型输出在 Windows 上也能正确数行。 - Plan 文件上下文反向渲染:常规模式只显示
/plan提示,condensed 模式(subagent 视图)反而展示全内容,反映 plan 在不同视图下的语义差异。 - BriefTool 全链路降级:附件上传从 bridge 状态、token 存在性、文件大小、读文件、网络请求五层做 graceful degradation,本地体验永不因云端失败而损坏。
与其他模块的关系
- utils/diff.ts:
getPatchForEdit、getPatchForDisplay、adjustHunkLineNumbers、CONTEXT_LINES是 FileEdit/FileWrite 共用的 diff 生成基础设施。 - utils/readEditContext.ts:
readEditContext、openForScan、readCapped提供有界读取能力,是拒绝 diff 不 OOM 的根本保障。 - utils/fileStateCache.ts:FileReadTool 的
file_unchanged状态依赖此缓存,避免重复读未变更文件。 - bridge 模块:
getBridgeAccessToken、getBridgeBaseUrlOverride给 BriefTool upload 提供 OAuth token 与 base URL,与bridgeConfig.ts、bridgeEnabled.ts协作。 - constants/oauth.ts:
getOauthConfig()提供生产/ staging 切换,BriefTool upload 在 base URL 选择上有特殊优先级(ANTHROPIC_BASE_URL>getOauthConfig().BASE_API_URL)。 - components/FileEditToolUpdatedMessage、FileEditToolUseRejectedMessage:FileEdit/FileWrite 共用同一套 diff 渲染组件,保持视觉一致。
- utils/plans.ts:
getPlansDirectory()让所有文件工具都能识别 plan 文件并触发特殊渲染路径。
小结
文件工具族展示了 Claude Code 工具系统在"统一接口"下的多样性:FileEditTool 专注精确字符串替换并处理 hashline 多点编辑;FileWriteTool 区分 create/update 双形态并对 plan 文件做反向渲染;FileReadTool 同时处理文本/图片/PDF/notebook/agent 输出五类形态;BriefTool 是消息通道而非文件工具,但其附件上传机制与文件系统紧密耦合。它们的共同点是"用输入字段语义反推操作意图、用 Suspense 懒加载避免 OOM、用 dimColor/error 颜色编码状态严重性"。这些模式构成了文件类工具的设计范式,也为后续 MCPTool、AgentTool 等复杂工具的 UI 实现提供了参考。
更多推荐




所有评论(0)