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"
|
||||
|
||||
@@ -17,6 +17,9 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
@@ -43,6 +46,11 @@ pub(crate) struct App<'a> {
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
config: Config,
|
||||
|
||||
/// Handle to a background file-search thread (if any). Older threads are
|
||||
/// not actively cancelled but we keep the JoinHandle so they remain
|
||||
/// detachable.
|
||||
file_search_inflight: Option<Arc<AtomicBool>>,
|
||||
|
||||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||||
/// after dismissing the Git-repo warning.
|
||||
chat_args: Option<ChatWidgetArgs>,
|
||||
@@ -162,6 +170,7 @@ impl<'a> App<'a> {
|
||||
app_state,
|
||||
config,
|
||||
chat_args,
|
||||
file_search_inflight: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +282,59 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
use codex_file_search as file_search;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
// spawn background search
|
||||
let tx = self.app_event_tx.clone();
|
||||
let search_dir = self.config.cwd.clone();
|
||||
|
||||
// Optionally detach previous thread.
|
||||
if let Some(cancel_flag) = self.file_search_inflight.take() {
|
||||
cancel_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancel_flag = cancel_flag.clone();
|
||||
std::thread::spawn(move || {
|
||||
tracing::warn!("file search: {query}");
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let limit = NonZeroUsize::new(8).unwrap();
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let threads = NonZeroUsize::new(2).unwrap();
|
||||
let matches = file_search::run(
|
||||
&query,
|
||||
limit,
|
||||
&search_dir,
|
||||
Vec::new(),
|
||||
threads,
|
||||
worker_cancel_flag.clone(),
|
||||
)
|
||||
.map(|res| {
|
||||
res.matches
|
||||
.into_iter()
|
||||
.map(|(_, p)| p)
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_cancelled = worker_cancel_flag.load(Ordering::Relaxed);
|
||||
if !is_cancelled {
|
||||
tracing::warn!("file search match: {query}");
|
||||
tx.send(AppEvent::FileSearchResult { query, matches });
|
||||
} else {
|
||||
tracing::warn!("file search cancelled: {query}");
|
||||
}
|
||||
});
|
||||
|
||||
self.file_search_inflight = Some(cancel_flag);
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
@@ -28,4 +28,17 @@ pub(crate) enum AppEvent {
|
||||
/// Dispatch a recognized slash command from the UI (composer) to the app
|
||||
/// layer so it can be handled centrally.
|
||||
DispatchCommand(SlashCommand),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
StartFileSearch(String),
|
||||
|
||||
/// Result of a completed asynchronous file search. The `query` echoes the
|
||||
/// original search term so the UI can decide whether the results are
|
||||
/// still relevant.
|
||||
FileSearchResult {
|
||||
query: String,
|
||||
matches: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ use tui_textarea::Input;
|
||||
use tui_textarea::Key;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
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,10 +42,27 @@ 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,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
|
||||
/// Debounce control for file-search requests.
|
||||
last_file_search_sent: Option<(String, std::time::Instant)>,
|
||||
|
||||
// Debounce helper: when a new query arrives within the debounce window we
|
||||
// spawn a background timer. The Arc flag allows cancelling the timer when
|
||||
// a fresher query supersedes it.
|
||||
debounce_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
enum ActivePopup {
|
||||
None,
|
||||
Command(CommandPopup),
|
||||
File(FileSearchPopup),
|
||||
}
|
||||
|
||||
impl ChatComposer<'_> {
|
||||
@@ -49,10 +73,14 @@ impl ChatComposer<'_> {
|
||||
|
||||
let mut this = Self {
|
||||
textarea,
|
||||
command_popup: None,
|
||||
active_popup: ActivePopup::None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
last_file_search_sent: None,
|
||||
debounce_flag: None,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -116,6 +144,23 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
let current_opt = Self::current_at_token(&self.textarea);
|
||||
let Some(current_token) = current_opt else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !current_token.starts_with(&query) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let ActivePopup::File(popup) = &mut self.active_popup {
|
||||
popup.set_matches(&query, matches);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
self.ctrl_c_quit_hint = show;
|
||||
self.update_border(has_focus);
|
||||
@@ -123,22 +168,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 +236,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 +246,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 `@`.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - 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 +473,103 @@ 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;
|
||||
}
|
||||
|
||||
let query_changed = self.current_file_query.as_deref() != Some(&query);
|
||||
|
||||
const DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
if query_changed {
|
||||
let now = Instant::now();
|
||||
|
||||
match &mut self.last_file_search_sent {
|
||||
None => {
|
||||
// First query – fire immediately
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(query.clone()));
|
||||
self.last_file_search_sent = Some((query.clone(), now));
|
||||
}
|
||||
Some((_prev_q, last_time)) => {
|
||||
// Within debounce window -> arm timer
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Cancel previous timer if any
|
||||
if let Some(old) = self.debounce_flag.replace(flag.clone()) {
|
||||
old.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let query_clone = query.clone();
|
||||
let tx = self.app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(DEBOUNCE);
|
||||
if !flag.load(Ordering::Relaxed) {
|
||||
tx.send(AppEvent::StartFileSearch(query_clone));
|
||||
}
|
||||
});
|
||||
|
||||
*last_time = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::File(popup) => {
|
||||
if query_changed {
|
||||
popup.set_query(&query);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut popup = FileSearchPopup::new();
|
||||
popup.set_query(&query);
|
||||
self.active_popup = ActivePopup::File(popup);
|
||||
}
|
||||
}
|
||||
|
||||
self.current_file_query = Some(query);
|
||||
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 +610,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
159
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
159
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Constraint;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
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;
|
||||
|
||||
/// Maximum number of suggestions shown in the popup.
|
||||
const MAX_RESULTS: usize = 8;
|
||||
|
||||
/// Visual state for the file-search popup.
|
||||
pub(crate) struct FileSearchPopup {
|
||||
/// Query corresponding to the `matches` currently shown.
|
||||
display_query: String,
|
||||
/// Latest query typed by the user. May differ from `display_query` when
|
||||
/// a search is still in-flight.
|
||||
pending_query: String,
|
||||
/// When `true` we are still waiting for results for `pending_query`.
|
||||
waiting: bool,
|
||||
/// Cached matches; paths relative to the search dir.
|
||||
matches: Vec<String>,
|
||||
/// Currently selected index inside `matches` (if any).
|
||||
selected_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl FileSearchPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
display_query: String::new(),
|
||||
pending_query: String::new(),
|
||||
waiting: true,
|
||||
matches: Vec::new(),
|
||||
selected_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the query and reset state to *waiting*.
|
||||
pub(crate) fn set_query(&mut self, query: &str) {
|
||||
if query == self.pending_query {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if current matches are still relevant.
|
||||
let keep_existing = query.starts_with(&self.display_query);
|
||||
|
||||
self.pending_query.clear();
|
||||
self.pending_query.push_str(query);
|
||||
|
||||
self.waiting = true; // waiting for new results
|
||||
|
||||
if !keep_existing {
|
||||
self.matches.clear();
|
||||
self.selected_idx = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace matches when a `FileSearchResult` arrives.
|
||||
/// Replace matches. Only applied when `query` matches `pending_query`.
|
||||
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<String>) {
|
||||
if query != self.pending_query {
|
||||
return; // stale
|
||||
}
|
||||
|
||||
self.display_query = query.to_string();
|
||||
self.matches = matches;
|
||||
self.waiting = false;
|
||||
self.selected_idx = if self.matches.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
/// 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(|idx| self.matches.get(idx))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// Preferred height (rows) including border.
|
||||
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
// Row count depends on whether we already have matches. If no matches
|
||||
// yet (e.g. initial search or query with no results) reserve a single
|
||||
// row so the popup is still visible. When matches are present we show
|
||||
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||
// remains stable while a newer search is in-flight.
|
||||
let rows = if self.matches.is_empty() {
|
||||
1
|
||||
} else {
|
||||
self.matches.len().clamp(1, MAX_RESULTS)
|
||||
} as u16;
|
||||
rows + 2 // border
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &FileSearchPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Prepare rows.
|
||||
let rows: Vec<Row> = if self.matches.is_empty() {
|
||||
vec![Row::new(vec![Cell::from(" no matches ")])]
|
||||
} else {
|
||||
self.matches
|
||||
.iter()
|
||||
.take(MAX_RESULTS)
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let mut cell = Cell::from(p.as_str());
|
||||
if Some(i) == self.selected_idx {
|
||||
cell = cell.style(Style::default().fg(Color::Yellow));
|
||||
}
|
||||
Row::new(vec![cell])
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut title = format!(" @{} ", self.pending_query);
|
||||
if self.waiting {
|
||||
title.push_str(" (searching …)");
|
||||
}
|
||||
|
||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title),
|
||||
)
|
||||
.widths([Constraint::Percentage(100)]);
|
||||
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -201,9 +202,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 ---
|
||||
@@ -226,6 +227,11 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
|
||||
@@ -143,7 +143,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,
|
||||
@@ -404,6 +404,11 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Forward file-search results to the bottom pane.
|
||||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<String>) {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Returns true if the key press was handled, false if it was not.
|
||||
/// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
|
||||
Reference in New Issue
Block a user