Skip to main content

j_agent/util/
text.rs

1const TAB_REPLACEMENT: &str = "    ";
2
3/// 快速判断字符串是否包含需要为终端/TUI 渲染做清洗的字符。
4///
5/// 覆盖:
6/// - ANSI/OSC 转义起始字节 `ESC`
7/// - `\t` / `\r`
8/// - 其他会扰乱终端渲染的控制字符(保留 `\n`)
9pub fn needs_terminal_sanitization(s: &str) -> bool {
10    s.chars()
11        .any(|c| matches!(c, '\t' | '\r' | '\x1b') || (c.is_control() && c != '\n'))
12}
13
14/// 将终端不会稳定按单列显示的控制字符归一化为可见文本。
15///
16/// 这里处理过一个很隐蔽的 TUI 渲染问题:聊天记录里如果混入原始 `\t` / `\r`
17/// (典型来源是 shell / make 输出),窄屏滚动时右边界会残留上一帧的 `/` 等字符。
18/// 根因不是滚动逻辑本身,而是 ratatui 会跳过 control char,而我们若仍按“有宽度”去参与
19/// wrap / bubble 宽度计算,就会让预计算宽度与实际写入 buffer 的宽度失配。
20///
21/// - `\t` 展开为 4 个空格,确保宽度计算与最终渲染一致
22/// - `\r` / 其他控制字符(BEL、BS、ESC、DEL 等)全部移除
23/// - `\n` 保留,由调用方按行切分
24pub fn normalize_terminal_text(s: &str) -> String {
25    let mut result = String::with_capacity(s.len());
26    for c in s.chars() {
27        match c {
28            '\t' => result.push_str(TAB_REPLACEMENT),
29            '\n' => result.push('\n'),
30            c if c.is_control() => { /* 跳过 BEL/BS/CR/ESC/DEL 等控制字符 */ }
31            c => result.push(c),
32        }
33    }
34    result
35}
36
37/// 清理终端/TUI 展示文本:剥离 ANSI 码,再归一化控制字符。
38///
39/// 与 `normalize_terminal_text()` 的区别是这里会先移除完整 ANSI/OSC 转义序列,
40/// 避免只删除 `ESC` 后留下裸露的 `[31m` / `]0;...` 等残片。
41pub fn sanitize_terminal_text(s: &str) -> String {
42    let stripped = strip_ansi_codes(s);
43    normalize_terminal_text(&stripped)
44}
45
46/// 清理单行终端展示文本:剥离 ANSI/控制字符,并将换行压平为空格。
47pub fn sanitize_single_line_text(s: &str) -> String {
48    sanitize_terminal_text(s)
49        .chars()
50        .map(|c| if c == '\n' { ' ' } else { c })
51        .collect()
52}
53
54/// 按显示宽度对文本进行自动换行
55/// `\n` 字符会在该处断行(产生新的 wrapped line),`\n` 本身不出现在返回的行中
56pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
57    let normalized;
58    let text = if needs_terminal_sanitization(text) {
59        normalized = sanitize_terminal_text(text);
60        normalized.as_str()
61    } else {
62        text
63    };
64    // 最小宽度保证至少能放下一个字符(中文字符宽度2),避免无限循环或不截断
65    let max_width = max_width.max(2);
66    let mut result = Vec::new();
67    let mut current_line = String::new();
68    let mut current_width = 0;
69
70    for ch in text.chars() {
71        // 遇到 \n 时断行:push 当前行,开始新行
72        if ch == '\n' {
73            result.push(current_line.clone());
74            current_line.clear();
75            current_width = 0;
76            continue;
77        }
78        let ch_width = char_width(ch);
79        if current_width + ch_width > max_width && !current_line.is_empty() {
80            result.push(current_line.clone());
81            current_line.clear();
82            current_width = 0;
83        }
84        current_line.push(ch);
85        current_width += ch_width;
86    }
87    if !current_line.is_empty() {
88        result.push(current_line);
89    }
90    if result.is_empty() {
91        result.push(String::new());
92    }
93    result
94}
95
96/// 计算字符串的显示宽度(使用 unicode-width crate,比手动范围匹配更准确)
97/// 约定:tab 视为 4 列,其他控制字符视为 0 列,与终端归一化策略保持一致
98pub fn display_width(s: &str) -> usize {
99    s.chars().map(char_width).sum()
100}
101
102/// 计算单个字符的显示宽度(使用 unicode-width crate)
103/// 约定:tab 视为 4 列,其他控制字符视为 0 列,与终端归一化策略保持一致
104pub fn char_width(c: char) -> usize {
105    if c == '\t' {
106        return TAB_REPLACEMENT.len();
107    }
108    if c.is_control() {
109        return 0;
110    }
111    use unicode_width::UnicodeWidthChar;
112    UnicodeWidthChar::width(c).unwrap_or(0)
113}
114
115/// 剥离 ANSI 转义序列(颜色、粗体等控制码),返回纯文本
116/// 例如 "\x1b[1;34mhello\x1b[0m" → "hello"
117pub fn strip_ansi_codes(s: &str) -> String {
118    use regex::Regex;
119    use std::sync::OnceLock;
120    static RE: OnceLock<Regex> = OnceLock::new();
121    let re = RE.get_or_init(|| {
122        // CSI 序列: ESC[ (参数字节 0x30-0x3F: 0-9;:?>=!等) (中间字节 0x20-0x2F) 终止字节
123        // 覆盖 ESC[?1049h (alternate screen), ESC[38;2;...m (RGB color) 等
124        // OSC 序列: ESC]...BEL 或 ESC]...ST
125        // 其他 ESC 序列: ESC + 单字节
126        Regex::new(r"\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[^\[\]()]")
127            .expect("正则表达式编译失败,这是一个静态模式,应该总是有效的")
128    });
129    re.replace_all(s, "").into_owned()
130}
131
132/// 清理工具输出文本:剥离 ANSI 码 + 将 tab 替换为空格 + 移除 \r
133pub fn sanitize_tool_output(s: &str) -> String {
134    sanitize_terminal_text(s)
135}
136
137/// 去除字符串两端的引号(单引号或双引号)
138pub fn remove_quotes(s: &str) -> String {
139    let s = s.trim();
140    if s.len() >= 2
141        && ((s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')))
142    {
143        return s[1..s.len() - 1].to_string();
144    }
145    s.to_string()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::{
151        needs_terminal_sanitization, normalize_terminal_text, sanitize_single_line_text,
152        sanitize_terminal_text, sanitize_tool_output, wrap_text,
153    };
154
155    #[test]
156    fn needs_terminal_sanitization_detects_ansi_and_control_chars() {
157        assert!(needs_terminal_sanitization("a\tb"));
158        assert!(needs_terminal_sanitization("a\r\nb"));
159        assert!(needs_terminal_sanitization("\x1b[31mred\x1b[0m"));
160        assert!(!needs_terminal_sanitization("plain\ntext"));
161    }
162
163    #[test]
164    fn normalize_terminal_text_expands_tabs_and_removes_cr() {
165        assert_eq!(normalize_terminal_text("a\tb\r\nc"), "a    b\nc");
166    }
167
168    #[test]
169    fn normalize_terminal_text_strips_control_chars() {
170        // BEL (\x07), BS (\x08), ESC (\x1b), DEL (\x7f) 等控制字符应被移除
171        assert_eq!(
172            normalize_terminal_text("hello\x07world\x1b[0m"),
173            "helloworld[0m"
174        );
175        assert_eq!(normalize_terminal_text("\x00\x01\x02"), "");
176        assert_eq!(normalize_terminal_text("a\x7fb"), "ab");
177    }
178
179    #[test]
180    fn normalize_terminal_text_preserves_newline() {
181        // \n 应被保留
182        assert_eq!(normalize_terminal_text("line1\nline2"), "line1\nline2");
183    }
184
185    #[test]
186    fn normalize_terminal_text_preserves_tab_expansion() {
187        // \t 应展开为 4 个空格
188        assert_eq!(normalize_terminal_text("\titem"), "    item");
189    }
190
191    #[test]
192    fn sanitize_tool_output_strips_ansi_and_controls() {
193        // ANSI 码 + 控制字符应同时被清理
194        assert_eq!(sanitize_tool_output("\x1b[32mok\x1b[0m\x07"), "ok");
195    }
196
197    #[test]
198    fn sanitize_terminal_text_strips_full_ansi_sequences() {
199        assert_eq!(sanitize_terminal_text("a\x1b[31mred\x1b[0m\x07b"), "aredb");
200    }
201
202    #[test]
203    fn sanitize_single_line_text_flattens_newlines() {
204        assert_eq!(
205            sanitize_single_line_text("a\x1b[31mred\x1b[0m\nb"),
206            "ared b"
207        );
208    }
209
210    #[test]
211    fn wrap_text_outputs_spaces_instead_of_tabs() {
212        let wrapped = wrap_text("ab\tcd", 4);
213        assert_eq!(wrapped, vec!["ab  ".to_string(), "  cd".to_string()]);
214        assert!(wrapped.iter().all(|line| !line.contains('\t')));
215    }
216
217    #[test]
218    fn wrap_text_strips_ansi_sequences_instead_of_leaking_fragments() {
219        let wrapped = wrap_text("x\x1b[31mred\x1b[0my", 80);
220        assert_eq!(wrapped, vec!["xredy".to_string()]);
221    }
222}