From 0a49bae47f567d7e83ec5497ead7f23983ccca29 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 6 Aug 2025 17:06:59 -0700 Subject: [PATCH] feat: add syntax highlighting when displaying bash commands --- codex-rs/Cargo.lock | 2 + codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/bash_highlight.rs | 260 +++++++++++++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 33 +++- codex-rs/tui/src/lib.rs | 1 + 5 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/bash_highlight.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4e21baf7f5..97fa9cd501 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -895,6 +895,8 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tree-sitter", + "tree-sitter-bash", "tui-input", "tui-markdown", "unicode-segmentation", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 49d843f046..87b00a1105 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -70,6 +70,8 @@ tui-markdown = "0.3.3" unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" +tree-sitter = "0.25.8" +tree-sitter-bash = "0.25.0" diff --git a/codex-rs/tui/src/bash_highlight.rs b/codex-rs/tui/src/bash_highlight.rs new file mode 100644 index 0000000000..28937d9c23 --- /dev/null +++ b/codex-rs/tui/src/bash_highlight.rs @@ -0,0 +1,260 @@ +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use tree_sitter::Parser; +use tree_sitter::Tree; +use tree_sitter_bash::LANGUAGE as BASH; + +/// Best‑effort syntax highlight for a shell command line. +/// +/// Uses tree-sitter-bash (via codex-core) to parse the command and styles +/// common token kinds. Falls back to plain text on parse failure. +fn try_parse_bash(src: &str) -> Option { + let lang = BASH.into(); + let mut parser = Parser::new(); + #[expect(clippy::expect_used)] + parser.set_language(&lang).expect("load bash grammar"); + parser.parse(src, None) +} + +pub(crate) fn highlight_shell_command_line(src: &str) -> Line<'static> { + let Some(tree) = try_parse_bash(src) else { + return Line::from(src.to_string()); + }; + + // Collect styled segments as byte ranges with a Style. + #[derive(Clone, Copy)] + struct Seg { + start: usize, + end: usize, + style: Style, + } + + let mut segs: Vec = Vec::new(); + + let root = tree.root_node(); + let mut cursor = root.walk(); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + // We only annotate a handful of common node kinds. + let kind = node.kind(); + let style = match kind { + // First word of a command (command_name) stands out. + "command_name" => Some( + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + // String literals. + "string" | "raw_string" => Some(Style::default().fg(Color::Green)), + // Numbers. + "number" => Some(Style::default().fg(Color::Cyan)), + // Words: if they look like flags, colour them; else leave for default. + "word" => { + if let Ok(text) = node.utf8_text(src.as_bytes()) { + if text.starts_with('-') { + Some(Style::default().fg(Color::Yellow)) + } else { + None + } + } else { + None + } + } + // Only color operator tokens when they are actual operator nodes. + _ if !node.is_named() && matches!(kind, "&&" | "||" | "|" | ";" | ">" | "<") => { + Some(Style::default().fg(Color::Gray)) + } + _ => None, + }; + + if let Some(style) = style { + let (start, end) = (node.start_byte(), node.end_byte()); + if start < end && end <= src.len() { + segs.push(Seg { start, end, style }); + } + // If we styled a whole string node, skip its children to avoid + // coloring operator tokens inside strings. + if matches!(kind, "string" | "raw_string") { + continue; + } + } + + for child in node.children(&mut cursor) { + stack.push(child); + } + } + + // Note: We do NOT globally scan for operator characters; we rely on + // tree-sitter nodes above so operators inside strings are not colored. + + if segs.is_empty() { + return Line::from(src.to_string()); + } + + // Merge segments into a sequence of Spans in order, preserving gaps. + segs.sort_by_key(|s| s.start); + let mut spans: Vec> = Vec::new(); + let mut pos = 0usize; + for Seg { start, end, style } in segs { + if start > pos { + spans.push(Span::raw(src[pos..start].to_string())); + } + let piece = &src[start..end]; + spans.push(Span::styled(piece.to_string(), style)); + pos = end; + } + if pos < src.len() { + spans.push(Span::raw(src[pos..].to_string())); + } + + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + #[test] + fn highlight_does_not_color_operators_inside_strings() { + // Example provided by user: regex pipes should remain inside a green string span, + // and not be treated as shell operators. + let cmd = r#"rg -n --no-ignore-vcs -S "TODO|FIXME|XXX|HACK|TBD|\bBUG\b|\bunimplemented!\(|\btodo!\(""#; + + let line = highlight_shell_command_line(cmd); + + // Reconstruct text + let reconstructed: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect::>() + .join(""); + assert_eq!(reconstructed, cmd); + + // There should be no gray operator tokens, since all pipes are inside quotes. + let has_gray_ops = line.spans.iter().any(|s| { + s.style.fg == Some(Color::Gray) && (s.content.contains('|') || s.content.contains("||")) + }); + assert!(!has_gray_ops, "found gray operator tokens inside quoted regex"); + + // There should be at least one green span that contains a pipe character from the regex. + let has_green_with_pipe = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Green) && s.content.contains('|')); + assert!(has_green_with_pipe, "expected quoted regex to be highlighted as a green string"); + } + + #[test] + fn highlight_colors_command_and_flags() { + let cmd = "rg -n --no-ignore-vcs foo"; + let line = highlight_shell_command_line(cmd); + let text: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect(); + assert_eq!(text, cmd); + + // Find first token 'rg' and ensure it's blue (command name) + let has_blue_cmd = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Blue) && s.content.contains("rg")); + assert!(has_blue_cmd, "expected command name to be blue"); + + // Flags should be yellow + let has_short_flag = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Yellow) && s.content.contains("-n")); + let has_long_flag = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Yellow) && s.content.contains("--no-ignore-vcs")); + assert!(has_short_flag && has_long_flag, "expected flags to be yellow"); + } + + #[test] + fn highlight_colors_numbers() { + let cmd = "echo 123 456"; + let line = highlight_shell_command_line(cmd); + let text: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect(); + assert_eq!(text, cmd); + + let has_cyan_numbers = line.spans.iter().any(|s| { + s.style.fg == Some(Color::Cyan) && (s.content.contains("123") || s.content.contains("456")) + }); + assert!(has_cyan_numbers, "expected numbers to be cyan"); + } + + #[test] + fn highlight_colors_operators_outside_strings() { + let cmd = "echo a | grep b && true;"; + let line = highlight_shell_command_line(cmd); + let text: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect(); + assert_eq!(text, cmd); + + // Operators outside strings should be gray + let has_gray_ops = line.spans.iter().any(|s| { + s.style.fg == Some(Color::Gray) + && (s.content.contains("|") || s.content.contains("&&") || s.content.contains(";")) + }); + assert!(has_gray_ops, "expected operators outside strings to be gray"); + } + + #[test] + fn highlight_handles_redirections() { + let cmd = "cat file > out && echo ok"; + let line = highlight_shell_command_line(cmd); + let text: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect(); + assert_eq!(text, cmd); + + let has_gray_redirect = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Gray) && s.content.contains(">")); + assert!(has_gray_redirect, "expected '>' to be colored (gray)"); + } + + #[test] + fn highlight_multiple_quoted_strings() { + let cmd = "echo \"a|b\" 'c|d'"; + let line = highlight_shell_command_line(cmd); + let text: String = line + .spans + .iter() + .map(|s| s.content.clone().into_owned()) + .collect(); + assert_eq!(text, cmd); + + let green_spans_with_pipes = line + .spans + .iter() + .filter(|s| s.style.fg == Some(Color::Green) && s.content.contains("|")) + .count(); + assert!(green_spans_with_pipes >= 2, "expected both quoted strings to be green with pipes"); + + let has_gray_pipes = line + .spans + .iter() + .any(|s| s.style.fg == Some(Color::Gray) && s.content.contains("|")); + assert!(!has_gray_pipes, "should not color '|' as operator inside quotes"); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c577ce17a0..467a4df28a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,3 +1,4 @@ +use crate::bash_highlight::highlight_shell_command_line; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::slash_command::SlashCommand; @@ -231,11 +232,21 @@ impl HistoryCell { pub(crate) fn new_active_exec_command(command: Vec) -> Self { let command_escaped = strip_bash_lc_and_escape(&command); - let lines: Vec> = vec![ - Line::from(vec!["command".magenta(), " running...".dim()]), - Line::from(format!("$ {command_escaped}")), - Line::from(""), - ]; + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec!["command".magenta(), " running...".dim()])); + // Render the command with basic shell syntax highlighting. + let mut cmd_line = highlight_shell_command_line(&command_escaped); + // Prepend the prompt marker dimmed. + let spans = std::mem::take(&mut cmd_line.spans); + if spans.is_empty() { + lines.push(Line::from(format!("$ {command_escaped}"))); + } else { + let mut new_spans = Vec::with_capacity(spans.len() + 1); + new_spans.push(Span::raw("$ ").dim()); + new_spans.extend(spans); + lines.push(Line::from(new_spans)); + } + lines.push(Line::from("")); HistoryCell::ActiveExecCommand { view: TextBlock::new(lines), @@ -267,7 +278,17 @@ impl HistoryCell { let src = if exit_code == 0 { stdout } else { stderr }; let cmdline = strip_bash_lc_and_escape(&command); - lines.push(Line::from(format!("$ {cmdline}"))); + // Render the command with basic shell syntax highlighting. + let mut cmd_line = highlight_shell_command_line(&cmdline); + let spans = std::mem::take(&mut cmd_line.spans); + if spans.is_empty() { + lines.push(Line::from(format!("$ {cmdline}"))); + } else { + let mut new_spans = Vec::with_capacity(spans.len() + 1); + new_spans.push(Span::raw("$ ").dim()); + new_spans.extend(spans); + lines.push(Line::from(new_spans)); + } let mut lines_iter = src.lines(); for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) { lines.push(ansi_escape_line(raw).dim()); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e65083bfe1..7e68fceb91 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -22,6 +22,7 @@ use tracing_subscriber::prelude::*; mod app; mod app_event; mod app_event_sender; +mod bash_highlight; mod bottom_pane; mod chatwidget; mod citation_regex;