Compare commits

...

3 Commits

Author SHA1 Message Date
Jeremy Rose
00d9203eeb Merge remote-tracking branch 'origin/main' into nornagon/show-agents-md 2025-08-14 17:01:19 -07:00
Jeremy Rose
52f8d08cef better message 2025-08-14 12:23:44 -07:00
Jeremy Rose
22aefec50e hide the "/init" hint if there's already an AGENTS.md 2025-08-14 12:12:04 -07:00
5 changed files with 115 additions and 65 deletions

7
codex-rs/Cargo.lock generated
View File

@@ -887,6 +887,7 @@ dependencies = [
"mcp-types",
"once_cell",
"path-clean",
"pathdiff",
"pretty_assertions",
"rand 0.8.5",
"ratatui",
@@ -3130,6 +3131,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.1"

View File

@@ -43,7 +43,7 @@ mod models;
mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
mod project_doc;
pub mod project_doc;
pub mod protocol;
mod rollout;
pub(crate) mod safety;

View File

@@ -13,6 +13,7 @@
use crate::config::Config;
use std::path::Path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use tracing::error;
@@ -26,12 +27,19 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
match find_project_doc(config).await {
Ok(Some(project_doc)) => match &config.user_instructions {
Some(original_instructions) => Some(format!(
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
)),
None => Some(project_doc),
match find_project_doc(&config.cwd).await {
Ok(Some(path)) => match read_limited(&path, config.project_doc_max_bytes).await {
Ok(Some(project_doc)) => match &config.user_instructions {
Some(original_instructions) => Some(format!(
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
)),
None => Some(project_doc),
},
Ok(None) => config.user_instructions.clone(),
Err(e) => {
error!("error trying to read project doc: {e:#}");
config.user_instructions.clone()
}
},
Ok(None) => config.user_instructions.clone(),
Err(e) => {
@@ -41,25 +49,31 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
}
}
/// Attempt to locate and load the project documentation. Currently, the search
/// starts from `Config::cwd`, but if we may want to consider other directories
/// in the future, e.g., additional writable directories in the `SandboxPolicy`.
///
/// On success returns `Ok(Some(contents))`. If no documentation file is found
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
/// `Err` so callers can decide how to handle them.
async fn find_project_doc(config: &Config) -> std::io::Result<Option<String>> {
let max_bytes = config.project_doc_max_bytes;
// Same as `find_project_doc` but sync.
pub fn find_project_doc_sync(cwd: &Path) -> std::io::Result<Option<PathBuf>> {
let cwd = cwd.to_path_buf();
let handle = tokio::runtime::Handle::current();
#[allow(clippy::expect_used)]
std::thread::spawn(move || handle.block_on(async { find_project_doc(&cwd).await }))
.join()
.expect("join should not fail")
}
// Attempt to load from the working directory first.
if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES, max_bytes).await? {
return Ok(Some(doc));
/// Attempt to locate the project documentation file.
///
/// On success returns `Ok(Some(path))`. If no documentation file is found the
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
/// callers can decide how to handle them.
pub(crate) async fn find_project_doc(cwd: &Path) -> std::io::Result<Option<PathBuf>> {
// Attempt to locate the file in the working directory first.
if let Some(path) = find_candidate_in_dir(cwd).await? {
return Ok(Some(path));
}
// Walk up towards the filesystem root, stopping once we encounter the Git
// repository root. The presence of **either** a `.git` *file* or
// *directory* counts.
let mut dir = config.cwd.clone();
let mut dir = cwd.to_path_buf();
// Canonicalize the path so that we do not end up in an infinite loop when
// `cwd` contains `..` components.
@@ -77,9 +91,8 @@ async fn find_project_doc(config: &Config) -> std::io::Result<Option<String>> {
};
if git_exists {
// We are at the repo root attempt one final load.
if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES, max_bytes).await? {
return Ok(Some(doc));
if let Some(path) = find_candidate_in_dir(&dir).await? {
return Ok(Some(path));
}
break;
}
@@ -90,48 +103,50 @@ async fn find_project_doc(config: &Config) -> std::io::Result<Option<String>> {
Ok(None)
}
/// Attempt to load the first candidate file found in `dir`. Returns the file
/// contents (truncated if it exceeds `max_bytes`) when successful.
async fn load_first_candidate(
dir: &Path,
names: &[&str],
max_bytes: usize,
) -> std::io::Result<Option<String>> {
for name in names {
async fn find_candidate_in_dir(dir: &Path) -> std::io::Result<Option<PathBuf>> {
for name in CANDIDATE_FILENAMES {
let candidate = dir.join(name);
let file = match tokio::fs::File::open(&candidate).await {
match tokio::fs::metadata(&candidate).await {
Ok(_) => return Ok(Some(candidate)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
Ok(f) => f,
};
let size = file.metadata().await?.len();
let reader = tokio::io::BufReader::new(file);
let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes));
let mut limited = reader.take(max_bytes as u64);
limited.read_to_end(&mut data).await?;
if size as usize > max_bytes {
tracing::warn!(
"Project doc `{}` exceeds {max_bytes} bytes - truncating.",
candidate.display(),
);
}
let contents = String::from_utf8_lossy(&data).to_string();
if contents.trim().is_empty() {
// Empty file treat as not found.
continue;
}
return Ok(Some(contents));
}
Ok(None)
}
/// Read the project documentation from `path`, returning the contents truncated
/// to `max_bytes`. Empty files are treated as absent.
async fn read_limited(path: &Path, max_bytes: usize) -> std::io::Result<Option<String>> {
let file = match tokio::fs::File::open(path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
Ok(f) => f,
};
let size = file.metadata().await?.len();
let reader = tokio::io::BufReader::new(file);
let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes));
let mut limited = reader.take(max_bytes as u64);
limited.read_to_end(&mut data).await?;
if size as usize > max_bytes {
tracing::warn!(
"Project doc `{}` exceeds {max_bytes} bytes - truncating.",
path.display(),
);
}
let contents = String::from_utf8_lossy(&data).to_string();
if contents.trim().is_empty() {
return Ok(None);
}
Ok(Some(contents))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]

View File

@@ -44,6 +44,7 @@ lazy_static = "1"
once_cell = "1"
mcp-types = { path = "../mcp-types" }
path-clean = "1.0.1"
pathdiff = "0.2"
ratatui = { version = "0.29.0", features = [
"scrolling-regions",
"unstable-rendered-line-info",

View File

@@ -13,6 +13,7 @@ use codex_core::parse_command::ParsedCommand;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::project_doc::find_project_doc_sync;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::SandboxPolicy;
@@ -24,6 +25,7 @@ use image::DynamicImage;
use image::ImageReader;
use mcp_types::EmbeddedResourceResource;
use mcp_types::ResourceLink;
use pathdiff::diff_paths;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -161,7 +163,9 @@ pub(crate) fn new_session_info(
None => config.cwd.display().to_string(),
};
let lines: Vec<Line<'static>> = vec![
let agents_path = find_project_doc_sync(&config.cwd).unwrap_or_default();
let mut lines: Vec<Line<'static>> = vec![
Line::from(vec![
Span::raw(">_ ").dim(),
Span::styled(
@@ -170,15 +174,38 @@ pub(crate) fn new_session_info(
),
Span::raw(format!(" {cwd_str}")).dim(),
]),
Line::from("".dim()),
Line::from(" To get started, describe a task or try one of these commands:".dim()),
Line::from("".dim()),
Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()),
Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()),
Line::from(format!(" /prompts - {}", SlashCommand::Prompts.description()).dim()),
Line::from("".dim()),
Line::from(""),
];
if let Some(p) = &agents_path {
let instructions_display_path = diff_paths(p, &config.cwd)
.map(|rel| rel.display().to_string())
.unwrap_or_else(|| p.display().to_string());
lines.push(Line::from(vec![
" Found ".dim(),
instructions_display_path.into(),
".".dim(),
]));
lines.push(Line::from(""));
}
lines.push(Line::from(
" To get started, describe a task or try one of these commands:".dim(),
));
lines.push(Line::from(""));
if agents_path.is_none() {
lines.push(Line::from(
format!(" /init - {}", SlashCommand::Init.description()).dim(),
));
}
lines.push(Line::from(
format!(" /status - {}", SlashCommand::Status.description()).dim(),
));
lines.push(Line::from(
format!(" /diff - {}", SlashCommand::Diff.description()).dim(),
));
lines.push(Line::from(
format!(" /prompts - {}", SlashCommand::Prompts.description()).dim(),
));
lines.push(Line::from("".dim()));
PlainHistoryCell { lines }
} else if config.model == model {
PlainHistoryCell { lines: Vec::new() }