Compare commits

..

1 Commits

Author SHA1 Message Date
Dylan Hurd
31d06def79 Release 0.14.0-alpha.3 2025-08-06 08:47:44 -07:00
31 changed files with 177 additions and 1280 deletions

View File

@@ -91,38 +91,7 @@ async function processLabel(
labelConfig: LabelConfig,
): Promise<void> {
const template = labelConfig.getPromptTemplate();
// If this is a review label, prepend explicit PR-diff scoping guidance to
// reduce out-of-scope feedback. Do this before rendering so placeholders in
// the guidance (e.g., {CODEX_ACTION_GITHUB_EVENT_PATH}) are substituted.
const isReview = label.toLowerCase().includes("review");
const reviewScopeGuidance = `
PR Diff Scope
- Only review changes between the PR's merge-base and head; do not comment on commits or files outside this range.
- Derive the base/head SHAs from the event JSON at {CODEX_ACTION_GITHUB_EVENT_PATH}, then compute and use the PR diff for all analysis and comments.
Commands to determine scope
- Resolve SHAs:
- BASE_SHA=$(jq -r '.pull_request.base.sha // .pull_request.base.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}")
- HEAD_SHA=$(jq -r '.pull_request.head.sha // .pull_request.head.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}")
- BASE_SHA=$(git rev-parse "$BASE_SHA")
- HEAD_SHA=$(git rev-parse "$HEAD_SHA")
- Prefer triple-dot (merge-base) semantics for PR diffs:
- Changed commits: git log --oneline "$BASE_SHA...$HEAD_SHA"
- Changed files: git diff --name-status "$BASE_SHA...$HEAD_SHA"
- Review hunks: git diff -U0 "$BASE_SHA...$HEAD_SHA"
Review rules
- Anchor every comment to a file and hunk present in git diff "$BASE_SHA...$HEAD_SHA".
- If you mention context outside the diff, label it as "Follow-up (outside this PR scope)" and keep it brief (<=2 bullets).
- Do not critique commits or files not reachable in the PR range (merge-base(base, head) → head).
`.trim();
const effectiveTemplate = isReview
? `${reviewScopeGuidance}\n\n${template}`
: template;
const populatedTemplate = await renderPromptTemplate(effectiveTemplate, ctx);
const populatedTemplate = await renderPromptTemplate(template, ctx);
// Always run Codex and post the resulting message as a comment.
let commentBody = await runCodex(populatedTemplate, ctx);

View File

@@ -20,7 +20,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.0.0"
version = "0.14.0-alpha.3"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024

View File

@@ -42,15 +42,6 @@ impl From<std::io::Error> for ApplyPatchError {
}
}
impl From<&std::io::Error> for ApplyPatchError {
fn from(err: &std::io::Error) -> Self {
ApplyPatchError::IoError(IoError {
context: "I/O error".to_string(),
source: std::io::Error::new(err.kind(), err.to_string()),
})
}
}
#[derive(Debug, Error)]
#[error("{context}: {source}")]
pub struct IoError {
@@ -375,21 +366,13 @@ pub fn apply_hunks(
match apply_hunks_to_files(hunks) {
Ok(affected) => {
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
Ok(())
}
Err(err) => {
let msg = err.to_string();
writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
if let Some(io) = err.downcast_ref::<std::io::Error>() {
Err(ApplyPatchError::from(io))
} else {
Err(ApplyPatchError::IoError(IoError {
context: msg,
source: std::io::Error::other(err),
}))
}
writeln!(stderr, "{err:?}").map_err(ApplyPatchError::from)?;
}
}
Ok(())
}
/// Applies each parsed patch hunk to the filesystem.
@@ -1255,24 +1238,4 @@ g
})
);
}
#[test]
fn test_apply_patch_fails_on_write_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("readonly.txt");
fs::write(&path, "before\n").unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&path, perms).unwrap();
let patch = wrap_patch(&format!(
"*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
path.display()
));
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = apply_patch(&patch, &mut stdout, &mut stderr);
assert!(result.is_err());
}
}

View File

@@ -121,9 +121,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
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?;
if !usage.is_zero() {
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
Some(Subcommand::Exec(mut exec_cli)) => {
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);

View File

@@ -127,16 +127,6 @@ impl ModelClient {
let auth_mode = auth.as_ref().map(|a| a.mode);
if self.config.model_family.requires_chatgpt_auth && auth_mode != Some(AuthMode::ChatGPT) {
return Err(CodexErr::UnexpectedStatus(
StatusCode::BAD_REQUEST,
format!(
"{slug} is only supported with ChatGPT auth, run `codex login status` to check your auth status and `codex login` to login with ChatGPT",
slug = self.config.model_family.slug
),
));
}
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
@@ -633,7 +623,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
requires_auth: false,
};
let events = collect_events(
@@ -693,7 +683,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
requires_auth: false,
};
let events = collect_events(&[sse1.as_bytes()], provider).await;
@@ -796,7 +786,7 @@ mod tests {
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
requires_auth: false,
};
let out = run_sse(evs, provider).await;

View File

@@ -70,7 +70,7 @@ pub struct Config {
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: bool,
/// User-provided instructions from AGENTS.md.
/// User-provided instructions from instructions.md.
pub user_instructions: Option<String>,
/// Base instructions override.
@@ -486,7 +486,6 @@ impl Config {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries,
uses_local_shell_tool: false,
requires_chatgpt_auth: false,
}
});
@@ -576,7 +575,7 @@ impl Config {
None => return None,
};
p.push("AGENTS.md");
p.push("instructions.md");
std::fs::read_to_string(&p).ok().and_then(|s| {
let s = s.trim();
if s.is_empty() {
@@ -843,7 +842,7 @@ disable_response_storage = true
request_max_retries: Some(4),
stream_max_retries: Some(10),
stream_idle_timeout_ms: Some(300_000),
requires_openai_auth: false,
requires_auth: false,
};
let model_provider_map = {
let mut model_provider_map = built_in_model_providers();

View File

@@ -23,8 +23,6 @@ pub struct ModelFamily {
// the model such that its description can be omitted.
// See https://platform.openai.com/docs/guides/tools-local-shell
pub uses_local_shell_tool: bool,
pub requires_chatgpt_auth: bool,
}
macro_rules! model_family {
@@ -38,7 +36,6 @@ macro_rules! model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
requires_chatgpt_auth: false,
};
// apply overrides
$(
@@ -58,7 +55,6 @@ macro_rules! simple_model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
requires_chatgpt_auth: false,
})
}};
}
@@ -93,12 +89,6 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
simple_model_family!(slug, "gpt-oss")
} else if slug.starts_with("gpt-3.5") {
simple_model_family!(slug, "gpt-3.5")
} else if slug.starts_with("2025-08-06-model") {
model_family!(
slug, "2025-08-06-model",
supports_reasoning_summaries: true,
requires_chatgpt_auth: true,
)
} else {
None
}

View File

@@ -9,6 +9,7 @@ use codex_login::AuthMode;
use codex_login::CodexAuth;
use serde::Deserialize;
use serde::Serialize;
use std::borrow::Cow;
use std::collections::HashMap;
use std::env::VarError;
use std::time::Duration;
@@ -78,7 +79,7 @@ pub struct ModelProviderInfo {
/// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
#[serde(default)]
pub requires_openai_auth: bool,
pub requires_auth: bool,
}
impl ModelProviderInfo {
@@ -86,32 +87,26 @@ impl ModelProviderInfo {
/// reqwest Client applying:
/// • provider-specific headers (static + env based)
/// • Bearer auth header when an API key is available.
/// • Auth token for OAuth.
///
/// If the provider declares an `env_key` but the variable is missing/empty, returns an [`Err`] identical to the
/// When `require_api_key` is true and the provider declares an `env_key`
/// but the variable is missing/empty, returns an [`Err`] identical to the
/// one produced by [`ModelProviderInfo::api_key`].
pub async fn create_request_builder<'a>(
&'a self,
client: &'a reqwest::Client,
auth: &Option<CodexAuth>,
) -> crate::error::Result<reqwest::RequestBuilder> {
let effective_auth = match self.api_key() {
Ok(Some(key)) => Some(CodexAuth::from_api_key(key)),
Ok(None) => auth.clone(),
Err(err) => {
if auth.is_some() {
auth.clone()
} else {
return Err(err);
}
}
let auth: Cow<'_, Option<CodexAuth>> = if auth.is_some() {
Cow::Borrowed(auth)
} else {
Cow::Owned(self.get_fallback_auth()?)
};
let url = self.get_full_url(&effective_auth);
let url = self.get_full_url(&auth);
let mut builder = client.post(url);
if let Some(auth) = effective_auth.as_ref() {
if let Some(auth) = auth.as_ref() {
builder = builder.bearer_auth(auth.get_token().await?);
}
@@ -221,6 +216,14 @@ impl ModelProviderInfo {
.map(Duration::from_millis)
.unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS))
}
fn get_fallback_auth(&self) -> crate::error::Result<Option<CodexAuth>> {
let api_key = self.api_key()?;
if let Some(api_key) = api_key {
return Ok(Some(CodexAuth::from_api_key(api_key)));
}
Ok(None)
}
}
const DEFAULT_OLLAMA_PORT: u32 = 11434;
@@ -272,7 +275,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: true,
requires_auth: true,
},
),
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
@@ -316,7 +319,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
requires_auth: false,
}
}
@@ -344,7 +347,7 @@ base_url = "http://localhost:11434/v1"
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -373,7 +376,7 @@ query_params = { api-version = "2025-04-01-preview" }
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
@@ -405,7 +408,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
requires_auth: false,
};
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();

View File

@@ -77,11 +77,6 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
max_output_tokens: 4_096,
}),
"2025-08-06-model" => Some(ModelInfo {
context_window: 200_000,
max_output_tokens: 100_000,
}),
_ => None,
}
}

View File

@@ -429,12 +429,6 @@ pub struct TokenUsage {
pub total_tokens: u64,
}
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FinalOutput {
pub token_usage: TokenUsage,

View File

@@ -458,7 +458,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
requires_auth: false,
};
// Init session
@@ -481,86 +481,6 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn env_var_overrides_loaded_auth() {
#![allow(clippy::unwrap_used)]
let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
// Mock server
let server = MockServer::start().await;
// First request must NOT include `previous_response_id`.
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse_completed("resp1"), "text/event-stream");
// Expect POST to /openai/responses with api-version query param
Mock::given(method("POST"))
.and(path("/openai/responses"))
.and(query_param("api-version", "2025-04-01-preview"))
.and(header_regex("Custom-Header", "Value"))
.and(header_regex(
"Authorization",
format!(
"Bearer {}",
std::env::var(existing_env_var_with_random_value).unwrap()
)
.as_str(),
))
.respond_with(first)
.expect(1)
.mount(&server)
.await;
let provider = ModelProviderInfo {
name: "custom".to_string(),
base_url: Some(format!("{}/openai", server.uri())),
// Reuse the existing environment variable to avoid using unsafe code
env_key: Some(existing_env_var_with_random_value.to_string()),
query_params: Some(std::collections::HashMap::from([(
"api-version".to_string(),
"2025-04-01-preview".to_string(),
)])),
env_key_instructions: None,
wire_api: WireApi::Responses,
http_headers: Some(std::collections::HashMap::from([(
"Custom-Header".to_string(),
"Value".to_string(),
)])),
env_http_headers: None,
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
};
// Init session
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let CodexSpawnOk { codex, .. } = Codex::spawn(
config,
Some(auth_from_token("Default Access Token".to_string())),
ctrl_c.clone(),
)
.await
.unwrap();
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
}
fn auth_from_token(id_token: String) -> CodexAuth {
CodexAuth::new(
None,

View File

@@ -90,7 +90,7 @@ async fn retries_on_early_close() {
request_max_retries: Some(0),
stream_max_retries: Some(1),
stream_idle_timeout_ms: Some(2000),
requires_openai_auth: false,
requires_auth: false,
};
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());

View File

@@ -4,7 +4,6 @@ use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::env;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Write;
@@ -12,7 +11,6 @@ use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
@@ -185,59 +183,6 @@ fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
/// Represents a running login subprocess. The child can be killed by holding
/// the mutex and calling `kill()`.
#[derive(Debug, Clone)]
pub struct SpawnedLogin {
pub child: Arc<Mutex<Child>>,
pub stdout: Arc<Mutex<Vec<u8>>>,
pub stderr: Arc<Mutex<Vec<u8>>>,
}
/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
let mut cmd = std::process::Command::new("python3");
cmd.arg("-c")
.arg(SOURCE_FOR_PYTHON_SERVER)
.env("CODEX_HOME", codex_home)
.env("CODEX_CLIENT_ID", CLIENT_ID)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout_buf = Arc::new(Mutex::new(Vec::new()));
let stderr_buf = Arc::new(Mutex::new(Vec::new()));
if let Some(mut out) = child.stdout.take() {
let buf = stdout_buf.clone();
std::thread::spawn(move || {
let mut tmp = Vec::new();
let _ = std::io::copy(&mut out, &mut tmp);
if let Ok(mut b) = buf.lock() {
b.extend_from_slice(&tmp);
}
});
}
if let Some(mut err) = child.stderr.take() {
let buf = stderr_buf.clone();
std::thread::spawn(move || {
let mut tmp = Vec::new();
let _ = std::io::copy(&mut err, &mut tmp);
if let Ok(mut b) = buf.lock() {
b.extend_from_slice(&tmp);
}
});
}
Ok(SpawnedLogin {
child: Arc::new(Mutex::new(child)),
stdout: stdout_buf,
stderr: stderr_buf,
})
}
/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
/// environment variable set to the provided `codex_home` path. If the
/// subprocess exits 0, read the OPENAI_API_KEY property out of
@@ -289,7 +234,7 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<(
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
/// Returns the full AuthDotJson structure after refreshing if necessary.
pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
let mut file = File::open(auth_file)?;
let mut file = std::fs::File::open(auth_file)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;

View File

@@ -110,7 +110,7 @@ def main() -> None:
eprint(f"Failed to open browser: {e}")
eprint(
f". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
f"If your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}"
)
# Run the server in the main thread until `shutdown()` is called by the

View File

@@ -1,40 +0,0 @@
Generate a file named AGENTS.md that serves as a contributor guide for this repository.
Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.
Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project.
Document Requirements
- Title the document "Repository Guidelines".
- Use Markdown headings (#, ##, etc.) for structure.
- Keep the document concise. 200-400 words is optimal.
- Keep explanations short, direct, and specific to this repository.
- Provide examples where helpful (commands, directory paths, naming patterns).
- Maintain a professional, instructional tone.
Recommended Sections
Project Structure & Module Organization
- Outline the project structure, including where the source code, tests, and assets are located.
Build, Test, and Development Commands
- List key commands for building, testing, and running locally (e.g., npm test, make build).
- Briefly explain what each command does.
Coding Style & Naming Conventions
- Specify indentation rules, language-specific style preferences, and naming patterns.
- Include any formatting or linting tools used.
Testing Guidelines
- Identify testing frameworks and coverage requirements.
- State test naming conventions and how to run tests.
Commit & Pull Request Guidelines
- Summarize commit message conventions found in the projects Git history.
- Outline pull request requirements (descriptions, linked issues, screenshots, etc.).
(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.

View File

@@ -5,10 +5,6 @@ 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::onboarding::onboarding_screen::KeyEventResult;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::should_show_login_screen;
use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
@@ -39,9 +35,6 @@ const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
enum AppState<'a> {
Onboarding {
screen: OnboardingScreen,
},
/// The main chat UI is visible.
Chat {
/// Boxed to avoid a large enum variant and reduce the overall size of
@@ -49,9 +42,7 @@ enum AppState<'a> {
widget: Box<ChatWidget<'a>>,
},
/// The start-up warning that recommends running codex inside a Git repo.
GitWarning {
screen: GitWarningScreen,
},
GitWarning { screen: GitWarningScreen },
}
pub(crate) struct App<'a> {
@@ -142,20 +133,7 @@ impl App<'_> {
});
}
let show_login_screen = should_show_login_screen(&config);
let (app_state, chat_args) = if show_login_screen {
(
AppState::Onboarding {
screen: OnboardingScreen::new(app_event_tx.clone(), config.codex_home.clone()),
},
Some(ChatWidgetArgs {
config: config.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
}),
)
} else if show_git_warning {
let (app_state, chat_args) = if show_git_warning {
(
AppState::GitWarning {
screen: GitWarningScreen::new(),
@@ -254,25 +232,12 @@ impl App<'_> {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::GitWarning { .. } => {
// Allow exiting the app with Ctrl+C from the warning screen.
self.app_event_tx.send(AppEvent::ExitRequest);
}
}
}
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.on_ctrl_z();
}
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -290,9 +255,6 @@ impl App<'_> {
self.dispatch_key_event(key_event);
}
}
AppState::Onboarding { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
@@ -320,12 +282,10 @@ impl App<'_> {
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
},
AppEvent::DispatchCommand(command) => match command {
@@ -340,13 +300,6 @@ impl App<'_> {
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Init => {
// Guard: do not run if a task is active.
if let AppState::Chat { widget } = &mut self.app_state {
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
widget.submit_text_message(INIT_PROMPT.to_string());
}
}
SlashCommand::Compact => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
@@ -422,12 +375,6 @@ impl App<'_> {
}));
}
},
AppEvent::OnboardingAuthComplete(result) => {
if let AppState::Onboarding { screen } = &mut self.app_state {
// Let the onboarding screen handle success/failure and emit follow-up events.
let _ = screen.on_auth_complete(result);
}
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -446,7 +393,6 @@ impl App<'_> {
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
}
}
@@ -475,7 +421,6 @@ impl App<'_> {
let size = terminal.size()?;
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::Onboarding { .. } => size.height,
AppState::GitWarning { .. } => size.height,
};
@@ -506,7 +451,6 @@ impl App<'_> {
}
frame.render_widget_ref(&**widget, frame.area())
}
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
})?;
Ok(())
@@ -519,25 +463,6 @@ impl App<'_> {
AppState::Chat { widget } => {
widget.handle_key_event(key_event);
}
AppState::Onboarding { screen } => match screen.handle_key_event(key_event) {
KeyEventResult::Continue => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
)),
};
}
KeyEventResult::Quit => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
KeyEventResult::None => {
// do nothing
}
},
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
GitWarningOutcome::Continue => {
// User accepted switch to chat view.
@@ -569,7 +494,6 @@ impl App<'_> {
fn dispatch_paste_event(&mut self, pasted: String) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
}
}
@@ -577,7 +501,6 @@ impl App<'_> {
fn dispatch_codex_event(&mut self, event: Event) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_codex_event(event),
AppState::Onboarding { .. } => {}
AppState::GitWarning { .. } => {}
}
}

View File

@@ -48,7 +48,4 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
}

View File

@@ -729,7 +729,6 @@ impl WidgetRef for &ChatComposer {
#[cfg(test)]
mod tests {
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
@@ -1005,49 +1004,6 @@ mod tests {
}
}
#[test]
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Type the slash command.
for ch in [
'/', 'i', 'n', 'i', 't', // "/init"
] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
// Press Enter to dispatch the selected command.
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// When a slash command is dispatched, the composer should not submit
// literal text and should clear its textarea.
match result {
InputResult::None => {}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
// Verify a DispatchCommand event for the "init" command was sent.
match rx.try_recv() {
Ok(AppEvent::DispatchCommand(cmd)) => {
assert_eq!(cmd.command(), "init");
}
Ok(_other) => panic!("unexpected app event"),
Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"),
Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"),
}
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;

View File

@@ -188,38 +188,3 @@ impl WidgetRef for CommandPopup {
table.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new();
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
// Access the filtered list via the selected command and ensure that
// one of the matches is the new "init" command.
let matches = popup.filtered_commands();
assert!(
matches.iter().any(|cmd| cmd.command() == "init"),
"expected '/init' to appear among filtered commands"
);
}
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new();
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
// command by default.
let selected = popup.selected_command();
match selected {
Some(cmd) => assert_eq!(cmd.command(), "init"),
None => panic!("expected a selected command for exact match"),
}
}
}

View File

@@ -206,10 +206,7 @@ impl TextArea {
match event {
KeyEvent {
code: KeyCode::Char(c),
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
// for word navigation. Those are handled explicitly below.
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
@@ -248,23 +245,6 @@ impl TextArea {
} => {
self.delete_backward_word();
}
// Meta-b -> move to beginning of previous word
// Meta-f -> move to end of next word
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
@@ -295,23 +275,6 @@ impl TextArea {
} => {
self.move_cursor_right();
}
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Up, ..
} => {
@@ -349,6 +312,20 @@ impl TextArea {
} => {
self.move_cursor_to_end_of_line(true);
}
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
o => {
tracing::debug!("Unhandled key event in TextArea: {:?}", o);
}

View File

@@ -30,8 +30,6 @@ use codex_core::protocol::TurnDiffEvent;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
@@ -64,7 +62,6 @@ pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>,
active_history_cell: Option<HistoryCell>,
config: Config,
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
@@ -110,33 +107,6 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget<'_> {
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
self.active_history_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
self.live_builder = RowBuilder::new(self.live_builder.width());
self.current_stream = None;
self.stream_header_emitted = false;
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.content_buffer.clear();
self.request_redraw();
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Max(
self.active_history_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
Constraint::Min(self.bottom_pane.desired_height(area.width)),
])
.areas(area)
}
fn emit_stream_header(&mut self, kind: StreamKind) {
use ratatui::text::Line as RLine;
if self.stream_header_emitted {
@@ -208,7 +178,6 @@ impl ChatWidget<'_> {
has_input_focus: true,
enhanced_keys_supported,
}),
active_history_cell: None,
config,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
@@ -228,10 +197,6 @@ impl ChatWidget<'_> {
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
.active_history_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
@@ -460,11 +425,9 @@ impl ChatWidget<'_> {
cwd: cwd.clone(),
},
);
self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
self.add_to_history(HistoryCell::new_active_exec_command(command));
}
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: _,
auto_approved,
@@ -475,12 +438,8 @@ impl ChatWidget<'_> {
changes,
));
}
EventMsg::PatchApplyEnd(event) => {
self.add_to_history(HistoryCell::new_patch_apply_end(
event.stdout,
event.stderr,
event.success,
));
EventMsg::PatchApplyEnd(patch_apply_end_event) => {
self.add_to_history(HistoryCell::new_patch_end_event(patch_apply_end_event));
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
@@ -491,7 +450,6 @@ impl ChatWidget<'_> {
}) => {
// Compute summary before moving stdout into the history cell.
let cmd = self.running_commands.remove(&call_id);
self.active_history_cell = None;
self.add_to_history(HistoryCell::new_completed_exec_command(
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
CommandOutput {
@@ -585,7 +543,17 @@ impl ChatWidget<'_> {
CancellationEvent::Ignored => {}
}
if self.bottom_pane.is_task_running() {
self.interrupt_running_task();
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
self.live_builder = RowBuilder::new(self.live_builder.width());
self.current_stream = None;
self.stream_header_emitted = false;
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.content_buffer.clear();
self.request_redraw();
CancellationEvent::Ignored
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
self.submit_op(Op::Shutdown);
@@ -596,10 +564,6 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn on_ctrl_z(&mut self) {
self.interrupt_running_task();
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
}
@@ -611,16 +575,6 @@ impl ChatWidget<'_> {
}
}
/// Programmatically submit a user text message as if typed in the
/// composer. The text will be added to conversation history and sent to
/// the agent.
pub(crate) fn submit_text_message(&mut self, text: String) {
if text.is_empty() {
return;
}
self.submit_user_message(text.into());
}
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.token_usage
}
@@ -632,8 +586,7 @@ impl ChatWidget<'_> {
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let [_, bottom_pane_area] = self.layout_areas(area);
self.bottom_pane.cursor_pos(bottom_pane_area)
self.bottom_pane.cursor_pos(area)
}
}
@@ -737,11 +690,10 @@ impl ChatWidget<'_> {
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
if let Some(cell) = &self.active_history_cell {
cell.render_ref(active_cell_area, buf);
}
// In the hybrid inline viewport mode we only draw the interactive
// bottom pane; history entries are injected directly into scrollback
// via `Terminal::insert_before`.
(&self.bottom_pane).render(area, buf);
}
}

View File

@@ -1,4 +0,0 @@
use ratatui::style::Color;
pub(crate) const LIGHT_BLUE: Color = Color::Rgb(134, 238, 255);
pub(crate) const SUCCESS_GREEN: Color = Color::Rgb(169, 230, 158);

View File

@@ -1,6 +1,4 @@
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand;
use crate::text_block::TextBlock;
use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine;
@@ -13,6 +11,7 @@ use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use image::DynamicImage;
@@ -25,9 +24,6 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
@@ -66,23 +62,35 @@ fn line_to_static(line: &Line) -> Line<'static> {
/// scrollable list.
pub(crate) enum HistoryCell {
/// Welcome message.
WelcomeMessage { view: TextBlock },
WelcomeMessage {
view: TextBlock,
},
/// Message from the user.
UserPrompt { view: TextBlock },
UserPrompt {
view: TextBlock,
},
// AgentMessage and AgentReasoning variants were unused and have been removed.
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: TextBlock },
ActiveExecCommand {
view: TextBlock,
},
/// Completed exec tool call.
CompletedExecCommand { view: TextBlock },
CompletedExecCommand {
view: TextBlock,
},
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall { view: TextBlock },
ActiveMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where we show the result serialized as JSON.
CompletedMcpToolCall { view: TextBlock },
CompletedMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
@@ -92,34 +100,51 @@ pub(crate) enum HistoryCell {
// resized version avoids doing the potentially expensive rescale twice
// because the scroll-view first calls `height()` for layouting and then
// `render_window()` for painting.
CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
},
/// Background event.
BackgroundEvent { view: TextBlock },
BackgroundEvent {
view: TextBlock,
},
/// Output from the `/diff` command.
GitDiffOutput { view: TextBlock },
GitDiffOutput {
view: TextBlock,
},
/// Output from the `/status` command.
StatusOutput { view: TextBlock },
StatusOutput {
view: TextBlock,
},
/// Error event from the backend.
ErrorEvent { view: TextBlock },
ErrorEvent {
view: TextBlock,
},
/// Info describing the newly-initialized session.
SessionInfo { view: TextBlock },
SessionInfo {
view: TextBlock,
},
/// A pending code patch that is awaiting user approval. Mirrors the
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
PendingPatch { view: TextBlock },
PendingPatch {
view: TextBlock,
},
PatchEventEnd {
view: TextBlock,
},
/// A humanfriendly rendering of the model's current plan and step
/// statuses provided via the `update_plan` tool.
PlanUpdate { view: TextBlock },
/// Result of applying a patch (success or failure) with optional output.
PatchApplyResult { view: TextBlock },
PlanUpdate {
view: TextBlock,
},
}
const TOOL_CALL_MAX_LINES: usize = 5;
@@ -140,8 +165,8 @@ impl HistoryCell {
| HistoryCell::CompletedExecCommand { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PatchEventEnd { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::PatchApplyResult { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
@@ -152,15 +177,6 @@ impl HistoryCell {
],
}
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
pub(crate) fn new_session_info(
config: &Config,
event: SessionConfiguredEvent,
@@ -168,35 +184,32 @@ impl HistoryCell {
) -> Self {
let SessionConfiguredEvent {
model,
session_id: _,
session_id,
history_log_id: _,
history_entry_count: _,
} = event;
if is_first_event {
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
Some(_) => "~".to_string(),
None => config.cwd.display().to_string(),
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
let lines: Vec<Line<'static>> = vec![
let mut lines: Vec<Line<'static>> = vec![
Line::from(vec![
Span::raw(">_ ").dim(),
Span::styled(
"You are using OpenAI Codex in",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {cwd_str}")).dim(),
"OpenAI ".into(),
"Codex".bold(),
format!(" v{VERSION}").into(),
" (research preview)".dim(),
]),
Line::from(""),
Line::from(vec![
"codex session".magenta().bold(),
" ".into(),
session_id.to_string().dim(),
]),
Line::from("".dim()),
Line::from(" Try one of the following commands to get started:".dim()),
Line::from("".dim()),
Line::from(format!(" 1. /init - {}", SlashCommand::Init.description()).dim()),
Line::from(format!(" 2. /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" 3. /compact - {}", SlashCommand::Compact.description()).dim()),
Line::from(format!(" 4. /new - {}", SlashCommand::New.description()).dim()),
Line::from("".dim()),
];
for (key, value) in create_config_summary_entries(config) {
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
}
lines.push(Line::from(""));
HistoryCell::WelcomeMessage {
view: TextBlock::new(lines),
}
@@ -599,10 +612,7 @@ impl HistoryCell {
PatchEventType::ApplyBegin {
auto_approved: false,
} => {
let lines: Vec<Line<'static>> = vec![
Line::from("applying patch".magenta().bold()),
Line::from(""),
];
let lines = vec![Line::from("patch applied".magenta().bold())];
return Self::PendingPatch {
view: TextBlock::new(lines),
};
@@ -651,63 +661,29 @@ impl HistoryCell {
}
}
pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
pub(crate) fn new_patch_end_event(patch_apply_end_event: PatchApplyEndEvent) -> Self {
let PatchApplyEndEvent {
call_id: _,
stdout: _,
stderr,
success,
} = patch_apply_end_event;
let status = if success {
RtSpan::styled("patch applied", Style::default().fg(Color::Green))
let mut lines: Vec<Line<'static>> = if success {
vec![Line::from("patch applied successfully".italic())]
} else {
RtSpan::styled(
"patch failed",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
let mut lines = vec![Line::from("patch failed".italic())];
lines.extend(stderr.lines().map(|l| Line::from(l.to_string())));
lines
};
lines.push(RtLine::from(vec![
"patch".magenta().bold(),
" ".into(),
status,
]));
let src = if success {
if stdout.trim().is_empty() {
&stderr
} else {
&stdout
}
} else if stderr.trim().is_empty() {
&stdout
} else {
&stderr
};
if !src.trim().is_empty() {
lines.push(Line::from(""));
let mut iter = src.lines();
for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(ansi_escape_line(raw).dim());
}
let remaining = iter.count();
if remaining > 0 {
lines.push(Line::from(format!("... {remaining} additional lines")).dim());
}
}
lines.push(Line::from(""));
HistoryCell::PatchApplyResult {
HistoryCell::PatchEventEnd {
view: TextBlock::new(lines),
}
}
}
impl WidgetRef for &HistoryCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
// Build a concise, humanreadable summary list similar to the
// `git status` short format so the user can reason about the

View File

@@ -13,6 +13,7 @@ use codex_login::load_auth;
use codex_ollama::DEFAULT_OSS_MODEL;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use tracing::error;
use tracing_appender::non_blocking;
@@ -26,7 +27,6 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod colors;
pub mod custom_terminal;
mod exec_command;
mod file_search;
@@ -37,8 +37,6 @@ pub mod insert_history;
pub mod live_wrap;
mod log_layer;
mod markdown;
pub mod onboarding;
mod shimmer;
mod slash_command;
mod status_indicator_widget;
mod text_block;
@@ -206,6 +204,24 @@ pub async fn run_main(
eprintln!("");
}
let show_login_screen = should_show_login_screen(&config);
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.
codex_login::login_with_chatgpt(&config.codex_home, false).await?;
std::io::stdout().write_all(b"Login successful.\n")?;
}
// Determine whether we need to display the "not a git repo" warning
// modal. The flag is shown when the current working directory is *not*
// inside a Git repository **and** the user did *not* pass the
@@ -271,7 +287,7 @@ fn restore() {
#[allow(clippy::unwrap_used)]
fn should_show_login_screen(config: &Config) -> bool {
if config.model_provider.requires_openai_auth {
if config.model_provider.requires_auth {
// 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();

View File

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

View File

@@ -1,316 +0,0 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_login::AuthMode;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors::LIGHT_BLUE;
use crate::colors::SUCCESS_GREEN;
use crate::onboarding::onboarding_screen::KeyEventResult;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::shimmer::FrameTicker;
use crate::shimmer::shimmer_spans;
use std::path::PathBuf;
// no additional imports
#[derive(Debug)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(#[allow(dead_code)] ContinueInBrowserState),
ChatGptSuccess,
}
#[derive(Debug)]
/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up.
pub(crate) struct ContinueInBrowserState {
_login_child: Option<codex_login::SpawnedLogin>,
_frame_ticker: Option<FrameTicker>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(child) = &self._login_child {
if let Ok(mut locked) = child.child.lock() {
// Best-effort terminate and reap the child to avoid zombies.
let _ = locked.kill();
let _ = locked.wait();
}
}
}
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.mode = AuthMode::ChatGPT;
KeyEventResult::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.mode = AuthMode::ApiKey;
KeyEventResult::None
}
KeyCode::Char('1') => {
self.mode = AuthMode::ChatGPT;
self.start_chatgpt_login();
KeyEventResult::None
}
KeyCode::Char('2') => {
self.mode = AuthMode::ApiKey;
self.verify_api_key()
}
KeyCode::Enter => match self.mode {
AuthMode::ChatGPT => match &self.sign_in_state {
SignInState::PickMode => self.start_chatgpt_login(),
SignInState::ChatGptContinueInBrowser(_) => KeyEventResult::None,
SignInState::ChatGptSuccess => KeyEventResult::Continue,
},
AuthMode::ApiKey => self.verify_api_key(),
},
KeyCode::Esc => {
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
self.sign_in_state = SignInState::PickMode;
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
} else {
KeyEventResult::Quit
}
}
KeyCode::Char('q') => KeyEventResult::Quit,
_ => KeyEventResult::None,
}
}
}
#[derive(Debug)]
pub(crate) struct AuthModeWidget {
pub mode: AuthMode,
pub error: Option<String>,
pub sign_in_state: SignInState,
pub event_tx: AppEventSender,
pub codex_home: PathBuf,
}
impl AuthModeWidget {
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::styled(
"Sign in with your ChatGPT account?",
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
let create_mode_item = |idx: usize,
selected_mode: AuthMode,
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
let line1 = if is_selected {
Line::from(vec![
Span::styled(
format!("{} {}. ", caret, idx + 1),
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
),
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
])
} else {
Line::from(format!(" {}. {text}", idx + 1))
};
let line2 = if is_selected {
Line::from(format!(" {description}"))
.style(Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM))
} else {
Line::from(format!(" {description}"))
.style(Style::default().add_modifier(Modifier::DIM))
};
vec![line1, line2]
};
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT or create a new account",
"Leverages your plan, starting at $20 a month for Plus",
));
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay only for what you use",
));
lines.push(Line::from(""));
lines.push(
Line::from("Press Enter to continue")
.style(Style::default().add_modifier(Modifier::DIM)),
);
if let Some(err) = &self.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
)));
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let idx = self.current_frame();
let mut spans = vec![Span::from("> ")];
spans.extend(shimmer_spans("Finish signing in via your browser", idx));
let lines = vec![
Line::from(spans),
Line::from(""),
Line::from(" Press Escape to cancel")
.style(Style::default().add_modifier(Modifier::DIM)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account")
.style(Style::default().fg(SUCCESS_GREEN)),
Line::from(""),
Line::from("> Before you start:"),
Line::from(""),
Line::from(" Codex can make mistakes"),
Line::from(" Check important info")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Due to prompt injection risks, only use it with code you trust"),
Line::from(" For more details see https://github.com/openai/codex")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Powered by your ChatGPT account"),
Line::from(" Uses your plan's rate limits and training data preferences")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Press Enter to continue").style(Style::default().fg(LIGHT_BLUE)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn start_chatgpt_login(&mut self) -> KeyEventResult {
self.error = None;
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
Ok(child) => {
self.spawn_completion_poller(child.clone());
self.sign_in_state =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
_login_child: Some(child),
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
});
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
Err(e) => {
self.sign_in_state = SignInState::PickMode;
self.error = Some(e.to_string());
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
}
}
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
fn verify_api_key(&mut self) -> KeyEventResult {
if std::env::var("OPENAI_API_KEY").is_err() {
self.error =
Some("Set OPENAI_API_KEY in your environment. Learn more: https://platform.openai.com/docs/libraries".to_string());
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
} else {
KeyEventResult::Continue
}
}
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
let child_arc = child.child.clone();
let stderr_buf = child.stderr.clone();
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
loop {
let done = {
if let Ok(mut locked) = child_arc.lock() {
match locked.try_wait() {
Ok(Some(status)) => Some(status.success()),
Ok(None) => None,
Err(_) => Some(false),
}
} else {
Some(false)
}
};
if let Some(success) = done {
if success {
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
} else {
let err = stderr_buf
.lock()
.ok()
.and_then(|b| String::from_utf8(b.clone()).ok())
.unwrap_or_else(|| "login_with_chatgpt subprocess failed".to_string());
event_tx.send(AppEvent::OnboardingAuthComplete(Err(err)));
}
break;
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
});
}
fn current_frame(&self) -> usize {
// Derive frame index from wall-clock time to avoid storing animation state.
// 100ms per frame to match the previous ticker cadence.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
(now_ms / 100) as usize
}
}
impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match self.sign_in_state {
SignInState::PickMode => {
self.render_pick_mode(area, buf);
}
SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf);
}
SignInState::ChatGptSuccess => {
self.render_chatgpt_success(area, buf);
}
}
}
}

View File

@@ -1,3 +0,0 @@
mod auth;
pub mod onboarding_screen;
mod welcome;

View File

@@ -1,157 +0,0 @@
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use codex_login::AuthMode;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInState;
use crate::onboarding::welcome::WelcomeWidget;
use std::path::PathBuf;
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
}
pub(crate) trait KeyboardHandler {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult;
}
pub(crate) enum KeyEventResult {
Continue,
Quit,
None,
}
pub(crate) struct OnboardingScreen {
event_tx: AppEventSender,
steps: Vec<Step>,
}
impl OnboardingScreen {
pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self {
let steps: Vec<Step> = vec![
Step::Welcome(WelcomeWidget {}),
Step::Auth(AuthModeWidget {
event_tx: event_tx.clone(),
mode: AuthMode::ChatGPT,
error: None,
sign_in_state: SignInState::PickMode,
codex_home,
}),
];
Self { event_tx, steps }
}
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) -> KeyEventResult {
if let Some(Step::Auth(state)) = self.steps.last_mut() {
match result {
Ok(()) => {
state.sign_in_state = SignInState::ChatGptSuccess;
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
Err(e) => {
state.sign_in_state = SignInState::PickMode;
state.error = Some(e);
self.event_tx.send(AppEvent::RequestRedraw);
KeyEventResult::None
}
}
} else {
KeyEventResult::None
}
}
}
impl KeyboardHandler for OnboardingScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
if let Some(last_step) = self.steps.last_mut() {
self.event_tx.send(AppEvent::RequestRedraw);
last_step.handle_key_event(key_event)
} else {
KeyEventResult::None
}
}
}
impl WidgetRef for &OnboardingScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Render steps top-to-bottom, measuring each step's height dynamically.
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
let width = area.width;
// Helper to scan a temporary buffer and return number of used rows.
fn used_rows(tmp: &Buffer, width: u16, height: u16) -> u16 {
if width == 0 || height == 0 {
return 0;
}
let mut last_non_empty: Option<u16> = None;
for yy in 0..height {
let mut any = false;
for xx in 0..width {
let sym = tmp[(xx, yy)].symbol();
if !sym.trim().is_empty() {
any = true;
break;
}
}
if any {
last_non_empty = Some(yy);
}
}
last_non_empty.map(|v| v + 2).unwrap_or(0)
}
let mut i = 0usize;
while i < self.steps.len() && y < bottom {
let step = &self.steps[i];
let max_h = bottom.saturating_sub(y);
if max_h == 0 || width == 0 {
break;
}
let scratch_area = Rect::new(0, 0, width, max_h);
let mut scratch = Buffer::empty(scratch_area);
step.render_ref(scratch_area, &mut scratch);
let h = used_rows(&scratch, width, max_h).min(max_h);
if h > 0 {
let target = Rect {
x: area.x,
y,
width,
height: h,
};
step.render_ref(target, buf);
y = y.saturating_add(h);
}
i += 1;
}
}
}
impl KeyboardHandler for Step {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
match self {
Step::Welcome(_) => KeyEventResult::None,
Step::Auth(widget) => widget.handle_key_event(key_event),
}
}
}
impl WidgetRef for Step {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match self {
Step::Welcome(widget) => {
widget.render_ref(area, buf);
}
Step::Auth(widget) => {
widget.render_ref(area, buf);
}
}
}
}

View File

@@ -1,23 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
pub(crate) struct WelcomeWidget {}
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let line = Line::from(vec![
Span::raw("> "),
Span::styled(
"Welcome to Codex, OpenAI's coding agent that runs in your terminal",
Style::default().add_modifier(Modifier::BOLD),
),
]);
line.render(area, buf);
}
}

View File

@@ -1,84 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
#[derive(Debug)]
pub(crate) struct FrameTicker {
running: Arc<AtomicBool>,
}
impl FrameTicker {
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let app_event_tx_clone = app_event_tx.clone();
std::thread::spawn(move || {
while running_clone.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(100));
app_event_tx_clone.send(AppEvent::RequestRedraw);
}
});
Self { running }
}
}
impl Drop for FrameTicker {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
}
}
pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec<Span<'static>> {
let chars: Vec<char> = text.chars().collect();
let padding = 10usize;
let period = chars.len() + padding * 2;
let pos = frame_idx % period;
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false);
let band_half_width = 6.0;
let mut spans: Vec<Span<'static>> = Vec::with_capacity(chars.len());
for (i, ch) in chars.iter().enumerate() {
let i_pos = i as isize + padding as isize;
let pos = pos as isize;
let dist = (i_pos - pos).abs() as f32;
let t = if dist <= band_half_width {
let x = std::f32::consts::PI * (dist / band_half_width);
0.5 * (1.0 + x.cos())
} else {
0.0
};
let brightness = 0.4 + 0.6 * t;
let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
let style = if has_true_color {
Style::default()
.fg(Color::Rgb(level, level, level))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color_for_level(level))
};
spans.push(Span::styled(ch.to_string(), style));
}
spans
}
fn color_for_level(level: u8) -> Color {
if level < 128 {
Color::DarkGray
} else if level < 192 {
Color::Gray
} else {
Color::White
}
}

View File

@@ -13,7 +13,6 @@ pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
New,
Init,
Compact,
Diff,
Status,
@@ -27,7 +26,6 @@ impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
SlashCommand::New => "Start a new chat",
SlashCommand::Init => "Create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "Compact the chat history",
SlashCommand::Quit => "Exit the application",
SlashCommand::Diff => "Show git diff (including untracked files)",