Compare commits

..

5 Commits

Author SHA1 Message Date
easong-openai
bd2a53d1cd merge 2025-07-28 17:47:48 -07:00
Ahmed Ibrahim
e744548aae lint 2025-07-09 19:07:42 -07:00
Ahmed Ibrahim
8b23e160c4 lint 2025-07-09 17:58:29 -07:00
Ahmed Ibrahim
3df732caa1 lint 2025-07-09 17:53:12 -07:00
aibrahim-oai
3a4f5435e8 Add /compact command to Rust CLI
compact

tests

working
2025-07-09 17:36:50 -07:00
31 changed files with 428 additions and 495 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -856,6 +856,8 @@ dependencies = [
"ratatui",
"ratatui-image",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"shlex",
"strum 0.27.2",

View File

@@ -106,7 +106,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
None => {
let mut tui_cli = cli.interactive;
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?;
let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
Some(Subcommand::Exec(mut exec_cli)) => {

View File

@@ -30,7 +30,6 @@ use crate::util::backoff;
pub(crate) async fn stream_chat_completions(
prompt: &Prompt,
model: &str,
include_plan_tool: bool,
client: &reqwest::Client,
provider: &ModelProviderInfo,
) -> Result<ResponseStream> {
@@ -106,7 +105,7 @@ pub(crate) async fn stream_chat_completions(
}
}
let tools_json = create_tools_json_for_chat_completions_api(prompt, model, include_plan_tool)?;
let tools_json = create_tools_json_for_chat_completions_api(prompt, model)?;
let payload = json!({
"model": model,
"messages": messages,

View File

@@ -77,7 +77,6 @@ impl ModelClient {
let response_stream = stream_chat_completions(
prompt,
&self.config.model,
self.config.include_plan_tool,
&self.client,
&self.provider,
)
@@ -116,11 +115,7 @@ impl ModelClient {
}
let full_instructions = prompt.get_full_instructions(&self.config.model);
let tools_json = create_tools_json_for_responses_api(
prompt,
&self.config.model,
self.config.include_plan_tool,
)?;
let tools_json = create_tools_json_for_responses_api(prompt, &self.config.model)?;
let reasoning = create_reasoning_param_for_request(&self.config, self.effort, self.summary);
// Request encrypted COT if we are not storing responses,

View File

@@ -55,7 +55,6 @@ use crate::models::ReasoningItemReasoningSummary;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
use crate::protocol::AgentMessageEvent;
@@ -220,6 +219,23 @@ impl Session {
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
/// Erases all previous messages from the conversation history (zdr_transcript), if present.
pub fn erase_conversation_history(&self) {
let mut state = self.state.lock().unwrap();
if let Some(transcript) = state.zdr_transcript.as_mut() {
transcript.clear();
}
// When using the experimental OpenAI Responses API with server-side
// storage enabled, `previous_response_id` is used to let the model
// access the earlier part of the conversation **without** having to
// resend the full transcript. To truly wipe all historical context
// we must drop this identifier as well, otherwise the backend will
// still be able to retrieve the prior messages via the ID even
// though our local transcript has been cleared. See
// https://platform.openai.com/docs/guides/responses for details.
state.previous_response_id = None;
}
}
/// Mutable state of the agent
@@ -559,6 +575,11 @@ async fn submission_loop(
debug!(?sub, "Submission");
match sub.op {
Op::EraseConversationHistory => {
if let Some(sess) = sess.as_ref() {
sess.erase_conversation_history();
}
}
Op::Interrupt => {
let sess = match sess.as_ref() {
Some(sess) => sess,
@@ -1202,19 +1223,13 @@ async fn try_run_turn(
token_usage,
} => {
if let Some(token_usage) = token_usage {
// Emit token count event to the frontend/UI
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage.clone()),
msg: EventMsg::TokenCount(token_usage),
})
.await
.ok();
// Record usage in rollout recorder for final summary
let rec_opt = sess.rollout.lock().unwrap().as_ref().cloned();
if let Some(rec) = rec_opt {
let _ = rec.record_usage(token_usage).await;
}
}
return Ok(output);
@@ -1343,7 +1358,6 @@ async fn handle_function_call(
};
handle_container_exec_with_params(params, sess, sub_id, call_id).await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
_ => {
match sess.mcp_connection_manager.parse_tool_name(&name) {
Some((server, tool_name)) => {

View File

@@ -143,9 +143,6 @@ pub struct Config {
/// Experimental rollout resume path (absolute path to .jsonl; undocumented).
pub experimental_resume: Option<PathBuf>,
/// Include an experimental plan tool that the model can use to update its current plan and status of each step.
pub include_plan_tool: bool,
}
impl Config {
@@ -369,7 +366,6 @@ pub struct ConfigOverrides {
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub base_instructions: Option<String>,
pub include_plan_tool: Option<bool>,
}
impl Config {
@@ -392,7 +388,6 @@ impl Config {
config_profile: config_profile_key,
codex_linux_sandbox_exe,
base_instructions,
include_plan_tool,
} = overrides;
let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) {
@@ -470,14 +465,9 @@ impl Config {
let experimental_resume = cfg.experimental_resume;
// Load base instructions override from a file if specified. If the
// path is relative, resolve it against the effective cwd so the
// behaviour matches other path-like config values.
let file_base_instructions = Self::get_base_instructions(
let base_instructions = base_instructions.or(Self::get_base_instructions(
cfg.experimental_instructions_file.as_ref(),
&resolved_cwd,
)?;
let base_instructions = base_instructions.or(file_base_instructions);
));
let config = Self {
model,
@@ -526,8 +516,8 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
experimental_resume,
include_plan_tool: include_plan_tool.unwrap_or(false),
};
Ok(config)
}
@@ -549,46 +539,13 @@ impl Config {
})
}
fn get_base_instructions(
path: Option<&PathBuf>,
cwd: &Path,
) -> std::io::Result<Option<String>> {
let p = match path.as_ref() {
None => return Ok(None),
Some(p) => p,
};
fn get_base_instructions(path: Option<&PathBuf>) -> Option<String> {
let path = path.as_ref()?;
// Resolve relative paths against the provided cwd to make CLI
// overrides consistent regardless of where the process was launched
// from.
let full_path = if p.is_relative() {
cwd.join(p)
} else {
p.to_path_buf()
};
let contents = std::fs::read_to_string(&full_path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"failed to read experimental instructions file {}: {e}",
full_path.display()
),
)
})?;
let s = contents.trim().to_string();
if s.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"experimental instructions file is empty: {}",
full_path.display()
),
))
} else {
Ok(Some(s))
}
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
}
@@ -834,7 +791,7 @@ disable_response_storage = true
///
/// 1. custom command-line argument, e.g. `--model o3`
/// 2. as part of a profile, where the `--profile` is specified via a CLI
/// (or in the config file itself)
/// (or in the config file itelf)
/// 3. as an entry in `config.toml`, e.g. `model = "o3"`
/// 4. the default value for a required field defined in code, e.g.,
/// `crate::flags::OPENAI_DEFAULT_MODEL`
@@ -884,7 +841,6 @@ disable_response_storage = true
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
},
o3_profile_config
);
@@ -933,7 +889,6 @@ disable_response_storage = true
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -997,7 +952,6 @@ disable_response_storage = true
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);

View File

@@ -30,6 +30,11 @@ impl ConversationHistory {
}
}
}
/// Clears the conversation history.
pub(crate) fn clear(&mut self) {
self.items.clear();
}
}
/// Anything that is not a system message or "reasoning" message is considered
@@ -44,3 +49,31 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Other => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ResponseItem;
#[test]
fn clear_removes_all_items() {
let mut hist = ConversationHistory::new();
use crate::models::ContentItem;
let items = [ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText {
text: "hello".into(),
}],
}];
hist.record_items(items.iter());
assert_eq!(hist.contents().len(), 1, "sanity item should be present");
hist.clear();
assert!(hist.contents().is_empty(), "all items should be removed");
}
}

View File

@@ -102,18 +102,6 @@ mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
use std::process::Stdio;
/// Skip tests that require the `git` executable when it's not available.
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
}
use super::*;
use std::fs;
@@ -176,12 +164,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
async fn test_collect_git_info_git_repository() {
if !git_available() {
eprintln!("skipping git repository info test: git not available");
return;
}
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
@@ -195,20 +178,17 @@ mod tests {
assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
// Should have a non-empty branch name
assert!(git_info.branch.as_ref().map(|s| !s.is_empty()).unwrap_or(false));
// Should have branch (likely "main" or "master")
assert!(git_info.branch.is_some());
let branch = git_info.branch.unwrap();
assert!(branch == "main" || branch == "master");
// Repository URL might be None for local repos without remote
// This is acceptable behavior
}
#[tokio::test]
#[ignore]
async fn test_collect_git_info_with_remote() {
if !git_available() {
eprintln!("skipping git remote info test: git not available");
return;
}
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
@@ -237,12 +217,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
async fn test_collect_git_info_detached_head() {
if !git_available() {
eprintln!("skipping detached HEAD info test: git not available");
return;
}
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
@@ -274,12 +249,7 @@ mod tests {
}
#[tokio::test]
#[ignore]
async fn test_collect_git_info_with_branch() {
if !git_available() {
eprintln!("skipping branch info test: git not available");
return;
}
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;

View File

@@ -34,7 +34,6 @@ mod models;
pub mod openai_api_key;
mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
mod project_doc;
pub mod protocol;
mod rollout;

View File

@@ -4,14 +4,13 @@ use std::collections::BTreeMap;
use std::sync::LazyLock;
use crate::client_common::Prompt;
use crate::plan_tool::PLAN_TOOL;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ResponsesApiTool {
pub(crate) name: &'static str,
pub(crate) description: &'static str,
pub(crate) strict: bool,
pub(crate) parameters: JsonSchema,
name: &'static str,
description: &'static str,
strict: bool,
parameters: JsonSchema,
}
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
@@ -75,7 +74,6 @@ static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
pub(crate) fn create_tools_json_for_responses_api(
prompt: &Prompt,
model: &str,
include_plan_tool: bool,
) -> crate::error::Result<Vec<serde_json::Value>> {
// Assemble tool list: built-in tools + any extra tools from the prompt.
let default_tools = if model.starts_with("codex") {
@@ -95,10 +93,6 @@ pub(crate) fn create_tools_json_for_responses_api(
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
);
if include_plan_tool {
tools_json.push(serde_json::to_value(PLAN_TOOL.clone())?);
}
Ok(tools_json)
}
@@ -108,12 +102,10 @@ pub(crate) fn create_tools_json_for_responses_api(
pub(crate) fn create_tools_json_for_chat_completions_api(
prompt: &Prompt,
model: &str,
include_plan_tool: bool,
) -> crate::error::Result<Vec<serde_json::Value>> {
// We start with the JSON for the Responses API and than rewrite it to match
// the chat completions tool call format.
let responses_api_tools_json =
create_tools_json_for_responses_api(prompt, model, include_plan_tool)?;
let responses_api_tools_json = create_tools_json_for_responses_api(prompt, model)?;
let tools_json = responses_api_tools_json
.into_iter()
.filter_map(|mut tool| {

View File

@@ -1,126 +0,0 @@
use std::collections::BTreeMap;
use std::sync::LazyLock;
use serde::Deserialize;
use serde::Serialize;
use crate::codex::Session;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
use crate::protocol::Event;
use crate::protocol::EventMsg;
// Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanItemArg {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub explanation: Option<String>,
pub plan: Vec<PlanItemArg>,
}
pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
let mut plan_item_props = BTreeMap::new();
plan_item_props.insert("step".to_string(), JsonSchema::String);
plan_item_props.insert("status".to_string(), JsonSchema::String);
let plan_items_schema = JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: plan_item_props,
required: &["step", "status"],
additional_properties: false,
}),
};
let mut properties = BTreeMap::new();
properties.insert("explanation".to_string(), JsonSchema::String);
properties.insert("plan".to_string(), plan_items_schema);
OpenAiTool::Function(ResponsesApiTool {
name: "update_plan",
description: r#"Use the update_plan tool to keep the user updated on the current plan for the task.
After understanding the user's task, call the update_plan tool with an initial plan. An example of a plan:
1. Explore the codebase to find relevant files (status: in_progress)
2. Implement the feature in the XYZ component (status: pending)
3. Commit changes and make a pull request (status: pending)
Each step should be a short, 1-sentence description.
Until all the steps are finished, there should always be exactly one in_progress step in the plan.
Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`.
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step.
Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
When all steps are completed, call update_plan one last time with all steps marked as `completed`."#,
strict: false,
parameters: JsonSchema::Object {
properties,
required: &["plan"],
additional_properties: false,
},
})
});
/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render.
/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other
/// than forcing it to come up and document a plan (TBD how that affects performance).
pub(crate) async fn handle_update_plan(
session: &Session,
arguments: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match parse_update_plan_arguments(arguments, &call_id) {
Ok(args) => {
let output = ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Plan updated".to_string(),
success: Some(true),
},
};
session
.send_event(Event {
id: sub_id.to_string(),
msg: EventMsg::PlanUpdate(args),
})
.await;
output
}
Err(output) => *output,
}
}
fn parse_update_plan_arguments(
arguments: String,
call_id: &str,
) -> Result<UpdatePlanArgs, Box<ResponseInputItem>> {
match serde_json::from_str::<UpdatePlanArgs>(&arguments) {
Ok(args) => Ok(args),
Err(e) => {
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload {
content: format!("failed to parse function arguments: {e}"),
success: None,
},
};
Err(Box::new(output))
}
}
}

View File

@@ -19,7 +19,6 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::model_provider_info::ModelProviderInfo;
use crate::plan_tool::UpdatePlanArgs;
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -36,6 +35,8 @@ pub struct Submission {
#[allow(clippy::large_enum_variant)]
#[non_exhaustive]
pub enum Op {
/// Erase all conversation history for the current session.
EraseConversationHistory,
/// Configure the model session.
ConfigureSession {
/// Provider identifier ("openai", "openrouter", ...).
@@ -336,8 +337,6 @@ pub enum EventMsg {
/// Response to GetHistoryEntryRequest.
GetHistoryEntryResponse(GetHistoryEntryResponseEvent),
PlanUpdate(UpdatePlanArgs),
/// Notification that the agent is shutting down.
ShutdownComplete,
}

View File

@@ -23,7 +23,6 @@ use crate::config::Config;
use crate::git_info::GitInfo;
use crate::git_info::collect_git_info;
use crate::models::ResponseItem;
use crate::protocol::TokenUsage;
const SESSIONS_SUBDIR: &str = "sessions";
@@ -45,16 +44,6 @@ struct SessionMetaWithGit {
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct SessionStateSnapshot {}
/// Summary record written at end of a session rollout.
#[derive(Serialize)]
struct Summary {
#[serde(rename = "type")]
kind: &'static str,
total_input_tokens: u64,
total_output_tokens: u64,
total_session_time: u64,
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct SavedSession {
pub session: SessionMeta,
@@ -82,11 +71,7 @@ pub(crate) struct RolloutRecorder {
enum RolloutCmd {
AddItems(Vec<ResponseItem>),
UpdateState(SessionStateSnapshot),
/// Record token usage for summary calculation.
RecordUsage(TokenUsage),
Shutdown {
ack: oneshot::Sender<()>,
},
Shutdown { ack: oneshot::Sender<()> },
}
impl RolloutRecorder {
@@ -101,13 +86,13 @@ impl RolloutRecorder {
let LogFileInfo {
file,
session_id,
timestamp: start_time,
timestamp,
} = create_log_file(config, uuid)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = start_time
let timestamp = timestamp
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
@@ -131,7 +116,6 @@ impl RolloutRecorder {
instructions,
}),
cwd,
start_time,
));
Ok(Self { tx })
@@ -171,14 +155,6 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed to queue rollout state: {e}")))
}
/// Record per-turn token usage to include in final summary.
pub(crate) async fn record_usage(&self, usage: TokenUsage) -> std::io::Result<()> {
self.tx
.send(RolloutCmd::RecordUsage(usage))
.await
.map_err(|e| IoError::other(format!("failed to queue rollout usage: {e}")))
}
pub async fn resume(
path: &Path,
cwd: std::path::PathBuf,
@@ -240,15 +216,11 @@ impl RolloutRecorder {
.open(path)?;
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
// Use current time when resuming as the summary start time.
let resume_start = OffsetDateTime::now_local()
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
None,
cwd,
resume_start,
));
info!("Resumed rollout successfully from {path:?}");
Ok((Self { tx }, saved))
@@ -320,13 +292,9 @@ async fn rollout_writer(
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
start_time: OffsetDateTime,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
// Initialize counters for final summary.
let mut total_input_tokens = 0u64;
let mut total_output_tokens = 0u64;
// If we have a meta, collect git info asynchronously and write meta first
if let Some(session_meta) = meta.take() {
let git_info = collect_git_info(&cwd).await;
@@ -370,22 +338,7 @@ async fn rollout_writer(
})
.await?;
}
RolloutCmd::RecordUsage(usage) => {
total_input_tokens = total_input_tokens.saturating_add(usage.input_tokens);
total_output_tokens = total_output_tokens.saturating_add(usage.output_tokens);
}
RolloutCmd::Shutdown { ack } => {
// Write a summary record at the end of the session.
let end_time = OffsetDateTime::now_local()
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
let duration = end_time - start_time;
let summary = Summary {
kind: "summary",
total_input_tokens,
total_output_tokens,
total_session_time: duration.whole_milliseconds() as u64,
};
writer.write_line(&summary).await?;
let _ = ack.send(());
}
}

View File

@@ -81,96 +81,6 @@ async fn chat_mode_stream_cli() {
server.verify().await;
}
/// Verify that passing `-c experimental_instructions_file=...` to the CLI
/// overrides the built-in base instructions by inspecting the request body
/// received by a mock OpenAI Responses endpoint.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_applies_experimental_instructions_file() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
// Start mock server which will capture the request and return a minimal
// SSE stream for a single turn.
let server = MockServer::start().await;
let sse = concat!(
"data: {\"type\":\"response.created\",\"response\":{}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n"
);
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream"),
)
.expect(1)
.mount(&server)
.await;
// Create a temporary instructions file with a unique marker we can assert
// appears in the outbound request payload.
let custom = TempDir::new().unwrap();
let marker = "cli-experimental-instructions-marker";
let custom_path = custom.path().join("instr.md");
std::fs::write(&custom_path, marker).unwrap();
let custom_path_str = custom_path.to_string_lossy().replace('\\', "/");
// Build a provider override that points at the mock server and instructs
// Codex to use the Responses API with the dummy env var.
let provider_override = format!(
"model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}",
server.uri()
);
let home = TempDir::new().unwrap();
let mut cmd = AssertCommand::new("cargo");
cmd.arg("run")
.arg("-p")
.arg("codex-cli")
.arg("--quiet")
.arg("--")
.arg("exec")
.arg("--skip-git-repo-check")
.arg("-c")
.arg(&provider_override)
.arg("-c")
.arg("model_provider=\"mock\"")
.arg("-c")
.arg(format!(
"experimental_instructions_file=\"{custom_path_str}\""
))
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("hello?\n");
cmd.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()));
let output = cmd.output().unwrap();
println!("Status: {}", output.status);
println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout));
println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr));
assert!(output.status.success());
// Inspect the captured request and verify our custom base instructions were
// included in the `instructions` field.
let request = &server.received_requests().await.unwrap()[0];
let body = request.body_json::<serde_json::Value>().unwrap();
let instructions = body
.get("instructions")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
assert!(
instructions.contains(marker),
"instructions did not contain custom marker; got: {instructions}"
);
}
/// Tests streaming responses through the CLI using a local SSE fixture file.
/// This test:
/// 1. Uses a pre-recorded SSE response fixture instead of a live server
@@ -452,17 +362,11 @@ async fn integration_creates_and_checks_session_file() {
}
/// Integration test to verify git info is collected and recorded in session files.
#[ignore]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn integration_git_info_unit_test() {
// This test verifies git info collection works independently
// without depending on the full CLI integration
// Skip if git is not available
if std::process::Command::new("git").arg("--version").output().is_err() {
eprintln!("skipping integration_git_info_unit_test: git not available");
return;
}
// 1. Create temp directory for git repo
let temp_dir = TempDir::new().unwrap();
let git_repo = temp_dir.path().to_path_buf();

View File

@@ -1,6 +1,5 @@
use codex_common::elapsed::format_elapsed;
use codex_core::config::Config;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -514,11 +513,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
ts_println!(self, "model: {}", model);
println!();
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event;
ts_println!(self, "explanation: {explanation:?}");
ts_println!(self, "plan: {plan:?}");
}
EventMsg::GetHistoryEntryResponse(_) => {
// Currently ignored in exec output.
}

View File

@@ -92,20 +92,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
),
};
// TODO(mbolin): Take a more thoughtful approach to logging.
let default_level = "error";
let _ = tracing_subscriber::fmt()
// Fallback to the `default_level` log filter if the environment
// variable is not set _or_ contains an invalid value
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap_or_else(|_| EnvFilter::new(default_level)),
)
.with_ansi(stderr_with_ansi)
.with_writer(std::io::stderr)
.try_init();
let sandbox_mode = if full_auto {
Some(SandboxMode::WorkspaceWrite)
} else if dangerously_bypass_approvals_and_sandbox {
@@ -126,7 +112,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
model_provider: None,
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: None,
};
// Parse `-c` overrides.
let cli_kv_overrides = match config_overrides.parse_overrides() {
@@ -157,6 +142,20 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
// TODO(mbolin): Take a more thoughtful approach to logging.
let default_level = "error";
let _ = tracing_subscriber::fmt()
// Fallback to the `default_level` log filter if the environment
// variable is not set _or_ contains an invalid value
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap_or_else(|_| EnvFilter::new(default_level)),
)
.with_ansi(stderr_with_ansi)
.with_writer(std::io::stderr)
.try_init();
let CodexConversation {
codex: codex_wrapper,
session_configured,

View File

@@ -9,7 +9,6 @@ use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py");
@@ -74,11 +73,7 @@ pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result<AuthDotJso
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
if is_expired(&auth_dot_json) {
let refresh_response =
tokio::time::timeout(Duration::from_secs(60), try_refresh_token(&auth_dot_json))
.await
.map_err(|_| std::io::Error::other("timed out while refreshing OpenAI API key"))?
.map_err(std::io::Error::other)?;
let refresh_response = try_refresh_token(&auth_dot_json).await?;
let mut auth_dot_json = auth_dot_json;
auth_dot_json.tokens.id_token = refresh_response.id_token;
if let Some(refresh_token) = refresh_response.refresh_token {

View File

@@ -50,10 +50,6 @@ pub struct CodexToolCallParam {
/// The set of instructions to use instead of the default ones.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_instructions: Option<String>,
/// Whether to include the plan tool in the conversation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_plan_tool: Option<bool>,
}
/// Custom enum mirroring [`AskForApproval`], but has an extra dependency on
@@ -144,10 +140,9 @@ impl CodexToolCallParam {
sandbox,
config: cli_overrides,
base_instructions,
include_plan_tool,
} = self;
// Build the `ConfigOverrides` recognized by codex-core.
// Build the `ConfigOverrides` recognised by codex-core.
let overrides = codex_core::config::ConfigOverrides {
model,
config_profile: profile,
@@ -157,7 +152,6 @@ impl CodexToolCallParam {
model_provider: None,
codex_linux_sandbox_exe,
base_instructions,
include_plan_tool,
};
let cli_overrides = cli_overrides
@@ -268,10 +262,6 @@ mod tests {
"description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.",
"type": "string"
},
"include-plan-tool": {
"description": "Whether to include the plan tool in the conversation.",
"type": "boolean"
},
"model": {
"description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").",
"type": "string"

View File

@@ -263,7 +263,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::PatchApplyBegin(_)
| EventMsg::PatchApplyEnd(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete => {
// For now, we do not do anything extra for these
// events. Note that

View File

@@ -81,7 +81,6 @@ async fn shell_command_interruption() -> anyhow::Result<()> {
sandbox: None,
config: None,
base_instructions: None,
include_plan_tool: None,
})
.await?;

View File

@@ -61,6 +61,8 @@ tui-textarea = "0.7.0"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
insta = "1.43.1"

View File

@@ -5,6 +5,7 @@ use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::login_screen::LoginScreen;
use crate::scroll_event_helper::ScrollEventHelper;
use crate::slash_command::SlashCommand;
use crate::tui;
@@ -36,6 +37,8 @@ enum AppState<'a> {
/// `AppState`.
widget: Box<ChatWidget<'a>>,
},
/// The login screen for the OpenAI provider.
Login { screen: LoginScreen },
/// The start-up warning that recommends running codex inside a Git repo.
GitWarning { screen: GitWarningScreen },
}
@@ -71,6 +74,7 @@ impl App<'_> {
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
show_login_screen: bool,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
) -> Self {
@@ -134,7 +138,18 @@ impl App<'_> {
});
}
let (app_state, chat_args) = if show_git_warning {
let (app_state, chat_args) = if show_login_screen {
(
AppState::Login {
screen: LoginScreen::new(app_event_tx.clone(), config.codex_home.clone()),
},
Some(ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
}),
)
} else if show_git_warning {
(
AppState::GitWarning {
screen: GitWarningScreen::new(),
@@ -228,7 +243,7 @@ impl App<'_> {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::GitWarning { .. } => {
AppState::Login { .. } | AppState::GitWarning { .. } => {
// No-op.
}
}
@@ -249,7 +264,7 @@ impl App<'_> {
self.dispatch_key_event(key_event);
}
}
AppState::GitWarning { .. } => {
AppState::Login { .. } | AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
@@ -273,11 +288,11 @@ impl App<'_> {
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::GitWarning { .. } => {}
AppState::Login { .. } | AppState::GitWarning { .. } => {}
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::GitWarning { .. } => {}
AppState::Login { .. } | AppState::GitWarning { .. } => {}
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
@@ -314,6 +329,11 @@ impl App<'_> {
widget.add_diff_output(text);
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_compact();
}
}
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
@@ -323,6 +343,11 @@ impl App<'_> {
widget.apply_file_search_result(query, matches);
}
}
AppEvent::CompactComplete(result) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_compact_summary(result);
}
}
}
}
terminal.clear()?;
@@ -333,7 +358,9 @@ impl App<'_> {
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
AppState::Login { .. } | AppState::GitWarning { .. } => {
codex_core::protocol::TokenUsage::default()
}
}
}
@@ -344,6 +371,9 @@ impl App<'_> {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
}
AppState::Login { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
@@ -358,6 +388,7 @@ impl App<'_> {
AppState::Chat { widget } => {
widget.handle_key_event(key_event);
}
AppState::Login { screen } => screen.handle_key_event(key_event),
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
GitWarningOutcome::Continue => {
// User accepted switch to chat view.
@@ -388,21 +419,21 @@ impl App<'_> {
fn dispatch_paste_event(&mut self, pasted: String) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::GitWarning { .. } => {}
AppState::Login { .. } | AppState::GitWarning { .. } => {}
}
}
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),
AppState::GitWarning { .. } => {}
AppState::Login { .. } | AppState::GitWarning { .. } => {}
}
}
fn dispatch_codex_event(&mut self, event: Event) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_codex_event(event),
AppState::GitWarning { .. } => {}
AppState::Login { .. } | AppState::GitWarning { .. } => {}
}
}
}

View File

@@ -51,5 +51,10 @@ pub(crate) enum AppEvent {
matches: Vec<FileMatch>,
},
/// Result of the asynchronous `/compact` summarization.
CompactComplete(Result<String, String>),
/// Insert the most recently appended history entry directly into the
/// terminal scrollback. Carries already formatted lines.
InsertHistory(Vec<Line<'static>>),
}

View File

@@ -477,17 +477,6 @@ impl ChatComposer<'_> {
}
}
if let Input {
key: Key::Char('u'),
ctrl: true,
alt: false,
..
} = input
{
self.textarea.delete_line_by_head();
return (InputResult::None, true);
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.lines().join("\n");

View File

@@ -36,6 +36,9 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::compact::Role;
use crate::compact::TranscriptEntry;
use crate::compact::generate_compact_summary;
use crate::conversation_history_widget::ConversationHistoryWidget;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::PatchEventType;
@@ -50,11 +53,12 @@ pub(crate) struct ChatWidget<'a> {
config: Config,
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
// Buffer for streaming assistant reasoning text; emitted on final event.
reasoning_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
// Buffer for streaming assistant answer text; emitted on final event.
answer_buffer: String,
// Transcript of chat for `/compact` summarization.
transcript: Vec<TranscriptEntry>,
}
struct UserMessage {
@@ -140,6 +144,7 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
transcript: Vec::new(),
}
}
@@ -198,8 +203,14 @@ impl ChatWidget<'_> {
// Only show text portion in conversation history for now.
if !text.is_empty() {
// Forward a copy for history and emit into scrollback.
self.conversation_history.add_user_message(text.clone());
self.emit_last_history_entry();
// Record in transcript for `/compact`.
self.transcript.push(TranscriptEntry {
role: Role::User,
text,
});
}
self.conversation_history.scroll_to_bottom();
}
@@ -230,10 +241,7 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// Final assistant answer. Prefer the fully provided message
// from the event; if it is empty fall back to any accumulated
// delta buffer (some providers may only stream deltas and send
// an empty final message).
// Final assistant answer. Prefer the fully provided message.
let full = if message.is_empty() {
std::mem::take(&mut self.answer_buffer)
} else {
@@ -242,8 +250,13 @@ impl ChatWidget<'_> {
};
if !full.is_empty() {
self.conversation_history
.add_agent_message(&self.config, full);
.add_agent_message(&self.config, full.clone());
self.emit_last_history_entry();
// Record final answer in transcript for `/compact`.
self.transcript.push(TranscriptEntry {
role: Role::Assistant,
text: full,
});
}
self.request_redraw();
}
@@ -469,6 +482,88 @@ impl ChatWidget<'_> {
self.bottom_pane.on_file_search_result(query, matches);
}
// (removed deprecated synchronous `compact` implementation)
/// Kick off an asynchronous summarization of the current transcript.
/// Returns immediately so the UI stays responsive.
pub(crate) fn start_compact(&mut self) {
// Show status indicator immediately.
self.bottom_pane.set_task_running(true);
self.bottom_pane
.update_status_text("Summarizing context…".to_string());
self.request_redraw();
// Clone data required for the background task.
let transcript = self.transcript.clone();
let model = self.config.model.clone();
let config_clone = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
// Spawn the summarization on a blocking thread to avoid CPU-bound work
// stalling the async runtime (and thus the UI).
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async move {
let result = generate_compact_summary(&transcript, &model, &config_clone).await;
let evt = match result {
Ok(summary) => AppEvent::CompactComplete(Ok(summary)),
Err(e) => AppEvent::CompactComplete(Err(format!("{e}"))),
};
app_event_tx.send(evt);
});
});
}
/// Apply the completed summary returned by the background task.
pub(crate) fn apply_compact_summary(&mut self, result: Result<String, String>) {
match result {
Ok(summary) => {
self.conversation_history.clear_agent_history();
self.transcript.clear();
// clear session history in backend
self.submit_op(Op::EraseConversationHistory);
self.conversation_history
.add_agent_message(&self.config, summary.clone());
self.transcript = vec![TranscriptEntry {
role: Role::Assistant,
text: summary,
}];
// Re-configure the Codex session so that the backend agent starts with
// a clean conversation context.
let op = Op::ConfigureSession {
provider: self.config.model_provider.clone(),
model: self.config.model.clone(),
model_reasoning_effort: self.config.model_reasoning_effort,
model_reasoning_summary: self.config.model_reasoning_summary,
user_instructions: self.config.user_instructions.clone(),
base_instructions: self.config.base_instructions.clone(),
approval_policy: self.config.approval_policy,
sandbox_policy: self.config.sandbox_policy.clone(),
disable_response_storage: self.config.disable_response_storage,
notify: self.config.notify.clone(),
cwd: self.config.cwd.clone(),
resume_path: None,
};
self.submit_op(op);
// Reset the recorded token usage because we start a fresh
// conversation context. This ensures the *context remaining*
// indicator in the composer is updated immediately.
self.token_usage = TokenUsage::default();
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
}
Err(msg) => {
self.conversation_history.add_error(msg);
}
}
// Hide status indicator and refresh UI.
self.bottom_pane.set_task_running(false);
self.request_redraw();
}
/// Handle Ctrl-C key press.
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).

View File

@@ -0,0 +1,91 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_core::config::Config;
use codex_core::openai_api_key::get_openai_api_key;
use serde::Serialize;
#[derive(Clone)]
pub enum Role {
User,
Assistant,
}
#[derive(Clone)]
pub struct TranscriptEntry {
pub role: Role,
pub text: String,
}
impl TranscriptEntry {
fn role_str(&self) -> &'static str {
match self.role {
Role::User => "user",
Role::Assistant => "assistant",
}
}
}
#[derive(Serialize)]
struct Message<'a> {
role: &'a str,
content: String,
}
#[derive(Serialize)]
struct Payload<'a> {
model: &'a str,
messages: Vec<Message<'a>>,
}
/// Generate a concise summary of the provided transcript using the OpenAI chat
/// completions API.
pub async fn generate_compact_summary(
transcript: &[TranscriptEntry],
model: &str,
config: &Config,
) -> Result<String> {
let conversation_text = transcript
.iter()
.map(|e| format!("{}: {}", e.role_str(), e.text))
.collect::<Vec<_>>()
.join("\n");
let messages = vec![
Message {
role: "assistant",
content: "You are an expert coding assistant. Your goal is to generate a concise, structured summary of the conversation below that captures all essential information needed to continue development after context replacement. Include tasks performed, code areas modified or reviewed, key decisions or assumptions, test results or errors, and outstanding tasks or next steps.".to_string(),
},
Message {
role: "user",
content: format!(
"Here is the conversation so far:\n{conversation_text}\n\nPlease summarize this conversation, covering:\n1. Tasks performed and outcomes\n2. Code files, modules, or functions modified or examined\n3. Important decisions or assumptions made\n4. Errors encountered and test or build results\n5. Remaining tasks, open questions, or next steps\nProvide the summary in a clear, concise format."
),
},
];
let api_key = get_openai_api_key().ok_or_else(|| anyhow!("OpenAI API key not set"))?;
let client = reqwest::Client::new();
let base = config.model_provider.base_url.trim_end_matches('/');
let url = format!("{}/chat/completions", base);
let payload = Payload { model, messages };
let res = client
.post(url)
.bearer_auth(api_key)
.json(&payload)
.send()
.await?;
let body: serde_json::Value = res.json().await?;
if let Some(summary) = body
.get("choices")
.and_then(|c| c.get(0))
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|v| v.as_str())
{
Ok(summary.to_string())
} else {
Ok("Unable to generate summary.".to_string())
}
}

View File

@@ -122,6 +122,10 @@ impl ConversationHistoryWidget {
self.add_to_history(HistoryCell::new_agent_message(config, message));
}
pub fn clear_agent_history(&mut self) {
self.clear_all();
}
pub fn add_agent_reasoning(&mut self, config: &Config, text: String) {
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
}
@@ -173,6 +177,10 @@ impl ConversationHistoryWidget {
});
}
fn clear_all(&mut self) {
self.entries.clear();
}
/// Return the lines for the most recently appended entry (if any) so the
/// parent widget can surface them via the new scrollback insertion path.
pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {

View File

@@ -14,7 +14,6 @@ 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::io::Write;
use std::path::PathBuf;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
@@ -28,6 +27,7 @@ mod cell_widget;
mod chatwidget;
mod citation_regex;
mod cli;
mod compact;
mod conversation_history_widget;
mod exec_command;
mod file_search;
@@ -36,6 +36,7 @@ mod git_warning_screen;
mod history_cell;
mod insert_history;
mod log_layer;
mod login_screen;
mod markdown;
mod scroll_event_helper;
mod slash_command;
@@ -47,7 +48,7 @@ mod user_approval_widget;
pub use cli::Cli;
pub async fn run_main(
pub fn run_main(
cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<codex_core::protocol::TokenUsage> {
@@ -79,7 +80,6 @@ pub async fn run_main(
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: None,
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
@@ -143,23 +143,7 @@ pub async fn run_main(
.with(tui_layer)
.try_init();
let show_login_screen = should_show_login_screen(&config).await;
if show_login_screen {
std::io::stdout()
.write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) {
std::process::exit(1);
}
// Spawn a task to run the login command.
// Block until the login command is finished.
let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?;
set_openai_api_key(new_key);
std::io::stdout().write_all(b"Login successful.\n")?;
}
let show_login_screen = should_show_login_screen(&config);
// Determine whether we need to display the "not a git repo" warning
// modal. The flag is shown when the current working directory is *not*
@@ -167,13 +151,14 @@ pub async 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_git_warning, log_rx)
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
}
fn run_ratatui_app(
cli: Cli,
config: Config,
show_login_screen: bool,
show_git_warning: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
@@ -188,7 +173,13 @@ fn run_ratatui_app(
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, show_git_warning, images);
let mut app = App::new(
config.clone(),
prompt,
show_login_screen,
show_git_warning,
images,
);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{
@@ -220,17 +211,26 @@ fn restore() {
}
}
async fn should_show_login_screen(config: &Config) -> bool {
#[allow(clippy::unwrap_used)]
fn should_show_login_screen(config: &Config) -> bool {
if is_in_need_of_openai_api_key(config) {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await {
set_openai_api_key(openai_api_key);
false
} else {
true
}
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
match try_read_openai_api_key(&codex_home).await {
Ok(openai_api_key) => {
set_openai_api_key(openai_api_key);
tx.send(false).unwrap();
}
Err(_) => {
tx.send(true).unwrap();
}
}
});
// TODO(mbolin): Impose some sort of timeout.
tokio::task::block_in_place(|| rx.blocking_recv()).unwrap()
} else {
false
}

View File

@@ -0,0 +1,46 @@
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget as _;
use ratatui::widgets::WidgetRef;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
pub(crate) struct LoginScreen {
app_event_tx: AppEventSender,
/// Use this with login_with_chatgpt() in login/src/lib.rs and, if
/// successful, update the in-memory config via
/// codex_core::openai_api_key::set_openai_api_key().
#[allow(dead_code)]
codex_home: PathBuf,
}
impl LoginScreen {
pub(crate) fn new(app_event_tx: AppEventSender, codex_home: PathBuf) -> Self {
Self {
app_event_tx,
codex_home,
}
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if let KeyCode::Char('q') = key_event.code {
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
}
impl WidgetRef for &LoginScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let text = Paragraph::new(
"Login using `codex login` and then run this command again. 'q' to quit.",
);
text.render(area, buf);
}
}

View File

@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
.config_overrides
.raw_overrides
.splice(0..0, top_cli.config_overrides.raw_overrides);
let usage = run_main(inner, codex_linux_sandbox_exe).await?;
let usage = run_main(inner, codex_linux_sandbox_exe)?;
println!("{}", codex_core::protocol::FinalOutput::from(usage));
Ok(())
})

View File

@@ -14,6 +14,7 @@ pub enum SlashCommand {
// more frequently used commands should be listed first.
New,
Diff,
Compact,
Quit,
}
@@ -26,6 +27,7 @@ impl SlashCommand {
SlashCommand::Diff => {
"Show git diff of the working directory (including untracked files)"
}
SlashCommand::Compact => "Condense context into a summary.",
}
}