Files
codex/prs/bolinfest/PR-2050.md
2025-09-02 15:17:45 -07:00

50 KiB
Raw Blame History

PR #2050: TUI: Show apply patch diff. Stack: [2/2]

Description

Show the diff for apply patch

image

Stack: -> #2050 #2049

Full Diff

diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 173ab64af2..6719cfb7a7 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -46,6 +46,7 @@ use crate::bottom_pane::BottomPane;
 use crate::bottom_pane::BottomPaneParams;
 use crate::bottom_pane::CancellationEvent;
 use crate::bottom_pane::InputResult;
+use crate::common::DEFAULT_WRAP_COLS;
 use crate::history_cell::CommandOutput;
 use crate::history_cell::ExecCell;
 use crate::history_cell::HistoryCell;
@@ -223,7 +224,7 @@ impl ChatWidget<'_> {
             content_buffer: String::new(),
             answer_buffer: String::new(),
             running_commands: HashMap::new(),
-            live_builder: RowBuilder::new(80),
+            live_builder: RowBuilder::new(DEFAULT_WRAP_COLS.into()),
             current_stream: None,
             stream_header_emitted: false,
             live_max_rows: 3,
diff --git a/codex-rs/tui/src/common.rs b/codex-rs/tui/src/common.rs
new file mode 100644
index 0000000000..2c19b58706
--- /dev/null
+++ b/codex-rs/tui/src/common.rs
@@ -0,0 +1 @@
+pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;
diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs
index f536681732..ada84b89d6 100644
--- a/codex-rs/tui/src/diff_render.rs
+++ b/codex-rs/tui/src/diff_render.rs
@@ -1,3 +1,4 @@
+use crossterm::terminal;
 use ratatui::style::Color;
 use ratatui::style::Modifier;
 use ratatui::style::Style;
@@ -6,36 +7,44 @@ use ratatui::text::Span as RtSpan;
 use std::collections::HashMap;
 use std::path::PathBuf;
 
+use crate::common::DEFAULT_WRAP_COLS;
 use codex_core::protocol::FileChange;
 
-struct FileSummary {
-    display_path: String,
-    added: usize,
-    removed: usize,
+use crate::history_cell::PatchEventType;
+
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+// Internal representation for diff line rendering
+enum DiffLineType {
+    Insert,
+    Delete,
+    Context,
 }
 
 pub(crate) fn create_diff_summary(
     title: &str,
-    changes: HashMap<PathBuf, FileChange>,
+    changes: &HashMap<PathBuf, FileChange>,
+    event_type: PatchEventType,
 ) -> Vec<RtLine<'static>> {
-    let mut files: Vec<FileSummary> = Vec::new();
+    struct FileSummary {
+        display_path: String,
+        added: usize,
+        removed: usize,
+    }
 
-    // Count additions/deletions from a unified diff body
     let count_from_unified = |diff: &str| -> (usize, usize) {
         if let Ok(patch) = diffy::Patch::from_str(diff) {
-            let mut adds = 0usize;
-            let mut dels = 0usize;
-            for hunk in patch.hunks() {
-                for line in hunk.lines() {
-                    match line {
-                        diffy::Line::Insert(_) => adds += 1,
-                        diffy::Line::Delete(_) => dels += 1,
-                        _ => {}
-                    }
-                }
-            }
-            (adds, dels)
+            patch
+                .hunks()
+                .iter()
+                .flat_map(|h| h.lines())
+                .fold((0, 0), |(a, d), l| match l {
+                    diffy::Line::Insert(_) => (a + 1, d),
+                    diffy::Line::Delete(_) => (a, d + 1),
+                    _ => (a, d),
+                })
         } else {
+            // Fallback: manual scan to preserve counts even for unparsable diffs
             let mut adds = 0usize;
             let mut dels = 0usize;
             for l in diff.lines() {
@@ -52,29 +61,23 @@ pub(crate) fn create_diff_summary(
         }
     };
 
-    for (path, change) in &changes {
-        use codex_core::protocol::FileChange::*;
+    let mut files: Vec<FileSummary> = Vec::new();
+    for (path, change) in changes.iter() {
         match change {
-            Add { content } => {
-                let added = content.lines().count();
-                files.push(FileSummary {
-                    display_path: path.display().to_string(),
-                    added,
-                    removed: 0,
-                });
-            }
-            Delete => {
-                let removed = std::fs::read_to_string(path)
+            FileChange::Add { content } => files.push(FileSummary {
+                display_path: path.display().to_string(),
+                added: content.lines().count(),
+                removed: 0,
+            }),
+            FileChange::Delete => files.push(FileSummary {
+                display_path: path.display().to_string(),
+                added: 0,
+                removed: std::fs::read_to_string(path)
                     .ok()
                     .map(|s| s.lines().count())
-                    .unwrap_or(0);
-                files.push(FileSummary {
-                    display_path: path.display().to_string(),
-                    added: 0,
-                    removed,
-                });
-            }
-            Update {
+                    .unwrap_or(0),
+            }),
+            FileChange::Update {
                 unified_diff,
                 move_path,
             } => {
@@ -142,11 +145,278 @@ pub(crate) fn create_diff_summary(
         let mut line = RtLine::from(spans);
         let prefix = if idx == 0 { "  ⎿ " } else { "    " };
         line.spans.insert(0, prefix.into());
-        line.spans.iter_mut().for_each(|span| {
-            span.style = span.style.add_modifier(Modifier::DIM);
-        });
+        line.spans
+            .iter_mut()
+            .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
         out.push(line);
     }
 
+    let show_details = matches!(
+        event_type,
+        PatchEventType::ApplyBegin {
+            auto_approved: true
+        } | PatchEventType::ApprovalRequest
+    );
+
+    if show_details {
+        out.extend(render_patch_details(changes));
+    }
+
     out
 }
+
+fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
+    let mut out: Vec<RtLine<'static>> = Vec::new();
+    let term_cols: usize = terminal::size()
+        .map(|(w, _)| w as usize)
+        .unwrap_or(DEFAULT_WRAP_COLS.into());
+
+    for (index, (path, change)) in changes.iter().enumerate() {
+        let is_first_file = index == 0;
+        // Add separator only between files (not at the very start)
+        if !is_first_file {
+            out.push(RtLine::from(vec![
+                RtSpan::raw("    "),
+                RtSpan::styled("...", style_dim()),
+            ]));
+        }
+        match change {
+            FileChange::Add { content } => {
+                for (i, raw) in content.lines().enumerate() {
+                    let ln = i + 1;
+                    out.extend(push_wrapped_diff_line(
+                        ln,
+                        DiffLineType::Insert,
+                        raw,
+                        term_cols,
+                    ));
+                }
+            }
+            FileChange::Delete => {
+                let original = std::fs::read_to_string(path).unwrap_or_default();
+                for (i, raw) in original.lines().enumerate() {
+                    let ln = i + 1;
+                    out.extend(push_wrapped_diff_line(
+                        ln,
+                        DiffLineType::Delete,
+                        raw,
+                        term_cols,
+                    ));
+                }
+            }
+            FileChange::Update {
+                unified_diff,
+                move_path: _,
+            } => {
+                if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
+                    for h in patch.hunks() {
+                        let mut old_ln = h.old_range().start();
+                        let mut new_ln = h.new_range().start();
+                        for l in h.lines() {
+                            match l {
+                                diffy::Line::Insert(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    out.extend(push_wrapped_diff_line(
+                                        new_ln,
+                                        DiffLineType::Insert,
+                                        s,
+                                        term_cols,
+                                    ));
+                                    new_ln += 1;
+                                }
+                                diffy::Line::Delete(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    out.extend(push_wrapped_diff_line(
+                                        old_ln,
+                                        DiffLineType::Delete,
+                                        s,
+                                        term_cols,
+                                    ));
+                                    old_ln += 1;
+                                }
+                                diffy::Line::Context(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    out.extend(push_wrapped_diff_line(
+                                        new_ln,
+                                        DiffLineType::Context,
+                                        s,
+                                        term_cols,
+                                    ));
+                                    old_ln += 1;
+                                    new_ln += 1;
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        out.push(RtLine::from(RtSpan::raw("")));
+    }
+
+    out
+}
+
+fn push_wrapped_diff_line(
+    line_number: usize,
+    kind: DiffLineType,
+    text: &str,
+    term_cols: usize,
+) -> Vec<RtLine<'static>> {
+    let indent = "    ";
+    let ln_str = line_number.to_string();
+    let mut remaining_text: &str = text;
+
+    // Reserve a fixed number of spaces after the line number so that content starts
+    // at a consistent column. The sign ("+"/"-") is rendered as part of the content
+    // with the same background as the edit, not as a separate dimmed column.
+    let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
+    let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+    let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+
+    let mut first = true;
+    let (sign_opt, bg_style) = match kind {
+        DiffLineType::Insert => (Some('+'), Some(style_add())),
+        DiffLineType::Delete => (Some('-'), Some(style_del())),
+        DiffLineType::Context => (None, None),
+    };
+    let mut lines: Vec<RtLine<'static>> = Vec::new();
+    while !remaining_text.is_empty() {
+        let prefix_cols = if first {
+            first_prefix_cols
+        } else {
+            cont_prefix_cols
+        };
+        // Fit the content for the current terminal row:
+        // compute how many columns are available after the prefix, then split
+        // at a UTF-8 character boundary so this row's chunk fits exactly.
+        let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1);
+        let split_at_byte_index = remaining_text
+            .char_indices()
+            .nth(available_content_cols)
+            .map(|(i, _)| i)
+            .unwrap_or_else(|| remaining_text.len());
+        let (chunk, rest) = remaining_text.split_at(split_at_byte_index);
+        remaining_text = rest;
+
+        if first {
+            let mut spans: Vec<RtSpan<'static>> = Vec::new();
+            spans.push(RtSpan::raw(indent));
+            spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
+            spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
+
+            // Prefix the content with the sign if it is an insertion or deletion, and color
+            // the sign with the same background as the edited text.
+            let display_chunk = match sign_opt {
+                Some(sign_char) => format!("{sign_char}{chunk}"),
+                None => chunk.to_string(),
+            };
+
+            let content_span = match bg_style {
+                Some(style) => RtSpan::styled(display_chunk, style),
+                None => RtSpan::raw(display_chunk),
+            };
+            spans.push(content_span);
+            lines.push(RtLine::from(spans));
+            first = false;
+        } else {
+            let hang_prefix = format!(
+                "{indent}{}{}",
+                " ".repeat(ln_str.len()),
+                " ".repeat(gap_after_ln)
+            );
+            let content_span = match bg_style {
+                Some(style) => RtSpan::styled(chunk.to_string(), style),
+                None => RtSpan::raw(chunk.to_string()),
+            };
+            lines.push(RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]));
+        }
+    }
+    lines
+}
+
+fn style_dim() -> Style {
+    Style::default().add_modifier(Modifier::DIM)
+}
+
+fn style_add() -> Style {
+    Style::default().bg(Color::Green)
+}
+
+fn style_del() -> Style {
+    Style::default().bg(Color::Red)
+}
+
+#[allow(clippy::expect_used)]
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::history_cell::HistoryCell;
+    use crate::text_block::TextBlock;
+    use insta::assert_snapshot;
+    use ratatui::Terminal;
+    use ratatui::backend::TestBackend;
+
+    fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
+        let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
+        let cell = HistoryCell::PendingPatch {
+            view: TextBlock::new(lines),
+        };
+        terminal
+            .draw(|f| f.render_widget_ref(&cell, f.area()))
+            .expect("draw");
+        assert_snapshot!(name, terminal.backend());
+    }
+
+    #[test]
+    fn ui_snapshot_add_details() {
+        let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
+        changes.insert(
+            PathBuf::from("README.md"),
+            FileChange::Add {
+                content: "first line\nsecond line\n".to_string(),
+            },
+        );
+
+        let lines =
+            create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
+
+        snapshot_lines("add_details", lines, 80, 10);
+    }
+
+    #[test]
+    fn ui_snapshot_update_details_with_rename() {
+        let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
+
+        let original = "line one\nline two\nline three\n";
+        let modified = "line one\nline two changed\nline three\n";
+        let patch = diffy::create_patch(original, modified).to_string();
+
+        changes.insert(
+            PathBuf::from("src/lib.rs"),
+            FileChange::Update {
+                unified_diff: patch,
+                move_path: Some(PathBuf::from("src/lib_new.rs")),
+            },
+        );
+
+        let lines =
+            create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
+
+        snapshot_lines("update_details_with_rename", lines, 80, 12);
+    }
+
+    #[test]
+    fn ui_snapshot_wrap_behavior_insert() {
+        // Narrow width to force wrapping within our diff line rendering
+        let long_line = "this is a very long line that should wrap across multiple terminal columns and continue";
+
+        // Call the wrapping function directly so we can precisely control the width
+        let lines =
+            push_wrapped_diff_line(1, DiffLineType::Insert, long_line, DEFAULT_WRAP_COLS.into());
+
+        // Render into a small terminal to capture the visual layout
+        snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8);
+    }
+}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 95ed8efac2..b49e59972c 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -449,7 +449,7 @@ impl HistoryCell {
     }
 
     pub(crate) fn new_completed_mcp_tool_call(
-        num_cols: u16,
+        num_cols: usize,
         invocation: McpInvocation,
         duration: Duration,
         success: bool,
@@ -487,7 +487,7 @@ impl HistoryCell {
                                 format_and_truncate_tool_result(
                                     &text.text,
                                     TOOL_CALL_MAX_LINES,
-                                    num_cols as usize,
+                                    num_cols,
                                 )
                             }
                             mcp_types::ContentBlock::ImageContent(_) => {
@@ -848,7 +848,9 @@ impl HistoryCell {
             }
         };
 
-        let lines: Vec<Line<'static>> = create_diff_summary(title, changes);
+        let mut lines: Vec<Line<'static>> = create_diff_summary(title, &changes, event_type);
+
+        lines.push(Line::from(""));
 
         HistoryCell::PendingPatch {
             view: TextBlock::new(lines),
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 27c850ca61..8f64f3247d 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -30,6 +30,7 @@ mod chatwidget;
 mod citation_regex;
 mod cli;
 mod colors;
+mod common;
 pub mod custom_terminal;
 mod diff_render;
 mod exec_command;
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap
new file mode 100644
index 0000000000..06fc8a68e8
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap
@@ -0,0 +1,14 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"proposed patch to 1 file (+2 -0)                                                "
+"  ⎿ README.md (+2 -0)                                                           "
+"    1     +first line                                                           "
+"    2     +second line                                                          "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap
new file mode 100644
index 0000000000..0eebe09d73
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap
@@ -0,0 +1,16 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"proposed patch to 1 file (+1 -1)                                                "
+"  ⎿ src/lib.rs → src/lib_new.rs (+1 -1)                                         "
+"    1     line one                                                              "
+"    2     -line two                                                             "
+"    2     +line two changed                                                     "
+"    3     line three                                                            "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
+"                                                                                "
diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap
new file mode 100644
index 0000000000..641552d5a1
--- /dev/null
+++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap
@@ -0,0 +1,12 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"    1     +this is a very long line that should wrap across multiple terminal col         "
+"          umns and continue                                                               "
+"                                                                                          "
+"                                                                                          "
+"                                                                                          "
+"                                                                                          "
+"                                                                                          "
+"                                                                                          "

Review Comments

codex-rs/tui/src/diff_render.rs

@@ -0,0 +1,354 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+const DEFAULT_WRAP_COLS: usize = 96;

Curious why 96. I see 80 hardcoded for a number of things in chatwidget.rs (admittedly, it's not pulled out as a const there, but should be).

@@ -0,0 +1,354 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+const DEFAULT_WRAP_COLS: usize = 96;
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+pub(crate) fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
+    let mut out: Vec<RtLine<'static>> = Vec::new();
+    let term_cols: usize = terminal::size()
+        .map(|(w, _)| w as usize)
+        .unwrap_or(DEFAULT_WRAP_COLS);
+
+    let mut is_first_file = true;
+    for (path, change) in changes.iter() {

Can also do enumerate() and then you have an extra index param and then inside the loop can do let is_first_file = index == 0;

@@ -0,0 +1,354 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+const DEFAULT_WRAP_COLS: usize = 96;
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+pub(crate) fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
+    let mut out: Vec<RtLine<'static>> = Vec::new();
+    let term_cols: usize = terminal::size()
+        .map(|(w, _)| w as usize)
+        .unwrap_or(DEFAULT_WRAP_COLS);
+
+    let mut is_first_file = true;
+    for (path, change) in changes.iter() {
+        // Add separator only between files (not at the very start)
+        if !is_first_file {
+            out.push(RtLine::from(vec![
+                RtSpan::raw("    "),
+                RtSpan::styled("...", style_dim()),
+            ]));
+        }
+        match change {
+            FileChange::Add { content } => {
+                let ln_width = usize::max(2, digits_len(content.lines().count()));
+                for (i, raw) in content.lines().enumerate() {
+                    let ln = i + 1;
+                    push_wrapped_diff_line(
+                        &mut out,
+                        ln,
+                        '+',
+                        raw,
+                        Some(style_add()),
+                        term_cols,
+                        ln_width,
+                    );
+                }
+            }
+            FileChange::Delete => {
+                let original = std::fs::read_to_string(path).unwrap_or_default();
+                let ln_width = usize::max(2, digits_len(original.lines().count()));
+                for (i, raw) in original.lines().enumerate() {
+                    let ln = i + 1;
+                    push_wrapped_diff_line(
+                        &mut out,
+                        ln,
+                        '-',
+                        raw,
+                        Some(style_del()),
+                        term_cols,
+                        ln_width,
+                    );
+                }
+            }
+            FileChange::Update {
+                unified_diff,
+                move_path: _,
+            } => {
+                if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
+                    for h in patch.hunks() {
+                        // determine a reasonable ln field width for this hunk
+                        let old_end = h.old_range().end();
+                        let new_end = h.new_range().end();
+                        let ln_width = usize::max(2, digits_len(old_end.max(new_end)));
+
+                        let mut old_ln = h.old_range().start();
+                        let mut new_ln = h.new_range().start();
+                        for l in h.lines() {
+                            match l {
+                                diffy::Line::Insert(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out,
+                                        new_ln,
+                                        '+',
+                                        s,
+                                        Some(style_add()),
+                                        term_cols,
+                                        ln_width,
+                                    );
+                                    new_ln += 1;
+                                }
+                                diffy::Line::Delete(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out,
+                                        old_ln,
+                                        '-',
+                                        s,
+                                        Some(style_del()),
+                                        term_cols,
+                                        ln_width,
+                                    );
+                                    old_ln += 1;
+                                }
+                                diffy::Line::Context(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out, new_ln, ' ', s, None, term_cols, ln_width,
+                                    );
+                                    old_ln += 1;
+                                    new_ln += 1;
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        out.push(RtLine::from(RtSpan::raw("")));
+        is_first_file = false;
+    }
+
+    out
+}
+
+pub(crate) fn create_diff_summary(
+    title: &str,
+    changes: &HashMap<PathBuf, FileChange>,
+) -> Vec<RtLine<'static>> {
+    struct FileSummary {
+        display_path: String,
+        added: usize,
+        removed: usize,
+    }
+
+    let count_from_unified = |diff: &str| -> (usize, usize) {
+        if let Ok(patch) = diffy::Patch::from_str(diff) {
+            patch
+                .hunks()
+                .iter()
+                .flat_map(|h| h.lines())
+                .fold((0, 0), |(a, d), l| match l {
+                    diffy::Line::Insert(_) => (a + 1, d),
+                    diffy::Line::Delete(_) => (a, d + 1),
+                    _ => (a, d),
+                })
+        } else {
+            // Fallback: manual scan to preserve counts even for unparseable diffs
+            let mut adds = 0usize;
+            let mut dels = 0usize;
+            for l in diff.lines() {
+                if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
+                    continue;
+                }
+                match l.as_bytes().first() {
+                    Some(b'+') => adds += 1,
+                    Some(b'-') => dels += 1,
+                    _ => {}
+                }
+            }
+            (adds, dels)
+        }
+    };
+
+    let mut files: Vec<FileSummary> = Vec::new();
+    for (path, change) in changes.iter() {
+        match change {
+            FileChange::Add { content } => files.push(FileSummary {
+                display_path: path.display().to_string(),
+                added: content.lines().count(),
+                removed: 0,
+            }),
+            FileChange::Delete => files.push(FileSummary {
+                display_path: path.display().to_string(),
+                added: 0,
+                removed: std::fs::read_to_string(path)
+                    .ok()
+                    .map(|s| s.lines().count())
+                    .unwrap_or(0),
+            }),
+            FileChange::Update {
+                unified_diff,
+                move_path,
+            } => {
+                let (added, removed) = count_from_unified(unified_diff);
+                let display_path = if let Some(new_path) = move_path {
+                    format!("{} → {}", path.display(), new_path.display())
+                } else {
+                    path.display().to_string()
+                };
+                files.push(FileSummary {
+                    display_path,
+                    added,
+                    removed,
+                });
+            }
+        }
+    }
+
+    let file_count = files.len();
+    let total_added: usize = files.iter().map(|f| f.added).sum();
+    let total_removed: usize = files.iter().map(|f| f.removed).sum();
+    let noun = if file_count == 1 { "file" } else { "files" };
+
+    let mut out: Vec<RtLine<'static>> = Vec::new();
+
+    // Header
+    let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
+    header_spans.push(RtSpan::styled(
+        title.to_owned(),
+        Style::default()
+            .fg(Color::Magenta)
+            .add_modifier(Modifier::BOLD),
+    ));
+    header_spans.push(RtSpan::raw(" to "));
+    header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
+    header_spans.push(RtSpan::raw("("));
+    header_spans.push(RtSpan::styled(
+        format!("+{total_added}"),
+        Style::default().fg(Color::Green),
+    ));
+    header_spans.push(RtSpan::raw(" "));
+    header_spans.push(RtSpan::styled(
+        format!("-{total_removed}"),
+        Style::default().fg(Color::Red),
+    ));
+    header_spans.push(RtSpan::raw(")"));
+    out.push(RtLine::from(header_spans));
+
+    // Dimmed per-file lines with prefix
+    for (idx, f) in files.iter().enumerate() {
+        let mut spans: Vec<RtSpan<'static>> = Vec::new();
+        spans.push(RtSpan::raw(f.display_path.clone()));
+        spans.push(RtSpan::raw(" ("));
+        spans.push(RtSpan::styled(
+            format!("+{}", f.added),
+            Style::default().fg(Color::Green),
+        ));
+        spans.push(RtSpan::raw(" "));
+        spans.push(RtSpan::styled(
+            format!("-{}", f.removed),
+            Style::default().fg(Color::Red),
+        ));
+        spans.push(RtSpan::raw(")"));
+
+        let mut line = RtLine::from(spans);
+        let prefix = if idx == 0 { "  ⎿ " } else { "    " };
+        line.spans.insert(0, prefix.into());
+        line.spans
+            .iter_mut()
+            .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
+        out.push(line);
+    }
+
+    out
+}
+
+fn push_wrapped_diff_line(
+    out: &mut Vec<RtLine<'static>>,
+    line_number: usize,
+    sign: char,
+    text: &str,
+    bg_style: Option<Style>,
+    term_cols: usize,
+    _ln_width: usize,
+) {
+    let indent = "    ";
+    let ln_str = line_number.to_string();
+    let mut remaining: &str = text;
+
+    // Reserve a fixed number of spaces after the line number so that content starts
+    // at a consistent column. The sign ("+"/"-") is rendered as part of the content
+    // with the same background as the edit, not as a separate dimmed column.
+    let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
+    let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+    let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+
+    let mut first = true;
+    while !remaining.is_empty() {
+        let prefix_cols = if first {
+            first_prefix_cols
+        } else {
+            cont_prefix_cols
+        };
+        let available = term_cols.saturating_sub(prefix_cols).max(1);
+        let take = remaining
+            .char_indices()
+            .nth(available)
+            .map(|(i, _)| i)
+            .unwrap_or_else(|| remaining.len());
+        let (chunk, rest) = remaining.split_at(take);
+        remaining = rest;
+
+        if first {
+            let mut spans: Vec<RtSpan<'static>> = Vec::new();
+            spans.push(RtSpan::raw(indent));
+            spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
+            spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
+
+            // Prefix the content with the sign if it is an insertion or deletion, and color
+            // the sign with the same background as the edited text.
+            let display_chunk = match sign {
+                '+' | '-' => {
+                    let mut s = String::with_capacity(1 + chunk.len());
+                    s.push(sign);
+                    s.push_str(chunk);
+                    s
+                }
+                _ => chunk.to_string(),
+            };
+
+            let content_span = match bg_style {
+                Some(style) => RtSpan::styled(display_chunk, style),
+                None => RtSpan::raw(display_chunk),
+            };
+            spans.push(content_span);
+            out.push(RtLine::from(spans));
+            first = false;
+        } else {
+            let hang_prefix = format!(
+                "{indent}{}{}",
+                " ".repeat(ln_str.len()),
+                " ".repeat(gap_after_ln)
+            );
+            let content_span = match bg_style {
+                Some(style) => RtSpan::styled(chunk.to_string(), style),
+                None => RtSpan::raw(chunk.to_string()),
+            };
+            out.push(RtLine::from(vec![RtSpan::raw(hang_prefix), content_span]));
+        }
+    }
+}
+
+fn style_dim() -> Style {
+    Style::default().add_modifier(Modifier::DIM)
+}
+
+fn style_add() -> Style {
+    Style::default().bg(Color::Green)
+}
+
+fn style_del() -> Style {
+    Style::default().bg(Color::Red)
+}
+
+#[inline]
+fn digits_len(n: usize) -> usize {

I asked GPT-5 and it recommened:

fn decimal_len(n: usize) -> usize {
    if n == 0 { 1 } else { n.ilog10() as usize + 1 }
}

claiming:

That last one is both concise and efficient—internally it uses hardware intrinsics to count leading zeros and computes log10 without looping. On modern CPUs, thats about as fast as it gets.

@@ -0,0 +1,354 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+const DEFAULT_WRAP_COLS: usize = 96;
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+pub(crate) fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {

Can we get some sort of test for this? Is .snap the way to go?

Maybe we need to introduce macros to make it easier to express the expected value?

@@ -0,0 +1,354 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codex_core::protocol::FileChange;
+
+const DEFAULT_WRAP_COLS: usize = 96;
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+pub(crate) fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {

@aibrahim-oai I thought you brought the first .snap file to the repo!

https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap

@@ -142,11 +145,259 @@ pub(crate) fn create_diff_summary(
         let mut line = RtLine::from(spans);
         let prefix = if idx == 0 { "  ⎿ " } else { "    " };
         line.spans.insert(0, prefix.into());
-        line.spans.iter_mut().for_each(|span| {
-            span.style = span.style.add_modifier(Modifier::DIM);
-        });
+        line.spans
+            .iter_mut()
+            .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
         out.push(line);
     }
 
+    let show_details = matches!(
+        event_type,
+        PatchEventType::ApplyBegin {
+            auto_approved: true
+        } | PatchEventType::ApprovalRequest
+    );
+
+    if show_details {
+        out.extend(render_patch_details(changes));
+    }
+
     out
 }
+
+fn render_patch_details(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
+    let mut out: Vec<RtLine<'static>> = Vec::new();
+    let term_cols: usize = terminal::size()
+        .map(|(w, _)| w as usize)
+        .unwrap_or(DEFAULT_WRAP_COLS);
+
+    for (index, (path, change)) in changes.iter().enumerate() {
+        let is_first_file = index == 0;
+        // Add separator only between files (not at the very start)
+        if !is_first_file {
+            out.push(RtLine::from(vec![
+                RtSpan::raw("    "),
+                RtSpan::styled("...", style_dim()),
+            ]));
+        }
+        match change {
+            FileChange::Add { content } => {
+                for (i, raw) in content.lines().enumerate() {
+                    let ln = i + 1;
+                    push_wrapped_diff_line(&mut out, ln, DiffLineType::Insert, raw, term_cols);
+                }
+            }
+            FileChange::Delete => {
+                let original = std::fs::read_to_string(path).unwrap_or_default();
+                for (i, raw) in original.lines().enumerate() {
+                    let ln = i + 1;
+                    push_wrapped_diff_line(&mut out, ln, DiffLineType::Delete, raw, term_cols);
+                }
+            }
+            FileChange::Update {
+                unified_diff,
+                move_path: _,
+            } => {
+                if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
+                    for h in patch.hunks() {
+                        let mut old_ln = h.old_range().start();
+                        let mut new_ln = h.new_range().start();
+                        for l in h.lines() {
+                            match l {
+                                diffy::Line::Insert(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out,
+                                        new_ln,
+                                        DiffLineType::Insert,
+                                        s,
+                                        term_cols,
+                                    );
+                                    new_ln += 1;
+                                }
+                                diffy::Line::Delete(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out,
+                                        old_ln,
+                                        DiffLineType::Delete,
+                                        s,
+                                        term_cols,
+                                    );
+                                    old_ln += 1;
+                                }
+                                diffy::Line::Context(text) => {
+                                    let s = text.trim_end_matches('\n');
+                                    push_wrapped_diff_line(
+                                        &mut out,
+                                        new_ln,
+                                        DiffLineType::Context,
+                                        s,
+                                        term_cols,
+                                    );
+                                    old_ln += 1;
+                                    new_ln += 1;
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        out.push(RtLine::from(RtSpan::raw("")));
+    }
+
+    out
+}
+
+fn push_wrapped_diff_line(
+    out: &mut Vec<RtLine<'static>>,
+    line_number: usize,
+    kind: DiffLineType,
+    text: &str,
+    term_cols: usize,
+) {
+    let indent = "    ";
+    let ln_str = line_number.to_string();
+    let mut remaining: &str = text;
+
+    // Reserve a fixed number of spaces after the line number so that content starts
+    // at a consistent column. The sign ("+"/"-") is rendered as part of the content
+    // with the same background as the edit, not as a separate dimmed column.
+    let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
+    let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+    let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+
+    let mut first = true;
+    let (sign_opt, bg_style) = match kind {
+        DiffLineType::Insert => (Some('+'), Some(style_add())),
+        DiffLineType::Delete => (Some('-'), Some(style_del())),
+        DiffLineType::Context => (None, None),
+    };
+    while !remaining.is_empty() {
+        let prefix_cols = if first {
+            first_prefix_cols
+        } else {
+            cont_prefix_cols
+        };
+        let available = term_cols.saturating_sub(prefix_cols).max(1);
+        let take = remaining
+            .char_indices()
+            .nth(available)
+            .map(|(i, _)| i)
+            .unwrap_or_else(|| remaining.len());
+        let (chunk, rest) = remaining.split_at(take);
+        remaining = rest;
+
+        if first {
+            let mut spans: Vec<RtSpan<'static>> = Vec::new();
+            spans.push(RtSpan::raw(indent));
+            spans.push(RtSpan::styled(ln_str.clone(), style_dim()));
+            spans.push(RtSpan::raw(" ".repeat(gap_after_ln)));
+
+            // Prefix the content with the sign if it is an insertion or deletion, and color
+            // the sign with the same background as the edited text.
+            let display_chunk = match sign_opt {
+                Some(sign_char) => {
+                    let mut s = String::with_capacity(1 + chunk.len());
+                    s.push(sign_char);
+                    s.push_str(chunk);
+                    s

FYI, probably more efficient as format!()?

codex-rs/tui/src/history_cell.rs

@@ -446,7 +446,7 @@ impl HistoryCell {
     }
 
     pub(crate) fn new_completed_mcp_tool_call(
-        num_cols: u16,

I think this was u16 because a number of Ratatui APIs expected u16, though I don't feel strongly about this.

codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__add_details.snap

@@ -0,0 +1,14 @@
+---
+source: tui/src/diff_render.rs
+expression: terminal.backend()
+---
+"proposed patch to 1 file (+2 -0)                                                "

Hmm, so one thing I did not take into account is that this loses style information. i.e., this test doesn't confirm where red and green backgrounds are applied.

I asked chat and it suggested comparing with JSON instead:

{
  "width": 16,
  "height": 3,
  "lines": [
    [
      { "text": "Hello world", "fg": "Red", "bg": null, "mods": ["bold"] },
      { "text": "!", "fg": "Cyan", "bg": null, "mods": [] },
      { "text": "     ", "fg": null, "bg": null, "mods": [] }
    ],
    [
      { "text": "", "fg": null, "bg": null, "mods": [] }
    ],
    [
      { "text": "", "fg": null, "bg": null, "mods": [] }
    ]
  ]
}

And using a helper like this to generate the JSON:

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::{Paragraph, Widget},
};
use serde::Serialize;

#[derive(Serialize)]
struct StyledSpan {
    text: String,
    fg: Option<String>,
    bg: Option<String>,
    mods: Vec<&'static str>,
}

#[derive(Serialize)]
struct Snapshot {
    width: u16,
    height: u16,
    lines: Vec<Vec<StyledSpan>>,
}

fn color_str(c: Color) -> Option<String> {
    use Color::*;
    match c {
        Reset => None,
        Black => Some("Black".into()),
        Red => Some("Red".into()),
        Green => Some("Green".into()),
        Yellow => Some("Yellow".into()),
        Blue => Some("Blue".into()),
        Magenta => Some("Magenta".into()),
        Cyan => Some("Cyan".into()),
        Gray => Some("Gray".into()),
        DarkGray => Some("DarkGray".into()),
        LightRed => Some("LightRed".into()),
        LightGreen => Some("LightGreen".into()),
        LightYellow => Some("LightYellow".into()),
        LightBlue => Some("LightBlue".into()),
        LightMagenta => Some("LightMagenta".into()),
        LightCyan => Some("LightCyan".into()),
        White => Some("White".into()),
        Rgb(r, g, b) => Some(format!("rgb({},{},{})", r, g, b)),
        Indexed(i) => Some(format!("idx({})", i)),
    }
}

fn mods_vec(m: Modifier) -> Vec<&'static str> {
    let all = [
        (Modifier::BOLD, "bold"),
        (Modifier::ITALIC, "italic"),
        (Modifier::UNDERLINED, "underline"),
        (Modifier::REVERSED, "reverse"),
        (Modifier::DIM, "dim"),
        (Modifier::CROSSED_OUT, "crossed"),
        (Modifier::SLOW_BLINK, "slow_blink"),
        (Modifier::RAPID_BLINK, "rapid_blink"),
        (Modifier::HIDDEN, "hidden"),
    ];
    all.iter().filter_map(|(flag, name)| if m.contains(*flag) { Some(*name) } else { None }).collect()
}

/// Compress each row of the Buffer into runs of same-style cells.
fn buffer_to_snapshot(buf: &Buffer) -> Snapshot {
    let w = buf.area.width;
    let h = buf.area.height;
    let mut lines: Vec<Vec<StyledSpan>> = Vec::with_capacity(h as usize);

    for y in 0..h {
        let mut spans: Vec<StyledSpan> = Vec::new();
        let mut cur_style: Option<Style> = None;
        let mut cur_text = String::new();

        for x in 0..w {
            let cell = &buf[(x, y)];
            let s = cell.style;

            if cur_style.map_or(true, |cs| cs != s) {
                // flush previous run
                if !cur_text.is_empty() {
                    let st = cur_style.unwrap();
                    spans.push(StyledSpan {
                        text: cur_text.clone(),
                        fg: color_str(st.fg),
                        bg: color_str(st.bg),
                        mods: mods_vec(st.add_modifier),
                    });
                    cur_text.clear();
                }
                cur_style = Some(s);
            }
            cur_text.push_str(&cell.symbol);
        }

        // flush last run on the line
        if let Some(st) = cur_style {
            spans.push(StyledSpan {
                text: cur_text,
                fg: color_str(st.fg),
                bg: color_str(st.bg),
                mods: mods_vec(st.add_modifier),
            });
        }

        // (Optional) trim trailing spaces-only final span for prettier diffs:
        if let Some(last) = spans.last_mut() {
            if last.text.chars().all(|c| c == ' ') {
                // if you want exact layout fidelity, comment this out
                while last.text.ends_with(' ') {
                    last.text.pop();
                }
            }
        }

        lines.push(spans);
    }

    Snapshot { width: w, height: h, lines }
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_json_snapshot;

    #[test]
    fn paragraph_as_styled_spans() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 16, 3));
        let widget = Paragraph::new("Hello world")
            .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
        widget.render(buf.area, &mut buf);

        // Add a differently-styled bit so you can see span splitting:
        let mut cell = &mut buf[(11, 0)];
        cell.set_symbol("!");
        cell.set_style(Style::default().fg(Color::Cyan));

        let snap = buffer_to_snapshot(&buf);
        assert_json_snapshot!("paragraph_styled_spans", &snap);
    }
}

I think this sort of effort is outside the scope of this PR, but would be generally helpful for someone to build in a follow up.

/cc @easong-openai who I believe has also been thinking about how to have better UI tests.