diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index d55eb929f3f..f38d4c8099c 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -209,7 +209,7 @@ pub fn run( Box::new(move |entry| { if let Some(path) = get_file_path(&entry, search_directory) { - best_list.insert(path); + best_list.insert(&path); } processed += 1; @@ -221,20 +221,25 @@ pub fn run( }) }); - fn get_file_path<'a>( - entry_result: &'a Result, + fn get_file_path( + entry_result: &Result, search_directory: &std::path::Path, - ) -> Option<&'a str> { + ) -> Option { let entry = match entry_result { Ok(e) => e, Err(_) => return None, }; - if entry.file_type().is_some_and(|ft| ft.is_dir()) { - return None; - } + let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); let path = entry.path(); match path.strip_prefix(search_directory) { - Ok(rel_path) => rel_path.to_str(), + Ok(rel_path) => { + let path_str = rel_path.to_str()?; + if is_dir { + Some(format!("{}/", path_str)) + } else { + Some(path_str.to_string()) + } + } Err(_) => None, } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 69a682f2df4..cc89a81af23 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -203,6 +203,50 @@ fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> Co } } +/// Format a directory listing as a tree with visual characters. +/// Limited to immediate children (depth 1), truncated at `limit` items. +fn format_directory_tree(path: &std::path::Path, limit: usize) -> std::io::Result { + let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + + let total = entries.len(); + let truncated = total > limit; + let display_count = if truncated { limit - 1 } else { total }; + + // Use the full path (not just filename) so the agent knows the exact location. + let dir_name = path.to_string_lossy().into_owned(); + let mut result = format!("{}/\n", dir_name); + + for (i, entry) in entries.iter().take(display_count).enumerate() { + let is_last = !truncated && i == display_count - 1; + let prefix = if is_last { + "\u{2514}\u{2500}" + } else { + "\u{251C}\u{2500}" + }; + let suffix = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + "/" + } else { + "" + }; + result.push_str(&format!( + "{} {}{}\n", + prefix, + entry.file_name().to_string_lossy(), + suffix + )); + } + + if truncated { + result.push_str(&format!( + "...\n\u{2514}\u{2500} ({} more items)\n", + total - display_count + )); + } + + Ok(result) +} + impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { @@ -331,6 +375,16 @@ impl From> for ResponseInputItem { } }, UserInput::Skill { .. } => None, // Skill bodies are injected later in core + UserInput::LocalDirectory { path } => match format_directory_tree(&path, 50) { + Ok(listing) => Some(ContentItem::InputText { text: listing }), + Err(err) => Some(ContentItem::InputText { + text: format!( + "Codex could not read the directory at `{}`: {}", + path.display(), + err + ), + }), + }, }) .collect::>(), } @@ -502,7 +556,6 @@ fn convert_content_blocks_to_items( ) -> Option> { let mut saw_image = false; let mut items = Vec::with_capacity(blocks.len()); - tracing::warn!("Blocks: {:?}", blocks); for block in blocks { match block { ContentBlock::TextContent(text) => { @@ -867,4 +920,93 @@ mod tests { Ok(()) } + + #[test] + fn local_directory_formats_tree() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("file1.txt"), b"content")?; + std::fs::write(dir.path().join("file2.rs"), b"fn main() {}")?; + std::fs::create_dir(dir.path().join("subdir"))?; + + let item = ResponseInputItem::from(vec![UserInput::LocalDirectory { + path: dir.path().to_path_buf(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!(content.len(), 1); + match &content[0] { + ContentItem::InputText { text } => { + assert!( + text.contains("file1.txt"), + "should contain file1.txt: {text}" + ); + assert!(text.contains("file2.rs"), "should contain file2.rs: {text}"); + assert!(text.contains("subdir/"), "should contain subdir/: {text}"); + assert!( + text.contains("\u{251C}\u{2500}") || text.contains("\u{2514}\u{2500}"), + "should contain tree chars: {text}" + ); + } + other => panic!("expected text but found {other:?}"), + } + } + other => panic!("expected message but got {other:?}"), + } + Ok(()) + } + + #[test] + fn local_directory_truncates_large_dirs() -> Result<()> { + let dir = tempdir()?; + for i in 0..55 { + std::fs::write(dir.path().join(format!("file{:03}.txt", i)), b"x")?; + } + + let item = ResponseInputItem::from(vec![UserInput::LocalDirectory { + path: dir.path().to_path_buf(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => match &content[0] { + ContentItem::InputText { text } => { + assert!( + text.contains("more items"), + "should show truncation: {text}" + ); + assert!( + !text.contains("file054.txt"), + "should not show last items: {text}" + ); + } + other => panic!("expected text but found {other:?}"), + }, + other => panic!("expected message but got {other:?}"), + } + Ok(()) + } + + #[test] + fn local_directory_read_error_adds_placeholder() -> Result<()> { + let missing = std::path::PathBuf::from("/nonexistent/path/to/dir"); + + let item = ResponseInputItem::from(vec![UserInput::LocalDirectory { + path: missing.clone(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => match &content[0] { + ContentItem::InputText { text } => { + assert!(text.contains("could not read"), "error text: {text}"); + assert!( + text.contains(&missing.display().to_string()), + "should contain path: {text}" + ); + } + other => panic!("expected text but found {other:?}"), + }, + other => panic!("expected message but got {other:?}"), + } + Ok(()) + } } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 26773e1a1a8..4284dbc62d3 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -27,4 +27,10 @@ pub enum UserInput { name: String, path: std::path::PathBuf, }, + + /// Local directory path provided by the user. This will be converted to + /// a text listing during request serialization. + LocalDirectory { + path: std::path::PathBuf, + }, } diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 22e62bb4fba..2872b30aa9b 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -93,6 +93,12 @@ struct AttachedImage { path: PathBuf, } +#[derive(Clone, Debug, PartialEq)] +struct AttachedDirectory { + placeholder: String, + path: PathBuf, +} + enum PromptSelectionMode { Completion, Submit, @@ -118,6 +124,7 @@ pub(crate) struct ChatComposer { large_paste_counters: HashMap, has_focus: bool, attached_images: Vec, + attached_directories: Vec, placeholder_text: String, is_task_running: bool, /// When false, the composer is temporarily read-only (e.g. during sandbox setup). @@ -176,6 +183,7 @@ impl ChatComposer { large_paste_counters: HashMap::new(), has_focus: has_input_focus, attached_images: Vec::new(), + attached_directories: Vec::new(), placeholder_text, is_task_running: false, input_enabled: true, @@ -320,6 +328,7 @@ impl ChatComposer { self.textarea.set_text(""); self.pending_pastes.clear(); self.attached_images.clear(); + self.attached_directories.clear(); self.textarea.set_text(&text); self.textarea.set_cursor(0); self.sync_popups(); @@ -360,6 +369,24 @@ impl ChatComposer { images.into_iter().map(|img| img.path).collect() } + /// Attach a directory to the message. The directory contents will be + /// listed when the message is submitted. + pub fn attach_directory(&mut self, path: PathBuf, item_count: usize) { + let dir_label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dir".to_string()); + let placeholder = format!("[{dir_label}/ {item_count} items]"); + self.textarea.insert_element(&placeholder); + self.attached_directories + .push(AttachedDirectory { placeholder, path }); + } + + pub fn take_recent_submission_directories(&mut self) -> Vec { + let dirs = std::mem::take(&mut self.attached_directories); + dirs.into_iter().map(|d| d.path).collect() + } + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.handle_paste_burst_flush(Instant::now()) } @@ -777,8 +804,43 @@ impl ChatComposer { // Fallback to plain path insertion if metadata read fails. self.insert_selected_path(&sel_path); } + } else if sel_path.ends_with('/') { + // Directory: attach it so contents will be listed on submit. + // Strip trailing '/' to get the actual path. + let dir_path_str = &sel_path[..sel_path.len() - 1]; + let dir_path = PathBuf::from(dir_path_str); + + // Count items for placeholder display. + let item_count = std::fs::read_dir(&dir_path) + .map(|entries| entries.count()) + .unwrap_or(0); + + // Remove the current @token (same logic as image attachment). + 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); + + self.attach_directory(dir_path, item_count); + self.textarea.insert_str(" "); } else { - // Non-image: inserting file path. + // Non-image, non-directory: inserting file path. self.insert_selected_path(&sel_path); } // No selection: treat Enter as closing the popup/session. @@ -1217,7 +1279,8 @@ impl ChatComposer { self.pending_pastes.clear(); // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); + let has_attachments = + !self.attached_images.is_empty() || !self.attached_directories.is_empty(); text = text.trim().to_string(); if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); @@ -1443,6 +1506,29 @@ impl ChatComposer { self.attached_images = kept; } + // Keep attached directories in proportion to how many matching placeholders exist. + if !self.attached_directories.is_empty() { + let mut needed: HashMap = HashMap::new(); + for dir in &self.attached_directories { + needed + .entry(dir.placeholder.clone()) + .or_insert_with(|| text_after.matches(&dir.placeholder).count()); + } + + let mut used: HashMap = HashMap::new(); + let mut kept: Vec = + Vec::with_capacity(self.attached_directories.len()); + for dir in self.attached_directories.drain(..) { + let total_needed = *needed.get(&dir.placeholder).unwrap_or(&0); + let used_count = used.entry(dir.placeholder.clone()).or_insert(0); + if *used_count < total_needed { + kept.push(dir); + *used_count += 1; + } + } + self.attached_directories = kept; + } + (InputResult::None, true) } @@ -1552,6 +1638,98 @@ impl ChatComposer { return true; } + // Try directory placeholders (cursor at END) + let mut dir_out: Option<(usize, String)> = None; + for (i, dir) in self.attached_directories.iter().enumerate() { + let ph = &dir.placeholder; + if p < ph.len() { + continue; + } + let start = p - ph.len(); + if text.get(start..p) != Some(ph.as_str()) { + continue; + } + + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < start { + let segment = match text.get(search_pos..start) { + Some(s) => s, + None => break, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break; + } + } + + dir_out = if let Some((remove_idx, _)) = self + .attached_directories + .iter() + .enumerate() + .filter(|(_, d)| d.placeholder == *ph) + .nth(occ_before) + { + Some((remove_idx, ph.clone())) + } else { + Some((i, ph.clone())) + }; + break; + } + if let Some((idx, placeholder)) = dir_out { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.attached_directories.remove(idx); + return true; + } + + // Try directory placeholders (cursor at START) + let dir_out: Option<(usize, String)> = 'dir_out: { + for (i, dir) in self.attached_directories.iter().enumerate() { + let ph = &dir.placeholder; + if p + ph.len() > text.len() { + continue; + } + if text.get(p..p + ph.len()) != Some(ph.as_str()) { + continue; + } + + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < p { + let segment = match text.get(search_pos..p) { + Some(s) => s, + None => break 'dir_out None, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break 'dir_out None; + } + } + + if let Some((remove_idx, _)) = self + .attached_directories + .iter() + .enumerate() + .filter(|(_, d)| d.placeholder == *ph) + .nth(occ_before) + { + break 'dir_out Some((remove_idx, ph.clone())); + } else { + break 'dir_out Some((i, ph.clone())); + } + } + None + }; + if let Some((idx, placeholder)) = dir_out { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.attached_directories.remove(idx); + return true; + } + // Then try pasted-content placeholders if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { if p < ph.len() { diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 4b6caf0d1aa..1bffb5dbc5a 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -546,6 +546,10 @@ impl BottomPane { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_directories(&mut self) -> Vec { + self.composer.take_recent_submission_directories() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index df5acb442a0..faa46875718 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -343,6 +343,7 @@ pub(crate) struct ChatWidget { struct UserMessage { text: String, image_paths: Vec, + directory_paths: Vec, } impl From for UserMessage { @@ -350,6 +351,7 @@ impl From for UserMessage { Self { text, image_paths: Vec::new(), + directory_paths: Vec::new(), } } } @@ -359,15 +361,24 @@ impl From<&str> for UserMessage { Self { text: text.to_string(), image_paths: Vec::new(), + directory_paths: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +fn create_initial_user_message( + text: String, + image_paths: Vec, + directory_paths: Vec, +) -> Option { + if text.is_empty() && image_paths.is_empty() && directory_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + Some(UserMessage { + text, + image_paths, + directory_paths, + }) } } @@ -1311,6 +1322,7 @@ impl ChatWidget { initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, + Vec::new(), // No initial directories ), token_info: None, rate_limit_snapshot: None, @@ -1395,6 +1407,7 @@ impl ChatWidget { initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, + Vec::new(), // No initial directories ), token_info: None, rate_limit_snapshot: None, @@ -1495,6 +1508,7 @@ impl ChatWidget { let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), + directory_paths: self.bottom_pane.take_recent_submission_directories(), }; self.queue_user_message(user_message); } @@ -1787,8 +1801,12 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + let UserMessage { + text, + image_paths, + directory_paths, + } = user_message; + if text.is_empty() && image_paths.is_empty() && directory_paths.is_empty() { return; } @@ -1820,6 +1838,10 @@ impl ChatWidget { items.push(UserInput::LocalImage { path }); } + for path in directory_paths { + items.push(UserInput::LocalDirectory { path }); + } + if let Some(skills) = self.bottom_pane.skills() { let skill_mentions = find_skill_mentions(&text, skills); for skill in skill_mentions {