feat: add support for @ to do file search

This commit is contained in:
Michael Bolin
2025-06-27 16:24:47 -07:00
parent b3ad764532
commit bd92cc30f1
6 changed files with 445 additions and 52 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

@@ -25,6 +25,7 @@ codex-common = { path = "../common", features = [
"elapsed",
"sandbox_summary",
] }
codex-file-search = { path = "../file-search" }
codex-linux-sandbox = { path = "../linux-sandbox" }
codex-login = { path = "../login" }
color-eyre = "0.6.3"

View File

@@ -14,8 +14,11 @@ use tui_textarea::Input;
use tui_textarea::Key;
use tui_textarea::TextArea;
use std::path::PathBuf;
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;
@@ -35,24 +38,35 @@ pub enum InputResult {
pub(crate) struct ChatComposer<'a> {
textarea: TextArea<'a>,
command_popup: Option<CommandPopup>,
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
cwd: std::path::PathBuf,
dismissed_file_popup_token: Option<String>,
}
/// Popup state at most one can be visible at any time.
enum ActivePopup {
None,
Command(CommandPopup),
File(FileSearchPopup),
}
impl ChatComposer<'_> {
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self {
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender, cwd: PathBuf) -> Self {
let mut textarea = TextArea::default();
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
textarea.set_cursor_line_style(ratatui::style::Style::default());
let mut this = Self {
textarea,
command_popup: None,
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
cwd,
dismissed_file_popup_token: None,
};
this.update_border(has_input_focus);
this
@@ -123,22 +137,27 @@ 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 = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
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) {
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);
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
match key_event.into() {
@@ -186,7 +205,7 @@ impl ChatComposer<'_> {
self.textarea.cut();
// Hide popup since the command has been dispatched.
self.command_popup = None;
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// Fallback to default newline handling if no command selected.
@@ -196,6 +215,149 @@ 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 ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!();
};
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.active_popup = ActivePopup::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.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
(InputResult::None, false)
}
input => self.handle_input_basic(input),
}
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
///
/// Behaviour:
/// • The cursor may be anywhere *inside* the token (including on the
/// leading `@`). It does **not** need to be at the end of the line.
/// • A token is delimited by ASCII whitespace (space, tab, newline).
/// • If the token under the cursor starts with `@` and contains at least
/// one additional character, that token (without `@`) is returned.
fn current_at_token(textarea: &tui_textarea::TextArea) -> Option<String> {
let (row, col) = textarea.cursor();
// Guard against out-of-bounds rows.
let line = textarea.lines().get(row)?.as_str();
// Clamp the cursor column to the line length to avoid slicing panics
// when the cursor is at the end of the line.
let col = col.min(line.len());
// Split the line at the cursor position so we can search for word
// boundaries on both sides.
let before_cursor = &line[..col];
let after_cursor = &line[col..];
// Find start index (first character **after** the previous whitespace).
let start_idx = before_cursor
.rfind(|c: char| c.is_whitespace())
.map(|idx| idx + 1)
.unwrap_or(0);
// Find end index (first whitespace **after** the cursor position).
let end_rel_idx = after_cursor
.find(|c: char| c.is_whitespace())
.unwrap_or(after_cursor.len());
let end_idx = col + end_rel_idx;
if start_idx >= end_idx {
return None;
}
let token = &line[start_idx..end_idx];
if token.starts_with('@') && token.len() > 1 {
Some(token[1..].to_string())
} else {
None
}
}
/// Replace the active `@token` (the one under the cursor) with `path`.
///
/// The algorithm mirrors `current_at_token` so replacement works no matter
/// where the cursor is within the token and regardless of how many
/// `@tokens` exist in the line.
fn insert_selected_path(&mut self, path: &str) {
let (row, col) = self.textarea.cursor();
// Materialize the textarea lines so we can mutate them easily.
let mut lines: Vec<String> = self.textarea.lines().to_vec();
if let Some(line) = lines.get_mut(row) {
let col = col.min(line.len());
let before_cursor = &line[..col];
let after_cursor = &line[col..];
// Determine token boundaries.
let start_idx = before_cursor
.rfind(|c: char| c.is_whitespace())
.map(|idx| idx + 1)
.unwrap_or(0);
let end_rel_idx = after_cursor
.find(|c: char| c.is_whitespace())
.unwrap_or(after_cursor.len());
let end_idx = col + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_line =
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
new_line.push_str(&line[..start_idx]);
new_line.push_str(path);
new_line.push(' ');
new_line.push_str(&line[end_idx..]);
*line = new_line;
// Re-populate the textarea.
let new_text = lines.join("\n");
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(new_text);
// Note: tui-textarea currently exposes only relative cursor
// movements. Leaving the cursor position unchanged is acceptable
// as subsequent typing will move the cursor naturally.
}
}
/// 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();
@@ -280,25 +442,63 @@ impl ChatComposer<'_> {
.map(|s| s.as_str())
.unwrap_or("");
if first_line.starts_with('/') {
// Create popup lazily when the user starts a slash command.
let popup = self.command_popup.get_or_insert_with(CommandPopup::new);
// Forward *only* the first line since `CommandPopup` only needs
// the command token.
popup.on_composer_text_change(first_line.to_string());
} else if self.command_popup.is_some() {
// Remove popup when '/' is no longer the first character.
self.command_popup = None;
let input_starts_with_slash = first_line.starts_with('/');
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
popup.on_composer_text_change(first_line.to_string());
} else {
self.active_popup = ActivePopup::None;
}
}
_ => {
if input_starts_with_slash {
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
}
}
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
// Determine if there is an @token underneath the cursor.
let query = match Self::current_at_token(&self.textarea) {
Some(token) => token,
None => {
self.active_popup = ActivePopup::None;
self.dismissed_file_popup_token = None;
return;
}
};
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
return;
}
match &mut self.active_popup {
ActivePopup::File(popup) => {
popup.update_query(&query);
}
_ => {
// Create a new file search popup with the current query.
let mut popup = FileSearchPopup::new(self.cwd.clone());
popup.update_query(&query);
self.active_popup = ActivePopup::File(popup);
}
}
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 {
0
let num_popup_rows = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(area),
ActivePopup::File(popup) => popup.calculate_required_height(area),
ActivePopup::None => 0,
};
rows as u16 + BORDER_LINES + num_popup_rows
@@ -339,36 +539,62 @@ impl ChatComposer<'_> {
);
}
pub(crate) fn is_command_popup_visible(&self) -> bool {
self.command_popup.is_some()
pub(crate) fn is_popup_visible(&self) -> bool {
match self.active_popup {
ActivePopup::Command(_) | ActivePopup::File(_) => true,
ActivePopup::None => false,
}
}
}
impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if let Some(popup) = &self.command_popup {
let popup_height = popup.calculate_required_height(&area);
match &self.active_popup {
ActivePopup::Command(popup) => {
let popup_height = popup.calculate_required_height(&area);
// Split the provided rect so that the popup is rendered at the
// *top* and the textarea occupies the remaining space below.
let popup_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: popup_height.min(area.height),
};
// Split the provided rect so that the popup is rendered at the
// *top* and the textarea occupies the remaining space below.
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),
};
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 {
self.textarea.render(area, buf);
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::File(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_height),
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::None => {
self.textarea.render(area, buf);
}
}
}
}

View File

@@ -0,0 +1,155 @@
use std::num::NonZeroUsize;
use codex_file_search::FileSearchResults;
use codex_file_search::{self as file_search};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use std::path::Path;
use std::path::PathBuf;
use tokio::runtime::Handle;
use tokio::task;
/// 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,
search_dir: PathBuf,
/// Cached search results.
matches: Vec<String>,
selected_idx: Option<usize>,
}
impl FileSearchPopup {
pub(crate) fn new(search_dir: PathBuf) -> Self {
Self {
query: String::new(),
search_dir,
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.search_dir);
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, search_dir: &Path) -> Vec<String> {
#[allow(clippy::unwrap_used)]
let limit = NonZeroUsize::new(MAX_RESULTS.max(1)).unwrap();
#[allow(clippy::unwrap_used)]
let threads = NonZeroUsize::new(4).unwrap();
// Execute the async search on the current runtime.
let future = file_search::run(prefix, limit, search_dir, Vec::new(), threads);
let handle = Handle::current();
let result: anyhow::Result<FileSearchResults> =
task::block_in_place(|| handle.block_on(future));
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

@@ -1,5 +1,7 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
use std::path::PathBuf;
use bottom_pane_view::BottomPaneView;
use bottom_pane_view::ConditionalUpdate;
use codex_core::protocol::TokenUsage;
@@ -17,6 +19,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;
@@ -43,12 +46,18 @@ pub(crate) struct BottomPane<'a> {
pub(crate) struct BottomPaneParams {
pub(crate) app_event_tx: AppEventSender,
pub(crate) has_input_focus: bool,
/// Current working directory for the conversation.
pub(crate) cwd: PathBuf,
}
impl BottomPane<'_> {
pub fn new(params: BottomPaneParams) -> Self {
Self {
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
composer: ChatComposer::new(
params.has_input_focus,
params.app_event_tx.clone(),
params.cwd,
),
active_view: None,
app_event_tx: params.app_event_tx,
has_input_focus: params.has_input_focus,
@@ -201,9 +210,9 @@ impl BottomPane<'_> {
self.app_event_tx.send(AppEvent::Redraw)
}
/// Returns true when the slash-command popup inside the composer is visible.
pub(crate) fn is_command_popup_visible(&self) -> bool {
self.active_view.is_none() && self.composer.is_command_popup_visible()
/// Returns true when a popup inside the composer is visible.
pub(crate) fn is_popup_visible(&self) -> bool {
self.active_view.is_none() && self.composer.is_popup_visible()
}
// --- History helpers ---

View File

@@ -126,6 +126,7 @@ impl ChatWidget<'_> {
bottom_pane: BottomPane::new(BottomPaneParams {
app_event_tx,
has_input_focus: true,
cwd: config.cwd.clone(),
}),
input_focus: InputFocus::BottomPane,
config,
@@ -143,7 +144,7 @@ impl ChatWidget<'_> {
// However, when the slash-command popup is visible we forward the key
// to the bottom pane so it can handle auto-completion.
if matches!(key_event.code, crossterm::event::KeyCode::Tab)
&& !self.bottom_pane.is_command_popup_visible()
&& !self.bottom_pane.is_popup_visible()
{
self.input_focus = match self.input_focus {
InputFocus::HistoryPane => InputFocus::BottomPane,