diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3461572779..541f54f3f1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -560,6 +560,13 @@ "null" ] }, + "includeDirs": { + "description": "When true, include matching directories in search results.", + "type": [ + "boolean", + "null" + ] + }, "query": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json index 3a72939de4..a12ed296c5 100644 --- a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json +++ b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchParams.json @@ -7,6 +7,13 @@ "null" ] }, + "includeDirs": { + "description": "When true, include matching directories in search results.", + "type": [ + "boolean", + "null" + ] + }, "query": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json index 3309b9fb5d..b8d104e459 100644 --- a/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json +++ b/codex-rs/app-server-protocol/schema/json/FuzzyFileSearchResponse.json @@ -18,6 +18,9 @@ "null" ] }, + "is_dir": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -32,6 +35,7 @@ }, "required": [ "file_name", + "is_dir", "path", "root", "score" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bc72ceb21f..7756c3db9a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5000,6 +5000,13 @@ "null" ] }, + "includeDirs": { + "description": "When true, include matching directories in search results.", + "type": [ + "boolean", + "null" + ] + }, "query": { "type": "string" }, @@ -5050,6 +5057,9 @@ "null" ] }, + "is_dir": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -5064,6 +5074,7 @@ }, "required": [ "file_name", + "is_dir", "path", "root", "score" diff --git a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchParams.ts b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchParams.ts index 02a7a7cfdf..18a49c7407 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchParams.ts @@ -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, cancellationToken: string | null, }; +export type FuzzyFileSearchParams = { query: string, roots: Array, cancellationToken: string | null, includeDirs?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts index e841dbfa04..c663398f20 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FuzzyFileSearchResult.ts @@ -5,4 +5,4 @@ /** * Superset of [`codex_file_search::FileMatch`] */ -export type FuzzyFileSearchResult = { root: string, path: string, file_name: string, score: number, indices: Array | null, }; +export type FuzzyFileSearchResult = { root: string, path: string, file_name: string, is_dir: boolean, score: number, indices: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d20f23a88a..2ee794bf00 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -656,6 +656,10 @@ pub struct FuzzyFileSearchParams { pub roots: Vec, // if provided, will cancel any previous request that used the same value pub cancellation_token: Option, + /// When true, include matching directories in search results. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub include_dirs: Option, } /// Superset of [`codex_file_search::FileMatch`] @@ -664,6 +668,7 @@ pub struct FuzzyFileSearchResult { pub root: String, pub path: String, pub file_name: String, + pub is_dir: bool, pub score: u32, pub indices: Option>, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9e462ca431..0efaa291ea 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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 { diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index fde3031813..5766e1f6e3 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -13,6 +13,7 @@ const MAX_THREADS: usize = 12; pub(crate) async fn run_fuzzy_file_search( query: String, roots: Vec, + include_dirs: bool, cancellation_flag: Arc, ) -> Vec { 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, } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 4804a8cd37..cf6dc40583 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -624,6 +624,7 @@ impl McpProcess { query: &str, roots: Vec, cancellation_token: Option, + include_dirs: Option, ) -> anyhow::Result { 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 } diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 87fdf39117..4be4c9ba3b 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -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(()) } diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 70cb583ebe..d6000cc00e 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -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>, // Sorted & deduplicated when present } @@ -93,6 +94,7 @@ pub struct FileSearchOptions { pub threads: NonZero, 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( 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, shutdown: Arc, reporter: Arc, @@ -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"); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 116e9c3abe..da403e8098 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 3e6ef814a2..735fe6d4e8 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -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() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a952b3354c..8f5568ad6a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 = 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 = 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,77 @@ impl ChatWidget { self.request_redraw(); } + fn expand_directory_mentions_for_model( + &self, + display_text: &str, + text_elements: &[TextElement], + ) -> (String, Vec) { + 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(); + 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); + 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()); + rebuilt.push_str(&format!("[{elem_text}]({abs_dir})")); + } else { + 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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d080140a32..f4c7550e4e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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![UserInput::Text { + text: format!("[docs/]({abs_docs}) 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::() + { + 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; diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index 90e2f7f149..35ce5b57f0 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -84,6 +84,7 @@ impl FileSearchManager { &self.search_dir, file_search::FileSearchOptions { compute_indices: true, + include_dirs: true, ..Default::default() }, reporter, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a71b1ddbe5..034b5ff81c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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",