Skip to main content

rpi_tui/
diff.rs

1//! Diff rendering for tool results — colored `+`/`-`/` ` lines with
2//! intra-line word-diff highlighting.
3//!
4//! Port of the TypeScript `renderDiff`
5//! (`packages/coding-agent/.../components/diff.ts`).
6//!
7//! The `edit` tool already produces a display-oriented diff string in
8//! `result.details.diff` (lines shaped `+123 …` / `-123 …` / ` 123 …`;
9//! see `rpi-tools/src/tools/edit_diff.rs::generate_diff_string`). This module
10//! consumes that *string* (no generator, no `rpi-tools` dependency) and renders
11//! it as ANSI-styled terminal lines:
12//!
13//! - removed (`-`) → red (`theme.colors.error`)
14//! - added   (`+`) → green (`theme.colors.success`)
15//! - context (` `) → muted/dim (`theme.colors.muted`)
16//! - for a single removed→added pair, the changed *words* are inverse-highlighted
17//!   so you can see what moved within the line.
18
19use similar::TextDiff;
20
21use crate::ansi::strip_ansi;
22use crate::theme::theme;
23use crate::utils::{truncate_to_width, visible_width};
24
25/// Render a display-diff string into styled terminal lines.
26///
27/// `width` is the available column count; long diff lines are clipped with
28/// `…` rather than wrapped (a wrapped diff is unreadable).
29///
30/// Layout: a fixed 4-col gutter `SIGN NUM│ ` where SIGN is `+`/`-`/` `, NUM
31/// is the right-aligned line number, and `│` is a thin themed rule. The
32/// content body follows after the gutter. This lines every diff row up on a
33/// shared left edge (the old `+123 content` format let the content column
34/// drift with line-number width) and gives +/- a consistent glyph column.
35pub fn render_diff(diff_text: &str, width: usize) -> Vec<String> {
36    let colors = &theme().colors;
37    let removed_color = colors.tool_diff_removed; // red
38    let added_color = colors.tool_diff_added; // green
39    let context_color = colors.tool_diff_context; // dim
40    let gutter_color = colors.dim;
41
42    let lines: Vec<&str> = diff_text.split('\n').collect();
43    // Right-align line numbers to a shared width so the gutter rule `│`
44    // stays in one column across the whole hunk.
45    let num_width = lines
46        .iter()
47        .filter_map(|l| parse_diff_line(l))
48        .map(|p| p.line_num.len())
49        .max()
50        .unwrap_or(1);
51    let mut out: Vec<String> = Vec::with_capacity(lines.len());
52
53    let mut i = 0;
54    while i < lines.len() {
55        let line = lines[i];
56        let Some(parsed) = parse_diff_line(line) else {
57            // Unparseable line — render as dim context with no gutter.
58            out.push(context_color.fg(&clip(line, width)));
59            i += 1;
60            continue;
61        };
62
63        if parsed.prefix == '-' {
64            // Collect consecutive removed lines.
65            let mut removed: Vec<ParsedLine> = Vec::new();
66            while i < lines.len() {
67                let Some(p) = parse_diff_line(lines[i]) else {
68                    break;
69                };
70                if p.prefix != '-' {
71                    break;
72                }
73                removed.push(p);
74                i += 1;
75            }
76            // Collect consecutive added lines immediately after.
77            let mut added: Vec<ParsedLine> = Vec::new();
78            while i < lines.len() {
79                let Some(p) = parse_diff_line(lines[i]) else {
80                    break;
81                };
82                if p.prefix != '+' {
83                    break;
84                }
85                added.push(p);
86                i += 1;
87            }
88
89            if removed.len() == 1 && added.len() == 1 {
90                // Single-line modification → intra-line word diff.
91                let (rem_body, add_body) = render_intra_line_diff(
92                    &replace_tabs(&removed[0].content),
93                    &replace_tabs(&added[0].content),
94                );
95                out.push(render_diff_row(
96                    '-',
97                    &removed[0].line_num,
98                    &rem_body,
99                    removed_color,
100                    gutter_color,
101                    num_width,
102                    width,
103                ));
104                out.push(render_diff_row(
105                    '+',
106                    &added[0].line_num,
107                    &add_body,
108                    added_color,
109                    gutter_color,
110                    num_width,
111                    width,
112                ));
113            } else {
114                for r in &removed {
115                    out.push(render_diff_row(
116                        '-',
117                        &r.line_num,
118                        &replace_tabs(&r.content),
119                        removed_color,
120                        gutter_color,
121                        num_width,
122                        width,
123                    ));
124                }
125                for a in &added {
126                    out.push(render_diff_row(
127                        '+',
128                        &a.line_num,
129                        &replace_tabs(&a.content),
130                        added_color,
131                        gutter_color,
132                        num_width,
133                        width,
134                    ));
135                }
136            }
137        } else if parsed.prefix == '+' {
138            out.push(render_diff_row(
139                '+',
140                &parsed.line_num,
141                &replace_tabs(&parsed.content),
142                added_color,
143                gutter_color,
144                num_width,
145                width,
146            ));
147            i += 1;
148        } else {
149            // Context line.
150            out.push(render_diff_row(
151                ' ',
152                &parsed.line_num,
153                &replace_tabs(&parsed.content),
154                context_color,
155                gutter_color,
156                num_width,
157                width,
158            ));
159            i += 1;
160        }
161    }
162
163    out
164}
165
166/// Render one diff row with a themed gutter: `SIGN NUM│ body`.
167/// `body` may already carry ANSI (intra-line inverse highlights); `clip`
168/// handles that. The sign and the gutter rule use `gutter_color` so the
169/// colored body (red/green/dim) reads as the payload, not the chrome.
170fn render_diff_row(
171    sign: char,
172    line_num: &str,
173    body: &str,
174    body_color: crate::theme::Color,
175    gutter_color: crate::theme::Color,
176    num_width: usize,
177    width: usize,
178) -> String {
179    let num = format!("{num:>num_width$}", num = line_num, num_width = num_width);
180    let gutter = format!(
181        "{} {}{}",
182        gutter_color.fg(&sign.to_string()),
183        gutter_color.fg(&num),
184        gutter_color.fg("│")
185    );
186    let body = format!(" {}", body_color.fg(body));
187    clip(&format!("{}{}", gutter, body), width)
188}
189
190#[derive(Debug)]
191struct ParsedLine {
192    prefix: char,
193    line_num: String,
194    content: String,
195}
196
197/// Parse a diff line: `"+123 content"`, `"-5 content"`, `" 7 context"`,
198/// or `"  ..."`. Returns `(prefix, line_num, content)` where `prefix` is the
199/// leading `+`/`-`/` ` character.
200fn parse_diff_line(line: &str) -> Option<ParsedLine> {
201    let mut chars = line.chars();
202    let prefix_char = chars.next()?;
203    if prefix_char != '+' && prefix_char != '-' && prefix_char != ' ' {
204        return None;
205    }
206    // The TS regex: `^([+-\s])(\s*\d*)\s(.*)$` — optional spaces, then digits,
207    // then a single separating space, then content.
208    let after_prefix = &line[prefix_char.len_utf8()..];
209    let mut rest = after_prefix;
210    while rest.starts_with(' ') {
211        rest = &rest[1..];
212    }
213    let mut num = String::new();
214    while let Some(c) = rest.chars().next() {
215        if c.is_ascii_digit() {
216            num.push(c);
217            rest = &rest[c.len_utf8()..];
218        } else {
219            break;
220        }
221    }
222    if let Some(stripped) = rest.strip_prefix(' ') {
223        rest = stripped;
224    }
225    Some(ParsedLine {
226        prefix: prefix_char,
227        line_num: num,
228        content: rest.to_string(),
229    })
230}
231
232/// Replace tabs with 3 spaces (matches the TS `replaceTabs`).
233fn replace_tabs(text: &str) -> String {
234    text.replace('\t', "   ")
235}
236
237/// Clip a (possibly already styled) line to `width` columns with a `…` suffix.
238fn clip(line: &str, width: usize) -> String {
239    if width == 0 {
240        return line.to_string();
241    }
242    let vw = visible_width(line);
243    if vw <= width {
244        line.to_string()
245    } else {
246        truncate_to_width(line, width, "…")
247    }
248}
249
250/// Compute a word-level diff between two lines and return the removed/added
251/// renderings with changed parts wrapped in inverse video (`\x1b[7m`).
252///
253/// Port of `renderIntraLineDiff` (diff.ts:26). Leading whitespace is not
254/// inverse-highlighted (it would visually smear the indentation).
255fn render_intra_line_diff(old_content: &str, new_content: &str) -> (String, String) {
256    let diff = TextDiff::from_unicode_words(old_content, new_content);
257
258    let mut removed_line = String::new();
259    let mut added_line = String::new();
260    let mut first_removed = true;
261    let mut first_added = true;
262
263    for change in diff.iter_all_changes() {
264        let value = change.value();
265        match change.tag() {
266            similar::ChangeTag::Delete => {
267                let (leading, rest) = split_leading_ws(value);
268                if first_removed {
269                    removed_line.push_str(leading);
270                    first_removed = false;
271                }
272                if !rest.is_empty() {
273                    removed_line.push_str(&inverse(rest));
274                }
275            }
276            similar::ChangeTag::Insert => {
277                let (leading, rest) = split_leading_ws(value);
278                if first_added {
279                    added_line.push_str(leading);
280                    first_added = false;
281                }
282                if !rest.is_empty() {
283                    added_line.push_str(&inverse(rest));
284                }
285            }
286            similar::ChangeTag::Equal => {
287                removed_line.push_str(value);
288                added_line.push_str(value);
289            }
290        }
291    }
292
293    (removed_line, added_line)
294}
295
296/// Split a string into its leading whitespace and the remainder.
297fn split_leading_ws(s: &str) -> (&str, &str) {
298    let idx = s.find(|c: char| !c.is_whitespace()).unwrap_or(s.len());
299    (&s[..idx], &s[idx..])
300}
301
302/// Apply ANSI inverse video to `text`, then reset. We strip any pre-existing
303/// styling from the wrapped segment so the inverse reads cleanly.
304fn inverse(text: &str) -> String {
305    let clean = strip_ansi(text);
306    format!("\x1b[7m{clean}\x1b[27m")
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_render_diff_colors_removed_and_added() {
315        let diff = "-1 old\n+1 new";
316        let lines = render_diff(diff, 80);
317        assert_eq!(lines.len(), 2);
318        // Red (error) fg escape for the removed line.
319        assert!(lines[0].contains("old"));
320        // Green (success) fg escape for the added line.
321        assert!(lines[1].contains("new"));
322    }
323
324    #[test]
325    fn test_render_diff_intra_line_word_highlight() {
326        let diff = "-1 hello world\n+1 hello earth";
327        let lines = render_diff(diff, 80);
328        // The removed line highlights "world", the added line highlights "earth".
329        assert!(
330            lines[0].contains("\x1b[7m"),
331            "removed intra-line not inversed"
332        );
333        assert!(
334            lines[1].contains("\x1b[7m"),
335            "added intra-line not inversed"
336        );
337        assert!(lines[1].contains("earth"));
338    }
339
340    #[test]
341    fn test_render_diff_context_dim() {
342        let diff = " 5 context line";
343        let lines = render_diff(diff, 80);
344        assert_eq!(lines.len(), 1);
345        // Muted context color (Ansi256(244) by default — tool_diff_context).
346        assert!(lines[0].contains("\x1b[38;5;244m"), "context not dimmed");
347    }
348
349    #[test]
350    fn test_render_diff_clips_long_lines() {
351        let long = format!("+1 {}", "x".repeat(200));
352        let lines = render_diff(&long, 40);
353        assert_eq!(lines.len(), 1);
354        assert!(lines[0].contains('…'));
355        assert!(visible_width(&lines[0]) <= 40);
356    }
357}