diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index fa32fc7..0776555 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -53,6 +53,46 @@ fn strip_system_tags(text: &str) -> Option { } /// Check if a JSONL entry is a system meta message (isMeta: true). +/// Rebuild a standard unified diff from `toolUseResult.structuredPatch`. +/// +/// Each hunk in `structuredPatch` has `oldStart`, `oldLines`, `newStart`, +/// `newLines`, and `lines` (prefixed with ` `, `+`, or `-`). +fn rebuild_diff_from_structured_patch( + file_path: &str, + structured_patch: &serde_json::Value, +) -> Option { + let hunks = structured_patch.as_array()?; + if hunks.is_empty() { + return None; + } + + let mut output = String::new(); + output.push_str(&format!("--- a/{}\n+++ b/{}\n", file_path, file_path)); + + for hunk in hunks { + let old_start = hunk.get("oldStart").and_then(|v| v.as_u64()).unwrap_or(1); + let old_lines = hunk.get("oldLines").and_then(|v| v.as_u64()).unwrap_or(0); + let new_start = hunk.get("newStart").and_then(|v| v.as_u64()).unwrap_or(1); + let new_lines = hunk.get("newLines").and_then(|v| v.as_u64()).unwrap_or(0); + + output.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + old_start, old_lines, new_start, new_lines + )); + + if let Some(lines) = hunk.get("lines").and_then(|v| v.as_array()) { + for line in lines { + if let Some(text) = line.as_str() { + output.push_str(text); + output.push('\n'); + } + } + } + } + + Some(output) +} + fn is_meta_message(value: &serde_json::Value) -> bool { value .get("isMeta") @@ -489,7 +529,7 @@ impl ClaudeParser { continue; } "user" => { - let content = extract_user_content(&value); + let mut content = extract_user_content(&value); // Skip user messages that are empty after system tag stripping if content.is_empty() { @@ -518,6 +558,31 @@ impl ClaudeParser { MessageRole::User }; + // Check toolUseResult.structuredPatch for real line numbers + if let Some(tur) = value.get("toolUseResult") { + if let Some(sp) = tur.get("structuredPatch") { + let fp = tur + .get("filePath") + .and_then(|v| v.as_str()) + .unwrap_or("file"); + if let Some(diff) = rebuild_diff_from_structured_patch(fp, sp) { + // Find the matching ToolResult in this user message's content + // and replace its output_preview with the real diff + for block in content.iter_mut() { + if let ContentBlock::ToolResult { + ref mut output_preview, + is_error: false, + .. + } = block + { + *output_preview = Some(diff.clone()); + break; + } + } + } + } + } + messages.push(UnifiedMessage { id: uuid, role, @@ -738,6 +803,7 @@ impl ClaudeParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, cwd.as_deref()); let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns); let context_window_max_tokens = claude_context_window_max_tokens_for_model(model.as_deref()); diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index a71ba9d..4508f67 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -774,6 +774,7 @@ impl CodexParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, cwd.as_deref()); let mut session_stats = super::compute_session_stats(&turns); session_stats = merge_codex_total_usage_stats(session_stats, latest_total_usage, latest_total_tokens); diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 9e69a87..3953816 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -557,6 +557,7 @@ impl GeminiParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref()); summary.message_count = turns.len() as u32; summary.id = conversation_id.to_string(); let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns); diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 56f3377..318c19d 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -403,6 +403,206 @@ fn strip_numbered_lines(text: &str) -> Option { ) } +/// Resolve line numbers for `*** Update File` / `*** Add File` style patches. +/// +/// When a hunk header is just `@@` without `-N,M +N,M`, this reads the actual +/// file from disk and matches the context lines to calculate real line numbers. +/// Falls back gracefully if the file doesn't exist or context doesn't match. +pub fn resolve_patch_line_numbers(turns: &mut [MessageTurn], cwd: Option<&str>) { + for turn in turns.iter_mut() { + for block in turn.blocks.iter_mut() { + if let ContentBlock::ToolUse { + ref tool_name, + ref mut input_preview, + .. + } = block + { + let name = tool_name.to_lowercase(); + if !matches!( + name.as_str(), + "apply_patch" | "edit" | "patch" | "applypatch" + ) { + continue; + } + if let Some(ref text) = input_preview { + if text.contains("@@\n") || text.contains("@@\r\n") { + if let Some(resolved) = resolve_patch_text(text, cwd) { + *input_preview = Some(resolved); + } + } + } + } + } + } +} + +/// Resolve a single patch text, replacing bare `@@` with `@@ -N,M +N,M @@`. +fn resolve_patch_text(patch: &str, cwd: Option<&str>) -> Option { + let mut output = String::with_capacity(patch.len() + 256); + let mut current_file_path: Option = None; + let mut file_lines: Option> = None; + let mut any_resolved = false; + + let lines: Vec<&str> = patch.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + + // Detect file markers + if line.starts_with("*** Update File: ") || line.starts_with("*** Add File: ") { + let marker_end = if line.starts_with("*** Update File: ") { + 17 + } else { + 14 + }; + let path = line[marker_end..].trim(); + current_file_path = Some(path.to_string()); + file_lines = load_file_lines(path, cwd); + output.push_str(line); + output.push('\n'); + i += 1; + continue; + } + + // Detect bare @@ hunk header (no line numbers) + if line == "@@" { + if let (Some(ref fl), true) = (&file_lines, current_file_path.is_some()) { + // Collect context lines from this hunk to find match position + let hunk_lines = collect_hunk_lines(&lines, i + 1); + if let Some((old_start, old_count, new_count)) = + find_hunk_position(fl, &hunk_lines) + { + let new_start = old_start; // same start for context-based patches + output.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + old_start, old_count, new_start, new_count + )); + any_resolved = true; + i += 1; + continue; + } + } + // Fallback: keep bare @@ + output.push_str(line); + output.push('\n'); + i += 1; + continue; + } + + output.push_str(line); + output.push('\n'); + i += 1; + } + + if any_resolved { Some(output) } else { None } +} + +/// Load file lines from disk, trying both absolute path and cwd-relative. +fn load_file_lines(path: &str, cwd: Option<&str>) -> Option> { + use std::fs; + use std::path::Path; + + let p = Path::new(path); + if p.is_absolute() { + if let Ok(content) = fs::read_to_string(p) { + return Some(content.lines().map(|l| l.to_string()).collect()); + } + } + if let Some(base) = cwd { + let full = Path::new(base).join(path); + if let Ok(content) = fs::read_to_string(&full) { + return Some(content.lines().map(|l| l.to_string()).collect()); + } + } + None +} + +/// Collect lines belonging to a hunk (until next `@@` or `*** ` marker or end). +fn collect_hunk_lines<'a>(lines: &'a [&'a str], start: usize) -> Vec<&'a str> { + let mut result = Vec::new(); + for &line in &lines[start..] { + if line == "@@" + || line.starts_with("*** ") + { + break; + } + result.push(line); + } + result +} + +/// Find where a hunk's context lines match in the file, returning (start_line, old_count, new_count). +/// `start_line` is 1-based. +fn find_hunk_position( + file_lines: &[String], + hunk_lines: &[&str], +) -> Option<(usize, usize, usize)> { + // Extract context lines (space-prefixed) with their positions in the hunk + let context_entries: Vec<(usize, &str)> = hunk_lines + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with(' ')) + .map(|(i, l)| (i, &l[1..])) // strip leading space + .collect(); + + if context_entries.is_empty() { + return None; + } + + // Use the first context line to find candidate positions + let (first_ctx_idx, first_ctx_text) = context_entries[0]; + + for (file_idx, file_line) in file_lines.iter().enumerate() { + if file_line.as_str() != first_ctx_text { + continue; + } + + // Verify all context lines match from this position + let hunk_start_in_file = file_idx as isize - first_ctx_idx as isize; + if hunk_start_in_file < 0 { + continue; + } + + let all_match = context_entries.iter().all(|(ctx_idx, ctx_text)| { + // Map hunk position to file position: only count context and deleted lines + let file_offset = hunk_lines[..*ctx_idx] + .iter() + .filter(|hl| hl.starts_with(' ') || hl.starts_with('-')) + .count(); + let target = hunk_start_in_file as usize + file_offset; + target < file_lines.len() && file_lines[target].as_str() == *ctx_text + }); + + if all_match { + // Calculate old_count (context + deleted) and new_count (context + added) + let mut old_count = 0usize; + let mut new_count = 0usize; + for hl in hunk_lines { + if hl.starts_with(' ') { + old_count += 1; + new_count += 1; + } else if hl.starts_with('-') { + old_count += 1; + } else if hl.starts_with('+') { + new_count += 1; + } + } + + // file_start is the first line of the old file covered by this hunk + // We need to find where context/deleted lines start in the file + let file_start_offset = hunk_lines[..first_ctx_idx] + .iter() + .filter(|hl| hl.starts_with(' ') || hl.starts_with('-')) + .count(); + let start_line = (hunk_start_in_file as usize - file_start_offset) + 1; // 1-based + return Some((start_line, old_count, new_count)); + } + } + + None +} + /// Extract the last path component as the folder name. pub fn folder_name_from_path(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index aaf2876..c8e47a5 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -653,6 +653,7 @@ impl OpenClawParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, cwd.as_deref()); let context_window_used_tokens = latest_turn_total_usage_tokens(&turns); let context_window_max_tokens = session_meta diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index df799b4..c500acd 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -190,6 +190,7 @@ impl OpenCodeParser { let mut turns = group_into_turns(messages); super::relocate_orphaned_tool_results(&mut turns); super::structurize_read_tool_output(&mut turns); + super::resolve_patch_line_numbers(&mut turns, summary.folder_path.as_deref()); let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns); let context_window_max_tokens = super::infer_context_window_max_tokens(summary.model.as_deref()); diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 6f38572..267242b 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -1755,6 +1755,16 @@ function StructuredToolInput({ ) } } + // Prefer tool output if it contains a structured diff with real line numbers + // (injected by backend from toolUseResult.structuredPatch) + if (output && typeof output === "string" && /^@@ /m.test(output)) { + return ( + <> + {truncationBanner} + + + ) + } if (isCanonicalEditPayload(parsed)) { return ( <> diff --git a/src/lib/session-files.ts b/src/lib/session-files.ts index 2fa8a2e..52a2bf6 100644 --- a/src/lib/session-files.ts +++ b/src/lib/session-files.ts @@ -1,4 +1,4 @@ -import type { MessageTurn } from "./types" +import type { ContentBlock, MessageTurn } from "./types" import { normalizeToolName } from "./tool-call-normalization" import { estimateChangedLineStats } from "./line-change-stats" import { generateUnifiedDiff } from "./unified-diff-generator" @@ -757,10 +757,12 @@ export function extractSessionFilesGrouped( block.input_preview, normalizedPath ) + const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id) const diffChunk = buildDiffChunk( normalized, block.input_preview, - normalizedPath + normalizedPath, + toolOutput ) currentFiles.push({ @@ -819,10 +821,12 @@ export function buildSessionFileDiff( ) if (!blockPaths.includes(normalizedTargetPath)) continue + const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id) const chunk = buildDiffChunk( normalized, block.input_preview, - normalizedTargetPath + normalizedTargetPath, + toolOutput ) if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim()) } @@ -835,10 +839,30 @@ export function buildSessionFileDiff( return chunks.join("\n\n") } +/** Find the tool_result output matching a tool_use_id within the same turn. */ +function findToolResultOutput( + blocks: ContentBlock[], + toolUseId: string | null +): string | null { + if (!toolUseId) return null + for (const block of blocks) { + if ( + block.type === "tool_result" && + block.tool_use_id === toolUseId && + block.output_preview && + !block.is_error + ) { + return block.output_preview + } + } + return null +} + function buildDiffChunk( op: string, inputPreview: string | null, - filePath: string + filePath: string, + toolOutput?: string | null ): string | null { if (!inputPreview) return null @@ -860,6 +884,11 @@ function buildDiffChunk( } } + // Prefer tool output if backend injected a real diff with line numbers + if (toolOutput && /^@@ /m.test(toolOutput)) { + return toolOutput.trim() + } + if (!parsed) return null const oldStr =