From 03fe6a9826b3e6a5412f8dcb5112451bad2d95cb Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 25 Jun 2025 16:36:50 -0700 Subject: [PATCH] feat: add support for /diff command --- codex-rs/tui/src/app.rs | 27 ++++++++ codex-rs/tui/src/chatwidget.rs | 8 +++ codex-rs/tui/src/get_git_diff.rs | 106 ++++++++++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 3 + 5 files changed, 145 insertions(+) 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..c8dcde708e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -250,6 +250,33 @@ impl<'a> App<'a> { SlashCommand::Quit => { break; } + SlashCommand::Diff => { + use crate::get_git_diff::get_git_diff; + + let (is_repo, diff_text) = match get_git_diff() { + Ok(v) => v, + Err(e) => { + let msg = format!("Failed to compute diff: {e}"); + match &mut self.app_state { + AppState::Chat { widget } => { + 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/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd5197c73b..2c705c7c55 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -376,6 +376,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..bd06781420 --- /dev/null +++ b/codex-rs/tui/src/get_git_diff.rs @@ -0,0 +1,106 @@ +//! 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, 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::new( + io::ErrorKind::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::new( + io::ErrorKind::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/lib.rs b/codex-rs/tui/src/lib.rs index 156951fff4..47c3ebc148 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -31,6 +31,7 @@ mod conversation_history_widget; mod exec_command; mod git_warning_screen; mod history_cell; +mod get_git_diff; mod log_layer; mod login_screen; mod markdown; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bfc02ceb13..dc80e8c2b9 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,6 +15,8 @@ pub enum SlashCommand { New, ToggleMouseMode, Quit, + /// Show git diff of the working directory. + Diff, } impl SlashCommand { @@ -26,6 +28,7 @@ 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)", } }