dynamic width for line numbers in diffs (#4664)

instead of always reserving 6 spaces for the line number and gutter, we
now dynamically adjust to the width of the longest number.

<img width="871" height="616" alt="Screenshot 2025-10-03 at 8 21 00 AM"
src="https://github.com/user-attachments/assets/5f18eae6-7c85-48fc-9a41-31978ae71a62"
/>
<img width="871" height="616" alt="Screenshot 2025-10-03 at 8 21 21 AM"
src="https://github.com/user-attachments/assets/9009297d-7690-42b9-ae42-9566b3fea86c"
/>
<img width="871" height="616" alt="Screenshot 2025-10-03 at 8 21 57 AM"
src="https://github.com/user-attachments/assets/669096fd-dddc-407e-bae8-d0c6626fa0bc"
/>
This commit is contained in:
Jeremy Rose
2025-10-07 11:32:07 -07:00
committed by GitHub
parent 12fd2b4160
commit 75176dae70
18 changed files with 182 additions and 96 deletions

View File

@@ -315,15 +315,16 @@ impl From<ApprovalRequest> for ApprovalRequestState {
changes,
} => {
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
header.push(DiffSummary::new(changes, cwd).into());
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(Box::new(Line::from("")));
header.push(Box::new(
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()]))
.wrap(Wrap { trim: false }),
));
header.push(Box::new(Line::from("")));
}
header.push(DiffSummary::new(changes, cwd).into());
Self {
variant: ApprovalVariant::ApplyPatch { id },
header: Box::new(ColumnRenderable::new(header)),

View File

@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines)
---
• Added foo.txt (+1 -0)
1 +hello
1 +hello

View File

@@ -4,12 +4,12 @@ expression: terminal.backend().vt100().screen().contents()
---
Would you like to make the following edits?
Reason: The model wants to apply changes
README.md (+2 -0)
1 +hello
2 +world
The model wants to apply changes
1 +hello
2 +world
1. Yes, proceed
2. No, and tell Codex what to do differently esc

View File

@@ -13,13 +13,14 @@ use std::path::Path;
use std::path::PathBuf;
use crate::exec_command::relativize_to_home;
use crate::render::Insets;
use crate::render::line_utils::prefix_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange;
const SPACES_AFTER_LINE_NUMBER: usize = 6;
// Internal representation for diff line rendering
enum DiffLineType {
Insert,
@@ -65,7 +66,10 @@ impl From<DiffSummary> for Box<dyn Renderable> {
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(row.change));
rows.push(Box::new(InsetRenderable::new(
Box::new(row.change),
Insets::tlbr(0, 2, 0, 0),
)));
}
Box::new(ColumnRenderable::new(rows))
@@ -181,7 +185,9 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
out.push(RtLine::from(header));
}
render_change(&r.change, &mut out, wrap_cols);
let mut lines = vec![];
render_change(&r.change, &mut lines, wrap_cols - 4);
out.extend(prefix_lines(lines, " ".into(), " ".into()));
}
out
@@ -190,31 +196,60 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
match change {
FileChange::Add { content } => {
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
));
}
}
FileChange::Delete { content } => {
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
));
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut max_line_number = 0;
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(_) => {
max_line_number = max_line_number.max(new_ln);
new_ln += 1;
}
diffy::Line::Delete(_) => {
max_line_number = max_line_number.max(old_ln);
old_ln += 1;
}
diffy::Line::Context(_) => {
max_line_number = max_line_number.max(new_ln);
old_ln += 1;
new_ln += 1;
}
}
}
}
let line_number_width = line_number_width(max_line_number);
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
let spacer_span = RtSpan::styled(spacer, style_gutter());
out.push(RtLine::from(vec![spacer_span, "".dim()]));
}
is_first_hunk = false;
@@ -229,6 +264,7 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
DiffLineType::Insert,
s,
width,
line_number_width,
));
new_ln += 1;
}
@@ -239,6 +275,7 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
DiffLineType::Delete,
s,
width,
line_number_width,
));
old_ln += 1;
}
@@ -249,6 +286,7 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
DiffLineType::Context,
s,
width,
line_number_width,
));
old_ln += 1;
new_ln += 1;
@@ -298,17 +336,15 @@ fn push_wrapped_diff_line(
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: 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. Content includes a 1-character diff sign prefix
// ("+"/"-" for inserts/deletes, or a space for context lines) so alignment
// stays consistent across all diff lines.
let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
let prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
// Reserve a fixed number of spaces (equal to the widest line number plus a
// trailing spacer) so the sign column stays aligned across the diff block.
let gutter_width = line_number_width.max(1);
let prefix_cols = gutter_width + 1;
let mut first = true;
let (sign_char, line_style) = match kind {
@@ -332,8 +368,8 @@ fn push_wrapped_diff_line(
remaining_text = rest;
if first {
// Build gutter (indent + line number + spacing) as a dimmed span
let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln));
// Build gutter (right-aligned line number plus spacer) as a dimmed span
let gutter = format!("{ln_str:>gutter_width$} ");
// Content with a sign ('+'/'-'/' ') styled per diff kind
let content = format!("{sign_char}{chunk}");
lines.push(RtLine::from(vec![
@@ -343,7 +379,7 @@ fn push_wrapped_diff_line(
first = false;
} else {
// Continuation lines keep a space for the sign column so content aligns
let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln));
let gutter = format!("{:gutter_width$} ", "");
lines.push(RtLine::from(vec![
RtSpan::styled(gutter, style_gutter()),
RtSpan::styled(chunk.to_string(), line_style),
@@ -356,6 +392,14 @@ fn push_wrapped_diff_line(
lines
}
fn line_number_width(max_line_number: usize) -> usize {
if max_line_number == 0 {
1
} else {
max_line_number.to_string().len()
}
}
fn style_gutter() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
@@ -421,7 +465,8 @@ mod tests {
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, 80);
let lines =
push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1));
// Render into a small terminal to capture the visual layout
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
@@ -442,11 +487,9 @@ mod tests {
},
);
for name in ["apply_update_block", "apply_update_block_manual"] {
let lines = diff_summary_for_tests(&changes);
let lines = diff_summary_for_tests(&changes);
snapshot_lines(name, lines, 80, 12);
}
snapshot_lines("apply_update_block", lines, 80, 12);
}
#[test]
@@ -573,14 +616,37 @@ mod tests {
},
);
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
// Drop the combined header for this text-only snapshot
if !lines.is_empty() {
lines.remove(0);
}
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() {
let original = (1..=110).map(|i| format!("line {i}\n")).collect::<String>();
let modified = (1..=110)
.map(|i| {
if i == 100 {
format!("line {i} changed\n")
} else {
format!("line {i}\n")
}
})
.collect::<String>();
let patch = diffy::create_patch(&original, &modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("hundreds.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_relativizes_path() {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));

View File

@@ -4,6 +4,7 @@ pub mod highlight;
pub mod line_utils;
pub mod renderable;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Insets {
pub left: u16,
pub top: u16,

View File

@@ -4,6 +4,9 @@ use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt as _;
pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
@@ -100,3 +103,26 @@ impl ColumnRenderable {
}
}
}
pub struct InsetRenderable {
child: Box<dyn Renderable>,
insets: Insets,
}
impl Renderable for InsetRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.child.render(area.inset(self.insets), buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.child
.desired_height(width - self.insets.left - self.insets.right)
+ self.insets.top
+ self.insets.bottom
}
}
impl InsetRenderable {
pub fn new(child: Box<dyn Renderable>, insets: Insets) -> Self {
Self { child, insets }
}
}

View File

@@ -3,8 +3,8 @@ source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Added new_file.txt (+2 -0) "
" 1 +alpha "
" 2 +beta "
" 1 +alpha "
" 2 +beta "
" "
" "
" "

View File

@@ -3,9 +3,9 @@ source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Deleted tmp_delete_example.txt (+0 -3) "
" 1 -first "
" 2 -second "
" 3 -third "
" 1 -first "
" 2 -second "
" 3 -third "
" "
" "
" "

View File

@@ -4,11 +4,11 @@ expression: terminal.backend()
---
"• Edited 2 files (+2 -1) "
" └ a.txt (+1 -1) "
" 1 -one "
" 1 +one changed "
" 1 -one "
" 1 +one changed "
" "
" └ b.txt (+1 -0) "
" 1 +new "
" 1 +new "
" "
" "
" "

View File

@@ -1,13 +1,12 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited example.txt (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
" "
" "
" "

View File

@@ -0,0 +1,13 @@
---
source: tui/src/diff_render.rs
expression: text
---
• Edited hundreds.txt (+1 -1)
97 line 97
98 line 98
99 line 99
100 -line 100
100 +line 100 changed
101 line 101
102 line 102
103 line 103

View File

@@ -1,16 +0,0 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Edited example.txt (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -1,12 +1,11 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited abs_old.rs → abs_new.rs (+1 -1) "
" 1 -X "
" 1 +X changed "
" 2 Y "
" 1 -X "
" 1 +X changed "
" 2 Y "
" "
" "
" "

View File

@@ -1,15 +1,14 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited long_example.txt (+1 -1) "
" 1 line 1 "
" 2 -short "
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro "
" ss_multiple_terminal_columns_and_continue_even_further_beyond "
" _eighty_columns_to_force_multiple_wraps "
" 3 line 3 "
" 1 line 1 "
" 2 -short "
" 2 +short this_is_a_very_long_modified_line_that_should_wrap_across_m "
" ultiple_terminal_columns_and_continue_even_further_beyond_eighty_ "
" columns_to_force_multiple_wraps "
" 3 line 3 "
" "
" "
" "

View File

@@ -2,15 +2,14 @@
source: tui/src/diff_render.rs
expression: text
---
1 1
2 -2
2 +added long line w
hich wraps and_if
_there_is_a_long_
token_it_will_be_
broken
3 3
4 -4
4 +4 context line wh
ich also wraps ac
ross
• Edited wrap_demo.txt (+2 -2)
1 1
2 -2
2 +added long line which
wraps and_if_there_i
s_a_long_token_it_wil
l_be_broken
3 3
4 -4
4 +4 context line which
also wraps across

View File

@@ -1,13 +1,12 @@
---
source: tui/src/diff_render.rs
assertion_line: 748
expression: terminal.backend()
---
"• Edited old_name.rs → new_name.rs (+1 -1) "
" 1 A "
" 2 -B "
" 2 +B changed "
" 3 C "
" 1 A "
" 2 -B "
" 2 +B changed "
" 3 C "
" "
" "
" "

View File

@@ -2,8 +2,8 @@
source: tui/src/diff_render.rs
expression: terminal.backend()
---
" 1 +this is a very long line that should wrap across multiple terminal co "
" lumns and continue "
"1 +this is a very long line that should wrap across multiple terminal columns an "
" d continue "
" "
" "
" "

View File

@@ -4,12 +4,12 @@ expression: snapshot
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
• Added foo.txt (+2 -0)
1 +hello
2 +world
1 +hello
2 +world
• Added foo.txt (+2 -0)
1 +hello
2 +world
1 +hello
2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev