Merge branch 'main' into cline

This commit is contained in:
xintaofei
2026-03-28 20:15:21 +08:00
28 changed files with 1272 additions and 545 deletions

View File

@@ -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<MessageTurn>) {
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<String> = 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::<u64>() {
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<String> {
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<String> {
let mut output = String::with_capacity(patch.len() + 256);
let mut current_file_path: Option<String> = None;
let mut file_lines: Option<Vec<String>> = 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<Vec<String>> {
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<usize> {
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<usize> {
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()