mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
add --experimental-prompt support
This commit is contained in:
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",
|
||||
|
||||
@@ -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 ExperimentalInstructionsOrigin {
|
||||
File(PathBuf),
|
||||
Literal,
|
||||
}
|
||||
|
||||
pub(crate) fn create_config_summary_entries(
|
||||
config: &Config,
|
||||
experimental_origin: Option<&ExperimentalInstructionsOrigin>,
|
||||
) -> 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) = experimental_origin {
|
||||
let prompt_val = match origin {
|
||||
ExperimentalInstructionsOrigin::Literal => "experimental".to_string(),
|
||||
ExperimentalInstructionsOrigin::File(path) => path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
};
|
||||
entries.push(("prompt", prompt_val));
|
||||
}
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& model_supports_reasoning_summaries(config)
|
||||
{
|
||||
@@ -68,3 +88,65 @@ 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_experimental_for_literal_origin() {
|
||||
let mut cfg = minimal_config();
|
||||
cfg.base_instructions = Some("hello".to_string());
|
||||
let entries =
|
||||
create_config_summary_entries(&cfg, Some(&ExperimentalInstructionsOrigin::Literal));
|
||||
let map: HashMap<_, _> = entries.into_iter().collect();
|
||||
assert_eq!(map.get("prompt").cloned(), Some("experimental".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_include_prompt_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(&ExperimentalInstructionsOrigin::File(path.clone())),
|
||||
);
|
||||
let map: HashMap<_, _> = entries.into_iter().collect();
|
||||
assert_eq!(
|
||||
map.get("prompt").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::ExperimentalInstructionsOrigin;
|
||||
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>,
|
||||
experimental_origin: Option<ExperimentalInstructionsOrigin>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithHumanOutput {
|
||||
@@ -66,6 +68,7 @@ impl EventProcessorWithHumanOutput {
|
||||
with_ansi: bool,
|
||||
config: &Config,
|
||||
last_message_path: Option<PathBuf>,
|
||||
experimental_origin: Option<ExperimentalInstructionsOrigin>,
|
||||
) -> 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,
|
||||
experimental_origin,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
@@ -104,6 +108,7 @@ impl EventProcessorWithHumanOutput {
|
||||
answer_started: false,
|
||||
reasoning_started: false,
|
||||
last_message_path,
|
||||
experimental_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.experimental_origin.as_ref());
|
||||
|
||||
for (key, value) in entries {
|
||||
println!("{} {}", format!("{key}:").style(self.bold), value);
|
||||
|
||||
@@ -9,22 +9,30 @@ use serde_json::json;
|
||||
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::ExperimentalInstructionsOrigin;
|
||||
use crate::event_processor::create_config_summary_entries;
|
||||
use crate::event_processor::handle_last_message;
|
||||
|
||||
pub(crate) struct EventProcessorWithJsonOutput {
|
||||
last_message_path: Option<PathBuf>,
|
||||
experimental_origin: Option<ExperimentalInstructionsOrigin>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithJsonOutput {
|
||||
pub fn new(last_message_path: Option<PathBuf>) -> Self {
|
||||
Self { last_message_path }
|
||||
pub fn new(
|
||||
last_message_path: Option<PathBuf>,
|
||||
experimental_origin: Option<ExperimentalInstructionsOrigin>,
|
||||
) -> Self {
|
||||
Self {
|
||||
last_message_path,
|
||||
experimental_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.experimental_origin.as_ref())
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_string(), value))
|
||||
.collect::<HashMap<String, String>>();
|
||||
|
||||
@@ -5,9 +5,11 @@ mod event_processor_with_json_output;
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::event_processor::ExperimentalInstructionsOrigin;
|
||||
pub use cli::Cli;
|
||||
use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::{self};
|
||||
@@ -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 experimental_origin = match experimental_instructions.as_deref() {
|
||||
Some(val) => {
|
||||
let p = std::path::Path::new(val);
|
||||
if p.is_file() {
|
||||
Some(ExperimentalInstructionsOrigin::File(p.to_path_buf()))
|
||||
} else {
|
||||
Some(ExperimentalInstructionsOrigin::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 {
|
||||
experimental_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(),
|
||||
experimental_origin.clone(),
|
||||
))
|
||||
} else {
|
||||
Box::new(EventProcessorWithHumanOutput::create_with_ansi(
|
||||
stdout_with_ansi,
|
||||
&config,
|
||||
last_message_file.clone(),
|
||||
experimental_origin,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -245,3 +284,66 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// If `val` is a path to a readable file, return its trimmed contents. If the
|
||||
// file is empty after trimming, return Ok(None). Otherwise, return Ok(Some(val)).
|
||||
fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
|
||||
let p = 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()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::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,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::error;
|
||||
#[cfg(test)]
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
@@ -151,6 +153,7 @@ impl HistoryCell {
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
prompt_label: Option<&str>,
|
||||
) -> Self {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
@@ -183,6 +186,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 +587,87 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_core::util::is_inside_git_repo;
|
||||
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 +69,38 @@ pub fn run_main(
|
||||
)
|
||||
};
|
||||
|
||||
let config = {
|
||||
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| maybe_read_file(s).unwrap_or(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 +109,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 +123,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);
|
||||
@@ -150,8 +181,28 @@ 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()))
|
||||
}
|
||||
|
||||
// If `val` is a path to a readable file, return its trimmed contents. If the
|
||||
// file is empty after trimming, return None. Otherwise, return Some(val).
|
||||
fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
|
||||
let p = 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()))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
@@ -159,6 +210,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 +230,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 +297,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 super::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