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 路径。

关键设计要点

  1. 意图反推:FileEditTool 用 old_string === '' 推断 Create,BriefTool 用 status 字段区分 normal / proactive,UI 标签随意图变化,避免"一刀切"的 Update/Write 表述。
  2. diff 懒加载与有界读取:拒绝 diff 用 Suspense + readEditContext 只读窗口上下文,超大文件回退到 create 视图,保证 UI 永不 OOM。
  3. 跨平台 EOL 一致性:FileWriteTool 强制 \n 分割,注释明确反对 os.EOL,保证模型输出在 Windows 上也能正确数行。
  4. Plan 文件上下文反向渲染:常规模式只显示 /plan 提示,condensed 模式(subagent 视图)反而展示全内容,反映 plan 在不同视图下的语义差异。
  5. BriefTool 全链路降级:附件上传从 bridge 状态、token 存在性、文件大小、读文件、网络请求五层做 graceful degradation,本地体验永不因云端失败而损坏。

与其他模块的关系

  • utils/diff.tsgetPatchForEditgetPatchForDisplayadjustHunkLineNumbersCONTEXT_LINES 是 FileEdit/FileWrite 共用的 diff 生成基础设施。
  • utils/readEditContext.tsreadEditContextopenForScanreadCapped 提供有界读取能力,是拒绝 diff 不 OOM 的根本保障。
  • utils/fileStateCache.ts:FileReadTool 的 file_unchanged 状态依赖此缓存,避免重复读未变更文件。
  • bridge 模块getBridgeAccessTokengetBridgeBaseUrlOverride 给 BriefTool upload 提供 OAuth token 与 base URL,与 bridgeConfig.tsbridgeEnabled.ts 协作。
  • constants/oauth.tsgetOauthConfig() 提供生产/ staging 切换,BriefTool upload 在 base URL 选择上有特殊优先级(ANTHROPIC_BASE_URL > getOauthConfig().BASE_API_URL)。
  • components/FileEditToolUpdatedMessageFileEditToolUseRejectedMessage:FileEdit/FileWrite 共用同一套 diff 渲染组件,保持视觉一致。
  • utils/plans.tsgetPlansDirectory() 让所有文件工具都能识别 plan 文件并触发特殊渲染路径。

小结

文件工具族展示了 Claude Code 工具系统在"统一接口"下的多样性:FileEditTool 专注精确字符串替换并处理 hashline 多点编辑;FileWriteTool 区分 create/update 双形态并对 plan 文件做反向渲染;FileReadTool 同时处理文本/图片/PDF/notebook/agent 输出五类形态;BriefTool 是消息通道而非文件工具,但其附件上传机制与文件系统紧密耦合。它们的共同点是"用输入字段语义反推操作意图、用 Suspense 懒加载避免 OOM、用 dimColor/error 颜色编码状态严重性"。这些模式构成了文件类工具的设计范式,也为后续 MCPTool、AgentTool 等复杂工具的 UI 实现提供了参考。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐