mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
2 Commits
exec-run-a
...
custom-ins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d36208931 | ||
|
|
d276932354 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -860,6 +860,7 @@ dependencies = [
|
||||
"shlex",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
|
||||
@@ -64,3 +64,21 @@ pub fn is_inside_git_repo(config: &Config) -> bool {
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// If `val` is a path to a readable file, return its trimmed contents.
|
||||
///
|
||||
/// - When `val` points to a file, this reads the file, trims leading/trailing
|
||||
/// whitespace and returns `Ok(Some(contents))` unless the trimmed contents are
|
||||
/// empty in which case it returns `Ok(None)`.
|
||||
/// - When `val` is not a file path, return `Ok(Some(val.to_string()))` so
|
||||
/// callers can treat the value as a literal string.
|
||||
pub fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
|
||||
let p = std::path::Path::new(val);
|
||||
if p.is_file() {
|
||||
let s = std::fs::read_to_string(p)?;
|
||||
let s = s.trim().to_string();
|
||||
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
|
||||
} else {
|
||||
Ok(Some(val.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,40 @@ pub struct Cli {
|
||||
/// if `-` is used), instructions are read from stdin.
|
||||
#[arg(value_name = "PROMPT")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Override the built-in system prompt (base instructions).
|
||||
///
|
||||
/// If the value looks like a path to an existing file, the contents of the
|
||||
/// file are used. Otherwise, the value itself is used verbatim as the
|
||||
/// instructions string.
|
||||
#[arg(long = "experimental-instructions")]
|
||||
pub experimental_instructions: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Cli;
|
||||
use clap::CommandFactory;
|
||||
|
||||
#[test]
|
||||
fn help_includes_file_behavior_for_experimental_instructions() {
|
||||
let mut cmd = Cli::command();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
|
||||
let help = match String::from_utf8(buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => panic!("invalid utf8: {e}"),
|
||||
};
|
||||
assert!(help.contains("Override the built-in system prompt (base instructions)."));
|
||||
assert!(help.contains(
|
||||
"If the value looks like a path to an existing file, the contents of the file are used."
|
||||
));
|
||||
assert!(
|
||||
help.contains(
|
||||
"Otherwise, the value itself is used verbatim as the instructions string."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
@@ -20,7 +21,16 @@ pub(crate) trait EventProcessor {
|
||||
fn process_event(&mut self, event: Event) -> CodexStatus;
|
||||
}
|
||||
|
||||
pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PromptOrigin {
|
||||
File(PathBuf),
|
||||
Literal,
|
||||
}
|
||||
|
||||
pub(crate) fn create_config_summary_entries(
|
||||
config: &Config,
|
||||
prompt_origin: Option<&PromptOrigin>,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
let mut entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
@@ -28,6 +38,16 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if let Some(origin) = prompt_origin {
|
||||
let prompt_val = match origin {
|
||||
PromptOrigin::Literal => "experimental".to_string(),
|
||||
PromptOrigin::File(path) => path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
};
|
||||
entries.push(("prompt_origin", prompt_val));
|
||||
}
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& model_supports_reasoning_summaries(config)
|
||||
{
|
||||
@@ -68,3 +88,64 @@ fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn minimal_config() -> Config {
|
||||
let cwd = match TempDir::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempdir error: {e}"),
|
||||
};
|
||||
let codex_home = match TempDir::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempdir error: {e}"),
|
||||
};
|
||||
let cfg = ConfigToml {
|
||||
..Default::default()
|
||||
};
|
||||
let overrides = ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
};
|
||||
match Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
overrides,
|
||||
codex_home.path().to_path_buf(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => panic!("config error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_include_prompt_origin_experimental_for_literal_origin() {
|
||||
let mut cfg = minimal_config();
|
||||
cfg.base_instructions = Some("hello".to_string());
|
||||
let entries = create_config_summary_entries(&cfg, Some(&PromptOrigin::Literal));
|
||||
let map: HashMap<_, _> = entries.into_iter().collect();
|
||||
assert_eq!(
|
||||
map.get("prompt_origin").cloned(),
|
||||
Some("experimental".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_include_prompt_origin_filename_for_file_origin() {
|
||||
let mut cfg = minimal_config();
|
||||
cfg.base_instructions = Some("hello".to_string());
|
||||
let path = PathBuf::from("/tmp/custom_instructions.txt");
|
||||
let entries = create_config_summary_entries(&cfg, Some(&PromptOrigin::File(path.clone())));
|
||||
let map: HashMap<_, _> = entries.into_iter().collect();
|
||||
assert_eq!(
|
||||
map.get("prompt_origin").cloned(),
|
||||
Some("custom_instructions.txt".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::PromptOrigin;
|
||||
use crate::event_processor::create_config_summary_entries;
|
||||
use crate::event_processor::handle_last_message;
|
||||
|
||||
@@ -59,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
|
||||
answer_started: bool,
|
||||
reasoning_started: bool,
|
||||
last_message_path: Option<PathBuf>,
|
||||
prompt_origin: Option<PromptOrigin>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithHumanOutput {
|
||||
@@ -66,6 +68,7 @@ impl EventProcessorWithHumanOutput {
|
||||
with_ansi: bool,
|
||||
config: &Config,
|
||||
last_message_path: Option<PathBuf>,
|
||||
prompt_origin: Option<PromptOrigin>,
|
||||
) -> Self {
|
||||
let call_id_to_command = HashMap::new();
|
||||
let call_id_to_patch = HashMap::new();
|
||||
@@ -87,6 +90,7 @@ impl EventProcessorWithHumanOutput {
|
||||
answer_started: false,
|
||||
reasoning_started: false,
|
||||
last_message_path,
|
||||
prompt_origin,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
@@ -104,6 +108,7 @@ impl EventProcessorWithHumanOutput {
|
||||
answer_started: false,
|
||||
reasoning_started: false,
|
||||
last_message_path,
|
||||
prompt_origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +155,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
VERSION
|
||||
);
|
||||
|
||||
let entries = create_config_summary_entries(config);
|
||||
let entries = create_config_summary_entries(config, self.prompt_origin.as_ref());
|
||||
|
||||
for (key, value) in entries {
|
||||
println!("{} {}", format!("{key}:").style(self.bold), value);
|
||||
|
||||
@@ -9,22 +9,27 @@ use serde_json::json;
|
||||
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::PromptOrigin;
|
||||
use crate::event_processor::create_config_summary_entries;
|
||||
use crate::event_processor::handle_last_message;
|
||||
|
||||
pub(crate) struct EventProcessorWithJsonOutput {
|
||||
last_message_path: Option<PathBuf>,
|
||||
prompt_origin: Option<PromptOrigin>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithJsonOutput {
|
||||
pub fn new(last_message_path: Option<PathBuf>) -> Self {
|
||||
Self { last_message_path }
|
||||
pub fn new(last_message_path: Option<PathBuf>, prompt_origin: Option<PromptOrigin>) -> Self {
|
||||
Self {
|
||||
last_message_path,
|
||||
prompt_origin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventProcessor for EventProcessorWithJsonOutput {
|
||||
fn print_config_summary(&mut self, config: &Config, prompt: &str) {
|
||||
let entries = create_config_summary_entries(config)
|
||||
let entries = create_config_summary_entries(config, self.prompt_origin.as_ref())
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_string(), value))
|
||||
.collect::<HashMap<String, String>>();
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::event_processor::PromptOrigin;
|
||||
pub use cli::Cli;
|
||||
use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::{self};
|
||||
@@ -21,6 +22,7 @@ use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use codex_core::util::maybe_read_file;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_json_output::EventProcessorWithJsonOutput;
|
||||
use tracing::debug;
|
||||
@@ -45,9 +47,38 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
json: json_mode,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt,
|
||||
experimental_instructions,
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
// Determine how to describe experimental instructions in the summary and
|
||||
// prepare the effective base instructions. If the flag points at a file,
|
||||
// read its contents; otherwise use the value verbatim.
|
||||
let mut prompt_origin = match experimental_instructions.as_deref() {
|
||||
Some(val) => {
|
||||
let p = std::path::Path::new(val);
|
||||
if p.is_file() {
|
||||
Some(PromptOrigin::File(p.to_path_buf()))
|
||||
} else {
|
||||
Some(PromptOrigin::Literal)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let experimental_instructions = match experimental_instructions {
|
||||
Some(val) => match maybe_read_file(&val) {
|
||||
Ok(Some(contents)) => Some(contents),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read --experimental-instructions file: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let has_experimental = experimental_instructions.is_some();
|
||||
|
||||
// Determine the prompt based on CLI arg and/or stdin.
|
||||
let prompt = match prompt {
|
||||
Some(p) if p != "-" => p,
|
||||
@@ -111,7 +142,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
|
||||
model_provider: None,
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
base_instructions: experimental_instructions,
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
@@ -123,13 +154,21 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
};
|
||||
|
||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
|
||||
if !has_experimental {
|
||||
prompt_origin = None;
|
||||
}
|
||||
|
||||
let mut event_processor: Box<dyn EventProcessor> = if json_mode {
|
||||
Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
|
||||
Box::new(EventProcessorWithJsonOutput::new(
|
||||
last_message_file.clone(),
|
||||
prompt_origin.clone(),
|
||||
))
|
||||
} else {
|
||||
Box::new(EventProcessorWithHumanOutput::create_with_ansi(
|
||||
stdout_with_ansi,
|
||||
&config,
|
||||
last_message_file.clone(),
|
||||
prompt_origin,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -245,3 +284,53 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_core::util::maybe_read_file;
|
||||
use std::fs;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_returns_literal_for_non_path() {
|
||||
let res = match maybe_read_file("You are a helpful assistant.") {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("error: {e}"),
|
||||
};
|
||||
assert_eq!(res, Some("You are a helpful assistant.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_reads_and_trims_file_contents() {
|
||||
let tf = match NamedTempFile::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempfile: {e}"),
|
||||
};
|
||||
if let Err(e) = fs::write(tf.path(), " Hello world\n") {
|
||||
panic!("write temp file: {e}");
|
||||
}
|
||||
let path_s = tf.path().to_string_lossy().to_string();
|
||||
let res = match maybe_read_file(&path_s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("should read file successfully: {e}"),
|
||||
};
|
||||
assert_eq!(res, Some("Hello world".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_empty_file_returns_none() {
|
||||
let tf = match NamedTempFile::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempfile: {e}"),
|
||||
};
|
||||
if let Err(e) = fs::write(tf.path(), " \n\t ") {
|
||||
panic!("write temp file: {e}");
|
||||
}
|
||||
let path_s = tf.path().to_string_lossy().to_string();
|
||||
let res = match maybe_read_file(&path_s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("should read file successfully: {e}"),
|
||||
};
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,3 +65,4 @@ uuid = "1"
|
||||
[dev-dependencies]
|
||||
insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3.13.0"
|
||||
|
||||
@@ -68,6 +68,7 @@ struct ChatWidgetArgs {
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
prompt_label: Option<String>,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
@@ -77,6 +78,7 @@ impl App<'_> {
|
||||
show_login_screen: bool,
|
||||
show_git_warning: bool,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
prompt_label: Option<String>,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
@@ -147,6 +149,7 @@ impl App<'_> {
|
||||
config: config.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
prompt_label: prompt_label.clone(),
|
||||
}),
|
||||
)
|
||||
} else if show_git_warning {
|
||||
@@ -158,6 +161,7 @@ impl App<'_> {
|
||||
config: config.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
prompt_label: prompt_label.clone(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
@@ -166,6 +170,7 @@ impl App<'_> {
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
prompt_label.clone(),
|
||||
);
|
||||
(
|
||||
AppState::Chat {
|
||||
@@ -301,6 +306,7 @@ impl App<'_> {
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
));
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
@@ -392,6 +398,7 @@ impl App<'_> {
|
||||
self.app_event_tx.clone(),
|
||||
args.initial_prompt,
|
||||
args.initial_images,
|
||||
args.prompt_label,
|
||||
));
|
||||
self.app_state = AppState::Chat { widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
|
||||
@@ -55,6 +55,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
// We wait for the final AgentMessage event and then emit the full text
|
||||
// at once into scrollback so the history contains a single message.
|
||||
answer_buffer: String,
|
||||
prompt_label: Option<String>,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -85,6 +86,7 @@ impl ChatWidget<'_> {
|
||||
app_event_tx: AppEventSender,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
prompt_label: Option<String>,
|
||||
) -> Self {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
@@ -140,6 +142,7 @@ impl ChatWidget<'_> {
|
||||
token_usage: TokenUsage::default(),
|
||||
reasoning_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
prompt_label,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +212,11 @@ impl ChatWidget<'_> {
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
// Record session information at the top of the conversation.
|
||||
self.conversation_history
|
||||
.add_session_info(&self.config, event.clone());
|
||||
self.conversation_history.add_session_info(
|
||||
&self.config,
|
||||
event.clone(),
|
||||
self.prompt_label.as_deref(),
|
||||
);
|
||||
// Immediately surface the session banner / settings summary in
|
||||
// scrollback so the user can review configuration (model,
|
||||
// sandbox, approvals, etc.) before interacting.
|
||||
|
||||
@@ -53,4 +53,38 @@ pub struct Cli {
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Override the built-in system prompt (base instructions).
|
||||
///
|
||||
/// If the value looks like a path to an existing file, the contents of the
|
||||
/// file are used. Otherwise, the value itself is used verbatim as the
|
||||
/// instructions string.
|
||||
#[arg(long = "experimental-instructions")]
|
||||
pub experimental_instructions: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Cli;
|
||||
use clap::CommandFactory;
|
||||
|
||||
#[test]
|
||||
fn help_includes_file_behavior_for_experimental_instructions() {
|
||||
let mut cmd = Cli::command();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
|
||||
let help = match String::from_utf8(buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => panic!("invalid utf8: {e}"),
|
||||
};
|
||||
assert!(help.contains("Override the built-in system prompt (base instructions)."));
|
||||
assert!(help.contains(
|
||||
"If the value looks like a path to an existing file, the contents of the file are used."
|
||||
));
|
||||
assert!(
|
||||
help.contains(
|
||||
"Otherwise, the value itself is used verbatim as the instructions string."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,12 @@ impl ConversationHistoryWidget {
|
||||
|
||||
/// Note `model` could differ from `config.model` if the agent decided to
|
||||
/// use a different model than the one requested by the user.
|
||||
pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) {
|
||||
pub fn add_session_info(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
prompt_label: Option<&str>,
|
||||
) {
|
||||
// In practice, SessionConfiguredEvent should always be the first entry
|
||||
// in the history, but it is possible that an error could be sent
|
||||
// before the session info.
|
||||
@@ -111,6 +116,7 @@ impl ConversationHistoryWidget {
|
||||
config,
|
||||
event,
|
||||
!has_welcome_message,
|
||||
prompt_label,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ impl HistoryCell {
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
prompt_label: Option<&str>,
|
||||
) -> Self {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
@@ -183,6 +184,9 @@ impl HistoryCell {
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if let Some(label) = prompt_label {
|
||||
entries.push(("prompt", label.to_string()));
|
||||
}
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& model_supports_reasoning_summaries(config)
|
||||
{
|
||||
@@ -581,6 +585,88 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use uuid::Uuid;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn minimal_config() -> Config {
|
||||
let cwd = match TempDir::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempdir error: {e}"),
|
||||
};
|
||||
let codex_home = match TempDir::new() {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("tempdir error: {e}"),
|
||||
};
|
||||
let cfg = ConfigToml {
|
||||
..Default::default()
|
||||
};
|
||||
let overrides = ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
};
|
||||
match Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
overrides,
|
||||
codex_home.path().to_path_buf(),
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => panic!("config error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.spans.iter().map(|s| s.content.to_string()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_includes_prompt_label_experimental() {
|
||||
let cfg = minimal_config();
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: Uuid::nil(),
|
||||
model: cfg.model.clone(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
};
|
||||
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("experimental"));
|
||||
let lines = cell.plain_lines();
|
||||
let strings = lines_to_strings(&lines);
|
||||
assert!(
|
||||
strings.iter().any(|s| s.contains("prompt: experimental")),
|
||||
"welcome should include prompt label; got: {strings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_includes_prompt_label_filename() {
|
||||
let cfg = minimal_config();
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: Uuid::nil(),
|
||||
model: cfg.model.clone(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
};
|
||||
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("instructions.md"));
|
||||
let lines = cell.plain_lines();
|
||||
let strings = lines_to_strings(&lines);
|
||||
assert!(
|
||||
strings
|
||||
.iter()
|
||||
.any(|s| s.contains("prompt: instructions.md")),
|
||||
"welcome should include filename prompt label; got: {strings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `CellWidget` implementation – most variants delegate to their internal
|
||||
// `TextBlock`. Variants that need custom painting can add their own logic in
|
||||
|
||||
@@ -11,9 +11,11 @@ use codex_core::openai_api_key::get_openai_api_key;
|
||||
use codex_core::openai_api_key::set_openai_api_key;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use codex_core::util::maybe_read_file;
|
||||
use codex_login::try_read_openai_api_key;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -68,8 +70,50 @@ pub fn run_main(
|
||||
)
|
||||
};
|
||||
|
||||
let config = {
|
||||
// Capture any read error for experimental instructions so we can log it
|
||||
// after the tracing subscriber has been initialized.
|
||||
let mut experimental_read_error: Option<String> = None;
|
||||
|
||||
let (config, experimental_prompt_label) = {
|
||||
// Load configuration and support CLI overrides.
|
||||
// If the experimental instructions flag points at a file, read its
|
||||
// contents; otherwise use the value verbatim. Avoid printing to stdout
|
||||
// or stderr in this library crate – fallback to the raw string on
|
||||
// errors.
|
||||
let base_instructions =
|
||||
cli.experimental_instructions
|
||||
.as_deref()
|
||||
.and_then(|s| match maybe_read_file(s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
experimental_read_error = Some(format!(
|
||||
"Failed to read experimental instructions from '{s}': {e}"
|
||||
));
|
||||
Some(s.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
// Derive a label shown in the welcome banner describing the origin of
|
||||
// the experimental instructions: filename for file paths and
|
||||
// "experimental" for literals.
|
||||
let experimental_prompt_label = cli.experimental_instructions.as_deref().map(|s| {
|
||||
let p = Path::new(s);
|
||||
if p.is_file() {
|
||||
p.file_name()
|
||||
.map(|os| os.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| s.to_string())
|
||||
} else {
|
||||
"experimental".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
// Do not show a label if the file was empty (base_instructions is None).
|
||||
let experimental_prompt_label = if base_instructions.is_some() {
|
||||
experimental_prompt_label
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
model: cli.model.clone(),
|
||||
approval_policy,
|
||||
@@ -78,7 +122,7 @@ pub fn run_main(
|
||||
model_provider: None,
|
||||
config_profile: cli.config_profile.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
base_instructions,
|
||||
};
|
||||
// Parse `-c` overrides from the CLI.
|
||||
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
|
||||
@@ -92,7 +136,7 @@ pub fn run_main(
|
||||
|
||||
#[allow(clippy::print_stderr)]
|
||||
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
|
||||
Ok(config) => config,
|
||||
Ok(config) => (config, experimental_prompt_label),
|
||||
Err(err) => {
|
||||
eprintln!("Error loading configuration: {err}");
|
||||
std::process::exit(1);
|
||||
@@ -142,6 +186,12 @@ pub fn run_main(
|
||||
.with(tui_layer)
|
||||
.try_init();
|
||||
|
||||
if let Some(msg) = experimental_read_error {
|
||||
// Now that logging is initialized, record a warning so the user
|
||||
// can see that Codex fell back to using the literal string.
|
||||
tracing::warn!("{msg}");
|
||||
}
|
||||
|
||||
let show_login_screen = should_show_login_screen(&config);
|
||||
|
||||
// Determine whether we need to display the "not a git repo" warning
|
||||
@@ -150,8 +200,15 @@ pub fn run_main(
|
||||
// `--allow-no-git-exec` flag.
|
||||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
|
||||
|
||||
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
run_ratatui_app(
|
||||
cli,
|
||||
config,
|
||||
show_login_screen,
|
||||
show_git_warning,
|
||||
experimental_prompt_label,
|
||||
log_rx,
|
||||
)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
@@ -159,6 +216,7 @@ fn run_ratatui_app(
|
||||
config: Config,
|
||||
show_login_screen: bool,
|
||||
show_git_warning: bool,
|
||||
experimental_prompt_label: Option<String>,
|
||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||||
color_eyre::install()?;
|
||||
@@ -178,6 +236,7 @@ fn run_ratatui_app(
|
||||
show_login_screen,
|
||||
show_git_warning,
|
||||
images,
|
||||
experimental_prompt_label,
|
||||
);
|
||||
|
||||
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
|
||||
@@ -244,3 +303,56 @@ fn is_in_need_of_openai_api_key(config: &Config) -> bool {
|
||||
.unwrap_or(false);
|
||||
is_using_openai_key && get_openai_api_key().is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_core::util::maybe_read_file;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_path() -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("codex_tui_test_{}.txt", Uuid::new_v4()));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_returns_literal_for_non_path() {
|
||||
let res = match maybe_read_file("Base instructions as a string") {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("error: {e}"),
|
||||
};
|
||||
assert_eq!(res, Some("Base instructions as a string".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_reads_and_trims_file_contents() {
|
||||
let p = temp_path();
|
||||
if let Err(e) = fs::write(&p, " file text \n") {
|
||||
panic!("write temp file: {e}");
|
||||
}
|
||||
let p_s = p.to_string_lossy().to_string();
|
||||
let res = match maybe_read_file(&p_s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("error: {e}"),
|
||||
};
|
||||
assert_eq!(res, Some("file text".to_string()));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_read_file_empty_file_returns_none() {
|
||||
let p = temp_path();
|
||||
if let Err(e) = fs::write(&p, " \n\t") {
|
||||
panic!("write temp file: {e}");
|
||||
}
|
||||
let p_s = p.to_string_lossy().to_string();
|
||||
let res = match maybe_read_file(&p_s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("error: {e}"),
|
||||
};
|
||||
assert_eq!(res, None);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user