source dump of claude code
at main 213 lines 6.7 kB view raw
1import type { StructuredPatchHunk } from 'diff' 2import { useMemo, useRef } from 'react' 3import type { FileEditOutput } from '../tools/FileEditTool/types.js' 4import type { Output as FileWriteOutput } from '../tools/FileWriteTool/FileWriteTool.js' 5import type { Message } from '../types/message.js' 6 7export type TurnFileDiff = { 8 filePath: string 9 hunks: StructuredPatchHunk[] 10 isNewFile: boolean 11 linesAdded: number 12 linesRemoved: number 13} 14 15export type TurnDiff = { 16 turnIndex: number 17 userPromptPreview: string 18 timestamp: string 19 files: Map<string, TurnFileDiff> 20 stats: { 21 filesChanged: number 22 linesAdded: number 23 linesRemoved: number 24 } 25} 26 27type FileEditResult = FileEditOutput | FileWriteOutput 28 29type TurnDiffCache = { 30 completedTurns: TurnDiff[] 31 currentTurn: TurnDiff | null 32 lastProcessedIndex: number 33 lastTurnIndex: number 34} 35 36function isFileEditResult(result: unknown): result is FileEditResult { 37 if (!result || typeof result !== 'object') return false 38 const r = result as Record<string, unknown> 39 // FileEditTool: has structuredPatch with content 40 // FileWriteTool (update): has structuredPatch with content 41 // FileWriteTool (create): has type='create' and content (structuredPatch is empty) 42 const hasFilePath = typeof r.filePath === 'string' 43 const hasStructuredPatch = 44 Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0 45 const isNewFile = r.type === 'create' && typeof r.content === 'string' 46 return hasFilePath && (hasStructuredPatch || isNewFile) 47} 48 49function isFileWriteOutput(result: FileEditResult): result is FileWriteOutput { 50 return ( 51 'type' in result && (result.type === 'create' || result.type === 'update') 52 ) 53} 54 55function countHunkLines(hunks: StructuredPatchHunk[]): { 56 added: number 57 removed: number 58} { 59 let added = 0 60 let removed = 0 61 for (const hunk of hunks) { 62 for (const line of hunk.lines) { 63 if (line.startsWith('+')) added++ 64 else if (line.startsWith('-')) removed++ 65 } 66 } 67 return { added, removed } 68} 69 70function getUserPromptPreview(message: Message): string { 71 if (message.type !== 'user') return '' 72 const content = message.message.content 73 const text = typeof content === 'string' ? content : '' 74 // Truncate to ~30 chars 75 if (text.length <= 30) return text 76 return text.slice(0, 29) + '…' 77} 78 79function computeTurnStats(turn: TurnDiff): void { 80 let totalAdded = 0 81 let totalRemoved = 0 82 for (const file of turn.files.values()) { 83 totalAdded += file.linesAdded 84 totalRemoved += file.linesRemoved 85 } 86 turn.stats = { 87 filesChanged: turn.files.size, 88 linesAdded: totalAdded, 89 linesRemoved: totalRemoved, 90 } 91} 92 93/** 94 * Extract turn-based diffs from messages. 95 * A turn is defined as a user prompt followed by assistant responses and tool results. 96 * Each turn with file edits is included in the result. 97 * 98 * Uses incremental accumulation - only processes new messages since last render. 99 */ 100export function useTurnDiffs(messages: Message[]): TurnDiff[] { 101 const cache = useRef<TurnDiffCache>({ 102 completedTurns: [], 103 currentTurn: null, 104 lastProcessedIndex: 0, 105 lastTurnIndex: 0, 106 }) 107 108 return useMemo(() => { 109 const c = cache.current 110 111 // Reset if messages shrunk (user rewound conversation) 112 if (messages.length < c.lastProcessedIndex) { 113 c.completedTurns = [] 114 c.currentTurn = null 115 c.lastProcessedIndex = 0 116 c.lastTurnIndex = 0 117 } 118 119 // Process only new messages 120 for (let i = c.lastProcessedIndex; i < messages.length; i++) { 121 const message = messages[i] 122 if (!message || message.type !== 'user') continue 123 124 // Check if this is a user prompt (not a tool result) 125 const isToolResult = 126 message.toolUseResult || 127 (Array.isArray(message.message.content) && 128 message.message.content[0]?.type === 'tool_result') 129 130 if (!isToolResult && !message.isMeta) { 131 // Start a new turn on user prompt 132 if (c.currentTurn && c.currentTurn.files.size > 0) { 133 computeTurnStats(c.currentTurn) 134 c.completedTurns.push(c.currentTurn) 135 } 136 137 c.lastTurnIndex++ 138 c.currentTurn = { 139 turnIndex: c.lastTurnIndex, 140 userPromptPreview: getUserPromptPreview(message), 141 timestamp: message.timestamp, 142 files: new Map(), 143 stats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }, 144 } 145 } else if (c.currentTurn && message.toolUseResult) { 146 // Collect file edits from tool results 147 const result = message.toolUseResult 148 if (isFileEditResult(result)) { 149 const { filePath, structuredPatch } = result 150 const isNewFile = 'type' in result && result.type === 'create' 151 152 // Get or create file entry 153 let fileEntry = c.currentTurn.files.get(filePath) 154 if (!fileEntry) { 155 fileEntry = { 156 filePath, 157 hunks: [], 158 isNewFile, 159 linesAdded: 0, 160 linesRemoved: 0, 161 } 162 c.currentTurn.files.set(filePath, fileEntry) 163 } 164 165 // For new files, generate synthetic hunk from content 166 if ( 167 isNewFile && 168 structuredPatch.length === 0 && 169 isFileWriteOutput(result) 170 ) { 171 const content = result.content 172 const lines = content.split('\n') 173 const syntheticHunk: StructuredPatchHunk = { 174 oldStart: 0, 175 oldLines: 0, 176 newStart: 1, 177 newLines: lines.length, 178 lines: lines.map(l => '+' + l), 179 } 180 fileEntry.hunks.push(syntheticHunk) 181 fileEntry.linesAdded += lines.length 182 } else { 183 // Append hunks (same file may be edited multiple times in a turn) 184 fileEntry.hunks.push(...structuredPatch) 185 186 // Update line counts 187 const { added, removed } = countHunkLines(structuredPatch) 188 fileEntry.linesAdded += added 189 fileEntry.linesRemoved += removed 190 } 191 192 // If file was created and then edited, it's still a new file 193 if (isNewFile) { 194 fileEntry.isNewFile = true 195 } 196 } 197 } 198 } 199 200 c.lastProcessedIndex = messages.length 201 202 // Build result: completed turns + current turn if it has files 203 const result = [...c.completedTurns] 204 if (c.currentTurn && c.currentTurn.files.size > 0) { 205 // Compute stats for current turn before including 206 computeTurnStats(c.currentTurn) 207 result.push(c.currentTurn) 208 } 209 210 // Return in reverse order (most recent first) 211 return result.reverse() 212 }, [messages]) 213}