mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
fixing at_command that trigger file search at the same time. add default files for @file command to prompt user what they can search
This commit is contained in:
@@ -18,7 +18,7 @@ use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use crate::slash_command::SlashCommand; // for typing
|
||||
use crate::at_command::AtCommand; // for typing
|
||||
use crate::at_command::{AtCommand, built_in_at_commands}; // for typing and lookup
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -46,6 +46,8 @@ pub(crate) struct ChatComposer<'a> {
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
attached_images: Vec<(String, std::path::PathBuf)>,
|
||||
recent_submission_images: Vec<std::path::PathBuf>,
|
||||
/// When true we are in an explicit file search session initiated via @file.
|
||||
file_search_mode: bool,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -73,6 +75,7 @@ impl ChatComposer<'_> {
|
||||
pending_pastes: Vec::new(),
|
||||
attached_images: Vec::new(),
|
||||
recent_submission_images: Vec::new(),
|
||||
file_search_mode: false,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -256,7 +259,7 @@ impl ChatComposer<'_> {
|
||||
} => {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
// Send command to the app layer.
|
||||
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
|
||||
self.app_event_tx.send(AppEvent::DispatchSlashCommand(*cmd));
|
||||
|
||||
// Clear textarea so no residual text remains.
|
||||
self.textarea.select_all();
|
||||
@@ -294,6 +297,7 @@ impl ChatComposer<'_> {
|
||||
self.dismissed_file_popup_token = Some(tok.to_string());
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.file_search_mode = false; // end session
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Tab, .. }
|
||||
@@ -308,6 +312,7 @@ impl ChatComposer<'_> {
|
||||
// Drop popup borrow before using self mutably again.
|
||||
self.insert_selected_path(&sel_path);
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.file_search_mode = false; // end session on selection
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
(InputResult::None, false)
|
||||
@@ -338,22 +343,21 @@ impl ChatComposer<'_> {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
match cmd {
|
||||
AtCommand::Image => {
|
||||
// Dispatch image import request but only remove the @token itself (not entire input).
|
||||
self.app_event_tx.send(AppEvent::DispatchAtCommand(*cmd));
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
self.remove_current_at_token();
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
AtCommand::File => {
|
||||
// Replace the textarea content with the token so file search logic picks it up.
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
let _ = self.textarea.insert_str("@file");
|
||||
// Initialize file search popup with the current query ("file").
|
||||
let mut file_popup = FileSearchPopup::new();
|
||||
file_popup.set_query("file");
|
||||
self.app_event_tx.send(AppEvent::StartFileSearch("file".to_string()));
|
||||
// Remove only current token then insert @file at cursor.
|
||||
self.remove_current_at_token();
|
||||
// Insert a bare '@' to begin a fresh file query (do not pre-fill with 'file').
|
||||
let _ = self.textarea.insert_str("@");
|
||||
let file_popup = FileSearchPopup::new(); // starts empty; we will show placeholder until user types.
|
||||
self.active_popup = ActivePopup::File(file_popup);
|
||||
self.file_search_mode = true; // mark explicit session
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +420,67 @@ impl ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the @token under the cursor (including partial) without clearing the rest of the text.
|
||||
/// If cursor not on an @token, no-op.
|
||||
fn remove_current_at_token(&mut self) {
|
||||
let (row, col) = self.textarea.cursor();
|
||||
let mut lines: Vec<String> = self.textarea.lines().to_vec();
|
||||
if let Some(line) = lines.get_mut(row) {
|
||||
// Compute byte offset of cursor.
|
||||
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
|
||||
let before_cursor = &line[..cursor_byte_offset];
|
||||
let after_cursor = &line[cursor_byte_offset..];
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = cursor_byte_offset + end_rel_idx;
|
||||
if start_idx < end_idx && line[start_idx..].starts_with('@') {
|
||||
let mut new_line = String::with_capacity(line.len() - (end_idx - start_idx));
|
||||
new_line.push_str(&line[..start_idx]);
|
||||
new_line.push_str(&line[end_idx..]);
|
||||
*line = new_line;
|
||||
// Re-populate textarea with modified content.
|
||||
let new_text = lines.join("\n");
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
let _ = self.textarea.insert_str(new_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Similar to `current_at_token` but returns Some("") if cursor is on a bare '@' token (no body yet).
|
||||
fn current_at_token_allow_empty(textarea: &tui_textarea::TextArea) -> Option<String> {
|
||||
let (row, col) = textarea.cursor();
|
||||
let line = textarea.lines().get(row)?.as_str();
|
||||
let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
|
||||
let before_cursor = &line[..cursor_byte_offset];
|
||||
let after_cursor = &line[cursor_byte_offset..];
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = cursor_byte_offset + end_rel_idx;
|
||||
if start_idx >= end_idx { return None; }
|
||||
let token = &line[start_idx..end_idx];
|
||||
if token.starts_with('@') {
|
||||
// Return body which may be empty.
|
||||
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
|
||||
@@ -728,31 +793,36 @@ impl ChatComposer<'_> {
|
||||
/// 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;
|
||||
}
|
||||
// Only active during an explicit @file initiated session.
|
||||
if !self.file_search_mode { return; }
|
||||
|
||||
// Determine current query (may be empty if user just selected @file and hasn't typed yet).
|
||||
let query_opt = Self::current_at_token_allow_empty(&self.textarea);
|
||||
let Some(query) = query_opt else {
|
||||
// Token removed – end session.
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.dismissed_file_popup_token = None;
|
||||
self.file_search_mode = false;
|
||||
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;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(query.clone()));
|
||||
// Only trigger a search when query non-empty. (Empty shows an idle popup.)
|
||||
if !query.is_empty() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(query.clone()));
|
||||
}
|
||||
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::File(popup) => {
|
||||
popup.set_query(&query);
|
||||
if !query.is_empty() { popup.set_query(&query); }
|
||||
}
|
||||
_ => {
|
||||
let mut popup = FileSearchPopup::new();
|
||||
popup.set_query(&query);
|
||||
if !query.is_empty() { popup.set_query(&query); }
|
||||
self.active_popup = ActivePopup::File(popup);
|
||||
}
|
||||
}
|
||||
@@ -763,18 +833,47 @@ impl ChatComposer<'_> {
|
||||
|
||||
// NEW: Synchronize @-command popup.
|
||||
fn sync_at_command_popup(&mut self) {
|
||||
// Do not show if slash or file popup active.
|
||||
// Do not show if slash or file popup already active.
|
||||
if matches!(self.active_popup, ActivePopup::Slash(_) | ActivePopup::File(_)) { return; }
|
||||
|
||||
let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or("");
|
||||
let input_starts_with_at = first_line.starts_with('@');
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::At(popup) => {
|
||||
if input_starts_with_at { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; }
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_at { let mut popup: CommandPopup<AtCommand> = CommandPopup::at(); popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::At(popup); }
|
||||
// Determine if cursor is within an @token (even partial like just '@').
|
||||
let (row, col) = self.textarea.cursor();
|
||||
let line = match self.textarea.lines().get(row) { Some(l) => l.as_str(), None => return };
|
||||
// Compute token boundaries similar to current_at_token but allow zero-length body.
|
||||
let cursor_byte = line.chars().take(col).map(|c| c.len_utf8()).sum::<usize>();
|
||||
let before = &line[..cursor_byte];
|
||||
let after = &line[cursor_byte..];
|
||||
let start_idx = before
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel = after
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after.len());
|
||||
let end_idx = cursor_byte + end_rel;
|
||||
let show = if start_idx < end_idx && line[start_idx..].starts_with('@') {
|
||||
let body = &line[start_idx + 1..end_idx]; // may be empty
|
||||
// Show popup if body is prefix of an at command.
|
||||
built_in_at_commands().iter().any(|(name, _)| name.starts_with(&body.to_ascii_lowercase()))
|
||||
} else { false };
|
||||
|
||||
if show {
|
||||
let body = &line[start_idx + 1..end_idx];
|
||||
let synthetic = format!("@{}", body);
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::At(popup) => popup.on_composer_text_change(synthetic),
|
||||
_ => {
|
||||
let mut popup: CommandPopup<AtCommand> = CommandPopup::at();
|
||||
popup.on_composer_text_change(synthetic);
|
||||
self.active_popup = ActivePopup::At(popup);
|
||||
}
|
||||
}
|
||||
} else if matches!(self.active_popup, ActivePopup::At(_)) {
|
||||
// Hide if no longer in an at-command context.
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,7 +1516,7 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE));
|
||||
// Press Enter (should dispatch Image since only option)
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
// Expect a DispatchCommand(Image)
|
||||
// Expect a DispatchAtCommand(Image) or DispatchAtCommand(File); slash commands use DispatchSlashCommand
|
||||
let ev = rx.try_recv().expect("expected an event");
|
||||
match ev { AppEvent::DispatchAtCommand(AtCommand::Image) => {}, AppEvent::DispatchAtCommand(AtCommand::File) => {}, other => panic!("unexpected event: {:?}", other) }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use ratatui::widgets::Row;
|
||||
use ratatui::widgets::Table;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::fs;
|
||||
|
||||
/// Maximum number of suggestions shown in the popup.
|
||||
const MAX_RESULTS: usize = 8;
|
||||
@@ -36,13 +37,16 @@ pub(crate) struct FileSearchPopup {
|
||||
|
||||
impl FileSearchPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
// If pending_query is empty, pre-populate matches with files in current dir.
|
||||
let mut popup = Self {
|
||||
display_query: String::new(),
|
||||
pending_query: String::new(),
|
||||
waiting: true,
|
||||
matches: Vec::new(),
|
||||
selected_idx: None,
|
||||
}
|
||||
};
|
||||
popup.populate_current_dir_if_empty_query();
|
||||
popup
|
||||
}
|
||||
|
||||
/// Update the query and reset state to *waiting*.
|
||||
@@ -63,6 +67,12 @@ impl FileSearchPopup {
|
||||
self.matches.clear();
|
||||
self.selected_idx = None;
|
||||
}
|
||||
|
||||
// If query is empty, show files in current directory.
|
||||
if query.is_empty() {
|
||||
self.populate_current_dir_if_empty_query();
|
||||
self.waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace matches when a `FileSearchResult` arrives.
|
||||
@@ -122,6 +132,40 @@ impl FileSearchPopup {
|
||||
} as u16;
|
||||
rows + 2 // border
|
||||
}
|
||||
|
||||
/// Populate matches with files in the current directory if the query is empty.
|
||||
fn populate_current_dir_if_empty_query(&mut self) {
|
||||
if !self.pending_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Only populate if matches is empty (avoid overwriting search results).
|
||||
if !self.matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut entries: Vec<FileMatch> = Vec::new();
|
||||
if let Ok(read_dir) = fs::read_dir(".") {
|
||||
for entry in read_dir.flatten().take(MAX_RESULTS) {
|
||||
if let Ok(file_type) = entry.file_type() {
|
||||
// Skip hidden files (dotfiles) for a cleaner popup.
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
if file_name_str.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// Only show files and directories (not symlinks, etc).
|
||||
if file_type.is_file() || file_type.is_dir() {
|
||||
entries.push(FileMatch {
|
||||
path: file_name_str.to_string(),
|
||||
indices: Some(Vec::new()), // No highlights for empty query.
|
||||
score: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.matches = entries;
|
||||
self.selected_idx = if self.matches.is_empty() { None } else { Some(0) };
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &FileSearchPopup {
|
||||
@@ -170,7 +214,9 @@ impl WidgetRef for &FileSearchPopup {
|
||||
};
|
||||
|
||||
let mut title = format!(" @{} ", self.pending_query);
|
||||
if self.waiting {
|
||||
if self.pending_query.is_empty() {
|
||||
title.push_str(" (type to search)");
|
||||
} else if self.waiting {
|
||||
title.push_str(" (searching …)");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user