Compare commits

...

4 Commits

Author SHA1 Message Date
canvrno-oai
dd5c805093 comments 2026-02-03 14:26:08 -08:00
canvrno-oai
09f6e85b13 Removed comment 2026-02-03 11:54:30 -08:00
canvrno-oai
a6dbb621f9 Merge branch 'main' into canvrno/folder_mentions_tui_2 2026-02-03 11:26:08 -08:00
canvrno-oai
cb094f2071 Add support for folder mentions in Codex TUI 2026-02-03 10:53:48 -08:00
18 changed files with 436 additions and 116 deletions

View File

@@ -560,6 +560,12 @@
"null"
]
},
"includeDirs": {
"type": [
"boolean",
"null"
]
},
"query": {
"type": "string"
},

View File

@@ -7,6 +7,12 @@
"null"
]
},
"includeDirs": {
"type": [
"boolean",
"null"
]
},
"query": {
"type": "string"
},

View File

@@ -18,6 +18,9 @@
"null"
]
},
"is_dir": {
"type": "boolean"
},
"path": {
"type": "string"
},
@@ -32,6 +35,7 @@
},
"required": [
"file_name",
"is_dir",
"path",
"root",
"score"

View File

@@ -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"

View File

@@ -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, };

View File

@@ -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, };

View File

@@ -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>>,
}

View File

@@ -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 {

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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(())
}

View File

@@ -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");

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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::<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;

View File

@@ -84,6 +84,7 @@ impl FileSearchManager {
&self.search_dir,
file_search::FileSearchOptions {
compute_indices: true,
include_dirs: true,
..Default::default()
},
reporter,

View File

@@ -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",