From fa0e17f83a7925bc023b5d35661f5af0828fc2c1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Jun 2025 13:03:31 -0700 Subject: [PATCH 1/3] feat: add support for /diff command (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for a `/diff` command comparable to the one available in the TypeScript CLI. Screenshot 2025-06-26 at 12 31 33 PM 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. --- codex-rs/tui/src/app.rs | 22 ++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 29 ++--- codex-rs/tui/src/chatwidget.rs | 5 + .../tui/src/conversation_history_widget.rs | 4 + codex-rs/tui/src/get_git_diff.rs | 114 ++++++++++++++++++ codex-rs/tui/src/history_cell.rs | 23 +++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 16 ++- 8 files changed, 190 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..73d512bcf0 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,27 @@ impl<'a> App<'a> { SlashCommand::Quit => { 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); + } + } }, } } 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..92c0122003 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -384,6 +384,11 @@ impl ChatWidget<'_> { 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) { // 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/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 714ac074a7..c0e5031d70 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -206,6 +206,10 @@ impl ConversationHistoryWidget { 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) { self.add_to_history(HistoryCell::new_error_event(message)); } 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..d424ee310b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -104,6 +104,9 @@ pub(crate) enum HistoryCell { /// Background event. BackgroundEvent { view: TextBlock }, + /// Output from the `/diff` command. + GitDiffOutput { view: TextBlock }, + /// Error event from the backend. ErrorEvent { view: TextBlock }, @@ -453,13 +456,29 @@ 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())); + lines.extend(message.lines().map(|line| ansi_escape_line(line).dim())); lines.push(Line::from("")); HistoryCell::BackgroundEvent { view: TextBlock::new(lines), } } + pub(crate) fn new_diff_output(message: String) -> Self { + let mut lines: Vec> = 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 { let lines: Vec> = vec![ vec!["ERROR: ".red().bold(), message.into()].into(), @@ -549,6 +568,7 @@ impl CellWidget for HistoryCell { | HistoryCell::AgentMessage { view } | HistoryCell::AgentReasoning { view } | HistoryCell::BackgroundEvent { view } + | HistoryCell::GitDiffOutput { view } | HistoryCell::ErrorEvent { view } | HistoryCell::SessionInfo { view } | HistoryCell::CompletedExecCommand { view } @@ -570,6 +590,7 @@ impl CellWidget for HistoryCell { | HistoryCell::AgentMessage { view } | HistoryCell::AgentReasoning { view } | HistoryCell::BackgroundEvent { view } + | HistoryCell::GitDiffOutput { view } | HistoryCell::ErrorEvent { view } | HistoryCell::SessionInfo { view } | HistoryCell::CompletedExecCommand { view } 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..bb72ce561c 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 Vec paired with their command string. +pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() } From 1af8c43aba3b1d4f2cd216ea8e92a66816b9519d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Jun 2025 22:38:30 -0700 Subject: [PATCH 2/3] fix: add tiebreaker logic for paths when scores are equal --- codex-rs/file-search/src/lib.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 8754181670..0b0c3949ec 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -183,7 +183,13 @@ pub async fn run( } let mut matches: Vec<(u32, String)> = global_heap.into_iter().map(|r| r.0).collect(); - matches.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + // Sort by descending score, then ascending path for deterministic ordering. + matches.sort_by(|a, b| { + match b.0.cmp(&a.0) { + std::cmp::Ordering::Equal => a.1.cmp(&b.1), + other => other, + } + }); Ok(FileSearchResults { matches, @@ -281,4 +287,28 @@ mod tests { let score = pattern.score(haystack, &mut matcher); assert_eq!(score, None); } + + #[test] + fn tie_breakers_sort_by_path_when_scores_equal() { + let mut matches = vec![ + (100, "b_path".to_string()), + (100, "a_path".to_string()), + (90, "zzz".to_string()), + ]; + + // Sort using the same comparator as production code. + matches.sort_by(|a, b| match b.0.cmp(&a.0) { + std::cmp::Ordering::Equal => a.1.cmp(&b.1), + other => other, + }); + + // Highest score first; ties broken alphabetically. + let expected = vec![ + (100, "a_path".to_string()), + (100, "b_path".to_string()), + (90, "zzz".to_string()), + ]; + + assert_eq!(matches, expected); + } } From 83d11b9cd45b29cbc4c23e72cf732a8bd584e9fe Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Jun 2025 22:35:08 -0700 Subject: [PATCH 3/3] feat: add support for @ to do file search --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 159 +++++++++++++++++- .../tui/src/bottom_pane/file_search_popup.rs | 158 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + 5 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/file_search_popup.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e034a99357..bfc78b65d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "codex-ansi-escape", "codex-common", "codex-core", + "codex-file-search", "codex-linux-sandbox", "codex-login", "color-eyre", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 0891517d0e..c1b77c6f6c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -58,6 +58,7 @@ tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" uuid = "1" +codex-file-search = { path = "../file-search" } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4ec8299081..14351eb9e7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -16,6 +16,7 @@ use tui_textarea::TextArea; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; +use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -36,8 +37,10 @@ pub enum InputResult { pub(crate) struct ChatComposer<'a> { textarea: TextArea<'a>, command_popup: Option, + file_search_popup: Option, app_event_tx: AppEventSender, history: ChatComposerHistory, + dismissed_file_popup_token: Option, } impl ChatComposer<'_> { @@ -49,8 +52,10 @@ impl ChatComposer<'_> { let mut this = Self { textarea, command_popup: None, + file_search_popup: None, app_event_tx, history: ChatComposerHistory::new(), + dismissed_file_popup_token: None, }; this.update_border(has_input_focus); this @@ -116,19 +121,23 @@ impl ChatComposer<'_> { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let result = match self.command_popup { - Some(_) => self.handle_key_event_with_popup(key_event), - None => self.handle_key_event_without_popup(key_event), + let result = if self.command_popup.is_some() { + self.handle_key_event_with_slash_popup(key_event) + } else if self.file_search_popup.is_some() { + self.handle_key_event_with_file_popup(key_event) + } else { + self.handle_key_event_without_popup(key_event) }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); + self.sync_file_search_popup(); result } /// Handle key event when the slash-command popup is visible. - fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let Some(popup) = self.command_popup.as_mut() else { tracing::error!("handle_key_event_with_popup called without an active popup"); return (InputResult::None, false); @@ -189,6 +198,87 @@ impl ChatComposer<'_> { } } + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup( + &mut self, + key_event: KeyEvent, + ) -> (InputResult, bool) { + let Some(popup) = self.file_search_popup.as_mut() else { + return (InputResult::None, false); + }; + + match key_event.into() { + Input { key: Key::Up, .. } => { + popup.move_up(); + (InputResult::None, true) + } + Input { key: Key::Down, .. } => { + popup.move_down(); + (InputResult::None, true) + } + Input { key: Key::Esc, .. } => { + // Hide popup without modifying text, remember token to avoid immediate reopen. + if let Some(tok) = Self::current_at_token(&self.textarea) { + self.dismissed_file_popup_token = Some(tok.to_string()); + } + self.file_search_popup = None; + (InputResult::None, true) + } + Input { key: Key::Tab, .. } | Input { key: Key::Enter, ctrl: false, alt: false, shift: false } => { + if let Some(sel) = popup.selected_match() { + let sel_path = sel.to_string(); + // Drop popup borrow before using self mutably again. + self.insert_selected_path(&sel_path); + self.file_search_popup = None; + return (InputResult::None, true); + } + (InputResult::None, false) + } + input => self.handle_input_basic(input), + } + } + + /// Extract current @token from textarea last line (without leading '@'). + fn current_at_token(textarea: &tui_textarea::TextArea) -> Option { + let current_line = textarea + .lines() + .last() + .map(|s| s.as_str())?; + let token = current_line.split_whitespace().last()?; + if token.starts_with('@') && token.len() > 1 { + Some(token[1..].to_string()) + } else { + None + } + } + + /// Replace the active @token with the provided path. + fn insert_selected_path(&mut self, path: &str) { + // Gather full text. + let mut lines: Vec = self.textarea.lines().to_vec(); + if let Some(last) = lines.last_mut() { + let mut parts = last.rsplitn(2, char::is_whitespace); + let token = parts.next().unwrap_or(""); + let prefix = parts.next().unwrap_or(""); + + // Build new last line. + let mut new_last = String::new(); + new_last.push_str(prefix); + if !prefix.is_empty() { + new_last.push(' '); + } + new_last.push_str(path); + new_last.push(' '); // trailing space after completion + + *last = new_last; + + let new_text = lines.join("\n"); + self.textarea.select_all(); + self.textarea.cut(); + let _ = self.textarea.insert_str(new_text); + } + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let input: Input = key_event.into(); @@ -286,10 +376,52 @@ impl ChatComposer<'_> { } } + /// Synchronize `self.file_search_popup` with the current text in the textarea. + fn sync_file_search_popup(&mut self) { + // Only consider the last whitespace-separated token on the *current* line. + // We treat the last line as the current line since tui-textarea does not + // expose the cursor position. + let current_line = self + .textarea + .lines() + .last() + .map(|s| s.as_str()) + .unwrap_or(""); + + let last_token = current_line.split_whitespace().last().unwrap_or(""); + + // The token must start with '@' and have at least one character after. + if last_token.starts_with('@') && last_token.len() > 1 { + let query = &last_token[1..]; + + // If user dismissed popup for this exact query, don't reopen until text changes. + if self + .dismissed_file_popup_token + .as_ref() + .map_or(false, |t| t == query) + { + return; + } + let query = &last_token[1..]; + + let popup = self + .file_search_popup + .get_or_insert_with(FileSearchPopup::new); + popup.update_query(query); + self.dismissed_file_popup_token = None; // popup visible, reset + } else { + // Hide the popup when no valid @token is active. + self.file_search_popup = None; + self.dismissed_file_popup_token = None; + } + } + pub fn calculate_required_height(&self, area: &Rect) -> u16 { let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); let num_popup_rows = if let Some(popup) = &self.command_popup { popup.calculate_required_height(area) + } else if let Some(popup) = &self.file_search_popup { + popup.calculate_required_height(area) } else { 0 }; @@ -351,6 +483,25 @@ impl WidgetRef for &ChatComposer<'_> { height: area.height.saturating_sub(popup_rect.height), }; + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } else if let Some(popup) = &self.file_search_popup { + let popup_height = popup.calculate_required_height(&area); + + let popup_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: popup_height.min(area.height), + }; + + let textarea_rect = Rect { + x: area.x, + y: area.y + popup_rect.height, + width: area.width, + height: area.height.saturating_sub(popup_rect.height), + }; + popup.render(popup_rect, buf); self.textarea.render(textarea_rect, buf); } else { diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs new file mode 100644 index 0000000000..a1e27c5061 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,158 @@ +use std::num::NonZeroUsize; + +use codex_file_search::{self as file_search, FileSearchResults}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table, WidgetRef, Widget}; + +/// Maximum number of suggestions shown in the popup. +const MAX_RESULTS: usize = 8; + +pub(crate) struct FileSearchPopup { + /// The query string (text after the `@`). + query: String, + /// Cached search results. + matches: Vec, + selected_idx: Option, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + query: String::new(), + matches: Vec::new(), + selected_idx: None, + } + } + + /// Update the popup based on the `query` prefix. If the query changed a new + /// search is executed (blocking) and the result list refreshed. + pub(crate) fn update_query(&mut self, query: &str) { + if query == self.query { + // No change – nothing to do. + return; + } + + self.query.clear(); + self.query.push_str(query); + + // Perform search synchronously – the underlying implementation is + // reasonably fast for short prefixes and the result count is small + // (MAX_RESULTS). + let matches = Self::search_files(query); + self.matches = matches; + + // Reset selection idx. + self.selected_idx = if self.matches.is_empty() { None } else { Some(0) }; + } + + /// Preferred height (rows) for the popup including borders. + pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + // For the empty-state we still reserve one row so that the border is + // rendered with a minimal height (top + bottom lines). + let rows = self + .matches + .len() + .clamp(1, MAX_RESULTS) as u16; + rows + 2 /* border */ + } + + fn search_files(prefix: &str) -> Vec { + use std::path::PathBuf; + + let limit = NonZeroUsize::new(MAX_RESULTS.max(1)).unwrap(); + let threads = NonZeroUsize::new(4).unwrap(); + + let search_dir: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + // Execute the async search on the current runtime. + use tokio::runtime::{Builder, Handle}; + use tokio::task; + + let fut = file_search::run(prefix, limit, search_dir, Vec::new(), threads); + + let result: anyhow::Result = if let Ok(handle) = Handle::try_current() { + // Already inside a runtime – run the search in a blocking section. + task::block_in_place(|| handle.block_on(fut)) + } else { + // No runtime active; create a lightweight current-thread one. + match Builder::new_current_thread().enable_all().build() { + Ok(rt) => rt.block_on(fut), + Err(e) => { + tracing::error!("failed to build temporary runtime for file search: {e}"); + return Vec::new(); + } + } + }; + + match result { + Ok(res) => res + .matches + .into_iter() + .map(|(_score, path)| path) + .collect(), + Err(err) => { + tracing::error!("file search failed: {err}"); + Vec::new() + } + } + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + if let Some(idx) = self.selected_idx { + if idx > 0 { + self.selected_idx = Some(idx - 1); + } + } + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + if let Some(idx) = self.selected_idx { + if idx + 1 < self.matches.len() { + self.selected_idx = Some(idx + 1); + } + } else if !self.matches.is_empty() { + self.selected_idx = Some(0); + } + } + + pub(crate) fn selected_match(&self) -> Option<&str> { + self.selected_idx + .and_then(|i| self.matches.get(i).map(|s| s.as_str())) + } +} + +impl WidgetRef for FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Build table rows – path only. + let mut rows: Vec = Vec::new(); + + if self.matches.is_empty() { + rows.push(Row::new(vec![Cell::from("No matches").italic()])); + } else { + for (idx, path) in self.matches.iter().take(MAX_RESULTS).enumerate() { + let mut cell = Cell::from(path.clone()); + if Some(idx) == self.selected_idx { + cell = cell.style(Style::default().fg(Color::Black).bg(Color::White)); + } + rows.push(Row::new(vec![cell])); + } + } + + let table = Table::new(rows, &[ratatui::layout::Constraint::Percentage(100)]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(format!("@{query}", query = self.query)) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .column_spacing(1); + + // Consume the table and render it. + table.render(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e3234e99a6..3ff806c4d6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,7 @@ mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +mod file_search_popup; mod status_indicator_view; pub(crate) use chat_composer::ChatComposer;