mirror of
https://github.com/openai/codex.git
synced 2026-04-20 20:54:48 +00:00
Compare commits
4 Commits
codex-debu
...
canvrno/fo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5c805093 | ||
|
|
09f6e85b13 | ||
|
|
a6dbb621f9 | ||
|
|
cb094f2071 |
@@ -560,6 +560,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeDirs": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeDirs": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_dir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -32,6 +35,7 @@
|
||||
},
|
||||
"required": [
|
||||
"file_name",
|
||||
"is_dir",
|
||||
"path",
|
||||
"root",
|
||||
"score"
|
||||
|
||||
@@ -5000,6 +5000,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"includeDirs": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5050,6 +5056,9 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"is_dir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5064,6 +5073,7 @@
|
||||
},
|
||||
"required": [
|
||||
"file_name",
|
||||
"is_dir",
|
||||
"path",
|
||||
"root",
|
||||
"score"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type FuzzyFileSearchParams = { query: string, roots: Array<string>, cancellationToken: string | null, };
|
||||
export type FuzzyFileSearchParams = { query: string, roots: Array<string>, cancellationToken: string | null, includeDirs?: boolean, };
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Superset of [`codex_file_search::FileMatch`]
|
||||
*/
|
||||
export type FuzzyFileSearchResult = { root: string, path: string, file_name: string, score: number, indices: Array<number> | null, };
|
||||
export type FuzzyFileSearchResult = { root: string, path: string, file_name: string, is_dir: boolean, score: number, indices: Array<number> | null, };
|
||||
|
||||
@@ -656,6 +656,9 @@ pub struct FuzzyFileSearchParams {
|
||||
pub roots: Vec<String>,
|
||||
// if provided, will cancel any previous request that used the same value
|
||||
pub cancellation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub include_dirs: Option<bool>,
|
||||
}
|
||||
|
||||
/// Superset of [`codex_file_search::FileMatch`]
|
||||
@@ -664,6 +667,7 @@ pub struct FuzzyFileSearchResult {
|
||||
pub root: String,
|
||||
pub path: String,
|
||||
pub file_name: String,
|
||||
pub is_dir: bool,
|
||||
pub score: u32,
|
||||
pub indices: Option<Vec<u32>>,
|
||||
}
|
||||
|
||||
@@ -4622,7 +4622,9 @@ impl CodexMessageProcessor {
|
||||
query,
|
||||
roots,
|
||||
cancellation_token,
|
||||
include_dirs,
|
||||
} = params;
|
||||
let include_dirs = include_dirs.unwrap_or(false);
|
||||
|
||||
let cancel_flag = match cancellation_token.clone() {
|
||||
Some(token) => {
|
||||
@@ -4641,7 +4643,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let results = match query.as_str() {
|
||||
"" => vec![],
|
||||
_ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await,
|
||||
_ => run_fuzzy_file_search(query, roots, include_dirs, cancel_flag.clone()).await,
|
||||
};
|
||||
|
||||
if let Some(token) = cancellation_token {
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_THREADS: usize = 12;
|
||||
pub(crate) async fn run_fuzzy_file_search(
|
||||
query: String,
|
||||
roots: Vec<String>,
|
||||
include_dirs: bool,
|
||||
cancellation_flag: Arc<AtomicBool>,
|
||||
) -> Vec<FuzzyFileSearchResult> {
|
||||
if roots.is_empty() {
|
||||
@@ -38,6 +39,7 @@ pub(crate) async fn run_fuzzy_file_search(
|
||||
limit,
|
||||
threads,
|
||||
compute_indices: true,
|
||||
include_dirs,
|
||||
..Default::default()
|
||||
},
|
||||
Some(cancellation_flag),
|
||||
@@ -54,6 +56,7 @@ pub(crate) async fn run_fuzzy_file_search(
|
||||
root: m.root.to_string_lossy().to_string(),
|
||||
path: m.path.to_string_lossy().to_string(),
|
||||
file_name: file_name.to_string_lossy().to_string(),
|
||||
is_dir: m.is_dir,
|
||||
score: m.score,
|
||||
indices: m.indices,
|
||||
}
|
||||
|
||||
@@ -624,6 +624,7 @@ impl McpProcess {
|
||||
query: &str,
|
||||
roots: Vec<String>,
|
||||
cancellation_token: Option<String>,
|
||||
include_dirs: Option<bool>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let mut params = serde_json::json!({
|
||||
"query": query,
|
||||
@@ -632,6 +633,9 @@ impl McpProcess {
|
||||
if let Some(token) = cancellation_token {
|
||||
params["cancellationToken"] = serde_json::json!(token);
|
||||
}
|
||||
if let Some(include_dirs) = include_dirs {
|
||||
params["includeDirs"] = serde_json::json!(include_dirs);
|
||||
}
|
||||
self.send_request("fuzzyFileSearch", Some(params)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
|
||||
let root_path = root.path().to_string_lossy().to_string();
|
||||
// Send fuzzyFileSearch request.
|
||||
let request_id = mcp
|
||||
.send_fuzzy_file_search_request("abe", vec![root_path.clone()], None)
|
||||
.send_fuzzy_file_search_request("abe", vec![root_path.clone()], None, None)
|
||||
.await?;
|
||||
|
||||
// Read response and verify shape and ordering.
|
||||
@@ -58,6 +58,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
|
||||
"root": root_path.clone(),
|
||||
"path": "abexy",
|
||||
"file_name": "abexy",
|
||||
"is_dir": false,
|
||||
"score": 84,
|
||||
"indices": [0, 1, 2],
|
||||
},
|
||||
@@ -65,6 +66,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
|
||||
"root": root_path.clone(),
|
||||
"path": sub_abce_rel,
|
||||
"file_name": "abce",
|
||||
"is_dir": false,
|
||||
"score": expected_score,
|
||||
"indices": [4, 5, 7],
|
||||
},
|
||||
@@ -72,6 +74,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
|
||||
"root": root_path.clone(),
|
||||
"path": "abcde",
|
||||
"file_name": "abcde",
|
||||
"is_dir": false,
|
||||
"score": 71,
|
||||
"indices": [0, 1, 4],
|
||||
},
|
||||
@@ -94,7 +97,7 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
|
||||
|
||||
let root_path = root.path().to_string_lossy().to_string();
|
||||
let request_id = mcp
|
||||
.send_fuzzy_file_search_request("alp", vec![root_path.clone()], None)
|
||||
.send_fuzzy_file_search_request("alp", vec![root_path.clone()], None, None)
|
||||
.await?;
|
||||
|
||||
let request_id_2 = mcp
|
||||
@@ -102,6 +105,7 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
|
||||
"alp",
|
||||
vec![root_path.clone()],
|
||||
Some(request_id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -122,6 +126,48 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0]["root"], root_path);
|
||||
assert_eq!(files[0]["path"], "alpha.txt");
|
||||
assert_eq!(files[0]["is_dir"], false);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_fuzzy_file_search_can_include_directories() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let root = TempDir::new()?;
|
||||
|
||||
std::fs::create_dir_all(root.path().join("docs"))?;
|
||||
std::fs::write(root.path().join("docs").join("guide.md"), "contents")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let root_path = root.path().to_string_lossy().to_string();
|
||||
let request_id = mcp
|
||||
.send_fuzzy_file_search_request("doc", vec![root_path.clone()], None, Some(true))
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let files = resp
|
||||
.result
|
||||
.get("files")
|
||||
.ok_or_else(|| anyhow!("files key missing"))?
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("files not array"))?
|
||||
.clone();
|
||||
|
||||
let docs_dir_match = files.iter().find(|f| {
|
||||
f.get("path") == Some(&json!("docs"))
|
||||
&& f.get("file_name") == Some(&json!("docs"))
|
||||
&& f.get("is_dir") == Some(&json!(true))
|
||||
});
|
||||
let docs_dir_match = docs_dir_match.ok_or_else(|| anyhow!("missing docs dir match"))?;
|
||||
assert_eq!(docs_dir_match["root"], root_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct FileMatch {
|
||||
pub score: u32,
|
||||
pub path: PathBuf,
|
||||
pub root: PathBuf,
|
||||
pub is_dir: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
|
||||
}
|
||||
@@ -93,6 +94,7 @@ pub struct FileSearchOptions {
|
||||
pub threads: NonZero<usize>,
|
||||
pub compute_indices: bool,
|
||||
pub respect_gitignore: bool,
|
||||
pub include_dirs: bool,
|
||||
}
|
||||
|
||||
impl Default for FileSearchOptions {
|
||||
@@ -105,6 +107,7 @@ impl Default for FileSearchOptions {
|
||||
threads: NonZero::new(2).unwrap(),
|
||||
compute_indices: false,
|
||||
respect_gitignore: true,
|
||||
include_dirs: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +166,7 @@ fn create_session_inner(
|
||||
threads,
|
||||
compute_indices,
|
||||
respect_gitignore,
|
||||
include_dirs,
|
||||
} = options;
|
||||
|
||||
let Some(primary_search_directory) = search_directories.first() else {
|
||||
@@ -191,6 +195,7 @@ fn create_session_inner(
|
||||
threads: threads.get(),
|
||||
compute_indices,
|
||||
respect_gitignore,
|
||||
include_dirs,
|
||||
cancelled: cancelled.clone(),
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
reporter,
|
||||
@@ -266,6 +271,7 @@ pub async fn run_main<T: Reporter>(
|
||||
threads,
|
||||
compute_indices,
|
||||
respect_gitignore: true,
|
||||
include_dirs: false,
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
@@ -344,6 +350,7 @@ struct SessionInner {
|
||||
threads: usize,
|
||||
compute_indices: bool,
|
||||
respect_gitignore: bool,
|
||||
include_dirs: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
reporter: Arc<dyn SessionReporter>,
|
||||
@@ -432,6 +439,7 @@ fn walker_worker(
|
||||
const CHECK_INTERVAL: usize = 1024;
|
||||
let mut n = 0;
|
||||
let search_directories = inner.search_directories.clone();
|
||||
let include_dirs = inner.include_dirs;
|
||||
let injector = injector.clone();
|
||||
let cancelled = inner.cancelled.clone();
|
||||
let shutdown = inner.shutdown.clone();
|
||||
@@ -441,7 +449,8 @@ fn walker_worker(
|
||||
Ok(entry) => entry,
|
||||
Err(_) => return ignore::WalkState::Continue,
|
||||
};
|
||||
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
if is_dir && !include_dirs {
|
||||
return ignore::WalkState::Continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
@@ -449,8 +458,15 @@ fn walker_worker(
|
||||
return ignore::WalkState::Continue;
|
||||
};
|
||||
if let Some((_, relative_path)) = get_file_path(path, &search_directories) {
|
||||
if relative_path.is_empty() {
|
||||
return ignore::WalkState::Continue;
|
||||
}
|
||||
injector.push(Arc::from(full_path), |_, cols| {
|
||||
cols[0] = Utf32String::from(relative_path);
|
||||
cols[0] = if is_dir {
|
||||
Utf32String::from(format!("{relative_path}/"))
|
||||
} else {
|
||||
Utf32String::from(relative_path)
|
||||
};
|
||||
});
|
||||
}
|
||||
n += 1;
|
||||
@@ -549,6 +565,7 @@ fn matcher_worker(
|
||||
score: match_.score,
|
||||
path: PathBuf::from(relative_path),
|
||||
root: inner.search_directories[root_idx].clone(),
|
||||
is_dir: Path::new(full_path).is_dir(),
|
||||
indices,
|
||||
})
|
||||
})
|
||||
@@ -895,6 +912,7 @@ mod tests {
|
||||
threads: NonZero::new(2).unwrap(),
|
||||
compute_indices: false,
|
||||
respect_gitignore: true,
|
||||
include_dirs: false,
|
||||
};
|
||||
let results =
|
||||
run("file-000", vec![dir.path().to_path_buf()], options, None).expect("run ok");
|
||||
|
||||
@@ -1347,53 +1347,58 @@ impl ChatComposer {
|
||||
return (InputResult::None, true);
|
||||
};
|
||||
|
||||
let sel_path = sel.to_string_lossy().to_string();
|
||||
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
||||
let is_image = Self::is_image_path(&sel_path);
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((width, height)) => {
|
||||
tracing::debug!("selected image dimensions={}x{}", width, height);
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
let sel_path = sel.path.to_string_lossy().to_string();
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
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 = safe_cursor + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("image dimensions lookup failed: {err}");
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
if sel.is_dir {
|
||||
self.insert_selected_dir_mention(&sel_path);
|
||||
} else {
|
||||
// Non-image: inserting file path.
|
||||
self.insert_selected_path(&sel_path);
|
||||
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
||||
let is_image = Self::is_image_path(&sel_path);
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
match image::image_dimensions(&path_buf) {
|
||||
Ok((width, height)) => {
|
||||
tracing::debug!("selected image dimensions={}x{}", width, height);
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
// Clamp to a valid char boundary to avoid panics when slicing.
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
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 = safe_cursor + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
self.attach_image(path_buf);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!("image dimensions lookup failed: {err}");
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-image: inserting file path.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
}
|
||||
// No selection: treat Enter as closing the popup/session.
|
||||
self.active_popup = ActivePopup::None;
|
||||
@@ -1471,7 +1476,7 @@ impl ChatComposer {
|
||||
if let Some(path) = path.as_deref() {
|
||||
self.record_mention_path(&insert_text, path);
|
||||
}
|
||||
self.insert_selected_mention(&insert_text);
|
||||
self.insert_selected_file_mention(&insert_text);
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
@@ -1764,21 +1769,18 @@ impl ChatComposer {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
||||
let mut new_text =
|
||||
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
|
||||
new_text.push_str(&text[..start_idx]);
|
||||
new_text.push_str(&inserted);
|
||||
new_text.push(' ');
|
||||
new_text.push_str(&text[end_idx..]);
|
||||
|
||||
// Path replacement is plain text; rebuild without carrying elements.
|
||||
self.textarea.set_text_clearing_elements(&new_text);
|
||||
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
|
||||
// Preserve existing elements (e.g. prior mentions) outside the replaced token.
|
||||
let mut replacement = String::with_capacity(inserted.len() + 1);
|
||||
replacement.push_str(&inserted);
|
||||
replacement.push(' ');
|
||||
self.textarea
|
||||
.replace_range(start_idx..end_idx, &replacement);
|
||||
let new_cursor = start_idx.saturating_add(replacement.len());
|
||||
self.textarea.set_cursor(new_cursor);
|
||||
}
|
||||
|
||||
fn insert_selected_mention(&mut self, insert_text: &str) {
|
||||
// Inserts the selected file mention's insert_text at the current @token position.
|
||||
fn insert_selected_file_mention(&mut self, insert_text: &str) {
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
@@ -1799,21 +1801,49 @@ impl ChatComposer {
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = safe_cursor + end_rel_idx;
|
||||
|
||||
let inserted = insert_text.to_string();
|
||||
|
||||
let mut new_text =
|
||||
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
|
||||
new_text.push_str(&text[..start_idx]);
|
||||
new_text.push_str(&inserted);
|
||||
new_text.push(' ');
|
||||
new_text.push_str(&text[end_idx..]);
|
||||
|
||||
// Mention insertion rebuilds plain text, so drop existing elements.
|
||||
self.textarea.set_text_clearing_elements(&new_text);
|
||||
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
|
||||
// Preserve existing elements (e.g. prior folder mentions) outside the replaced token.
|
||||
let mut replacement = String::with_capacity(insert_text.len() + 1);
|
||||
replacement.push_str(insert_text);
|
||||
replacement.push(' ');
|
||||
self.textarea
|
||||
.replace_range(start_idx..end_idx, &replacement);
|
||||
let new_cursor = start_idx.saturating_add(replacement.len());
|
||||
self.textarea.set_cursor(new_cursor);
|
||||
}
|
||||
|
||||
// Inserts the selected directory mention's insert_text at the current @token position.
|
||||
fn insert_selected_dir_mention(&mut self, path: &str) {
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
|
||||
|
||||
let before_cursor = &text[..safe_cursor];
|
||||
let after_cursor = &text[safe_cursor..];
|
||||
|
||||
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 = safe_cursor + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let mut dir_path = path.to_string();
|
||||
if !dir_path.ends_with('/') {
|
||||
dir_path.push('/');
|
||||
}
|
||||
self.textarea.insert_element(&dir_path);
|
||||
self.textarea.insert_str(" ");
|
||||
}
|
||||
|
||||
fn record_mention_path(&mut self, insert_text: &str, path: &str) {
|
||||
let Some(name) = Self::mention_name_from_insert_text(insert_text) else {
|
||||
return;
|
||||
@@ -5061,7 +5091,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_plan_args_preserve_text_elements() {
|
||||
fn selecting_file_after_folder_preserves_folder_text_element() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -5075,27 +5105,48 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_collaboration_modes_enabled(true);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']);
|
||||
let placeholder = local_image_label_text(1);
|
||||
composer.attach_image(PathBuf::from("/tmp/plan.png"));
|
||||
composer.insert_str("@doc");
|
||||
composer.on_file_search_result(
|
||||
"doc".to_string(),
|
||||
vec![FileMatch {
|
||||
score: 100,
|
||||
path: PathBuf::from("docs"),
|
||||
root: PathBuf::from("/repo"),
|
||||
is_dir: true,
|
||||
indices: None,
|
||||
}],
|
||||
);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let elements_after_dir = composer.text_elements();
|
||||
assert_eq!(composer.textarea.text(), "docs/ ");
|
||||
assert_eq!(elements_after_dir.len(), 1);
|
||||
assert_eq!(
|
||||
elements_after_dir[0].placeholder(composer.textarea.text()),
|
||||
Some("docs/")
|
||||
);
|
||||
|
||||
match result {
|
||||
InputResult::CommandWithArgs(cmd, args, text_elements) => {
|
||||
assert_eq!(cmd.command(), "plan");
|
||||
assert_eq!(args, placeholder);
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(
|
||||
text_elements[0].placeholder(&args),
|
||||
Some(placeholder.as_str())
|
||||
);
|
||||
}
|
||||
_ => panic!("expected CommandWithArgs for /plan with args"),
|
||||
}
|
||||
composer.insert_str("@main");
|
||||
composer.on_file_search_result(
|
||||
"main".to_string(),
|
||||
vec![FileMatch {
|
||||
score: 99,
|
||||
path: PathBuf::from("main.rs"),
|
||||
root: PathBuf::from("/repo"),
|
||||
is_dir: false,
|
||||
indices: None,
|
||||
}],
|
||||
);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let elements_after_file = composer.text_elements();
|
||||
assert_eq!(composer.textarea.text(), "docs/ main.rs ");
|
||||
assert_eq!(elements_after_file.len(), 1);
|
||||
assert_eq!(
|
||||
elements_after_file[0].placeholder(composer.textarea.text()),
|
||||
Some("docs/"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_file_search::FileMatch;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -91,11 +89,10 @@ impl FileSearchPopup {
|
||||
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||
}
|
||||
|
||||
pub(crate) fn selected_match(&self) -> Option<&PathBuf> {
|
||||
pub(crate) fn selected_match(&self) -> Option<&FileMatch> {
|
||||
self.state
|
||||
.selected_idx
|
||||
.and_then(|idx| self.matches.get(idx))
|
||||
.map(|file_match| &file_match.path)
|
||||
}
|
||||
|
||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
@@ -118,7 +115,11 @@ impl WidgetRef for &FileSearchPopup {
|
||||
self.matches
|
||||
.iter()
|
||||
.map(|m| GenericDisplayRow {
|
||||
name: m.path.to_string_lossy().to_string(),
|
||||
name: if m.is_dir {
|
||||
format!("{}/", m.path.to_string_lossy())
|
||||
} else {
|
||||
m.path.to_string_lossy().to_string()
|
||||
},
|
||||
match_indices: m
|
||||
.indices
|
||||
.as_ref()
|
||||
|
||||
@@ -108,6 +108,7 @@ use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -3230,23 +3231,28 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
let UserMessage {
|
||||
text,
|
||||
text: display_text,
|
||||
local_images,
|
||||
text_elements,
|
||||
text_elements: display_text_elements,
|
||||
mention_paths,
|
||||
} = user_message;
|
||||
if text.is_empty() && local_images.is_empty() {
|
||||
if display_text.is_empty() && local_images.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !local_images.is_empty() && !self.current_model_supports_images() {
|
||||
self.restore_blocked_image_submission(text, text_elements, local_images, mention_paths);
|
||||
self.restore_blocked_image_submission(
|
||||
display_text,
|
||||
display_text_elements,
|
||||
local_images,
|
||||
mention_paths,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut items: Vec<UserInput> = Vec::new();
|
||||
|
||||
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
|
||||
if let Some(stripped) = text.strip_prefix('!') {
|
||||
if let Some(stripped) = display_text.strip_prefix('!') {
|
||||
let cmd = stripped.trim();
|
||||
if cmd.is_empty() {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
@@ -3263,20 +3269,23 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let (model_text, model_text_elements) =
|
||||
self.expand_directory_mentions_for_model(&display_text, &display_text_elements);
|
||||
|
||||
for image in &local_images {
|
||||
items.push(UserInput::LocalImage {
|
||||
path: image.path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !model_text.is_empty() {
|
||||
items.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: text_elements.clone(),
|
||||
text: model_text,
|
||||
text_elements: model_text_elements,
|
||||
});
|
||||
}
|
||||
|
||||
let mentions = collect_tool_mentions(&text, &mention_paths);
|
||||
let mentions = collect_tool_mentions(&display_text, &mention_paths);
|
||||
let mut skill_names_lower: HashSet<String> = HashSet::new();
|
||||
|
||||
if let Some(skills) = self.bottom_pane.skills() {
|
||||
@@ -3335,20 +3344,22 @@ impl ChatWidget {
|
||||
});
|
||||
|
||||
// Persist the text to cross-session message history.
|
||||
if !text.is_empty() {
|
||||
if !display_text.is_empty() {
|
||||
self.codex_op_tx
|
||||
.send(Op::AddToHistory { text: text.clone() })
|
||||
.send(Op::AddToHistory {
|
||||
text: display_text.clone(),
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send AddHistory op: {e}");
|
||||
});
|
||||
}
|
||||
|
||||
// Only show the text portion in conversation history.
|
||||
if !text.is_empty() {
|
||||
if !display_text.is_empty() {
|
||||
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
text,
|
||||
text_elements,
|
||||
display_text,
|
||||
display_text_elements,
|
||||
local_image_paths,
|
||||
));
|
||||
}
|
||||
@@ -3384,6 +3395,82 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
// Expand directory mentions in the display text to markdown links for model input.
|
||||
fn expand_directory_mentions_for_model(
|
||||
&self,
|
||||
display_text: &str,
|
||||
text_elements: &[TextElement],
|
||||
) -> (String, Vec<TextElement>) {
|
||||
use std::fs;
|
||||
|
||||
if display_text.is_empty() || text_elements.is_empty() {
|
||||
return (display_text.to_string(), text_elements.to_vec());
|
||||
}
|
||||
|
||||
fn normalize_dir_path_for_prompt(path: &Path) -> String {
|
||||
let mut s = path.to_string_lossy().replace('\\', "/");
|
||||
if !s.ends_with('/') {
|
||||
s.push('/');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
let mut elements = text_elements.to_vec();
|
||||
// Rebuild left-to-right so byte ranges stay coherent.
|
||||
elements.sort_by_key(|e| e.byte_range.start);
|
||||
|
||||
let mut rebuilt = String::with_capacity(display_text.len());
|
||||
let mut rebuilt_elements = Vec::with_capacity(elements.len());
|
||||
let mut cursor = 0usize;
|
||||
|
||||
for elem in elements {
|
||||
let start = elem.byte_range.start.min(display_text.len());
|
||||
let end = elem.byte_range.end.min(display_text.len());
|
||||
if start > end {
|
||||
continue;
|
||||
}
|
||||
if start > cursor {
|
||||
rebuilt.push_str(&display_text[cursor..start]);
|
||||
}
|
||||
|
||||
let elem_text = &display_text[start..end];
|
||||
let payload = elem.placeholder(display_text).map(str::to_string);
|
||||
// Treat only trailing-slash mentions that resolve to real directories.
|
||||
let resolved_dir = payload
|
||||
.as_deref()
|
||||
.filter(|payload| payload.ends_with('/'))
|
||||
.and_then(|payload| {
|
||||
AbsolutePathBuf::resolve_path_against_base(payload, &self.config.cwd).ok()
|
||||
})
|
||||
.and_then(|abs| {
|
||||
fs::metadata(abs.as_path())
|
||||
.ok()
|
||||
.and_then(|meta| meta.is_dir().then_some(abs))
|
||||
});
|
||||
|
||||
if let Some(abs) = resolved_dir {
|
||||
let abs_dir = normalize_dir_path_for_prompt(abs.as_path());
|
||||
// Expand directory mention to a markdown link for model input.
|
||||
rebuilt.push_str(&format!("[{elem_text}]({abs_dir})"));
|
||||
} else {
|
||||
// Non-directory elements remain as text elements with remapped ranges.
|
||||
let new_start = rebuilt.len();
|
||||
rebuilt.push_str(elem_text);
|
||||
let new_end = rebuilt.len();
|
||||
let placeholder = payload.or_else(|| Some(elem_text.to_string()));
|
||||
rebuilt_elements.push(TextElement::new((new_start..new_end).into(), placeholder));
|
||||
}
|
||||
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if cursor < display_text.len() {
|
||||
rebuilt.push_str(&display_text[cursor..]);
|
||||
}
|
||||
|
||||
(rebuilt, rebuilt_elements)
|
||||
}
|
||||
|
||||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||||
/// resuming an existing session. This approximates the live event flow and
|
||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||
|
||||
@@ -368,6 +368,83 @@ async fn blocked_image_restore_preserves_mention_paths() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_expands_directory_mentions() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let project_root = tempdir().unwrap();
|
||||
std::fs::create_dir_all(project_root.path().join("docs")).unwrap();
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: project_root.path().to_path_buf(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: Some(rollout_file.path().to_path_buf()),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
drain_insert_history(&mut rx);
|
||||
chat.config.cwd = project_root.path().to_path_buf();
|
||||
|
||||
let text = "docs/ review".to_string();
|
||||
let text_elements = vec![TextElement::new(
|
||||
(0.."docs/".len()).into(),
|
||||
Some("docs/".into()),
|
||||
)];
|
||||
chat.bottom_pane
|
||||
.set_composer_text(text.clone(), text_elements.clone(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
|
||||
let abs_docs = format!(
|
||||
"{}/",
|
||||
project_root
|
||||
.path()
|
||||
.join("docs")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec review"),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((cell.message.clone(), cell.text_elements.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_message, stored_elements) =
|
||||
user_cell.expect("expected submitted user history cell");
|
||||
assert_eq!(stored_message, text);
|
||||
assert_eq!(stored_elements, text_elements);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
@@ -84,6 +84,7 @@ impl FileSearchManager {
|
||||
&self.search_dir,
|
||||
file_search::FileSearchOptions {
|
||||
compute_indices: true,
|
||||
include_dirs: true,
|
||||
..Default::default()
|
||||
},
|
||||
reporter,
|
||||
|
||||
@@ -60,7 +60,7 @@ impl SlashCommand {
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Mention => "mention a file or folder",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Ps => "list background terminals",
|
||||
|
||||
Reference in New Issue
Block a user