import { normalizeToolName } from "@/lib/tool-call-normalization" import { countUnifiedDiffLineChanges, estimateChangedLineStats, splitNormalizedLines, } from "@/lib/line-change-stats" type ObjectLike = Record export interface PermissionFileChange { path: string oldText: string newText: string unifiedDiff?: string } export interface PermissionPlanEntry { text: string status: string | null } export interface PermissionAllowedPrompt { prompt: string tool: string } export interface ParsedPermissionToolCall { title: string normalizedKind: string command: string | null cwd: string | null fileChanges: PermissionFileChange[] additions: number deletions: number diffPreview: string | null planEntries: PermissionPlanEntry[] planExplanation: string | null planMarkdown: string | null allowedPrompts: PermissionAllowedPrompt[] modeTarget: string | null url: string | null query: string | null prompt: string | null jsonPreview: string } function asObject(value: unknown): ObjectLike | null { if (!value) return null if (typeof value === "object" && !Array.isArray(value)) { return value as ObjectLike } if (typeof value !== "string") return null try { const parsed: unknown = JSON.parse(value) return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as ObjectLike) : null } catch { return null } } function asArray(value: unknown): unknown[] | null { return Array.isArray(value) ? value : null } function pickValue(record: ObjectLike | null, keys: string[]): unknown { if (!record) return null for (const key of keys) { if (!(key in record)) continue const value = record[key] if (value !== undefined && value !== null) return value } return null } function pickString(record: ObjectLike | null, keys: string[]): string | null { const value = pickValue(record, keys) if (typeof value !== "string") return null const trimmed = value.trim() return trimmed.length > 0 ? trimmed : null } function joinStringArray(values: unknown): string | null { if (!Array.isArray(values)) return null const parts = values.filter( (item): item is string => typeof item === "string" && item.trim().length > 0 ) return parts.length > 0 ? parts.join(" ") : null } function unescapeInlineEscapes(text: string): string { return text .replace(/\\r\\n/g, "\n") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") } function looksLikeDiffPayload(input: string): boolean { const normalized = unescapeInlineEscapes(input) return ( normalized.includes("*** Begin Patch") || normalized.includes("*** Update File:") || /^diff --git /m.test(normalized) || (/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) || /^@@ /m.test(normalized) ) } function buildCompactDiffFromTexts( path: string, oldText: string, newText: string, contextLines: number = 2 ): string | null { const oldLines = splitNormalizedLines(oldText) const newLines = splitNormalizedLines(newText) let prefix = 0 while ( prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix] ) { prefix += 1 } let suffix = 0 while ( suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) { suffix += 1 } const removed = oldLines.slice(prefix, oldLines.length - suffix) const added = newLines.slice(prefix, newLines.length - suffix) if (removed.length === 0 && added.length === 0) return null const before = oldLines.slice(Math.max(0, prefix - contextLines), prefix) const after = oldLines.slice( oldLines.length - suffix, Math.min(oldLines.length, oldLines.length - suffix + contextLines) ) const parts: string[] = [`--- ${path}`, `+++ ${path}`] for (const line of before) parts.push(` ${line}`) for (const line of removed) parts.push(`-${line}`) for (const line of added) parts.push(`+${line}`) for (const line of after) parts.push(` ${line}`) return parts.join("\n") } function buildDiffPreviewFromChanges( changes: PermissionFileChange[], maxFiles: number = 8, maxLines: number = 1200 ): string | null { const meaningful = changes.filter((change) => { if ( typeof change.unifiedDiff === "string" && change.unifiedDiff.trim().length > 0 ) { return true } return change.oldText.length > 0 || change.newText.length > 0 }) if (meaningful.length === 0) return null const limited = meaningful.slice(0, maxFiles) const lines: string[] = [] let lineCount = 0 let truncated = false const pushLine = (line: string) => { if (lineCount >= maxLines) { truncated = true return } lines.push(line) lineCount += 1 } for (const change of limited) { const block = typeof change.unifiedDiff === "string" && change.unifiedDiff.trim().length > 0 ? change.unifiedDiff.trim() : buildCompactDiffFromTexts(change.path, change.oldText, change.newText) if (!block) continue for (const line of block.split("\n")) { pushLine(line) if (truncated) break } if (truncated) break pushLine("") } if (meaningful.length > limited.length) { lines.push(`# ... ${meaningful.length - limited.length} more files omitted`) } if (truncated) { lines.push("# ... diff preview truncated") } const preview = lines.join("\n").trim() return preview.length > 0 ? preview : null } function stringifyJson(value: unknown): string { try { return JSON.stringify(value, null, 2) } catch { return String(value ?? "") } } function extractCommandFromUnknownValue( value: unknown, depth: number = 0 ): string | null { if (depth > 4 || value === null || value === undefined) return null if (typeof value === "string") { const trimmed = value.trim() if (!trimmed || looksLikeDiffPayload(trimmed)) return null if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { return trimmed } try { const parsed: unknown = JSON.parse(trimmed) return extractCommandFromUnknownValue(parsed, depth + 1) } catch { return null } } if (Array.isArray(value)) { const joined = joinStringArray(value) return joined && joined.trim().length > 0 ? joined.trim() : null } if (typeof value !== "object") return null const obj = value as ObjectLike const directKeys = [ "command", "cmd", "script", "args", "argv", "command_args", ] for (const key of directKeys) { const direct = extractCommandFromUnknownValue(obj[key], depth + 1) if (direct) return direct } const nestedKeys = [ "rawInput", "raw_input", "input", "arguments", "params", "payload", ] for (const key of nestedKeys) { const nested = extractCommandFromUnknownValue(obj[key], depth + 1) if (nested) return nested } return null } function extractDiffPreview( rawInput: unknown, rawInputObj: ObjectLike | null ): string | null { const candidates: unknown[] = [rawInput] if (rawInputObj) { candidates.push( rawInputObj.patch, rawInputObj.diff, rawInputObj.unified_diff, rawInputObj.unifiedDiff ) } for (const candidate of candidates) { if (typeof candidate !== "string") continue const normalized = unescapeInlineEscapes(candidate).trim() if (!normalized) continue if (looksLikeDiffPayload(normalized)) return normalized } return null } function parseChangeRecord( path: string, value: unknown ): PermissionFileChange | null { const normalizedPath = path.trim() if (!normalizedPath) return null if (typeof value === "string") { return { path: normalizedPath, oldText: "", newText: value, unifiedDiff: undefined, } } const record = asObject(value) if (!record) { return { path: normalizedPath, oldText: "", newText: "", } } const oldText = pickString(record, [ "old_string", "oldString", "old_text", "oldText", "old", "before", ]) ?? "" const newText = pickString(record, [ "new_string", "newString", "new_text", "newText", "new", "after", "content", "text", "new_source", "newSource", ]) ?? "" const unifiedDiff = pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ?? undefined return { path: normalizedPath, oldText, newText, unifiedDiff, } } function extractRawInputFileChanges( rawInputObj: ObjectLike | null ): PermissionFileChange[] { if (!rawInputObj) return [] const changes: PermissionFileChange[] = [] const byChangesObject = asObject(rawInputObj.changes) if (byChangesObject) { for (const [path, value] of Object.entries(byChangesObject)) { const parsed = parseChangeRecord(path, value) if (parsed) changes.push(parsed) } } const directPath = pickString(rawInputObj, [ "file_path", "filePath", "path", "notebook_path", "target_file", "targetFile", ]) ?? null if (directPath) { const oldText = pickString(rawInputObj, [ "old_string", "oldString", "old_text", "oldText", ]) ?? "" const newText = pickString(rawInputObj, [ "new_string", "newString", "new_text", "newText", "content", "text", "new_source", ]) ?? "" if (oldText || newText || changes.length === 0) { changes.push({ path: directPath, oldText, newText, unifiedDiff: undefined, }) } } return changes } function extractContentDiffChanges( toolCallObj: ObjectLike | null ): PermissionFileChange[] { if (!toolCallObj) return [] const content = asArray(toolCallObj.content) if (!content) return [] const changes: PermissionFileChange[] = [] for (const item of content) { const record = asObject(item) if (!record) continue const type = pickString(record, ["type"])?.toLowerCase() if (type !== "diff") continue const path = pickString(record, ["path"]) if (!path) continue changes.push({ path, oldText: pickString(record, ["old_text", "oldText"]) ?? "", newText: pickString(record, ["new_text", "newText"]) ?? "", unifiedDiff: undefined, }) } return changes } function collectLocationPaths(toolCallObj: ObjectLike | null): string[] { if (!toolCallObj) return [] const locations = asArray(toolCallObj.locations) if (!locations) return [] const paths: string[] = [] for (const item of locations) { const record = asObject(item) if (!record) continue const path = pickString(record, ["path"]) if (path) paths.push(path) } return paths } function collectDiffPaths(diffText: string | null): string[] { if (!diffText) return [] const paths = new Set() for (const line of diffText.split("\n")) { if (line.startsWith("*** Add File: ")) { paths.add(line.slice(14).trim()) continue } if (line.startsWith("*** Update File: ")) { paths.add(line.slice(17).trim()) continue } if (line.startsWith("*** Delete File: ")) { paths.add(line.slice(17).trim()) continue } if (line.startsWith("+++ ")) { const path = line.slice(4).replace(/^b\//, "").trim() if (path && path !== "/dev/null") paths.add(path) } } return Array.from(paths) } function mergeFileChanges( changes: PermissionFileChange[] ): PermissionFileChange[] { const merged = new Map() for (const change of changes) { const path = change.path.trim() if (!path) continue const prev = merged.get(path) if (!prev) { merged.set(path, { ...change, path }) continue } const oldText = prev.oldText || change.oldText const newText = prev.newText || change.newText const unifiedDiff = prev.unifiedDiff || change.unifiedDiff merged.set(path, { path, oldText, newText, unifiedDiff }) } return Array.from(merged.values()) } function parsePlanEntries( rawInputObj: ObjectLike | null ): PermissionPlanEntry[] { if (!rawInputObj) return [] const candidates = [ pickValue(rawInputObj, ["plan"]), pickValue(rawInputObj, ["entries"]), pickValue(rawInputObj, ["steps"]), pickValue(rawInputObj, ["todos"]), ] for (const candidate of candidates) { const list = asArray(candidate) if (!list || list.length === 0) continue const entries: PermissionPlanEntry[] = [] for (const item of list) { const record = asObject(item) if (!record) continue const text = pickString(record, [ "step", "content", "title", "task", "description", ]) ?? null if (!text) continue entries.push({ text, status: pickString(record, ["status", "state"]), }) } if (entries.length > 0) return entries } return [] } function parseAllowedPrompts( rawInputObj: ObjectLike | null ): PermissionAllowedPrompt[] { if (!rawInputObj) return [] const list = asArray( pickValue(rawInputObj, ["allowedPrompts", "allowed_prompts"]) ) if (!list || list.length === 0) return [] const prompts: PermissionAllowedPrompt[] = [] for (const item of list) { const record = asObject(item) if (!record) continue const prompt = pickString(record, ["prompt", "description", "text"]) const tool = pickString(record, ["tool", "toolName", "tool_name"]) if (prompt) { prompts.push({ prompt, tool: tool ?? "" }) } } return prompts } function formatFallbackTitle(kind: string): string { const normalized = kind.replace(/_/g, " ").trim() if (!normalized) return "Permission Request" return normalized .split(/\s+/) .map((part) => part[0].toUpperCase() + part.slice(1)) .join(" ") } export function parsePermissionToolCall( toolCall: unknown ): ParsedPermissionToolCall { const toolCallObj = asObject(toolCall) const rawKind = pickString(toolCallObj, [ "kind", "tool_name", "toolName", "name", "type", ]) ?? "tool" const normalizedKind = normalizeToolName(rawKind) const rawInputValue = pickValue(toolCallObj, [ "rawInput", "raw_input", "input", "arguments", "params", "payload", ]) ?? null const rawInputObj = asObject(rawInputValue) const command = extractCommandFromUnknownValue(rawInputValue) ?? extractCommandFromUnknownValue(toolCallObj) const cwd = pickString(rawInputObj, [ "cwd", "workdir", "working_directory", "workingDirectory", ]) ?? pickString(toolCallObj, [ "cwd", "workdir", "working_directory", "workingDirectory", ]) const explicitDiffPreview = extractDiffPreview(rawInputValue, rawInputObj) const rawInputFileChanges = extractRawInputFileChanges(rawInputObj) const contentDiffChanges = extractContentDiffChanges(toolCallObj) const locationPaths = collectLocationPaths(toolCallObj) const diffPaths = collectDiffPaths(explicitDiffPreview) const combinedChanges = mergeFileChanges([ ...rawInputFileChanges, ...contentDiffChanges, ...locationPaths.map((path) => ({ path, oldText: "", newText: "", unifiedDiff: undefined, })), ...diffPaths.map((path) => ({ path, oldText: "", newText: "", unifiedDiff: undefined, })), ]) const diffPreview = explicitDiffPreview ?? buildDiffPreviewFromChanges(combinedChanges) let additions = 0 let deletions = 0 if (diffPreview) { const stats = countUnifiedDiffLineChanges(diffPreview) additions = stats.additions deletions = stats.deletions } else { for (const change of combinedChanges) { if ( typeof change.unifiedDiff === "string" && change.unifiedDiff.trim().length > 0 ) { const stats = countUnifiedDiffLineChanges(change.unifiedDiff) additions += stats.additions deletions += stats.deletions continue } const stats = estimateChangedLineStats(change.oldText, change.newText) additions += stats.additions deletions += stats.deletions } } const planEntries = parsePlanEntries(rawInputObj) const planExplanation = pickString(rawInputObj, ["explanation"]) const rawPlan = rawInputObj ? pickValue(rawInputObj, ["plan"]) : null const planMarkdown = typeof rawPlan === "string" && rawPlan.trim().length > 0 ? rawPlan : null const allowedPrompts = parseAllowedPrompts(rawInputObj) const modeTarget = pickString(rawInputObj, [ "mode_id", "modeId", "target_mode", "targetMode", ]) ?? null const url = pickString(rawInputObj, ["url"]) ?? pickString(toolCallObj, ["url"]) const query = pickString(rawInputObj, ["query"]) ?? pickString(toolCallObj, ["query"]) const prompt = pickString(rawInputObj, ["prompt"]) ?? pickString(toolCallObj, ["prompt"]) const title = pickString(toolCallObj, ["title", "tool_name", "toolName", "name"]) ?? formatFallbackTitle(normalizedKind) return { title, normalizedKind, command, cwd, fileChanges: combinedChanges, additions, deletions, diffPreview, planEntries, planExplanation, planMarkdown, allowedPrompts, modeTarget, url, query, prompt, jsonPreview: stringifyJson(toolCallObj ?? toolCall), } }