Skip to content

Commit 26a98c5

Browse files
BunsDevclaude
andcommitted
feat(tui): color-coded diffs in Claude Code style
Full-row red/green tint for removed/added lines (gutter, marker, and content), brighter saturated bg on inline word-level changes, soft brand-consistent fg colors, and a single-line slate hunk header that no longer doubles the @@ prefix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e4b79a7 commit 26a98c5

1 file changed

Lines changed: 146 additions & 43 deletions

File tree

src-rust/crates/tui/src/diff_viewer.rs

Lines changed: 146 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ use crate::overlays::{
2727
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
2828
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
2929

30+
// ---------------------------------------------------------------------------
31+
// Diff palette — tuned to match Claude Code's terminal diff:
32+
// • dim red/green tint across the entire row for removed/added lines
33+
// • brighter highlight bg on inline word-level changes inside that row
34+
// • soft red/green foreground for markers so they pop on the tint
35+
// • subtle slate bg for hunk headers
36+
// ---------------------------------------------------------------------------
37+
const DIFF_BG_REMOVED: Color = Color::Rgb( 52, 18, 24); // dim red row tint
38+
const DIFF_BG_ADDED: Color = Color::Rgb( 14, 44, 22); // dim green row tint
39+
const DIFF_BG_WORD_DEL: Color = Color::Rgb(150, 38, 52); // bright red — changed word
40+
const DIFF_BG_WORD_INS: Color = Color::Rgb( 34, 120, 52); // bright green — changed word
41+
const DIFF_FG_REMOVED: Color = Color::Rgb(255, 168, 178); // soft red text/marker
42+
const DIFF_FG_ADDED: Color = Color::Rgb(168, 240, 184); // soft green text/marker
43+
const DIFF_FG_GUTTER: Color = Color::Rgb(108, 108, 122); // dim line-number gutter
44+
const DIFF_FG_HEADER: Color = Color::Rgb(167, 139, 250); // hunk header (@@ lines)
45+
const DIFF_BG_HEADER: Color = Color::Rgb( 18, 18, 28); // subtle slate band
46+
3047
// ---------------------------------------------------------------------------
3148
// Data types
3249
// ---------------------------------------------------------------------------
@@ -744,18 +761,20 @@ fn render_diff_detail(state: &DiffViewerState, area: Rect, buf: &mut Buffer) {
744761
return;
745762
}
746763

747-
// Build lines for rendering
748-
let lines = build_diff_lines(file, inner.width);
749-
let total_lines = lines.len();
750-
let scroll = (state.detail_scroll as usize).min(total_lines.saturating_sub(inner.height as usize));
751-
let visible = &lines[scroll..];
752-
753-
// Shrink inner width by 1 to leave room for scrollbar
754-
let text_width = if total_lines > inner.height as usize {
764+
// The diff lines are width-padded so the bg tint extends across the panel,
765+
// so pre-compute the rendered width and pass it in (avoids re-padding past
766+
// the scrollbar column).
767+
let raw_line_count: usize = file.hunks.iter().map(|h| h.lines.len()).sum();
768+
let needs_scrollbar = raw_line_count > inner.height as usize;
769+
let text_width = if needs_scrollbar {
755770
inner.width.saturating_sub(1)
756771
} else {
757772
inner.width
758773
};
774+
let lines = build_diff_lines(file, text_width);
775+
let total_lines = lines.len();
776+
let scroll = (state.detail_scroll as usize).min(total_lines.saturating_sub(inner.height as usize));
777+
let visible = &lines[scroll..];
759778

760779
for (i, line) in visible.iter().enumerate() {
761780
if i as u16 >= inner.height { break; }
@@ -853,13 +872,19 @@ fn build_inline_diff_spans(old: &str, new: &str) -> (Vec<Span<'static>>, Vec<Spa
853872
ChangeTag::Delete => {
854873
old_spans.push(Span::styled(
855874
s,
856-
Style::default().fg(Color::White).bg(Color::Rgb(150, 30, 30)),
875+
Style::default()
876+
.fg(Color::White)
877+
.bg(DIFF_BG_WORD_DEL)
878+
.add_modifier(Modifier::BOLD),
857879
));
858880
}
859881
ChangeTag::Insert => {
860882
new_spans.push(Span::styled(
861883
s,
862-
Style::default().fg(Color::White).bg(Color::Rgb(30, 130, 30)),
884+
Style::default()
885+
.fg(Color::White)
886+
.bg(DIFF_BG_WORD_INS)
887+
.add_modifier(Modifier::BOLD),
863888
));
864889
}
865890
}
@@ -933,7 +958,8 @@ fn build_diff_lines(file: &FileDiffStats, width: u16) -> Vec<Line<'static>> {
933958
// Gutter = 10 chars ("dddd dddd "), prefix marker = 3 chars ("+ " etc.)
934959
let gutter_width: usize = 10;
935960
let prefix_width: usize = 3;
936-
let avail = (width as usize).saturating_sub(gutter_width + prefix_width);
961+
let total_width = width as usize;
962+
let avail = total_width.saturating_sub(gutter_width + prefix_width);
937963

938964
for hunk in &file.hunks {
939965
let hunk_lines = &hunk.lines;
@@ -951,21 +977,43 @@ fn build_diff_lines(file: &FileDiffStats, width: u16) -> Vec<Line<'static>> {
951977
let mut removed_row = vec![
952978
Span::styled(
953979
format_gutter(diff_line.old_line_no, None),
954-
Style::default().fg(Color::DarkGray),
980+
Style::default().fg(DIFF_FG_GUTTER).bg(DIFF_BG_REMOVED),
955981
),
956-
Span::styled("- ", Style::default().fg(Color::Red)),
982+
Span::styled(
983+
"- ",
984+
Style::default()
985+
.fg(DIFF_FG_REMOVED)
986+
.bg(DIFF_BG_REMOVED)
987+
.add_modifier(Modifier::BOLD),
988+
),
989+
Span::styled(" ", Style::default().bg(DIFF_BG_REMOVED)),
957990
];
958-
removed_row.extend(truncate_spans_to_width(old_spans, avail));
991+
let mut old_clipped = truncate_spans_to_width(old_spans, avail);
992+
recolor_equal_spans(&mut old_clipped, DIFF_FG_REMOVED);
993+
apply_row_bg(&mut old_clipped, DIFF_BG_REMOVED);
994+
removed_row.extend(old_clipped);
995+
pad_to_width(&mut removed_row, total_width, DIFF_BG_REMOVED);
959996
lines.push(Line::from(removed_row));
960997

961998
let mut added_row = vec![
962999
Span::styled(
9631000
format_gutter(None, next_line.new_line_no),
964-
Style::default().fg(Color::DarkGray),
1001+
Style::default().fg(DIFF_FG_GUTTER).bg(DIFF_BG_ADDED),
9651002
),
966-
Span::styled("+ ", Style::default().fg(Color::Green)),
1003+
Span::styled(
1004+
"+ ",
1005+
Style::default()
1006+
.fg(DIFF_FG_ADDED)
1007+
.bg(DIFF_BG_ADDED)
1008+
.add_modifier(Modifier::BOLD),
1009+
),
1010+
Span::styled(" ", Style::default().bg(DIFF_BG_ADDED)),
9671011
];
968-
added_row.extend(truncate_spans_to_width(new_spans, avail));
1012+
let mut new_clipped = truncate_spans_to_width(new_spans, avail);
1013+
recolor_equal_spans(&mut new_clipped, DIFF_FG_ADDED);
1014+
apply_row_bg(&mut new_clipped, DIFF_BG_ADDED);
1015+
added_row.extend(new_clipped);
1016+
pad_to_width(&mut added_row, total_width, DIFF_BG_ADDED);
9691017
lines.push(Line::from(added_row));
9701018

9711019
i += 2;
@@ -974,42 +1022,64 @@ fn build_diff_lines(file: &FileDiffStats, width: u16) -> Vec<Line<'static>> {
9741022
}
9751023
}
9761024

977-
// Standard single-line rendering
978-
let (marker, content_style) = match diff_line.kind {
979-
DiffLineKind::Header => (
980-
Span::styled("@@ ", Style::default().fg(Color::Rgb(167, 139, 250))),
981-
Style::default().fg(Color::Rgb(167, 139, 250)),
982-
),
983-
DiffLineKind::Added => (
984-
Span::styled("+ ", Style::default().fg(Color::Green)),
985-
Style::default().fg(Color::Green),
986-
),
987-
DiffLineKind::Removed => (
988-
Span::styled("- ", Style::default().fg(Color::Red)),
989-
Style::default().fg(Color::Red),
990-
),
991-
DiffLineKind::Context => (
992-
Span::styled(" ", Style::default().fg(Color::DarkGray)),
993-
Style::default().fg(Color::White),
994-
),
1025+
// Hunk header: render the @@ line full-width on a slate band, no gutter/marker.
1026+
if diff_line.kind == DiffLineKind::Header {
1027+
let content: String = diff_line.content.chars().take(total_width).collect();
1028+
let mut row = vec![Span::styled(
1029+
format!(" {} ", content),
1030+
Style::default()
1031+
.fg(DIFF_FG_HEADER)
1032+
.bg(DIFF_BG_HEADER)
1033+
.add_modifier(Modifier::BOLD),
1034+
)];
1035+
pad_to_width(&mut row, total_width, DIFF_BG_HEADER);
1036+
lines.push(Line::from(row));
1037+
i += 1;
1038+
continue;
1039+
}
1040+
1041+
// Added / Removed / Context rows.
1042+
let (marker_text, marker_fg, row_bg, fallback_fg): (&str, Color, Option<Color>, Color) =
1043+
match diff_line.kind {
1044+
DiffLineKind::Added => ("+ ", DIFF_FG_ADDED, Some(DIFF_BG_ADDED), DIFF_FG_ADDED),
1045+
DiffLineKind::Removed => ("- ", DIFF_FG_REMOVED, Some(DIFF_BG_REMOVED), DIFF_FG_REMOVED),
1046+
DiffLineKind::Context => (" ", DIFF_FG_GUTTER, None, COVEN_CODE_TEXT),
1047+
DiffLineKind::Header => unreachable!(),
1048+
};
1049+
1050+
let gutter_style = match row_bg {
1051+
Some(bg) => Style::default().fg(DIFF_FG_GUTTER).bg(bg),
1052+
None => Style::default().fg(DIFF_FG_GUTTER),
1053+
};
1054+
let marker_style = match row_bg {
1055+
Some(bg) => Style::default().fg(marker_fg).bg(bg).add_modifier(Modifier::BOLD),
1056+
None => Style::default().fg(marker_fg),
9951057
};
9961058

9971059
let ln_str = format_gutter(diff_line.old_line_no, diff_line.new_line_no);
9981060
let content: String = diff_line.content.chars().take(avail).collect();
9991061

10001062
let mut row = vec![
1001-
Span::styled(ln_str, Style::default().fg(Color::DarkGray)),
1002-
marker,
1063+
Span::styled(ln_str, gutter_style),
1064+
Span::styled(marker_text.to_string(), marker_style),
1065+
Span::styled(" ", row_bg.map(|bg| Style::default().bg(bg)).unwrap_or_default()),
10031066
];
10041067

1005-
// Apply syntax highlighting for code lines (not headers)
1006-
if diff_line.kind == DiffLineKind::Header {
1007-
row.push(Span::styled(content, content_style));
1008-
} else {
1009-
let highlighted = highlight_code_line(&content, &file.path, content_style);
1010-
row.extend(highlighted);
1068+
// Syntax-highlight the content. The fallback fg is used only for ranges
1069+
// where syntect returned a near-default color (so the diff tint reads cleanly).
1070+
let mut highlighted = highlight_code_line(
1071+
&content,
1072+
&file.path,
1073+
Style::default().fg(fallback_fg),
1074+
);
1075+
if let Some(bg) = row_bg {
1076+
apply_row_bg(&mut highlighted, bg);
10111077
}
1078+
row.extend(highlighted);
10121079

1080+
if let Some(bg) = row_bg {
1081+
pad_to_width(&mut row, total_width, bg);
1082+
}
10131083
lines.push(Line::from(row));
10141084

10151085
i += 1;
@@ -1019,6 +1089,39 @@ fn build_diff_lines(file: &FileDiffStats, width: u16) -> Vec<Line<'static>> {
10191089
lines
10201090
}
10211091

1092+
/// Add `bg` to every span that doesn't already declare a background.
1093+
/// Preserves word-level inline highlight bgs (which are already set).
1094+
fn apply_row_bg(spans: &mut Vec<Span<'static>>, bg: Color) {
1095+
for span in spans.iter_mut() {
1096+
if span.style.bg.is_none() {
1097+
span.style = span.style.bg(bg);
1098+
}
1099+
}
1100+
}
1101+
1102+
/// Pad `spans` out to `width` characters by appending a single bg-only space span.
1103+
fn pad_to_width(spans: &mut Vec<Span<'static>>, width: usize, bg: Color) {
1104+
let used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1105+
if used < width {
1106+
spans.push(Span::styled(
1107+
" ".repeat(width - used),
1108+
Style::default().bg(bg),
1109+
));
1110+
}
1111+
}
1112+
1113+
/// Inside an inline-diff row, the COVEN_CODE_TEXT fg used by `build_inline_diff_spans`
1114+
/// for the unchanged-equal segments is too neutral against the dim red/green row tint.
1115+
/// Recolor those plain-text spans with `fg` so the row reads as a single coherent
1116+
/// removed/added block, while leaving the highlighted word spans alone.
1117+
fn recolor_equal_spans(spans: &mut Vec<Span<'static>>, fg: Color) {
1118+
for span in spans.iter_mut() {
1119+
if span.style.bg.is_none() {
1120+
span.style = span.style.fg(fg);
1121+
}
1122+
}
1123+
}
1124+
10221125
// ---------------------------------------------------------------------------
10231126
// Tests
10241127
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)