mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
feat: add support for @ to do file search
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -770,6 +770,7 @@ dependencies = [
|
||||
"codex-ansi-escape",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-file-search",
|
||||
"codex-linux-sandbox",
|
||||
"codex-login",
|
||||
"color-eyre",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -16,6 +18,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;
|
||||
@@ -39,10 +42,15 @@ pub(crate) struct ChatComposer<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
ctrl_c_quit_hint: bool,
|
||||
|
||||
/// Current working directory for the conversation.
|
||||
cwd: PathBuf,
|
||||
file_search_popup: Option<FileSearchPopup>,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -53,6 +61,9 @@ impl ChatComposer<'_> {
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
cwd,
|
||||
file_search_popup: None,
|
||||
dismissed_file_popup_token: None,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -123,19 +134,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);
|
||||
@@ -196,6 +211,126 @@ 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 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 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();
|
||||
@@ -293,10 +428,35 @@ impl ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||
fn sync_file_search_popup(&mut self) {
|
||||
// Determine if there is an @token underneath the cursor.
|
||||
if let Some(token) = Self::current_at_token(&self.textarea) {
|
||||
let query = token;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
let popup = self
|
||||
.file_search_popup
|
||||
.get_or_insert_with(|| FileSearchPopup::new(self.cwd.clone()));
|
||||
popup.update_query(&query);
|
||||
self.dismissed_file_popup_token = None; // popup visible again, reset dismissal record
|
||||
} 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
|
||||
};
|
||||
@@ -365,6 +525,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 {
|
||||
|
||||
155
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
155
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user