feat: add support for @ to do file search

This commit is contained in:
Michael Bolin
2025-06-26 22:35:08 -07:00
parent 27c4edd69f
commit a46b3bacb0
5 changed files with 316 additions and 4 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -770,6 +770,7 @@ dependencies = [
"codex-ansi-escape",
"codex-common",
"codex-core",
"codex-file-search",
"codex-linux-sandbox",
"codex-login",
"color-eyre",

View File

@@ -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"

View File

@@ -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<CommandPopup>,
file_search_popup: Option<FileSearchPopup>,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
dismissed_file_popup_token: Option<String>,
}
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<String> {
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<String> = 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 {

View File

@@ -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<String>,
selected_idx: Option<usize>,
}
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<String> {
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<FileSearchResults> = 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<Row> = 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);
}
}

View File

@@ -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;