diff --git a/codex-rs/README.md b/codex-rs/README.md index caa21639fb..dcc15d6acf 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -2,6 +2,8 @@ We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install. +w00t! + ## Installing Codex Today, the easiest way to install Codex is via `npm`, though we plan to publish Codex to other package managers soon. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff61b5c941..e8c10e87ef 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -250,6 +250,30 @@ 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}"); + 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/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..9fb0e66086 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,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)" + } } }