Files
claude-code/src/hooks/useTurnDiffs.ts
claude-code-best 2fb1c9dcd8 feat: 工具层及 mcp 大重构 (#252)
* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 09:52:05 +08:00

214 lines
6.6 KiB
TypeScript

import type { StructuredPatchHunk } from 'diff'
import { useMemo, useRef } from 'react'
import type { FileEditOutput } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js'
import type { Output as FileWriteOutput } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js'
import type { Message } from '../types/message.js'
export type TurnFileDiff = {
filePath: string
hunks: StructuredPatchHunk[]
isNewFile: boolean
linesAdded: number
linesRemoved: number
}
export type TurnDiff = {
turnIndex: number
userPromptPreview: string
timestamp: string
files: Map<string, TurnFileDiff>
stats: {
filesChanged: number
linesAdded: number
linesRemoved: number
}
}
type FileEditResult = FileEditOutput | FileWriteOutput
type TurnDiffCache = {
completedTurns: TurnDiff[]
currentTurn: TurnDiff | null
lastProcessedIndex: number
lastTurnIndex: number
}
function isFileEditResult(result: unknown): result is FileEditResult {
if (!result || typeof result !== 'object') return false
const r = result as Record<string, unknown>
// FileEditTool: has structuredPatch with content
// FileWriteTool (update): has structuredPatch with content
// FileWriteTool (create): has type='create' and content (structuredPatch is empty)
const hasFilePath = typeof r.filePath === 'string'
const hasStructuredPatch =
Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0
const isNewFile = r.type === 'create' && typeof r.content === 'string'
return hasFilePath && (hasStructuredPatch || isNewFile)
}
function isFileWriteOutput(result: FileEditResult): result is FileWriteOutput {
return (
'type' in result && (result.type === 'create' || result.type === 'update')
)
}
function countHunkLines(hunks: StructuredPatchHunk[]): {
added: number
removed: number
} {
let added = 0
let removed = 0
for (const hunk of hunks) {
for (const line of hunk.lines) {
if (line.startsWith('+')) added++
else if (line.startsWith('-')) removed++
}
}
return { added, removed }
}
function getUserPromptPreview(message: Message): string {
if (message.type !== 'user') return ''
const content = message.message!.content
const text = typeof content === 'string' ? content : ''
// Truncate to ~30 chars
if (text.length <= 30) return text
return text.slice(0, 29) + '…'
}
function computeTurnStats(turn: TurnDiff): void {
let totalAdded = 0
let totalRemoved = 0
for (const file of turn.files.values()) {
totalAdded += file.linesAdded
totalRemoved += file.linesRemoved
}
turn.stats = {
filesChanged: turn.files.size,
linesAdded: totalAdded,
linesRemoved: totalRemoved,
}
}
/**
* Extract turn-based diffs from messages.
* A turn is defined as a user prompt followed by assistant responses and tool results.
* Each turn with file edits is included in the result.
*
* Uses incremental accumulation - only processes new messages since last render.
*/
export function useTurnDiffs(messages: Message[]): TurnDiff[] {
const cache = useRef<TurnDiffCache>({
completedTurns: [],
currentTurn: null,
lastProcessedIndex: 0,
lastTurnIndex: 0,
})
return useMemo(() => {
const c = cache.current
// Reset if messages shrunk (user rewound conversation)
if (messages.length < c.lastProcessedIndex) {
c.completedTurns = []
c.currentTurn = null
c.lastProcessedIndex = 0
c.lastTurnIndex = 0
}
// Process only new messages
for (let i = c.lastProcessedIndex; i < messages.length; i++) {
const message = messages[i]
if (!message || message.type !== 'user') continue
// Check if this is a user prompt (not a tool result)
const isToolResult =
message.toolUseResult ||
(Array.isArray(message.message!.content) &&
message.message!.content[0]?.type === 'tool_result')
if (!isToolResult && !message.isMeta) {
// Start a new turn on user prompt
if (c.currentTurn && c.currentTurn.files.size > 0) {
computeTurnStats(c.currentTurn)
c.completedTurns.push(c.currentTurn)
}
c.lastTurnIndex++
c.currentTurn = {
turnIndex: c.lastTurnIndex,
userPromptPreview: getUserPromptPreview(message),
timestamp: message.timestamp as string,
files: new Map(),
stats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 },
}
} else if (c.currentTurn && message.toolUseResult) {
// Collect file edits from tool results
const result = message.toolUseResult
if (isFileEditResult(result)) {
const { filePath, structuredPatch } = result
const isNewFile = 'type' in result && result.type === 'create'
// Get or create file entry
let fileEntry = c.currentTurn.files.get(filePath)
if (!fileEntry) {
fileEntry = {
filePath,
hunks: [],
isNewFile,
linesAdded: 0,
linesRemoved: 0,
}
c.currentTurn.files.set(filePath, fileEntry)
}
// For new files, generate synthetic hunk from content
if (
isNewFile &&
structuredPatch.length === 0 &&
isFileWriteOutput(result)
) {
const content = result.content
const lines = content.split('\n')
const syntheticHunk: StructuredPatchHunk = {
oldStart: 0,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: lines.map(l => '+' + l),
}
fileEntry.hunks.push(syntheticHunk)
fileEntry.linesAdded += lines.length
} else {
// Append hunks (same file may be edited multiple times in a turn)
fileEntry.hunks.push(...structuredPatch)
// Update line counts
const { added, removed } = countHunkLines(structuredPatch)
fileEntry.linesAdded += added
fileEntry.linesRemoved += removed
}
// If file was created and then edited, it's still a new file
if (isNewFile) {
fileEntry.isNewFile = true
}
}
}
}
c.lastProcessedIndex = messages.length
// Build result: completed turns + current turn if it has files
const result = [...c.completedTurns]
if (c.currentTurn && c.currentTurn.files.size > 0) {
// Compute stats for current turn before including
computeTurnStats(c.currentTurn)
result.push(c.currentTurn)
}
// Return in reverse order (most recent first)
return result.reverse()
}, [messages])
}