feat: add support for /diff command (#1389)
Adds support for a `/diff` command comparable to the one available in the TypeScript CLI. <img width="1103" alt="Screenshot 2025-06-26 at 12 31 33 PM" src="https://github.com/user-attachments/assets/5dc646ca-301f-41ff-92a7-595c68db64b6" /> While here, changed the `SlashCommand` enum so the declared variant order is the order the commands appear in the popup menu. This way, `/toggle-mouse-mode` is listed last, as it is the least likely to be used. Fixes https://github.com/openai/codex/issues/1253.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::chatwidget::ChatWidget;
|
use crate::chatwidget::ChatWidget;
|
||||||
|
use crate::get_git_diff::get_git_diff;
|
||||||
use crate::git_warning_screen::GitWarningOutcome;
|
use crate::git_warning_screen::GitWarningOutcome;
|
||||||
use crate::git_warning_screen::GitWarningScreen;
|
use crate::git_warning_screen::GitWarningScreen;
|
||||||
use crate::login_screen::LoginScreen;
|
use crate::login_screen::LoginScreen;
|
||||||
@@ -250,6 +251,27 @@ impl<'a> App<'a> {
|
|||||||
SlashCommand::Quit => {
|
SlashCommand::Quit => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
SlashCommand::Diff => {
|
||||||
|
let (is_git_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_diff_output(msg);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
|
let text = if is_git_repo {
|
||||||
|
diff_text
|
||||||
|
} else {
|
||||||
|
"`/diff` — _not inside a git repository_".to_string()
|
||||||
|
};
|
||||||
|
widget.add_diff_output(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
@@ -25,7 +23,7 @@ use ratatui::style::Modifier;
|
|||||||
|
|
||||||
pub(crate) struct CommandPopup {
|
pub(crate) struct CommandPopup {
|
||||||
command_filter: String,
|
command_filter: String,
|
||||||
all_commands: HashMap<&'static str, SlashCommand>,
|
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||||
selected_idx: Option<usize>,
|
selected_idx: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,23 +82,20 @@ impl CommandPopup {
|
|||||||
/// Return the list of commands that match the current filter. Matching is
|
/// Return the list of commands that match the current filter. Matching is
|
||||||
/// performed using a *prefix* comparison on the command name.
|
/// performed using a *prefix* comparison on the command name.
|
||||||
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
||||||
let mut cmds: Vec<&SlashCommand> = self
|
self.all_commands
|
||||||
.all_commands
|
.iter()
|
||||||
.values()
|
.filter_map(|(_name, cmd)| {
|
||||||
.filter(|cmd| {
|
if self.command_filter.is_empty()
|
||||||
if self.command_filter.is_empty() {
|
|| cmd
|
||||||
true
|
.command()
|
||||||
} else {
|
|
||||||
cmd.command()
|
|
||||||
.starts_with(&self.command_filter.to_ascii_lowercase())
|
.starts_with(&self.command_filter.to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
Some(cmd)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Vec<&SlashCommand>>()
|
||||||
|
|
||||||
// Sort the commands alphabetically so the order is stable and
|
|
||||||
// predictable.
|
|
||||||
cmds.sort_by(|a, b| a.command().cmp(b.command()));
|
|
||||||
cmds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection cursor one step up.
|
/// Move the selection cursor one step up.
|
||||||
|
|||||||
@@ -384,6 +384,11 @@ impl ChatWidget<'_> {
|
|||||||
self.app_event_tx.send(AppEvent::Redraw);
|
self.app_event_tx.send(AppEvent::Redraw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||||
|
self.conversation_history.add_diff_output(diff_output);
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) {
|
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
|
// 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.
|
// otherwise we assume they are trying to scroll in larger increments.
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ impl ConversationHistoryWidget {
|
|||||||
self.add_to_history(HistoryCell::new_background_event(message));
|
self.add_to_history(HistoryCell::new_background_event(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_diff_output(&mut self, diff_output: String) {
|
||||||
|
self.add_to_history(HistoryCell::new_diff_output(diff_output));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_error(&mut self, message: String) {
|
pub fn add_error(&mut self, message: String) {
|
||||||
self.add_to_history(HistoryCell::new_error_event(message));
|
self.add_to_history(HistoryCell::new_error_event(message));
|
||||||
}
|
}
|
||||||
|
|||||||
114
codex-rs/tui/src/get_git_diff.rs
Normal file
114
codex-rs/tui/src/get_git_diff.rs
Normal file
@@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,9 @@ pub(crate) enum HistoryCell {
|
|||||||
/// Background event.
|
/// Background event.
|
||||||
BackgroundEvent { view: TextBlock },
|
BackgroundEvent { view: TextBlock },
|
||||||
|
|
||||||
|
/// Output from the `/diff` command.
|
||||||
|
GitDiffOutput { view: TextBlock },
|
||||||
|
|
||||||
/// Error event from the backend.
|
/// Error event from the backend.
|
||||||
ErrorEvent { view: TextBlock },
|
ErrorEvent { view: TextBlock },
|
||||||
|
|
||||||
@@ -453,13 +456,29 @@ impl HistoryCell {
|
|||||||
pub(crate) fn new_background_event(message: String) -> Self {
|
pub(crate) fn new_background_event(message: String) -> Self {
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
lines.push(Line::from("event".dim()));
|
lines.push(Line::from("event".dim()));
|
||||||
lines.extend(message.lines().map(|l| Line::from(l.to_string()).dim()));
|
lines.extend(message.lines().map(|line| ansi_escape_line(line).dim()));
|
||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
HistoryCell::BackgroundEvent {
|
HistoryCell::BackgroundEvent {
|
||||||
view: TextBlock::new(lines),
|
view: TextBlock::new(lines),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_diff_output(message: String) -> Self {
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
lines.push(Line::from("/diff".magenta()));
|
||||||
|
|
||||||
|
if message.trim().is_empty() {
|
||||||
|
lines.push(Line::from("No changes detected.".italic()));
|
||||||
|
} else {
|
||||||
|
lines.extend(message.lines().map(ansi_escape_line));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
HistoryCell::GitDiffOutput {
|
||||||
|
view: TextBlock::new(lines),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new_error_event(message: String) -> Self {
|
pub(crate) fn new_error_event(message: String) -> Self {
|
||||||
let lines: Vec<Line<'static>> = vec![
|
let lines: Vec<Line<'static>> = vec![
|
||||||
vec!["ERROR: ".red().bold(), message.into()].into(),
|
vec!["ERROR: ".red().bold(), message.into()].into(),
|
||||||
@@ -549,6 +568,7 @@ impl CellWidget for HistoryCell {
|
|||||||
| HistoryCell::AgentMessage { view }
|
| HistoryCell::AgentMessage { view }
|
||||||
| HistoryCell::AgentReasoning { view }
|
| HistoryCell::AgentReasoning { view }
|
||||||
| HistoryCell::BackgroundEvent { view }
|
| HistoryCell::BackgroundEvent { view }
|
||||||
|
| HistoryCell::GitDiffOutput { view }
|
||||||
| HistoryCell::ErrorEvent { view }
|
| HistoryCell::ErrorEvent { view }
|
||||||
| HistoryCell::SessionInfo { view }
|
| HistoryCell::SessionInfo { view }
|
||||||
| HistoryCell::CompletedExecCommand { view }
|
| HistoryCell::CompletedExecCommand { view }
|
||||||
@@ -570,6 +590,7 @@ impl CellWidget for HistoryCell {
|
|||||||
| HistoryCell::AgentMessage { view }
|
| HistoryCell::AgentMessage { view }
|
||||||
| HistoryCell::AgentReasoning { view }
|
| HistoryCell::AgentReasoning { view }
|
||||||
| HistoryCell::BackgroundEvent { view }
|
| HistoryCell::BackgroundEvent { view }
|
||||||
|
| HistoryCell::GitDiffOutput { view }
|
||||||
| HistoryCell::ErrorEvent { view }
|
| HistoryCell::ErrorEvent { view }
|
||||||
| HistoryCell::SessionInfo { view }
|
| HistoryCell::SessionInfo { view }
|
||||||
| HistoryCell::CompletedExecCommand { view }
|
| HistoryCell::CompletedExecCommand { view }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ mod citation_regex;
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod conversation_history_widget;
|
mod conversation_history_widget;
|
||||||
mod exec_command;
|
mod exec_command;
|
||||||
|
mod get_git_diff;
|
||||||
mod git_warning_screen;
|
mod git_warning_screen;
|
||||||
mod history_cell;
|
mod history_cell;
|
||||||
mod log_layer;
|
mod log_layer;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use strum::IntoEnumIterator;
|
use strum::IntoEnumIterator;
|
||||||
use strum_macros::AsRefStr; // derive macro
|
use strum_macros::AsRefStr;
|
||||||
use strum_macros::EnumIter;
|
use strum_macros::EnumIter;
|
||||||
use strum_macros::EnumString;
|
use strum_macros::EnumString;
|
||||||
use strum_macros::IntoStaticStr;
|
use strum_macros::IntoStaticStr;
|
||||||
@@ -12,9 +10,12 @@ use strum_macros::IntoStaticStr;
|
|||||||
)]
|
)]
|
||||||
#[strum(serialize_all = "kebab-case")]
|
#[strum(serialize_all = "kebab-case")]
|
||||||
pub enum SlashCommand {
|
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,
|
New,
|
||||||
ToggleMouseMode,
|
Diff,
|
||||||
Quit,
|
Quit,
|
||||||
|
ToggleMouseMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlashCommand {
|
impl SlashCommand {
|
||||||
@@ -26,6 +27,9 @@ impl SlashCommand {
|
|||||||
"Toggle mouse mode (enable for scrolling, disable for text selection)"
|
"Toggle mouse mode (enable for scrolling, disable for text selection)"
|
||||||
}
|
}
|
||||||
SlashCommand::Quit => "Exit the application.",
|
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.
|
/// Return all built-in commands in a Vec paired with their command string.
|
||||||
pub fn built_in_slash_commands() -> HashMap<&'static str, SlashCommand> {
|
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user