Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
3136b5a616 feat: add syntax highlighting when displaying bash commands 2025-08-06 17:34:24 -07:00
5 changed files with 312 additions and 9 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -895,6 +895,8 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter",
"tree-sitter-bash",
"tui-input",
"tui-markdown",
"unicode-segmentation",

View File

@@ -65,17 +65,17 @@ tokio = { version = "1", features = [
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
tui-input = "0.14.0"
tui-markdown = "0.3.3"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
vt100 = "0.16.2"

View File

@@ -0,0 +1,279 @@
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;
/// Besteffort 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<Tree> {
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<Seg> = 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<Span<'static>> = 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::<Vec<_>>()
.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"
);
}
}

View File

@@ -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<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);
let lines: Vec<Line<'static>> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
Line::from(format!("$ {command_escaped}")),
Line::from(""),
];
let mut lines: Vec<Line<'static>> = 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());

View File

@@ -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;