From 5c3ff9fc109721e6bcb709315a3cd60bcba50baf Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Jun 2025 12:10:49 -0700 Subject: [PATCH] feat: add support for /diff command --- codex-rs/tui/src/app.rs | 23 ++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 29 ++--- codex-rs/tui/src/chatwidget.rs | 8 ++ codex-rs/tui/src/get_git_diff.rs | 114 ++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 9 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 16 ++- 7 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 codex-rs/tui/src/get_git_diff.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff61b5c941..ecfa513b2b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,6 +1,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; +use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::login_screen::LoginScreen; @@ -250,6 +251,28 @@ impl<'a> App<'a> { SlashCommand::Quit => { break; } + SlashCommand::Diff => { + let (is_repo, diff_text) = match get_git_diff() { + Ok(v) => v, + Err(e) => { + let msg = format!("Failed to compute diff: {e}"); + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_background_event(msg); + } + continue; + } + }; + + let text = if is_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + }; + + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_background_event(text); + } + } }, } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 0dcb98865c..fd865047ef 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -25,7 +23,7 @@ use ratatui::style::Modifier; pub(crate) struct CommandPopup { command_filter: String, - all_commands: HashMap<&'static str, SlashCommand>, + all_commands: Vec<(&'static str, SlashCommand)>, selected_idx: Option, } @@ -84,23 +82,20 @@ impl CommandPopup { /// Return the list of commands that match the current filter. Matching is /// performed using a *prefix* comparison on the command name. fn filtered_commands(&self) -> Vec<&SlashCommand> { - let mut cmds: Vec<&SlashCommand> = self - .all_commands - .values() - .filter(|cmd| { - if self.command_filter.is_empty() { - true - } else { - cmd.command() + self.all_commands + .iter() + .filter_map(|(_name, cmd)| { + if self.command_filter.is_empty() + || cmd + .command() .starts_with(&self.command_filter.to_ascii_lowercase()) + { + Some(cmd) + } else { + None } }) - .collect(); - - // Sort the commands alphabetically so the order is stable and - // predictable. - cmds.sort_by(|a, b| a.command().cmp(b.command())); - cmds + .collect::>() } /// Move the selection cursor one step up. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fad72e3ab9..6850ee761a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -384,6 +384,14 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::Redraw); } + /// Inject a background event into the conversation history. This is used + /// for displaying informational messages that originate from the UI + /// itself (e.g. the `/diff` command) rather than from the backend agent. + pub(crate) fn add_background_event(&mut self, message: String) { + self.conversation_history.add_background_event(message); + self.request_redraw(); + } + pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) { // If the user is trying to scroll exactly one line, we let them, but // otherwise we assume they are trying to scroll in larger increments. diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs new file mode 100644 index 0000000000..ff89fdcf1e --- /dev/null +++ b/codex-rs/tui/src/get_git_diff.rs @@ -0,0 +1,114 @@ +//! Utility to compute the current Git diff for the working directory. +//! +//! The implementation mirrors the behaviour of the TypeScript version in +//! `codex-cli`: it returns the diff for tracked changes as well as any +//! untracked files. When the current directory is not inside a Git +//! repository, the function returns `Ok((false, String::new()))`. + +use std::io; +use std::path::Path; +use std::process::Command; +use std::process::Stdio; + +/// Return value of [`get_git_diff`]. +/// +/// * `bool` – Whether the current working directory is inside a Git repo. +/// * `String` – The concatenated diff (may be empty). +pub(crate) fn get_git_diff() -> io::Result<(bool, String)> { + // First check if we are inside a Git repository. + if !inside_git_repo()? { + return Ok((false, String::new())); + } + + // 1. Diff for tracked files. + let tracked_diff = run_git_capture_diff(&["diff", "--color"])?; + + // 2. Determine untracked files. + let untracked_output = run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"])?; + + let mut untracked_diff = String::new(); + let null_device: &Path = if cfg!(windows) { + Path::new("NUL") + } else { + Path::new("/dev/null") + }; + + for file in untracked_output + .split('\n') + .map(str::trim) + .filter(|s| !s.is_empty()) + { + // Use `git diff --no-index` to generate a diff against the null device. + let args = [ + "diff", + "--color", + "--no-index", + "--", + null_device.to_str().unwrap_or("/dev/null"), + file, + ]; + + match run_git_capture_diff(&args) { + Ok(diff) => untracked_diff.push_str(&diff), + // If the file disappeared between ls-files and diff we ignore the error. + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + } + + Ok((true, format!("{}{}", tracked_diff, untracked_diff))) +} + +/// Helper that executes `git` with the given `args` and returns `stdout` as a +/// UTF-8 string. Any non-zero exit status is considered an *error*. +fn run_git_capture_stdout(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and +/// returns stdout. Git returns 1 for diffs when differences are present. +fn run_git_capture_diff(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + + if output.status.success() || output.status.code() == Some(1) { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Determine if the current directory is inside a Git repository. +fn inside_git_repo() -> io::Result { + let status = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + match status { + Ok(s) if s.success() => Ok(true), + Ok(_) => Ok(false), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed + Err(e) => Err(e), + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e2a54283c1..e3707f3e61 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -453,7 +453,14 @@ impl HistoryCell { pub(crate) fn new_background_event(message: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("event".dim())); - lines.extend(message.lines().map(|l| Line::from(l.to_string()).dim())); + + for raw in message.lines() { + // Parse ANSI color sequences so they render correctly in Ratatui. + // We preserve any colors encoded in the input; additionally mark + // the text as dim to distinguish background events from regular + // conversation. + lines.push(ansi_escape_line(raw).dim()); + } lines.push(Line::from("")); HistoryCell::BackgroundEvent { view: TextBlock::new(lines), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 156951fff4..b17bb0421b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -29,6 +29,7 @@ mod citation_regex; mod cli; mod conversation_history_widget; mod exec_command; +mod get_git_diff; mod git_warning_screen; mod history_cell; mod log_layer; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bfc02ceb13..0ae68fe7f1 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -1,7 +1,5 @@ -use std::collections::HashMap; - use strum::IntoEnumIterator; -use strum_macros::AsRefStr; // derive macro +use strum_macros::AsRefStr; use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; @@ -12,9 +10,12 @@ use strum_macros::IntoStaticStr; )] #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { + // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so + // more frequently used commands should be listed first. New, - ToggleMouseMode, + Diff, Quit, + ToggleMouseMode, } impl SlashCommand { @@ -26,6 +27,9 @@ impl SlashCommand { "Toggle mouse mode (enable for scrolling, disable for text selection)" } SlashCommand::Quit => "Exit the application.", + SlashCommand::Diff => { + "Show git diff of the working directory (including untracked files)" + } } } @@ -36,7 +40,7 @@ impl SlashCommand { } } -/// Return all built-in commands in a HashMap keyed by their command string. -pub fn built_in_slash_commands() -> HashMap<&'static str, SlashCommand> { +/// Return all built-in commands in a BTreeMap keyed by their command string. +pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() }