Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 76 additions & 14 deletions peri-middlewares/src/tools/filesystem/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ fn build_not_found_hint(content: &str, old_string: &str) -> String {
"建议先 Read 此文件获取最新内容再重试。".to_string()
}

/// 把字符串中每行行首的连续 4-空格组转换为 1 个 tab。
/// 用于 tab 缩进文件场景:LLM 通常把 Read 出的 tab 错读为 4 空格,
/// 这里把 old_string/new_string 行首空格转回 tab 以匹配原文风格。
/// 非行首字符不动,行首不足 4 的余数空格保留。
fn convert_leading_spaces_to_tabs(s: &str) -> String {
s.lines()
.map(|line| {
let stripped = line.trim_start_matches(' ');
let leading = line.len() - stripped.len();
let tabs = leading / 4;
let rem = leading % 4;
format!("{}{}{}", "\t".repeat(tabs), " ".repeat(rem), stripped)
})
.collect::<Vec<_>>()
.join("\n")
}

/// 精确匹配失败时的 tab fallback:仅当文件本身用 tab 缩进,且把 old_string
/// 行首空格转 tab 后能在文件中至少匹配到一处时启用。返回转换后的 (old, new)
/// 用于后续替换;new 同步转换以保持文件的 tab 风格。否则返回 None,让上层
/// 走原本的 not found 错误路径。
/// 注:调用方负责按单次/replace_all 语义对结果做 unique 校验。
fn try_tab_fallback(content: &str, old_string: &str, new_string: &str) -> Option<(String, String)> {
if !content.lines().any(|l| l.starts_with('\t')) {
return None;
}
let old_tabs = convert_leading_spaces_to_tabs(old_string);
if old_tabs == old_string {
return None;
}
if !content.contains(&old_tabs) {
return None;
}
Some((old_tabs, convert_leading_spaces_to_tabs(new_string)))
}

#[async_trait::async_trait]
impl BaseTool for EditFileTool {
fn name(&self) -> &str {
Expand Down Expand Up @@ -194,16 +230,25 @@ impl BaseTool for EditFileTool {
};

if replace_all {
if !content.contains(old_string) {
let hint = build_not_found_hint(&content, old_string);
return Err(format!(
"Error: old_string not found in {}\n{hint}",
resolved.display()
)
.into());
}
let new_content = content.replace(old_string, new_string);
let occurrences = content.matches(old_string).count();
let (new_content, occurrences) = if !content.contains(old_string) {
match try_tab_fallback(&content, old_string, new_string) {
Some((old_tabs, new_tabs)) => {
let n = content.matches(&old_tabs).count();
(content.replace(&old_tabs, &new_tabs), n)
}
None => {
let hint = build_not_found_hint(&content, old_string);
return Err(format!(
"Error: old_string not found in {}\n{hint}",
resolved.display()
)
.into());
}
}
} else {
let n = content.matches(old_string).count();
(content.replace(old_string, new_string), n)
};
// 恢复原始行尾格式
let final_content = if is_crlf {
new_content.replace('\n', "\r\n")
Expand All @@ -228,7 +273,24 @@ impl BaseTool for EditFileTool {
}
}
} else {
let occurrences = content.matches(old_string).count();
// tab fallback:精确匹配 0 次时尝试把 old/new 行首空格转 tab。
// 命中后用转换后的字符串重新计数,复用下面的 unique 校验路径。
let (old_eff, new_eff): (String, String) = if !content.contains(old_string) {
match try_tab_fallback(&content, old_string, new_string) {
Some(pair) => pair,
None => {
let hint = build_not_found_hint(&content, old_string);
return Err(format!(
"Error: old_string not found in {}\n{hint}",
resolved.display()
)
.into());
}
}
} else {
(old_string.to_string(), new_string.to_string())
};
let occurrences = content.matches(old_eff.as_str()).count();
if occurrences == 0 {
let hint = build_not_found_hint(&content, old_string);
return Err(format!(
Expand All @@ -239,11 +301,11 @@ impl BaseTool for EditFileTool {
}
if occurrences > 1 {
let locations: Vec<String> = content
.match_indices(old_string)
.match_indices(old_eff.as_str())
.take(10)
.map(|(offset, _)| {
let line = content[..offset].lines().count() + 1;
let end_line = line + old_string.lines().count().saturating_sub(1);
let end_line = line + old_eff.lines().count().saturating_sub(1);
if end_line > line {
format!("第 {}-{} 行", line, end_line)
} else {
Expand All @@ -269,7 +331,7 @@ impl BaseTool for EditFileTool {
)
.into());
}
let new_content = content.replacen(old_string, new_string, 1);
let new_content = content.replacen(old_eff.as_str(), new_eff.as_str(), 1);
// 恢复原始行尾格式
let final_content = if is_crlf {
new_content.replace('\n', "\r\n")
Expand Down
86 changes: 86 additions & 0 deletions peri-middlewares/src/tools/filesystem/edit_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,90 @@
.unwrap();
let content = std::fs::read_to_string(dir.path().join("f.txt")).unwrap();
assert_eq!(content, "baz\r\nbar\r\nbaz\r\n", "replace_all 后应保持 CRLF");
}

#[tokio::test]
async fn test_edit_tab_indented_file_with_space_old_string_single() {
// 文件用 tab 缩进,LLM 给的 old_string 用 4 空格(精确匹配失败)。
// tab fallback 应转换并命中唯一一处,写回时保持 tab 风格。
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.py"), "\tdef foo():\n\tpass\n").unwrap();
let tool = EditFileTool::new(dir.path().to_str().unwrap());
tool.invoke(serde_json::json!({
"file_path": "f.py",
"old_string": " def foo():\n pass\n",
"new_string": " def foo():\n return 1\n"
}))
.await
.expect("tab fallback 命中时应成功");
let content = std::fs::read_to_string(dir.path().join("f.py")).unwrap();
assert_eq!(content, "\tdef foo():\n\treturn 1\n", "写回应保持 tab 缩进");
}

#[tokio::test]
async fn test_edit_tab_indented_file_with_space_old_string_replace_all() {
// replace_all 路径也应支持 tab fallback
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.py"), "\tprint('a')\n\tprint('a')\n").unwrap();
let tool = EditFileTool::new(dir.path().to_str().unwrap());
tool.invoke(serde_json::json!({
"file_path": "f.py",
"old_string": " print('a')",
"new_string": " print('b')",
"replace_all": true
}))
.await
.expect("replace_all + tab fallback 应成功");
let content = std::fs::read_to_string(dir.path().join("f.py")).unwrap();
assert_eq!(content, "\tprint('b')\n\tprint('b')\n", "两处都应替换且保持 tab");
}

#[tokio::test]
async fn test_edit_tab_fallback_skipped_when_no_tab_in_file() {
// 文件不含 tab 时不应启用 fallback,按原 not found 报错
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.py"), " def foo():\n pass\n").unwrap();
let tool = EditFileTool::new(dir.path().to_str().unwrap());
let err = tool
.invoke(serde_json::json!({
"file_path": "f.py",
"old_string": " return 1\n",
"new_string": "x"
}))
.await
.unwrap_err();
assert!(err.to_string().contains("not found"), "应报 not found: {err}");
}

#[tokio::test]
async fn test_edit_tab_fallback_skipped_when_ambiguous() {
// 转换后多次匹配应按 not unique 路径报错,不静默替换
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f.py"), "\tfoo\n\tfoo\n").unwrap();
let tool = EditFileTool::new(dir.path().to_str().unwrap());
let err = tool
.invoke(serde_json::json!({
"file_path": "f.py",
"old_string": " foo",
"new_string": " bar"
}))
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not unique"), "应报 not unique: {msg}");
}

#[test]
fn test_convert_leading_spaces_to_tabs_basic() {
assert_eq!(convert_leading_spaces_to_tabs(" a"), "\ta");
assert_eq!(convert_leading_spaces_to_tabs(" b"), "\t\tb");
// 余数空格保留
assert_eq!(convert_leading_spaces_to_tabs(" c"), "\t c");
// 行中空格不动
assert_eq!(convert_leading_spaces_to_tabs(" a b c"), "\ta b c");
// 多行
assert_eq!(
convert_leading_spaces_to_tabs(" x\n y"),
"\tx\n\t\ty"
);
}
26 changes: 26 additions & 0 deletions peri-tui/src/app/hitl_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ pub struct HitlBatchPrompt {
pub approved: Vec<bool>,
/// 当前光标所在的行(工具索引)
pub cursor: usize,
/// 渲染时记录的内容区可见行数(hitl_move 据此判断是否滚动)
pub last_visible_height: u16,
/// 当前滚动偏移(行)。Paragraph::scroll 用,让光标保持可见。
pub scroll_offset: u16,
/// 回复 channel
pub response_tx: tokio::sync::oneshot::Sender<Vec<HitlDecision>>,
}
Expand All @@ -41,6 +45,8 @@ impl HitlBatchPrompt {
items,
approved: vec![true; len], // 默认全部批准
cursor: 0,
last_visible_height: 0,
scroll_offset: 0,
response_tx,
}
}
Expand All @@ -51,6 +57,26 @@ impl HitlBatchPrompt {
return;
}
self.cursor = ((self.cursor as isize + delta).rem_euclid(len as isize)) as usize;
// 光标跟随:每项渲染 2 行(tool 名 + 参数预览),用 cursor_row = cursor*2
// 作为实际行号近似(足够防止光标移出可视区,误差 1-2 行可由 visible_height
// 的尾数吸收)。底部统计行 +1 但作为缓冲不纳入计算。
let cursor_row = (self.cursor as u16).saturating_mul(2);
let vis = if self.last_visible_height > 0 {
self.last_visible_height
} else {
10 // fallback:未渲染前用保守值
};
// 钳位到 [vis/3, vis-1] 区间:光标进入上方 1/3 时上滚,
// 接近底部(最后一行)时下滚。注释与代码对齐(cc-claws PR #76 review)。
let lower = vis / 3;
let upper = vis.saturating_sub(1);
if cursor_row < self.scroll_offset.saturating_add(lower) {
// 光标进入上方缓冲区,往上滚
self.scroll_offset = cursor_row.saturating_sub(lower);
} else if cursor_row >= self.scroll_offset + upper {
// 光标超出底部,往下滚
self.scroll_offset = cursor_row.saturating_sub(upper) + 1;
}
}

/// 切换当前项的批准/拒绝状态
Expand Down
7 changes: 6 additions & 1 deletion peri-tui/src/ui/main_ui/popups/ask_user_height.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,10 @@ pub(crate) fn ask_user_content_height(q: &AskUserQuestionData, panel_width: usiz
}

// header tab 行 + 分隔线 + BorderedPanel 上下边框 = 4
lines + 4
// 额外 +3 行 safety margin:div_ceil 是字符级换行,ratatui Paragraph::wrap
// 是词级换行(WordWrapper),视觉行数 ≥ 字符级行数;CJK 无空格场景两者接近,
// 但英文长 label/description 经常多出 1-2 行。+3 吸收差异 + 自定义输入
// 在光标态显示完整内容时的额外行(issue #30 多次精确估算尝试均失败,
// safety margin 是最稳妥的兜底)。
lines + 4 + 3
}
Loading
Loading