diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 6fdf3a43f5..d3c0d6b50e 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -12,8 +12,11 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use unicode_width::UnicodeWidthChar; + use crate::exec_command::relativize_to_home; use crate::render::Insets; +use crate::render::highlight::highlight_code_to_styled_spans; use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; @@ -42,13 +45,13 @@ impl DiffSummary { impl Renderable for FileChange { fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines = vec![]; - render_change(self, &mut lines, area.width as usize); + render_change(self, &mut lines, area.width as usize, None); Paragraph::new(lines).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { let mut lines = vec![]; - render_change(self, &mut lines, width as usize); + render_change(self, &mut lines, width as usize, None); lines.len() as u16 } } @@ -185,38 +188,79 @@ fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec>, width: usize) { +/// Detect the programming language for a file path by its extension. +/// Returns the raw extension string for `normalize_lang` / `find_syntax` +/// to resolve downstream. +fn detect_lang_for_path(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + Some(ext.to_string()) +} + +fn render_change( + change: &FileChange, + out: &mut Vec>, + width: usize, + lang: Option<&str>, +) { match change { FileChange::Add { content } => { + // Pre-highlight the entire file content as a whole. + let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l)); 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, - )); + let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); + if let Some(spans) = syn { + out.extend(push_wrapped_diff_line_with_syntax( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + spans, + )); + } else { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Insert, + raw, + width, + line_number_width, + )); + } } } FileChange::Delete { content } => { + let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l)); 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, - )); + let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i)); + if let Some(spans) = syn { + out.extend(push_wrapped_diff_line_with_syntax( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + spans, + )); + } else { + out.extend(push_wrapped_diff_line( + i + 1, + DiffLineType::Delete, + raw, + width, + line_number_width, + )); + } } } FileChange::Update { unified_diff, .. } => { @@ -256,38 +300,77 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); for l in h.lines() { + // Per-line highlighting for unified diffs. + let highlight_line = |s: &str| -> Option>> { + let l = lang?; + let spans = highlight_code_to_styled_spans(s, l)?; + spans.into_iter().next() + }; match l { diffy::Line::Insert(text) => { let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - )); + if let Some(syn) = highlight_line(s) { + out.extend(push_wrapped_diff_line_with_syntax( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + &syn, + )); + } else { + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Insert, + s, + width, + line_number_width, + )); + } 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, - width, - line_number_width, - )); + if let Some(syn) = highlight_line(s) { + out.extend(push_wrapped_diff_line_with_syntax( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + &syn, + )); + } else { + out.extend(push_wrapped_diff_line( + old_ln, + DiffLineType::Delete, + s, + width, + line_number_width, + )); + } 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, - width, - line_number_width, - )); + if let Some(syn) = highlight_line(s) { + out.extend(push_wrapped_diff_line_with_syntax( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + &syn, + )); + } else { + out.extend(push_wrapped_diff_line( + new_ln, + DiffLineType::Context, + s, + width, + line_number_width, + )); + } old_ln += 1; new_ln += 1; } @@ -348,27 +431,101 @@ fn push_wrapped_diff_line( text: &str, width: usize, line_number_width: usize, +) -> Vec> { + push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None) +} + +fn push_wrapped_diff_line_with_syntax( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: &[RtSpan<'static>], +) -> Vec> { + push_wrapped_diff_line_inner( + line_number, + kind, + text, + width, + line_number_width, + Some(syntax_spans), + ) +} + +fn push_wrapped_diff_line_inner( + line_number: usize, + kind: DiffLineType, + text: &str, + width: usize, + line_number_width: usize, + syntax_spans: Option<&[RtSpan<'static>]>, ) -> Vec> { let ln_str = line_number.to_string(); - let mut remaining_text: &str = text; // 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 { DiffLineType::Insert => ('+', style_add()), DiffLineType::Delete => ('-', style_del()), DiffLineType::Context => (' ', style_context()), }; + + // When we have syntax spans, compose them with the diff style for a richer + // view. The sign character keeps the diff color; content gets syntax colors + // with an overlay modifier for delete lines (dim). + if let Some(syn_spans) = syntax_spans { + let gutter = format!("{ln_str:>gutter_width$} "); + let sign = format!("{sign_char}"); + let dim_overlay = matches!(kind, DiffLineType::Delete); + + // Apply dim overlay to syntax spans if this is a delete line. + let styled: Vec> = syn_spans + .iter() + .map(|sp| { + let mut style = sp.style; + if dim_overlay { + style.add_modifier |= Modifier::DIM; + } + RtSpan::styled(sp.content.clone().into_owned(), style) + }) + .collect(); + + // Determine how many display columns remain for content after the + // gutter and sign character. + let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); + + // Wrap the styled content spans to fit within the available columns. + let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols); + + let mut lines: Vec> = Vec::new(); + for (i, chunk) in wrapped_chunks.into_iter().enumerate() { + let mut row_spans: Vec> = Vec::new(); + if i == 0 { + // First line: gutter + sign + content + row_spans.push(RtSpan::styled(gutter.clone(), style_gutter())); + row_spans.push(RtSpan::styled(sign.clone(), line_style)); + } else { + // Continuation: empty gutter + two-space indent (matches + // the plain-text wrapping continuation style). + let cont_gutter = format!("{:gutter_width$} ", ""); + row_spans.push(RtSpan::styled(cont_gutter, style_gutter())); + } + row_spans.extend(chunk); + lines.push(RtLine::from(row_spans)); + } + return lines; + } + + // Fallback: no syntax spans — use the original wrapping behavior. + let mut remaining_text: &str = text; + let mut first = true; let mut lines: Vec> = Vec::new(); loop { - // 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 = width.saturating_sub(prefix_cols + 1).max(1); let split_at_byte_index = remaining_text .char_indices() @@ -379,9 +536,7 @@ fn push_wrapped_diff_line( remaining_text = rest; if first { - // 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![ RtSpan::styled(gutter, style_gutter()), @@ -389,7 +544,6 @@ fn push_wrapped_diff_line( ])); first = false; } else { - // Continuation lines keep a space for the sign column so content aligns let gutter = format!("{:gutter_width$} ", ""); lines.push(RtLine::from(vec![ RtSpan::styled(gutter, style_gutter()), @@ -403,6 +557,73 @@ fn push_wrapped_diff_line( lines } +/// Split styled spans into chunks that fit within `max_cols` display columns. +/// Returns one `Vec` per output line. Styles are preserved across +/// split boundaries so that wrapping never loses syntax coloring. +fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec>> { + let mut result: Vec>> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut col: usize = 0; + + for span in spans { + let style = span.style; + let text = span.content.as_ref(); + let mut remaining = text; + + while !remaining.is_empty() { + // Accumulate characters until we fill the line. + let mut byte_end = 0; + let mut chars_col = 0; + + for ch in remaining.chars() { + let w = ch.width().unwrap_or(0); + if col + chars_col + w > max_cols && byte_end > 0 { + // Adding this character would exceed the line width and we + // already have some content — break here. + break; + } + byte_end += ch.len_utf8(); + chars_col += w; + } + + if byte_end == 0 { + // Single character wider than remaining space — force onto a + // new line so we make progress. + if !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + } + // Take at least one character to avoid an infinite loop. + let Some(ch) = remaining.chars().next() else { + break; + }; + let ch_len = ch.len_utf8(); + current_line.push(RtSpan::styled(remaining[..ch_len].to_string(), style)); + col = ch.width().unwrap_or(1); + remaining = &remaining[ch_len..]; + continue; + } + + let (chunk, rest) = remaining.split_at(byte_end); + current_line.push(RtSpan::styled(chunk.to_string(), style)); + col += chars_col; + remaining = rest; + + // If we exactly filled or exceeded the line, start a new one. + if col >= max_cols && !remaining.is_empty() { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + } + } + + // Push the last line (always at least one, even if empty). + if !current_line.is_empty() || result.is_empty() { + result.push(current_line); + } + + result +} + fn line_number_width(max_line_number: usize) -> usize { if max_line_number == 0 { 1 @@ -694,4 +915,99 @@ mod tests { snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); } + + #[test] + fn ui_snapshot_syntax_highlighted_insert_wraps() { + // A long Rust line that exceeds 80 cols with syntax highlighting should + // wrap to multiple output lines rather than being clipped. + let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result> { Ok(arg_one) }"; + + let syntax_spans = + highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting"); + let spans = &syntax_spans[0]; + + let lines = push_wrapped_diff_line_with_syntax( + 1, + DiffLineType::Insert, + long_rust, + 80, + line_number_width(1), + spans, + ); + + assert!( + lines.len() > 1, + "syntax-highlighted long line should wrap to multiple lines, got {}", + lines.len() + ); + + snapshot_lines("syntax_highlighted_insert_wraps", lines, 90, 10); + } + + #[test] + fn ui_snapshot_syntax_highlighted_insert_wraps_text() { + let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result> { Ok(arg_one) }"; + + let syntax_spans = + highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting"); + let spans = &syntax_spans[0]; + + let lines = push_wrapped_diff_line_with_syntax( + 1, + DiffLineType::Insert, + long_rust, + 80, + line_number_width(1), + spans, + ); + + snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines); + } + + #[test] + fn detect_lang_for_common_paths() { + // Standard extensions are detected. + assert!(detect_lang_for_path(Path::new("foo.rs")).is_some()); + assert!(detect_lang_for_path(Path::new("bar.py")).is_some()); + assert!(detect_lang_for_path(Path::new("app.tsx")).is_some()); + + // Extensionless files return None. + assert!(detect_lang_for_path(Path::new("Makefile")).is_none()); + assert!(detect_lang_for_path(Path::new("randomfile")).is_none()); + } + + #[test] + fn wrap_styled_spans_single_line() { + // Content that fits in one line should produce exactly one chunk. + let spans = vec![RtSpan::raw("short")]; + let result = wrap_styled_spans(&spans, 80); + assert_eq!(result.len(), 1); + } + + #[test] + fn wrap_styled_spans_splits_long_content() { + // Content wider than max_cols should produce multiple chunks. + let long_text = "a".repeat(100); + let spans = vec![RtSpan::raw(long_text)]; + let result = wrap_styled_spans(&spans, 40); + assert!( + result.len() >= 3, + "100 chars at 40 cols should produce at least 3 lines, got {}", + result.len() + ); + } + + #[test] + fn wrap_styled_spans_preserves_styles() { + // Verify that styles survive split boundaries. + let style = Style::default().fg(Color::Green); + let text = "x".repeat(50); + let spans = vec![RtSpan::styled(text, style)]; + let result = wrap_styled_spans(&spans, 20); + for chunk in &result { + for span in chunk { + assert_eq!(span.style, style, "style should be preserved across wraps"); + } + } + } } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 4ba7b61316..a008958b2e 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,3 +1,4 @@ +use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -99,6 +100,8 @@ where pending_marker_line: bool, in_paragraph: bool, in_code_block: bool, + code_block_lang: Option, + code_block_buffer: String, wrap_width: Option, current_line_content: Option>, current_initial_indent: Vec>, @@ -124,6 +127,8 @@ where pending_marker_line: false, in_paragraph: false, in_code_block: false, + code_block_lang: None, + code_block_buffer: String::new(), wrap_width, current_line_content: None, current_initial_indent: Vec::new(), @@ -278,6 +283,17 @@ where self.push_line(Line::default()); } self.pending_marker_line = false; + + // When inside a fenced code block with a known language, accumulate + // text into the buffer for batch highlighting in end_codeblock(). + if self.in_code_block && self.code_block_lang.is_some() { + if !self.code_block_buffer.is_empty() { + self.code_block_buffer.push('\n'); + } + self.code_block_buffer.push_str(&text); + return; + } + if self.in_code_block && !self.needs_newline { let has_content = self .current_line_content @@ -394,12 +410,18 @@ where self.needs_newline = false; } - fn start_codeblock(&mut self, _lang: Option, indent: Option>) { + fn start_codeblock(&mut self, lang: Option, indent: Option>) { self.flush_current_line(); if !self.text.lines.is_empty() { self.push_blank_line(); } self.in_code_block = true; + + // Store the language for syntax highlighting; clear the buffer. + let lang = lang.filter(|l| !l.is_empty()); + self.code_block_lang = lang; + self.code_block_buffer.clear(); + self.indent_stack.push(IndentContext::new( vec![indent.unwrap_or_default()], None, @@ -409,6 +431,22 @@ where } fn end_codeblock(&mut self) { + // If we buffered code for a known language, syntax-highlight it now. + if let Some(lang) = self.code_block_lang.take() { + let code = std::mem::take(&mut self.code_block_buffer); + // Trim trailing newline to avoid a spurious empty line. + let code = code.trim_end_matches('\n'); + if !code.is_empty() { + let highlighted = highlight_code_to_lines(code, &lang); + for hl_line in highlighted { + self.push_line(Line::default()); + for span in hl_line.spans { + self.push_span(span); + } + } + } + } + self.needs_newline = true; self.in_code_block = false; self.indent_stack.pop(); diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index c549699695..c20930b7f1 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -652,10 +652,71 @@ fn link() { } #[test] -fn code_block_unhighlighted() { +fn code_block_known_lang_has_syntax_colors() { let text = render_markdown_text("```rust\nfn main() {}\n```\n"); - let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]); - assert_eq!(text, expected); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + // Content should be preserved; ignore trailing empty line from highlighting. + let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect(); + assert_eq!(content, vec!["fn main() {}"]); + + // At least one span should have non-default style (syntax highlighting). + let has_colored_span = text + .lines + .iter() + .flat_map(|l| l.spans.iter()) + .any(|sp| sp.style.fg.is_some()); + assert!(has_colored_span, "expected syntax-highlighted spans with color"); +} + +#[test] +fn code_block_unknown_lang_plain() { + let text = render_markdown_text("```xyzlang\nhello world\n```\n"); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect(); + assert_eq!(content, vec!["hello world"]); + + // No syntax coloring for unknown language — all spans have default style. + let has_colored_span = text + .lines + .iter() + .flat_map(|l| l.spans.iter()) + .any(|sp| sp.style.fg.is_some()); + assert!(!has_colored_span, "expected no syntax coloring for unknown lang"); +} + +#[test] +fn code_block_no_lang_plain() { + let text = render_markdown_text("```\nno lang specified\n```\n"); + let content: Vec = text + .lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect(); + let content: Vec<&str> = content.iter().map(|s| s.as_str()).filter(|s| !s.is_empty()).collect(); + assert_eq!(content, vec!["no lang specified"]); } #[test] @@ -721,16 +782,25 @@ Here is a code block that shows another fenced block: .collect::() }) .collect(); + // Filter empty trailing lines for stability; the code block may or may + // not emit a trailing blank depending on the highlighting path. + let trimmed: Vec<&str> = { + let mut v: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + while v.last() == Some(&"") { + v.pop(); + } + v + }; assert_eq!( - lines, + trimmed, vec![ - "Here is a code block that shows another fenced block:".to_string(), - String::new(), - "```md".to_string(), - "# Inside fence".to_string(), - "- bullet".to_string(), - "- `inline code`".to_string(), - "```".to_string(), + "Here is a code block that shows another fenced block:", + "", + "```md", + "# Inside fence", + "- bullet", + "- `inline code`", + "```", ] ); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps.snap new file mode 100644 index 0000000000..ed0ff00518 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin " +" g, arg_four: String) -> Result> { Ok(arg_o " +" ne) } " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps_text.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps_text.snap new file mode 100644 index 0000000000..4555b79028 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__syntax_highlighted_insert_wraps_text.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/diff_render.rs +expression: text +--- +1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin + g, arg_four: String) -> Result> { Ok(arg_o + ne) }