diff --git a/peri-middlewares/src/tools/filesystem/edit.rs b/peri-middlewares/src/tools/filesystem/edit.rs index 30d2861a..61acae87 100644 --- a/peri-middlewares/src/tools/filesystem/edit.rs +++ b/peri-middlewares/src/tools/filesystem/edit.rs @@ -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::>() + .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 { @@ -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") @@ -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!( @@ -239,11 +301,11 @@ impl BaseTool for EditFileTool { } if occurrences > 1 { let locations: Vec = 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 { @@ -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") diff --git a/peri-middlewares/src/tools/filesystem/edit_test.rs b/peri-middlewares/src/tools/filesystem/edit_test.rs index 37991165..f7b98c5f 100644 --- a/peri-middlewares/src/tools/filesystem/edit_test.rs +++ b/peri-middlewares/src/tools/filesystem/edit_test.rs @@ -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" + ); } \ No newline at end of file diff --git a/peri-tui/src/app/hitl_prompt.rs b/peri-tui/src/app/hitl_prompt.rs index a06b2a0a..d54c191e 100644 --- a/peri-tui/src/app/hitl_prompt.rs +++ b/peri-tui/src/app/hitl_prompt.rs @@ -27,6 +27,10 @@ pub struct HitlBatchPrompt { pub approved: Vec, /// 当前光标所在的行(工具索引) pub cursor: usize, + /// 渲染时记录的内容区可见行数(hitl_move 据此判断是否滚动) + pub last_visible_height: u16, + /// 当前滚动偏移(行)。Paragraph::scroll 用,让光标保持可见。 + pub scroll_offset: u16, /// 回复 channel pub response_tx: tokio::sync::oneshot::Sender>, } @@ -41,6 +45,8 @@ impl HitlBatchPrompt { items, approved: vec![true; len], // 默认全部批准 cursor: 0, + last_visible_height: 0, + scroll_offset: 0, response_tx, } } @@ -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; + } } /// 切换当前项的批准/拒绝状态 diff --git a/peri-tui/src/ui/main_ui/popups/ask_user_height.rs b/peri-tui/src/ui/main_ui/popups/ask_user_height.rs index dd3235d6..d1b56f32 100644 --- a/peri-tui/src/ui/main_ui/popups/ask_user_height.rs +++ b/peri-tui/src/ui/main_ui/popups/ask_user_height.rs @@ -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 } diff --git a/peri-tui/src/ui/main_ui/popups/hitl.rs b/peri-tui/src/ui/main_ui/popups/hitl.rs index 50add38f..3f204d69 100644 --- a/peri-tui/src/ui/main_ui/popups/hitl.rs +++ b/peri-tui/src/ui/main_ui/popups/hitl.rs @@ -11,102 +11,94 @@ use peri_widgets::BorderedPanel; use crate::{app::App, ui::theme}; /// HITL 批量确认弹窗(底部展开区) -pub(crate) fn render_hitl_popup(f: &mut Frame, app: &App, area: Rect) { - let Some(crate::app::InteractionPrompt::Approval(prompt)) = - &app.session_mgr.current().agent.interaction_prompt - else { - return; - }; - - let lc = &app.services.lc; - let item_count = prompt.items.len(); - let popup_area = area; - - let title = if item_count == 1 { - lc.tr("hitl-single-title") - } else { - lc.tr("hitl-batch-title") - }; - - let inner = BorderedPanel::new(Span::styled( - title, - Style::default() - .fg(theme::THINKING) - .add_modifier(Modifier::BOLD), - )) - .border_style(Style::default().fg(theme::WARNING)) - .render(f, popup_area); - let max_width = inner.width as usize; - - // 渲染每个工具调用项 - let mut lines: Vec = Vec::new(); - - for (i, (item, &approved)) in prompt.items.iter().zip(prompt.approved.iter()).enumerate() { - let is_cursor = i == prompt.cursor; +pub(crate) fn render_hitl_popup(f: &mut Frame, app: &mut App, area: Rect) { + // 先借不可变引用读完渲染需要的纯数据,drop 借用后再写入 last_visible_height + let (scroll_offset, lines, inner, inner_height) = { + let Some(crate::app::InteractionPrompt::Approval(prompt)) = + &app.session_mgr.current().agent.interaction_prompt + else { + return; + }; + let lc = &app.services.lc; + let item_count = prompt.items.len(); + let popup_area = area; - // 状态图标和颜色 - let (status_icon, status_color) = if approved { - ("✓", theme::SAGE) + let title = if item_count == 1 { + lc.tr("hitl-single-title") } else { - ("✗", theme::ERROR) + lc.tr("hitl-batch-title") }; - // 光标高亮 - let cursor_indicator = if is_cursor { "❯ " } else { " " }; - let _row_style = Style::default(); - - // 工具名行 - lines.push(Line::styled( - format!( - "{}{} {} {}", - cursor_indicator, - status_icon, - item.tool_name, - if approved { - lc.tr("hitl-approved") - } else { - lc.tr("hitl-rejected") - } - ), - if is_cursor { - Style::default() - .fg(theme::THINKING) - .add_modifier(Modifier::BOLD) + let inner = BorderedPanel::new(Span::styled( + title, + Style::default() + .fg(theme::THINKING) + .add_modifier(Modifier::BOLD), + )) + .border_style(Style::default().fg(theme::WARNING)) + .render(f, popup_area); + let inner_height = inner.height; + let max_width = inner.width as usize; + + let mut lines: Vec = Vec::new(); + for (i, (item, &approved)) in prompt.items.iter().zip(prompt.approved.iter()).enumerate() { + let is_cursor = i == prompt.cursor; + let (status_icon, status_color) = if approved { + ("✓", theme::SAGE) } else { - Style::default().fg(status_color) - }, - )); - - // 参数预览行 - let input_preview = format_input_preview(&item.input, max_width.saturating_sub(6)); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(input_preview, Style::default().fg(theme::MUTED)), - ])); - } + ("✗", theme::ERROR) + }; + let cursor_indicator = if is_cursor { "❯ " } else { " " }; + let approved_label = if approved { + lc.tr("hitl-approved") + } else { + lc.tr("hitl-rejected") + }; + lines.push(Line::styled( + format!( + "{}{} {} {}", + cursor_indicator, status_icon, item.tool_name, approved_label + ), + if is_cursor { + Style::default() + .fg(theme::THINKING) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(status_color) + }, + )); + let input_preview = format_input_preview(&item.input, max_width.saturating_sub(6)); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(input_preview, Style::default().fg(theme::MUTED)), + ])); + } + if item_count > 1 { + let approved_count = prompt.approved.iter().filter(|&&v| v).count() as i64; + let rejected_count = prompt.approved.iter().filter(|&&v| !v).count() as i64; + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + lc.tr_args( + "hitl-summary", + &[ + ("approved".into(), approved_count.into()), + ("rejected".into(), rejected_count.into()), + ], + ), + Style::default().fg(theme::MUTED), + ))); + } + (prompt.scroll_offset, lines, inner, inner_height) + }; - // 底部:多项时显示统计摘要(快捷键由状态栏统一负责) - if item_count > 1 { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - lc.tr_args( - "hitl-summary", - &[ - ( - "approved".into(), - (prompt.approved.iter().filter(|&&v| v).count() as i64).into(), - ), - ( - "rejected".into(), - (prompt.approved.iter().filter(|&&v| !v).count() as i64).into(), - ), - ], - ), - Style::default().fg(theme::MUTED), - ))); + // 写入 last_visible_height 供 hitl_move 使用 + if let Some(crate::app::InteractionPrompt::Approval(p)) = + &mut app.session_mgr.current_mut().agent.interaction_prompt + { + p.last_visible_height = inner_height; } - let para = Paragraph::new(Text::from(lines)); + let para = Paragraph::new(Text::from(lines)).scroll((scroll_offset, 0)); f.render_widget(para, inner); }