diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 1348c1e..b6511a2 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -514,10 +514,11 @@ async fn run_connection( let conn_id = conn_id.clone(); let handle = handle.clone(); let perms = perms.clone(); + let perm_cwd = cwd_string.clone(); async move |req: RequestPermissionRequest, responder: Responder, _cx: ConnectionTo| { - handle_permission_request(&conn_id, &handle, &perms, req, responder).await; + handle_permission_request(&conn_id, &handle, &perms, &perm_cwd, req, responder).await; Ok(()) } }, @@ -689,7 +690,7 @@ async fn run_connection( notif.update, SessionUpdate::AvailableCommandsUpdate(_) ) { - emit_conversation_update(&cid, &h, notif.update); + emit_conversation_update(&cid, &h, notif.update, None); } Ok(()) }) @@ -870,6 +871,7 @@ async fn handle_permission_request( conn_id: &str, handle: &tauri::AppHandle, perms: &PendingPermissions, + cwd: &str, req: RequestPermissionRequest, responder: Responder, ) { @@ -891,7 +893,38 @@ async fn handle_permission_request( }) .collect(); - let tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default(); + let mut tool_call_value = serde_json::to_value(&req.tool_call).unwrap_or_default(); + + // Resolve line numbers in rawInput for edit tool permission requests + if let Some(obj) = tool_call_value.as_object_mut() { + let key = ["rawInput", "raw_input"] + .into_iter() + .find(|k| obj.contains_key(*k)); + if let Some(key) = key { + match obj.get_mut(key) { + // rawInput is a JSON object: inject _start_line in place + Some(v) if v.is_object() => { + inject_start_line(v, Some(cwd)); + } + // rawInput is a JSON string: parse, inject, write back as object + Some(serde_json::Value::String(text)) => { + let text = text.clone(); + if let Ok(mut parsed) = serde_json::from_str::(&text) { + if inject_start_line(&mut parsed, Some(cwd)) { + obj.insert(key.to_string(), parsed); + } + } else if text.contains("@@\n") || text.contains("@@\r\n") { + if let Some(resolved) = + crate::parsers::resolve_patch_text(&text, Some(cwd)) + { + obj.insert(key.to_string(), serde_json::Value::String(resolved)); + } + } + } + _ => {} + } + } + } perms.lock().await.insert(request_id.clone(), responder); @@ -1437,10 +1470,11 @@ async fn run_conversation_loop<'a>( Ok(SessionMessage::SessionMessage(dispatch)) => { let cid = conn_id.to_string(); let h = handle.clone(); + let cwd_opt = Some(cwd); let _ = MatchDispatch::new(dispatch) .if_notification( async |notif: SessionNotification| { - emit_conversation_update(&cid, &h, notif.update); + emit_conversation_update(&cid, &h, notif.update, cwd_opt); Ok(()) }, ) @@ -1520,6 +1554,7 @@ async fn run_conversation_loop<'a>( let h = handle.clone(); let runtime = terminal_runtime.clone(); let session_id = sid.clone(); + let cwd_opt = Some(cwd); if let Err(e) = MatchDispatch::new(dispatch) .if_notification( async |notif: SessionNotification| { @@ -1527,7 +1562,7 @@ async fn run_conversation_loop<'a>( ¬if.update, &mut tracked_terminal_tool_calls, ); - emit_conversation_update(&cid, &h, notif.update); + emit_conversation_update(&cid, &h, notif.update, cwd_opt); if should_poll_now { poll_tracked_terminal_tool_calls( runtime.as_ref(), @@ -1894,6 +1929,71 @@ fn serialize_tool_call_content(content: &[ToolCallContent]) -> Option { } } +/// If the output looks like numbered lines (` 115→content`), strip them +/// and return `{"start_line":N,"content":"..."}` — same as the historical path. +fn structurize_live_output(text: &str) -> String { + if let Some(json) = crate::parsers::strip_numbered_lines(text) { + return json; + } + text.to_string() +} + +/// Resolve line numbers for live tool call input. +/// +/// Resolve line numbers for live tool call input (string form). +/// +/// - For apply_patch with bare `@@`: resolve line numbers in place. +/// - For canonical edit JSON: inject `_start_line`. +fn resolve_live_tool_input(text: &str, cwd: Option<&str>) -> String { + if text.contains("@@\n") || text.contains("@@\r\n") { + if let Some(resolved) = crate::parsers::resolve_patch_text(text, cwd) { + return resolved; + } + } + if let Ok(mut parsed) = serde_json::from_str::(text) { + if inject_start_line(&mut parsed, cwd) { + return parsed.to_string(); + } + } + text.to_string() +} + +/// Try to inject `_start_line` into a JSON object with `file_path` + `old_string`. +/// Returns true if injected. +fn inject_start_line(value: &mut serde_json::Value, cwd: Option<&str>) -> bool { + let obj = match value.as_object_mut() { + Some(o) => o, + None => return false, + }; + let fp = obj + .get("file_path") + .or_else(|| obj.get("path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let old_str = obj + .get("old_string") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let (Some(fp), Some(old_str)) = (fp, old_str) { + if let Some(sl) = find_string_start_line(&fp, &old_str, cwd) { + obj.insert("_start_line".to_string(), serde_json::json!(sl)); + return true; + } + } + false +} + +/// Find the 1-based start line of `needle` in the file at `path`. +fn find_string_start_line(path: &str, needle: &str, cwd: Option<&str>) -> Option { + if needle.is_empty() { + return None; + } + let file_lines = crate::parsers::load_file_lines(path, cwd)?; + let file_content = file_lines.join("\n"); + let byte_offset = file_content.find(needle)?; + Some(file_content[..byte_offset].matches('\n').count() as u64 + 1) +} + fn json_value_to_text(val: &Option) -> Option { match val { Some(serde_json::Value::String(text)) => Some(text.clone()), @@ -1938,6 +2038,7 @@ fn emit_conversation_update( connection_id: &str, app_handle: &tauri::AppHandle, update: SessionUpdate, + cwd: Option<&str>, ) { match update { SessionUpdate::UserMessageChunk(_) => { @@ -1978,8 +2079,10 @@ fn emit_conversation_update( } SessionUpdate::ToolCall(tc) => { let content = serialize_tool_call_content(&tc.content); - let raw_input = json_value_to_text(&tc.raw_input); - let raw_output = json_value_to_text(&tc.raw_output); + let raw_input = json_value_to_text(&tc.raw_input) + .map(|text| resolve_live_tool_input(&text, cwd)); + let raw_output = json_value_to_text(&tc.raw_output) + .map(|text| structurize_live_output(&text)); crate::web::event_bridge::emit_event( app_handle, "acp://event", @@ -2001,8 +2104,10 @@ fn emit_conversation_update( .content .as_deref() .and_then(serialize_tool_call_content); - let raw_input = json_value_to_text(&tcu.fields.raw_input); - let raw_output = json_value_to_text(&tcu.fields.raw_output); + let raw_input = json_value_to_text(&tcu.fields.raw_input) + .map(|text| resolve_live_tool_input(&text, cwd)); + let raw_output = json_value_to_text(&tcu.fields.raw_output) + .map(|text| structurize_live_output(&text)); crate::web::event_bridge::emit_event( app_handle, "acp://event", diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 34df1ab..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, @@ -574,6 +639,160 @@ impl ClaudeParser { } } } + "tool_use" => { + // Top-level tool_use record (Claude Code JSONL format) + let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now); + let tool_name = value + .get("tool_name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + let input_preview = value.get("tool_input").map(|i| i.to_string()); + let synthetic_id = format!("tl-tool-{}", messages.len()); + + // Attach to last assistant message, or create a synthetic one + if let Some(last) = messages + .iter_mut() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + { + last.content.push(ContentBlock::ToolUse { + tool_use_id: Some(synthetic_id), + tool_name, + input_preview, + }); + } else { + messages.push(UnifiedMessage { + id: format!("synth-assistant-{}", messages.len()), + role: MessageRole::Assistant, + content: vec![ContentBlock::ToolUse { + tool_use_id: Some(synthetic_id), + tool_name, + input_preview, + }], + timestamp, + usage: None, + duration_ms: None, + model: None, + }); + } + } + "tool_result" => { + // Top-level tool_result record (Claude Code JSONL format) + let tool_output = value.get("tool_output"); + let tool_name = value + .get("tool_name") + .and_then(|n| n.as_str()) + .unwrap_or(""); + let is_error = tool_output + .and_then(|o| o.get("exit")) + .and_then(|e| e.as_i64()) + .is_some_and(|code| code != 0); + + // Extract output text: prefer "preview" (read), then "output" (bash) + let output_text = tool_output + .and_then(|o| { + o.get("preview") + .or_else(|| o.get("output")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_string()); + + // Don't structurize here — `structurize_read_tool_output` + // will handle Read tool output uniformly after grouping. + let output_preview = output_text; + + // Find the matching ToolUse by tool_name (reverse scan so the + // most recent match wins), then fall back to the last ToolUse + // without a paired ToolResult yet. + let existing_result_ids: std::collections::HashSet = messages + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + .map(|m| { + m.content + .iter() + .filter_map(|b| { + if let ContentBlock::ToolResult { + tool_use_id: Some(ref id), + .. + } = b + { + Some(id.clone()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + + let matching_id = messages + .iter() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + .and_then(|m| { + // First: try to find an unpaired ToolUse with the same tool_name + let by_name = m.content.iter().rev().find_map(|b| { + if let ContentBlock::ToolUse { + tool_use_id: Some(ref id), + tool_name: ref tn, + .. + } = b + { + if tn == tool_name + && !existing_result_ids.contains(id) + { + return Some(id.clone()); + } + } + None + }); + if by_name.is_some() { + return by_name; + } + // Fallback: last unpaired ToolUse regardless of name + m.content.iter().rev().find_map(|b| { + if let ContentBlock::ToolUse { + tool_use_id: Some(ref id), + .. + } = b + { + if !existing_result_ids.contains(id) { + return Some(id.clone()); + } + } + None + }) + }); + + // Append ToolResult to the same assistant message so they stay in the same turn + if let Some(last) = messages + .iter_mut() + .rev() + .find(|m| matches!(m.role, MessageRole::Assistant)) + { + last.content.push(ContentBlock::ToolResult { + tool_use_id: matching_id, + output_preview, + is_error, + }); + } else { + messages.push(UnifiedMessage { + id: format!("synth-result-{}", messages.len()), + role: MessageRole::Assistant, + content: vec![ContentBlock::ToolResult { + tool_use_id: matching_id, + output_preview, + is_error, + }], + timestamp: parse_timestamp(&value).unwrap_or_else(Utc::now), + usage: None, + duration_ms: None, + model: None, + }); + } + } _ => {} } } @@ -583,6 +802,8 @@ 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 50c6cda..4508f67 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -773,6 +773,8 @@ 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 399f37c..3953816 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -556,6 +556,8 @@ 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 2fb46a7..a8ef4bf 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -5,7 +5,7 @@ pub mod gemini; pub mod openclaw; pub mod opencode; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use regex::Regex; @@ -293,6 +293,355 @@ pub fn relocate_orphaned_tool_results(turns: &mut Vec) { turns.retain(|turn| !turn.blocks.is_empty()); } +/// Convert Read tool output from numbered-line format to `{"start_line":N,"content":"..."}`. +/// +/// Claude Code embeds line numbers in Read output like ` 115→content`. +/// This splits on the `→` delimiter (or tab for older `cat -n` format), +/// extracts the starting line number, and returns clean content. +pub fn structurize_read_tool_output(turns: &mut [MessageTurn]) { + let mut read_tool_ids: HashSet = HashSet::new(); + for turn in turns.iter() { + for block in &turn.blocks { + if let ContentBlock::ToolUse { + tool_use_id: Some(ref id), + ref tool_name, + .. + } = block + { + let name = tool_name.to_lowercase(); + if matches!( + name.as_str(), + "read" | "read_file" | "readfile" | "read file" | "cat" | "view" + ) { + read_tool_ids.insert(id.clone()); + } + } + } + } + + for turn in turns.iter_mut() { + for block in turn.blocks.iter_mut() { + let is_read_result = matches!( + block, + ContentBlock::ToolResult { tool_use_id: Some(ref id), .. } + if read_tool_ids.contains(id) + ); + if !is_read_result { + continue; + } + if let ContentBlock::ToolResult { + ref mut output_preview, + .. + } = block + { + if let Some(ref text) = output_preview { + if let Some(json) = strip_numbered_lines(text) { + *output_preview = Some(json); + } + } + } + } + } +} + +/// Known delimiters between line number and content. +const LINE_NUM_DELIMITERS: &[&str] = &["→", "\t"]; + +/// Try to split a line at a known delimiter, returning (line_number, content). +fn split_line_number(line: &str) -> Option<(u64, &str)> { + for delim in LINE_NUM_DELIMITERS { + if let Some(pos) = line.find(delim) { + let prefix = line[..pos].trim(); + if let Ok(num) = prefix.parse::() { + let content_start = pos + delim.len(); + return Some((num, &line[content_start..])); + } + } + } + None +} + +/// If most lines have a recognized line-number prefix, strip them all +/// and return `{"start_line":N,"content":"clean text"}`. +pub fn strip_numbered_lines(text: &str) -> Option { + let raw_lines: Vec<&str> = text.lines().collect(); + if raw_lines.len() < 2 { + return None; + } + + let matched = raw_lines + .iter() + .filter(|l| l.is_empty() || split_line_number(l).is_some()) + .count(); + if matched < raw_lines.len() * 4 / 5 { + return None; + } + + let mut start_line: u64 = 1; + let mut first = true; + let stripped: Vec<&str> = raw_lines + .iter() + .map(|line| { + if let Some((num, content)) = split_line_number(line) { + if first { + start_line = num; + first = false; + } + content + } else { + first = false; + *line + } + }) + .collect(); + + Some( + serde_json::json!({ + "start_line": start_line, + "content": stripped.join("\n") + }) + .to_string(), + ) +} + +/// 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 @@`. +pub 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. +pub 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. +/// +/// The file on disk may be in either pre-patch or post-patch state, and may +/// have been further modified. We try three strategies in order: +/// 1. Contiguous match of context+added lines (post-patch file, no further edits) +/// 2. Contiguous match of context+deleted lines (pre-patch file) +/// 3. Subsequence match of context-only lines (file has been further modified) +fn find_hunk_position( + file_lines: &[String], + hunk_lines: &[&str], +) -> Option<(usize, usize, usize)> { + 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; + } + } + + // Strategy 1: contiguous match of context+added (post-patch) + let new_view: Vec<&str> = hunk_lines + .iter() + .filter(|l| l.starts_with(' ') || l.starts_with('+')) + .map(|l| &l[1..]) + .collect(); + if let Some(pos) = find_contiguous(file_lines, &new_view) { + return Some((pos + 1, old_count, new_count)); + } + + // Strategy 2: contiguous match of context+deleted (pre-patch) + let old_view: Vec<&str> = hunk_lines + .iter() + .filter(|l| l.starts_with(' ') || l.starts_with('-')) + .map(|l| &l[1..]) + .collect(); + if let Some(pos) = find_contiguous(file_lines, &old_view) { + return Some((pos + 1, old_count, new_count)); + } + + // Strategy 3: subsequence match of context-only lines (file further modified) + let ctx_only: Vec<&str> = hunk_lines + .iter() + .filter(|l| l.starts_with(' ')) + .map(|l| &l[1..]) + .collect(); + if let Some(pos) = find_subsequence(file_lines, &ctx_only) { + return Some((pos + 1, old_count, new_count)); + } + + None +} + +/// Find contiguous `view` lines in `file_lines`. Returns 0-based start index. +fn find_contiguous(file_lines: &[String], view: &[&str]) -> Option { + if view.is_empty() || view.len() > file_lines.len() { + return None; + } + let first = view[0]; + for i in 0..=(file_lines.len() - view.len()) { + if file_lines[i].as_str() != first { + continue; + } + if view.iter().enumerate().all(|(j, v)| file_lines[i + j].as_str() == *v) { + return Some(i); + } + } + None +} + +/// Find `needles` as an ordered subsequence in `file_lines` within a small window. +/// Returns 0-based index of the first needle's position. +fn find_subsequence(file_lines: &[String], needles: &[&str]) -> Option { + if needles.is_empty() { + return None; + } + let first = needles[0]; + for start in 0..file_lines.len() { + if file_lines[start].as_str() != first { + continue; + } + let mut cursor = start + 1; + let mut all_found = true; + for &needle in &needles[1..] { + // Allow up to 10 lines gap between consecutive context lines + let limit = std::cmp::min(cursor + 10, file_lines.len()); + match file_lines[cursor..limit] + .iter() + .position(|fl| fl.as_str() == needle) + { + Some(offset) => cursor = cursor + offset + 1, + None => { + all_found = false; + break; + } + } + } + if all_found { + return Some(start); + } + } + 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 1fcc8df..c8e47a5 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -652,6 +652,8 @@ impl OpenClawParser { let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p)); 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 @@ -983,9 +985,15 @@ fn extract_assistant_content(value: &serde_json::Value) -> Vec { .and_then(|n| n.as_str()) .unwrap_or("unknown") .to_string(); + let is_edit_tool = matches!( + tool_name.to_lowercase().as_str(), + "edit" | "write" | "apply_patch" | "patch" | "applypatch" + | "edit_file" | "editfile" + ); + let max_len = if is_edit_tool { 50000 } else { 500 }; let input_preview = item.get("arguments").map(|a| { let s = a.to_string(); - truncate_str(&s, 500) + truncate_str(&s, max_len) }); blocks.push(ContentBlock::ToolUse { tool_use_id, diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index af2f526..c500acd 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -189,6 +189,8 @@ impl OpenCodeParser { let messages = self.load_sqlite_messages(&conn, conversation_id).await?; 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/ai-elements/message-thread.tsx b/src/components/ai-elements/message-thread.tsx index c4611da..9ab6c71 100644 --- a/src/components/ai-elements/message-thread.tsx +++ b/src/components/ai-elements/message-thread.tsx @@ -91,7 +91,7 @@ export const MessageThreadScrollButton = ({ !isAtBottom && ( - +