Compare commits

..

4 Commits

Author SHA1 Message Date
Ahmed Ibrahim
9f63b77fb2 tests 2025-10-01 17:52:13 -07:00
Ahmed Ibrahim
7750d859ef tests 2025-10-01 17:49:56 -07:00
Ahmed Ibrahim
cd70f68240 tests 2025-10-01 17:49:34 -07:00
Ahmed Ibrahim
0c3e584af6 plan is on by default 2025-10-01 17:30:49 -07:00
90 changed files with 529 additions and 1509 deletions

View File

@@ -1,26 +0,0 @@
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate existing labels for the issue described later in this prompt.
Follow these rules:
- Only pick labels out of the list below.
- Prefer a small set of precise labels over many broad ones.
- If none of the labels fit, respond with an empty JSON array: []
- Output must be a JSON array of label names (strings) with no additional commentary.
Labels to apply:
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. extension — VS Code (or other IDE) extension-specific issues.
4. windows-os — Bugs or friction specific to Windows environments (PowerShell behavior, path handling, copy/paste, OS-specific auth or tooling failures).
5. mcp — Topics involving Model Context Protocol servers/clients.
6. codex-web — Issues targeting the Codex web UI/Cloud experience.
8. azure — Problems or requests tied to Azure OpenAI deployments.
9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
Issue information is available in environment variables:
ISSUE_NUMBER
ISSUE_TITLE
ISSUE_BODY
REPO_FULL_NAME

View File

@@ -1,76 +0,0 @@
name: Issue Labeler
on:
issues:
types:
# - opened - disabled while testing
- labeled
jobs:
gather-labels:
name: Generate label suggestions
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }}
runs-on: ubuntu-latest
permissions:
contents: read
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
REPO_FULL_NAME: ${{ github.repository }}
outputs:
codex_output: ${{ steps.codex.outputs.final_message }}
steps:
- uses: actions/checkout@v4
- id: codex
uses: openai/codex-action@main
with:
openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }}
prompt_file: .github/prompts/issue-labeler.txt
apply-labels:
name: Apply labels from Codex output
needs: gather-labels
if: ${{ needs.gather-labels.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }}
steps:
- name: Apply labels
run: |
json=${CODEX_OUTPUT//$'\r'/}
if [ -z "$json" ]; then
echo "Codex produced no output. Skipping label application."
exit 0
fi
if ! printf '%s' "$json" | jq -e 'type == "array"' >/dev/null 2>&1; then
echo "Codex output was not a JSON array. Raw output: $json"
exit 0
fi
labels=$(printf '%s' "$json" | jq -r '.[] | tostring')
if [ -z "$labels" ]; then
echo "Codex returned an empty array. Nothing to do."
exit 0
fi
cmd=(gh issue edit "$ISSUE_NUMBER")
while IFS= read -r label; do
cmd+=(--add-label "$label")
done <<< "$labels"
"${cmd[@]}" || true
- name: Remove codex-label trigger
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }}
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true
echo "Attempted to remove label: codex-label"

View File

@@ -164,6 +164,7 @@ jobs:
sudo apt install -y musl-tools pkg-config && sudo rm -rf /var/lib/apt/lists/*
- name: cargo clippy
id: clippy
run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} -- -D warnings
# Running `cargo build` from the workspace root builds the workspace using
@@ -172,6 +173,7 @@ jobs:
# run `cargo check` for each crate individually, though because this is
# slower, we only do this for the x86_64-unknown-linux-gnu target.
- name: cargo check individual crates
id: cargo_check_all_crates
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release' }}
continue-on-error: true
run: |
@@ -184,14 +186,24 @@ jobs:
version: 0.9.103
- name: tests
id: test
# Tests take too long for release builds to run them on every PR.
# Though run the tests even if `cargo clippy` failed to get more
# complete information.
if: ${{ always() && matrix.profile != 'release' }}
if: ${{ matrix.profile != 'release' }}
continue-on-error: true
run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }}
env:
RUST_BACKTRACE: 1
# Fail the job if any of the previous steps failed.
- name: verify all steps passed
if: |
steps.clippy.outcome == 'failure' ||
steps.cargo_check_all_crates.outcome == 'failure' ||
steps.test.outcome == 'failure'
run: |
echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details."
exit 1
# --- Gatherer job that you mark as the ONLY required status -----------------
results:
name: CI results (required)

View File

@@ -83,7 +83,6 @@ Codex CLI supports a rich set of configuration options, with preferences stored
- [**Authentication**](./docs/authentication.md)
- [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced)
- [Login on a "Headless" machine](./docs/authentication.md#connecting-on-a-headless-machine)
- [**Non-interactive mode**](./docs/exec.md)
- [**Advanced**](./docs/advanced.md)
- [Non-interactive / CI mode](./docs/advanced.md#non-interactive--ci-mode)
- [Tracing / verbose logging](./docs/advanced.md#tracing--verbose-logging)

View File

@@ -53,7 +53,6 @@ use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::Cursor as RolloutCursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::NewConversation;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
@@ -709,7 +708,6 @@ impl CodexMessageProcessor {
&self.config.codex_home,
page_size,
cursor_ref,
INTERACTIVE_SESSION_SOURCES,
)
.await
{

View File

@@ -17,7 +17,6 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use codex_protocol::protocol::SessionSource;
use std::sync::Arc;
pub(crate) struct MessageProcessor {
@@ -35,11 +34,8 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone(), false);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::VSCode,
));
let auth_manager = AuthManager::shared(config.codex_home.clone());
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
conversation_manager,

View File

@@ -190,7 +190,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
// Require ChatGPT login (SWIC). Exit with a clear message if missing.
let _token = match codex_core::config::find_codex_home()
.ok()
.map(|home| codex_login::AuthManager::new(home, false))
.map(codex_login::AuthManager::new)
.and_then(|am| am.auth())
{
Some(auth) => {

View File

@@ -70,7 +70,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Ok(home) = codex_core::config::find_codex_home() {
let am = codex_login::AuthManager::new(home, false);
let am = codex_login::AuthManager::new(home);
if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()

View File

@@ -20,49 +20,49 @@ const PRESETS: &[ModelPreset] = &[
ModelPreset {
id: "gpt-5-codex-low",
label: "gpt-5-codex low",
description: "Fastest responses with limited reasoning",
description: "",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-codex-medium",
label: "gpt-5-codex medium",
description: "Dynamically adjusts reasoning based on the task",
description: "",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-codex-high",
label: "gpt-5-codex high",
description: "Maximizes reasoning depth for complex or ambiguous problems",
description: "",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5-minimal",
label: "gpt-5 minimal",
description: "Fastest responses with little reasoning",
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Minimal),
},
ModelPreset {
id: "gpt-5-low",
label: "gpt-5 low",
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations",
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-medium",
label: "gpt-5 medium",
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks",
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-high",
label: "gpt-5 high",
description: "Maximizes reasoning depth for complex or ambiguous problems",
description: "— maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5",
effort: Some(ReasoningEffort::High),
},

View File

@@ -89,7 +89,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; add a language hint whenever obvious.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.

View File

@@ -73,7 +73,7 @@ impl CodexAuth {
/// Loads the available auth information from the auth.json.
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, false)
load_auth(codex_home)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -188,7 +188,6 @@ impl CodexAuth {
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
pub fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
@@ -197,13 +196,6 @@ pub fn read_openai_api_key_from_env() -> Option<String> {
.filter(|value| !value.is_empty())
}
pub fn read_codex_api_key_from_env() -> Option<String> {
env::var(CODEX_API_KEY_ENV_VAR)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
@@ -229,18 +221,7 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<(
write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
}
fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
) -> std::io::Result<Option<CodexAuth>> {
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
let client = crate::default_client::create_client();
return Ok(Some(CodexAuth::from_api_key_with_client(
api_key.as_str(),
client,
)));
}
fn load_auth(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
let auth_file = get_auth_file(codex_home);
let client = crate::default_client::create_client();
let auth_dot_json = match try_read_auth_json(&auth_file) {
@@ -474,7 +455,7 @@ mod tests {
auth_dot_json,
auth_file: _,
..
} = super::load_auth(codex_home.path(), false).unwrap().unwrap();
} = super::load_auth(codex_home.path()).unwrap().unwrap();
assert_eq!(None, api_key);
assert_eq!(AuthMode::ChatGPT, mode);
@@ -513,7 +494,7 @@ mod tests {
)
.unwrap();
let auth = super::load_auth(dir.path(), false).unwrap().unwrap();
let auth = super::load_auth(dir.path()).unwrap().unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
@@ -596,7 +577,6 @@ mod tests {
pub struct AuthManager {
codex_home: PathBuf,
inner: RwLock<CachedAuth>,
enable_codex_api_key_env: bool,
}
impl AuthManager {
@@ -604,14 +584,11 @@ impl AuthManager {
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self {
let auth = load_auth(&codex_home, enable_codex_api_key_env)
.ok()
.flatten();
pub fn new(codex_home: PathBuf) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten();
Self {
codex_home,
inner: RwLock::new(CachedAuth { auth }),
enable_codex_api_key_env,
}
}
@@ -621,7 +598,6 @@ impl AuthManager {
Arc::new(Self {
codex_home: PathBuf::new(),
inner: RwLock::new(cached),
enable_codex_api_key_env: false,
})
}
@@ -633,9 +609,7 @@ impl AuthManager {
/// Force a reload of the auth information from auth.json. Returns
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env)
.ok()
.flatten();
let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten();
if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth;
@@ -654,8 +628,8 @@ impl AuthManager {
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc<Self> {
Arc::new(Self::new(codex_home, enable_codex_api_key_env))
pub fn shared(codex_home: PathBuf) -> Arc<Self> {
Arc::new(Self::new(codex_home))
}
/// Attempt to refresh the current auth token (if any). On success, reload

View File

@@ -22,7 +22,6 @@ use codex_protocol::protocol::ConversationPathResponseEvent;
use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TaskStartedEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
@@ -110,7 +109,6 @@ use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
use crate::protocol::ViewImageToolCallEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
@@ -173,7 +171,6 @@ impl Codex {
config: Config,
auth_manager: Arc<AuthManager>,
conversation_history: InitialHistory,
session_source: SessionSource,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -202,7 +199,6 @@ impl Codex {
auth_manager.clone(),
tx_event.clone(),
conversation_history,
session_source,
)
.await
.map_err(|e| {
@@ -337,7 +333,6 @@ impl Session {
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
initial_history: InitialHistory,
session_source: SessionSource,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let ConfigureSession {
provider,
@@ -361,11 +356,7 @@ impl Session {
let conversation_id = ConversationId::default();
(
conversation_id,
RolloutRecorderParams::new(
conversation_id,
user_instructions.clone(),
session_source,
),
RolloutRecorderParams::new(conversation_id, user_instructions.clone()),
)
}
InitialHistory::Resumed(resumed_history) => (
@@ -2478,21 +2469,13 @@ async fn handle_function_call(
))
})?;
let abs = turn_context.resolve_path(Some(args.path));
sess.inject_input(vec![InputItem::LocalImage { path: abs.clone() }])
sess.inject_input(vec![InputItem::LocalImage { path: abs }])
.await
.map_err(|_| {
FunctionCallError::RespondToModel(
"unable to attach image (no active task)".to_string(),
)
})?;
sess.send_event(Event {
id: sub_id.clone(),
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
call_id: call_id.clone(),
path: abs,
}),
})
.await;
Ok("attached local image path".to_string())
}

View File

@@ -1079,7 +1079,7 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
include_plan_tool: include_plan_tool.unwrap_or(false),
include_plan_tool: include_plan_tool.unwrap_or(true),
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
tools_web_search_request,
use_experimental_streamable_shell_tool: cfg
@@ -1842,7 +1842,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: false,
include_plan_tool: true,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -1903,7 +1903,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: false,
include_plan_tool: true,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -1979,7 +1979,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: false,
include_plan_tool: true,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -2041,7 +2041,7 @@ model_verbosity = "high"
model_verbosity: Some(Verbosity::High),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: false,
include_plan_tool: true,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,

View File

@@ -17,7 +17,6 @@ use codex_protocol::ConversationId;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -36,25 +35,20 @@ pub struct NewConversation {
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager,
session_source,
}
}
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub fn with_auth(auth: CodexAuth) -> Self {
Self::new(
crate::AuthManager::from_auth_for_testing(auth),
SessionSource::Exec,
)
Self::new(crate::AuthManager::from_auth_for_testing(auth))
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
@@ -70,13 +64,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(
config,
auth_manager,
InitialHistory::New,
self.session_source,
)
.await?;
} = Codex::spawn(config, auth_manager, InitialHistory::New).await?;
self.finalize_spawn(codex, conversation_id).await
}
@@ -133,7 +121,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(config, auth_manager, initial_history, self.session_source).await?;
} = Codex::spawn(config, auth_manager, initial_history).await?;
self.finalize_spawn(codex, conversation_id).await
}
@@ -167,7 +155,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(config, auth_manager, history, self.session_source).await?;
} = Codex::spawn(config, auth_manager, history).await?;
self.finalize_spawn(codex, conversation_id).await
}

View File

@@ -49,7 +49,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
}
}
@@ -92,7 +92,7 @@ Can write control characters (\u0003 for Ctrl-C), or an empty string to just pol
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string(), "chars".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
}
}

View File

@@ -68,7 +68,6 @@ pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;

View File

@@ -122,26 +122,6 @@ impl ToolsConfig {
}
}
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
impl From<bool> for AdditionalProperties {
fn from(b: bool) -> Self {
Self::Boolean(b)
}
}
impl From<JsonSchema> for AdditionalProperties {
fn from(s: JsonSchema) -> Self {
Self::Schema(Box::new(s))
}
}
/// Generic JSONSchema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -174,7 +154,7 @@ pub(crate) enum JsonSchema {
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
additional_properties: Option<AdditionalProperties>,
additional_properties: Option<bool>,
},
}
@@ -220,7 +200,7 @@ fn create_unified_exec_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
})
}
@@ -267,7 +247,7 @@ fn create_shell_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
})
}
@@ -291,7 +271,7 @@ fn create_view_image_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
})
}
@@ -728,130 +708,7 @@ mod tests {
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: None,
},
description: "Do something cool".to_string(),
strict: false,
})
);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
include_view_image_tool: true,
experimental_unified_exec_tool: true,
});
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
},
"required": [
"string_property",
"number_property",
],
"additionalProperties": {
"type": "object",
"properties": {
"addtl_prop": { "type": "string" },
},
"required": [
"addtl_prop",
],
"additionalProperties": false,
},
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
)])),
);
assert_eq_tool_names(
&tools,
&[
"unified_exec",
"web_search",
"view_image",
"test_server/do_something_cool",
],
);
assert_eq!(
tools[3],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
),
]),
required: Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(
JsonSchema::Object {
properties: BTreeMap::from([(
"addtl_prop".to_string(),
JsonSchema::String { description: None }
),]),
required: Some(vec!["addtl_prop".to_string(),]),
additional_properties: Some(false.into()),
}
.into()
),
additional_properties: Some(false),
},
),
]),

View File

@@ -32,7 +32,7 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
items: Box::new(JsonSchema::Object {
properties: plan_item_props,
required: Some(vec!["step".to_string(), "status".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
}),
};
@@ -54,7 +54,7 @@ At most one step can be in_progress at a time.
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
})
});

View File

@@ -17,7 +17,6 @@ use super::SESSIONS_SUBDIR;
use crate::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource;
/// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)]
@@ -53,7 +52,6 @@ struct HeadTailSummary {
tail: Vec<serde_json::Value>,
saw_session_meta: bool,
saw_user_event: bool,
source: Option<SessionSource>,
created_at: Option<String>,
updated_at: Option<String>,
}
@@ -108,7 +106,6 @@ pub(crate) async fn get_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
@@ -124,8 +121,7 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned();
let result =
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?;
Ok(result)
}
@@ -144,7 +140,6 @@ async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -201,13 +196,6 @@ async fn traverse_directories_for_paths(
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
@@ -353,7 +341,6 @@ async fn read_head_and_tail(
match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
summary.source = Some(session_meta_line.meta.source);
summary.created_at = summary
.created_at
.clone()

View File

@@ -1,11 +1,7 @@
//! Rollout module: persistence and discovery of session rollout files.
use codex_protocol::protocol::SessionSource;
pub const SESSIONS_SUBDIR: &str = "sessions";
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
&[SessionSource::Cli, SessionSource::VSCode];
pub mod list;
pub(crate) mod policy;

View File

@@ -70,7 +70,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::ConversationPath(_) => false,
}
}

View File

@@ -32,7 +32,6 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
@@ -54,7 +53,6 @@ pub enum RolloutRecorderParams {
Create {
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
},
Resume {
path: PathBuf,
@@ -73,15 +71,10 @@ enum RolloutCmd {
}
impl RolloutRecorderParams {
pub fn new(
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
) -> Self {
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
Self::Create {
conversation_id,
instructions,
source,
}
}
@@ -96,9 +89,8 @@ impl RolloutRecorder {
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor, allowed_sources).await
get_conversations(codex_home, page_size, cursor).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -109,7 +101,6 @@ impl RolloutRecorder {
RolloutRecorderParams::Create {
conversation_id,
instructions,
source,
} => {
let LogFileInfo {
file,
@@ -136,7 +127,6 @@ impl RolloutRecorder {
originator: originator().value.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
}),
)
}

View File

@@ -12,7 +12,6 @@ use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::list::ConversationItem;
use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor;
@@ -29,17 +28,13 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::UserMessageEvent;
const NO_SOURCE_FILTER: &[SessionSource] = &[];
fn write_session_file(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -57,23 +52,17 @@ fn write_session_file(
let file_path = dir.join(filename);
let mut file = File::create(file_path)?;
let mut payload = serde_json::json!({
"id": uuid,
"timestamp": ts_str,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
});
if let Some(source) = source {
payload["source"] = serde_json::to_value(source).unwrap();
}
let meta = serde_json::json!({
"timestamp": ts_str,
"type": "session_meta",
"payload": payload,
"payload": {
"id": uuid,
"timestamp": ts_str,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
}
});
writeln!(file, "{meta}")?;
@@ -110,34 +99,11 @@ async fn test_list_conversations_latest_first() {
let u3 = Uuid::from_u128(3);
// Create three sessions across three days
write_session_file(
home,
"2025-01-01T12-00-00",
u1,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-02T12-00-00",
u2,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-03T12-00-00",
u3,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap();
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let page = get_conversations(home, 10, None).await.unwrap();
// Build expected objects
let p1 = home
@@ -165,8 +131,7 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -174,8 +139,7 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let head_1 = vec![serde_json::json!({
"id": u1,
@@ -183,8 +147,7 @@ async fn test_list_conversations_latest_first() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let expected_cursor: Cursor =
@@ -235,50 +198,13 @@ async fn test_pagination_cursor() {
let u5 = Uuid::from_u128(55);
// Oldest to newest
write_session_file(
home,
"2025-03-01T09-00-00",
u1,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-02T09-00-00",
u2,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-03T09-00-00",
u3,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-04T09-00-00",
u4,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-05T09-00-00",
u5,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let p5 = home
.join("sessions")
.join("2025")
@@ -297,8 +223,7 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let head_4 = vec![serde_json::json!({
"id": u4,
@@ -306,8 +231,7 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let expected_cursor1: Cursor =
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
@@ -334,14 +258,9 @@ async fn test_pagination_cursor() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let p3 = home
.join("sessions")
.join("2025")
@@ -360,8 +279,7 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -369,8 +287,7 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let expected_cursor2: Cursor =
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
@@ -397,14 +314,9 @@ async fn test_pagination_cursor() {
};
assert_eq!(page2, expected_page2);
let page3 = get_conversations(
home,
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref())
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -417,8 +329,7 @@ async fn test_pagination_cursor() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let expected_cursor3: Cursor =
serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap();
@@ -444,11 +355,9 @@ async fn test_get_conversation_contents() {
let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, uuid, 2).unwrap();
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let page = get_conversations(home, 1, None).await.unwrap();
let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap();
@@ -466,8 +375,7 @@ async fn test_get_conversation_contents() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})];
let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap();
let expected_page = ConversationsPage {
@@ -485,19 +393,7 @@ async fn test_get_conversation_contents() {
assert_eq!(page, expected_page);
// Entire file contents equality
let meta = serde_json::json!({
"timestamp": ts,
"type": "session_meta",
"payload": {
"id": uuid,
"timestamp": ts,
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
}
});
let meta = serde_json::json!({"timestamp": ts, "type": "session_meta", "payload": {"id": uuid, "timestamp": ts, "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version"}});
let user_event = serde_json::json!({
"timestamp": ts,
"type": "event_msg",
@@ -532,7 +428,6 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -565,7 +460,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None).await?;
let item = page.items.first().expect("conversation item");
let tail_len = item.tail.len();
assert_eq!(tail_len, 10usize.min(total_messages));
@@ -616,7 +511,6 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -648,7 +542,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None).await?;
let tail = &page.items.first().expect("conversation item").tail;
assert_eq!(tail.len(), 3);
@@ -701,7 +595,6 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
},
git: None,
}),
@@ -747,7 +640,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?;
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None).await?;
let tail = &page.items.first().expect("conversation item").tail;
let expected: Vec<serde_json::Value> = (0..4)
@@ -785,13 +678,11 @@ async fn test_stable_ordering_same_second_pagination() {
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u1, 0).unwrap();
write_session_file(home, ts, u2, 0).unwrap();
write_session_file(home, ts, u3, 0).unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let p3 = home
.join("sessions")
@@ -812,8 +703,7 @@ async fn test_stable_ordering_same_second_pagination() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"cli_version": "test_version"
})]
};
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
@@ -840,14 +730,9 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -869,59 +754,3 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page2, expected_page2);
}
#[tokio::test]
async fn test_source_filter_excludes_non_matching_sessions() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let interactive_id = Uuid::from_u128(42);
let non_interactive_id = Uuid::from_u128(77);
write_session_file(
home,
"2025-08-02T10-00-00",
interactive_id,
2,
Some(SessionSource::Cli),
)
.unwrap();
write_session_file(
home,
"2025-08-01T10-00-00",
non_interactive_id,
2,
Some(SessionSource::Exec),
)
.unwrap();
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let paths: Vec<_> = interactive_only
.items
.iter()
.map(|item| item.path.as_path())
.collect();
assert_eq!(paths.len(), 1);
assert!(paths.iter().all(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
.items
.into_iter()
.map(|item| item.path)
.collect();
assert_eq!(all_paths.len(), 2);
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl")
}));
}

View File

@@ -116,7 +116,7 @@ It is important to remember:
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false.into()),
additional_properties: Some(false),
},
})
}

View File

@@ -1,5 +1,4 @@
#![allow(clippy::expect_used)]
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use std::path::Path;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -15,7 +14,7 @@ impl TestCodexExecBuilder {
.expect("should find binary for codex-exec");
cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path())
.env(CODEX_API_KEY_ENV_VAR, "dummy");
.env("OPENAI_API_KEY", "dummy");
cmd
}
pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command {

View File

@@ -76,7 +76,7 @@ async fn chat_mode_stream_cli() {
server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[])
let page = RolloutRecorder::list_conversations(home.path(), 10, None)
.await
.expect("list conversations");
assert!(

View File

@@ -17,7 +17,6 @@ use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::ConversationId;
use codex_protocol::models::ReasoningItemReasoningSummary;
@@ -539,7 +538,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec);
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
..

View File

@@ -580,14 +580,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ListCustomPromptsResponse(_) => {
// Currently ignored in exec output.
}
EventMsg::ViewImageToolCall(view) => {
ts_println!(
self,
"{} {}",
"viewed image".style(self.magenta),
view.path.display()
);
}
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted");

View File

@@ -17,7 +17,6 @@ use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TaskCompleteEvent;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
@@ -237,8 +236,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let auth_manager = AuthManager::shared(config.codex_home.clone(), true);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
let conversation_manager =
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
let NewConversation {
@@ -250,7 +249,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if let Some(path) = resume_path {
conversation_manager
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
.resume_conversation_from_rollout(
config.clone(),
path,
AuthManager::shared(config.codex_home.clone()),
)
.await?
} else {
conversation_manager
@@ -376,9 +379,7 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[])
.await
{
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
error!("Error listing conversations: {e}");

View File

@@ -1,34 +0,0 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use core_test_support::responses::ev_completed;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex_exec::test_codex_exec;
use wiremock::Mock;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> {
let test = test_codex_exec();
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header("Authorization", "Bearer dummy"))
.respond_with(sse_response(sse(vec![ev_completed("request_0")])))
.expect(1)
.mount(&server)
.await;
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("echo testing codex api key")
.assert()
.success();
Ok(())
}

View File

@@ -1,6 +1,5 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod auth_env;
mod output_schema;
mod resume;
mod sandbox;

View File

@@ -1,9 +1,10 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Context;
use core_test_support::test_codex_exec::test_codex_exec;
use assert_cmd::prelude::*;
use serde_json::Value;
use std::path::Path;
use std::process::Command;
use std::string::ToString;
use tempfile::TempDir;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -71,15 +72,18 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
#[test]
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session with a unique marker in the content.
let marker = format!("resume-last-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
test.cmd()
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -90,7 +94,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.success();
// Find the created session file containing the marker.
let sessions_dir = test.home_path().join("sessions");
let sessions_dir = home.path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
@@ -98,7 +102,11 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-last-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
test.cmd()
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -106,9 +114,8 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg("--last")
.assert()
.success();
.arg("--last");
cmd.assert().success();
// Ensure the same file was updated and contains both markers.
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
@@ -125,15 +132,18 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session
let marker = format!("resume-by-id-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
test.cmd()
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -143,7 +153,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = test.home_path().join("sessions");
let sessions_dir = home.path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let session_id = extract_conversation_id(&path);
@@ -156,7 +166,11 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
test.cmd()
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -164,9 +178,8 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg(&session_id)
.assert()
.success();
.arg(&session_id);
cmd.assert().success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
@@ -182,14 +195,17 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-config-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
test.cmd()
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -203,15 +219,17 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = test.home_path().join("sessions");
let sessions_dir = home.path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let marker2 = format!("resume-config-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let output = test
.cmd()
let output = Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")

View File

@@ -14,7 +14,6 @@ pub use codex_core::AuthManager;
pub use codex_core::CodexAuth;
pub use codex_core::auth::AuthDotJson;
pub use codex_core::auth::CLIENT_ID;
pub use codex_core::auth::CODEX_API_KEY_ENV_VAR;
pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
pub use codex_core::auth::get_auth_file;
pub use codex_core::auth::login_with_api_key;

View File

@@ -280,7 +280,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::ConversationPath(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_) => {
// For now, we do not do anything extra for these

View File

@@ -8,7 +8,6 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -53,9 +52,8 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(config.codex_home.clone(), false);
let conversation_manager =
Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp));
let auth_manager = AuthManager::shared(config.codex_home.clone());
let conversation_manager = Arc::new(ConversationManager::new(auth_manager));
Self {
outgoing,
initialized: false,

View File

@@ -477,9 +477,6 @@ pub enum EventMsg {
ExecCommandEnd(ExecCommandEndEvent),
/// Notification that the agent attached a local image via the view_image tool.
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -919,20 +916,7 @@ impl InitialHistory {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum SessionSource {
Cli,
#[default]
VSCode,
Exec,
Mcp,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[derive(Serialize, Deserialize, Clone, Default, Debug, TS)]
pub struct SessionMeta {
pub id: ConversationId,
pub timestamp: String,
@@ -940,22 +924,6 @@ pub struct SessionMeta {
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
#[serde(default)]
pub source: SessionSource,
}
impl Default for SessionMeta {
fn default() -> Self {
SessionMeta {
id: ConversationId::default(),
timestamp: String::new(),
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
instructions: None,
source: SessionSource::default(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
@@ -1106,14 +1074,6 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ViewImageToolCallEvent {
/// Identifier for the originating tool call.
pub call_id: String,
/// Local filesystem path provided to the tool.
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {

View File

@@ -18,7 +18,6 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config::persist_model_selection;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::SessionSource;
use codex_core::protocol::TokenUsage;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::ConversationId;
@@ -87,10 +86,7 @@ impl App {
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
));
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let enhanced_keys_supported = tui.enhanced_keys_supported();
@@ -324,28 +320,24 @@ impl App {
self.config.model_family = family;
}
}
AppEvent::OpenReasoningPopup { model, presets } => {
self.chat_widget.open_reasoning_popup(model, presets);
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match persist_model_selection(&self.config.codex_home, profile, &model, effort)
.await
{
Ok(()) => {
let effort_label = effort
.map(|eff| format!(" with {eff} reasoning"))
.unwrap_or_else(|| " with default reasoning".to_string());
if let Some(profile) = profile {
self.chat_widget.add_info_message(
format!(
"Model changed to {model}{effort_label} for {profile} profile"
),
format!("Model changed to {model}{reasoning_effort} for {profile} profile", reasoning_effort = effort.map(|e| format!(" {e}")).unwrap_or_default()),
None,
);
} else {
self.chat_widget.add_info_message(
format!("Model changed to {model}{effort_label}"),
format!(
"Model changed to {model}{reasoning_effort}",
reasoning_effort =
effort.map(|e| format!(" {e}")).unwrap_or_default()
),
None,
);
}

View File

@@ -1,6 +1,5 @@
use std::path::PathBuf;
use codex_common::model_presets::ModelPreset;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
@@ -61,12 +60,6 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: String,
presets: Vec<ModelPreset>,
},
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),

View File

@@ -11,7 +11,6 @@ use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -117,13 +116,7 @@ impl ApprovalOverlay {
.collect();
let params = SelectionViewParams {
footer_hint: Some(Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".into(),
])),
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items,
header,
..Default::default()

View File

@@ -14,7 +14,7 @@ use std::cell::RefCell;
use crate::render::renderable::Renderable;
use super::popup_consts::standard_popup_hint_line;
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -221,7 +221,7 @@ impl Renderable for CustomPromptView {
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render(
Paragraph::new(STANDARD_POPUP_HINT_LINE).render(
Rect {
x: area.x,
y: hint_y,

View File

@@ -1,15 +1,13 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::line_utils::prefix_lines;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use std::iter;
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
@@ -63,12 +61,15 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 {
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
Paragraph::new(prefix_lines(
footer_lines(props),
" ".repeat(FOOTER_INDENT_COLS).into(),
" ".repeat(FOOTER_INDENT_COLS).into(),
))
.render(area, buf);
let lines = footer_lines(props);
for (idx, line) in lines.into_iter().enumerate() {
let y = area.y + idx as u16;
if y >= area.y + area.height {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
}
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
@@ -80,10 +81,7 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
if props.is_task_running {
vec![context_window_line(props.context_window_percent)]
} else {
vec![Line::from(vec![
key_hint::plain(KeyCode::Char('?')).into(),
" for shortcuts".dim(),
])]
vec![dim_line(indent_text("? for shortcuts"))]
}
}
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
@@ -112,36 +110,27 @@ fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
} else {
"quit"
};
Line::from(vec![
key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
let text = format!("ctrl + c again to {action}");
dim_line(indent_text(&text))
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
let esc = key_hint::plain(KeyCode::Esc);
if esc_backtrack_hint {
Line::from(vec![esc.into(), " again to edit previous message".into()]).dim()
let text = if esc_backtrack_hint {
"esc again to edit previous message"
} else {
Line::from(vec![
esc.into(),
" ".into(),
esc.into(),
" to edit previous message".into(),
])
.dim()
}
"esc esc to edit previous message"
};
dim_line(indent_text(text))
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut commands = Line::from("");
let mut newline = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
let mut commands = String::new();
let mut newline = String::new();
let mut file_paths = String::new();
let mut paste_image = String::new();
let mut edit_previous = String::new();
let mut quit = String::new();
let mut show_transcript = String::new();
for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) {
@@ -164,14 +153,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
paste_image,
edit_previous,
quit,
Line::from(""),
String::new(),
show_transcript,
];
build_columns(ordered)
}
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
@@ -185,7 +174,7 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
let mut entries = entries;
if entries.len() < target_len {
entries.extend(std::iter::repeat_n(
Line::from(""),
String::new(),
target_len - entries.len(),
));
}
@@ -194,7 +183,7 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.width());
column_widths[column] = column_widths[column].max(entry.len());
}
for (idx, width) in column_widths.iter_mut().enumerate() {
@@ -204,30 +193,42 @@ fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {
entries
.chunks(COLUMNS)
.map(|chunk| {
let mut line = Line::from("");
let mut line = String::new();
for (col, entry) in chunk.iter().enumerate() {
line.extend(entry.spans.clone());
line.push_str(entry);
if col < COLUMNS - 1 {
let target_width = column_widths[col];
let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP;
line.push_span(Span::from(" ".repeat(padding)));
let padding = target_width.saturating_sub(entry.len()) + COLUMN_GAP;
line.push_str(&" ".repeat(padding));
}
}
line.dim()
let indented = indent_text(&line);
dim_line(indented)
})
.collect()
}
fn indent_text(text: &str) -> String {
let mut indented = String::with_capacity(FOOTER_INDENT_COLS + text.len());
indented.extend(iter::repeat_n(' ', FOOTER_INDENT_COLS));
indented.push_str(text);
indented
}
fn dim_line(text: String) -> Line<'static> {
Line::from(text).dim()
}
fn context_window_line(percent: Option<u8>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(indent_text("").into());
match percent {
Some(percent) => {
spans.push(format!("{percent}%").bold());
spans.push(" context left".dim());
}
None => {
spans.push(key_hint::plain(KeyCode::Char('?')).into());
spans.push(" for shortcuts".dim());
spans.push("? for shortcuts".dim());
}
}
Line::from(spans)
@@ -246,7 +247,9 @@ enum ShortcutId {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ShortcutBinding {
key: KeyBinding,
code: KeyCode,
modifiers: KeyModifiers,
overlay_text: &'static str,
condition: DisplayCondition,
}
@@ -285,24 +288,20 @@ impl ShortcutDescriptor {
self.bindings.iter().find(|binding| binding.matches(state))
}
fn overlay_entry(&self, state: ShortcutsState) -> Option<Line<'static>> {
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
let binding = self.binding_for(state)?;
let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]);
match self.id {
let label = match self.id {
ShortcutId::EditPrevious => {
if state.esc_backtrack_hint {
line.push_span(" again to edit previous message");
" again to edit previous message"
} else {
line.extend(vec![
" ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to edit previous message".into(),
]);
" esc to edit previous message"
}
}
_ => line.push_span(self.label),
_ => self.label,
};
Some(line)
let text = format!("{}{}{}", self.prefix, binding.overlay_text, label);
Some(text)
}
}
@@ -310,7 +309,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Commands,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Char('/')),
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
overlay_text: "/",
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -320,11 +321,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
id: ShortcutId::InsertNewline,
bindings: &[
ShortcutBinding {
key: key_hint::shift(KeyCode::Enter),
code: KeyCode::Enter,
modifiers: KeyModifiers::SHIFT,
overlay_text: "shift + enter",
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('j')),
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + j",
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
@@ -334,7 +339,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Char('@')),
code: KeyCode::Char('@'),
modifiers: KeyModifiers::NONE,
overlay_text: "@",
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -343,7 +350,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::PasteImage,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('v')),
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + v",
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -352,7 +361,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
key: key_hint::plain(KeyCode::Esc),
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
overlay_text: "esc",
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -361,7 +372,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('c')),
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + c",
condition: DisplayCondition::Always,
}],
prefix: "",
@@ -370,7 +383,9 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('t')),
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + t",
condition: DisplayCondition::Always,
}],
prefix: "",

View File

@@ -43,7 +43,7 @@ pub(crate) struct SelectionItem {
pub(crate) struct SelectionViewParams {
pub title: Option<String>,
pub subtitle: Option<String>,
pub footer_hint: Option<Line<'static>>,
pub footer_hint: Option<String>,
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
@@ -65,7 +65,7 @@ impl Default for SelectionViewParams {
}
pub(crate) struct ListSelectionView {
footer_hint: Option<Line<'static>>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
state: ScrollState,
complete: bool,
@@ -416,7 +416,7 @@ impl Renderable for ListSelectionView {
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
hint.clone().dim().render(hint_area, buf);
Line::from(hint.clone().dim()).render(hint_area, buf);
}
}
}
@@ -425,7 +425,7 @@ impl Renderable for ListSelectionView {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -455,7 +455,7 @@ mod tests {
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
},
@@ -517,7 +517,7 @@ mod tests {
let mut view = ListSelectionView::new(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),

View File

@@ -1,21 +1,8 @@
//! Shared popup-related constants for bottom pane widgets.
use crossterm::event::KeyCode;
use ratatui::text::Line;
use crate::key_hint;
/// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;
/// Standard footer hint text used by popups.
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
}
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back";

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 1497
expression: terminal.backend()
---
" "

View File

@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 389
expression: terminal.backend()
---
" / for commands shift + enter for newline "

View File

@@ -9,4 +9,4 @@ expression: render_lines(&view)
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press enter to confirm or esc to go back
Press Enter to confirm or Esc to go back

View File

@@ -8,4 +8,4 @@ expression: render_lines(&view)
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press enter to confirm or esc to go back
Press Enter to confirm or Esc to go back

View File

@@ -1,4 +1,3 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
@@ -40,7 +39,6 @@ use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::ConversationId;
@@ -70,7 +68,7 @@ use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -112,7 +110,6 @@ use codex_git_tooling::GhostCommit;
use codex_git_tooling::GitToolingError;
use codex_git_tooling::create_ghost_commit;
use codex_git_tooling::restore_ghost_commit;
use strum::IntoEnumIterator;
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
@@ -285,16 +282,6 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
fn model_description_for(slug: &str) -> Option<&'static str> {
if slug.starts_with("gpt-5-codex") {
Some("Optimized for coding tasks with many tools.")
} else if slug.starts_with("gpt-5") {
Some("Broad world knowledge with strong general reasoning.")
} else {
None
}
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
@@ -551,15 +538,6 @@ impl ChatWidget {
));
}
fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_view_image_tool_call(
event.path,
&self.config.cwd,
));
self.request_redraw();
}
fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
let ev2 = event.clone();
self.defer_or_handle(
@@ -1420,7 +1398,6 @@ impl ChatWidget {
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev),
EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
@@ -1591,38 +1568,47 @@ impl ChatWidget {
));
}
/// Open a popup to choose the model (stage 1). After selecting a model,
/// a second popup is shown to choose the reasoning effort.
/// Open a popup to choose the model preset (model + reasoning effort).
pub(crate) fn open_model_popup(&mut self) {
let current_model = self.config.model.clone();
let current_effort = self.config.model_reasoning_effort;
let auth_mode = self.auth_manager.auth().map(|auth| auth.mode);
let presets: Vec<ModelPreset> = builtin_model_presets(auth_mode);
let mut grouped: BTreeMap<&str, Vec<ModelPreset>> = BTreeMap::new();
for preset in presets.into_iter() {
grouped.entry(preset.model).or_default().push(preset);
}
let mut items: Vec<SelectionItem> = Vec::new();
for (model_slug, entries) in grouped.into_iter() {
let name = model_slug.to_string();
let description = Self::model_description_for(model_slug)
.map(std::string::ToString::to_string)
.or_else(|| {
entries
.iter()
.find(|preset| !preset.description.is_empty())
.map(|preset| preset.description.to_string())
})
.or_else(|| entries.first().map(|preset| preset.description.to_string()));
let is_current = model_slug == current_model;
let model_slug_string = model_slug.to_string();
let presets_for_model = entries.clone();
for preset in presets.iter() {
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let is_current = preset.model == current_model && preset.effort == current_effort;
let model_slug = preset.model.to_string();
let effort = preset.effort;
let current_model = current_model.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReasoningPopup {
model: model_slug_string.clone(),
presets: presets_for_model.clone(),
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_slug.clone()),
effort: Some(effort),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_slug.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tx.send(AppEvent::PersistModelSelection {
model: model_slug.clone(),
effort,
});
tracing::info!(
"New model: {}, New effort: {}, Current model: {}, Current effort: {}",
model_slug.clone(),
effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string()),
current_model,
current_effort
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string())
);
})];
items.push(SelectionItem {
name,
@@ -1635,128 +1621,11 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Model".to_string()),
subtitle: Some("Switch the model for this and future Codex CLI sessions".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
pub(crate) fn open_reasoning_popup(&mut self, model_slug: String, presets: Vec<ModelPreset>) {
let default_effort = ReasoningEffortConfig::default();
let has_none_choice = presets.iter().any(|preset| preset.effort.is_none());
struct EffortChoice {
stored: Option<ReasoningEffortConfig>,
display: ReasoningEffortConfig,
}
let mut choices: Vec<EffortChoice> = Vec::new();
for effort in ReasoningEffortConfig::iter() {
if presets.iter().any(|preset| preset.effort == Some(effort)) {
choices.push(EffortChoice {
stored: Some(effort),
display: effort,
});
}
if has_none_choice && default_effort == effort {
choices.push(EffortChoice {
stored: None,
display: effort,
});
}
}
if choices.is_empty() {
choices.push(EffortChoice {
stored: Some(default_effort),
display: default_effort,
});
}
let default_choice: Option<ReasoningEffortConfig> = if has_none_choice {
None
} else if choices
.iter()
.any(|choice| choice.stored == Some(default_effort))
{
Some(default_effort)
} else {
choices
.iter()
.find_map(|choice| choice.stored)
.or(Some(default_effort))
};
let is_current_model = self.config.model == model_slug;
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
default_choice
};
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.display;
let mut effort_label = effort.to_string();
if let Some(first) = effort_label.get_mut(0..1) {
first.make_ascii_uppercase();
}
if choice.stored == default_choice {
effort_label.push_str(" (default)");
}
let description = presets
.iter()
.find(|preset| preset.effort == choice.stored && !preset.description.is_empty())
.map(|preset| preset.description.to_string())
.or_else(|| {
presets
.iter()
.find(|preset| preset.effort == choice.stored)
.map(|preset| preset.description.to_string())
});
let model_for_action = model_slug.clone();
let effort_for_action = choice.stored;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: Some(model_for_action.clone()),
effort: Some(effort_for_action),
summary: None,
}));
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: effort_for_action,
});
tracing::info!(
"Selected model: {}, Selected effort: {}",
model_for_action,
effort_for_action
.map(|e| e.to_string())
.unwrap_or_else(|| "default".to_string())
);
})];
items.push(SelectionItem {
name: effort_label,
description,
is_current: is_current_model && choice.stored == highlight_choice,
actions,
dismiss_on_select: true,
search_value: None,
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Reasoning Level".to_string()),
subtitle: Some(format!("Reasoning for model {model_slug}")),
footer_hint: Some(standard_popup_hint_line()),
title: Some("Select model and reasoning level".to_string()),
subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
});
@@ -1799,7 +1668,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
});
@@ -1974,7 +1843,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review preset".into()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
});
@@ -2010,7 +1879,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
@@ -2051,7 +1920,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
@@ -2276,7 +2145,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),

View File

@@ -13,4 +13,4 @@ expression: terminal.backend().vt100().screen().contents()
rest of the session
3. Cancel Do not run the command
Press enter to confirm or esc to cancel
Press Enter to confirm or Esc to cancel

View File

@@ -13,5 +13,5 @@ expression: terminal.backend()
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press enter to confirm or esc to cancel "
" Press Enter to confirm or Esc to cancel "
" "

View File

@@ -15,5 +15,5 @@ expression: terminal.backend()
" 1. Approve Apply the proposed changes "
" 2. Cancel Do not apply the changes "
" "
" Press enter to confirm or esc to cancel "
" Press Enter to confirm or Esc to cancel "
" "

View File

@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" Thinking (0s • esc to interrupt) "
" Thinking (0s • Esc to interrupt) "
" Ask Codex to do anything "

View File

@@ -9,7 +9,7 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved
Read diff_render.rs
Investigating rendering code (0s • esc to interrupt)
Investigating rendering code (0s • Esc to interrupt)
Summarize recent commits

View File

@@ -18,7 +18,7 @@ Buffer {
" rest of the session ",
" 3. Cancel Do not run the command ",
" ",
" Press enter to confirm or esc to cancel ",
" Press Enter to confirm or Esc to cancel ",
" ",
],
styles: [
@@ -37,6 +37,6 @@ Buffer {
x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 41, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Viewed Image
└ example.png

View File

@@ -1,13 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Reasoning Level
Reasoning for model gpt-5-codex
1. Low Fastest responses with limited reasoning
2. Medium (default) Dynamically adjusts reasoning based on the task
3. High (current) Maximizes reasoning depth for complex or ambiguous
problems
Press enter to confirm or esc to go back

View File

@@ -1,12 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Model
Switch the model for this and future Codex CLI sessions
1. gpt-5 Broad world knowledge with strong general
reasoning.
2. gpt-5-codex (current) Optimized for coding tasks with many tools.
Press enter to confirm or esc to go back

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Analyzing (0s • esc to interrupt) "
" Analyzing (0s • Esc to interrupt) "
" "
" "
" Ask Codex to do anything "

View File

@@ -15,5 +15,5 @@ expression: terminal.backend()
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press enter to confirm or esc to cancel "
" Press Enter to confirm or Esc to cancel "
" "

View File

@@ -35,7 +35,6 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
use codex_core::protocol::ViewImageToolCallEvent;
use codex_protocol::ConversationId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -795,25 +794,6 @@ fn custom_prompt_enter_empty_does_not_send() {
assert!(rx.try_recv().is_err(), "no app event should be sent");
}
#[test]
fn view_image_tool_call_adds_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
let image_path = chat.config.cwd.join("example.png");
chat.handle_codex_event(Event {
id: "sub-image".into(),
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
call_id: "call-image".into(),
path: image_path,
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
let combined = lines_to_single_string(&cells[0]);
assert_snapshot!("local_image_attachment_history_snapshot", combined);
}
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
// marker (replacing the spinner) and flushes it into history.
#[test]
@@ -936,65 +916,6 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
String::new()
}
fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
let height = chat.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
let mut lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
lines.join("\n")
}
#[test]
fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.model = "gpt-5-codex".to_string();
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_selection_popup", popup);
}
#[test]
fn model_reasoning_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.model = "gpt-5-codex".to_string();
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High);
let presets = builtin_model_presets(None)
.into_iter()
.filter(|preset| preset.model == "gpt-5-codex")
.collect::<Vec<_>>();
chat.open_reasoning_popup("gpt-5-codex".to_string(), presets);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_reasoning_selection_popup", popup);
}
#[test]
fn exec_history_extends_previous_when_consecutive() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();

View File

@@ -268,9 +268,7 @@ pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
let chosen = if path_in_same_repo {
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
} else {
relativize_to_home(path)
.map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()]))
.unwrap_or_else(|| path.to_path_buf())
relativize_to_home(path).unwrap_or_else(|| path.to_path_buf())
};
chosen.display().to_string()
}

View File

@@ -1,5 +1,4 @@
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
@@ -1038,17 +1037,6 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
let display_path = display_path_for(&path, cwd);
let lines: Vec<Line<'static>> = vec![
vec!["".dim(), "Viewed Image".bold()].into(),
vec!["".dim(), display_path.dim()].into(),
];
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,

View File

@@ -1,86 +1,23 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Span;
use std::fmt::Display;
const ALT_PREFIX: &str = "alt + ";
const CTRL_PREFIX: &str = "ctrl + ";
const SHIFT_PREFIX: &str = "shift + ";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct KeyBinding {
key: KeyCode,
modifiers: KeyModifiers,
}
impl KeyBinding {
pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
Self { key, modifiers }
}
pub fn is_press(&self, event: KeyEvent) -> bool {
self.key == event.code
&& self.modifiers == event.modifiers
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
}
}
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::NONE)
}
pub(crate) const fn alt(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::ALT)
}
pub(crate) const fn shift(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::SHIFT)
}
pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::CONTROL)
}
fn modifiers_to_string(modifiers: KeyModifiers) -> String {
let mut result = String::new();
if modifiers.contains(KeyModifiers::CONTROL) {
result.push_str(CTRL_PREFIX);
}
if modifiers.contains(KeyModifiers::SHIFT) {
result.push_str(SHIFT_PREFIX);
}
if modifiers.contains(KeyModifiers::ALT) {
result.push_str(ALT_PREFIX);
}
result
}
impl From<KeyBinding> for Span<'static> {
fn from(binding: KeyBinding) -> Self {
(&binding).into()
}
}
impl From<&KeyBinding> for Span<'static> {
fn from(binding: &KeyBinding) -> Self {
let KeyBinding { key, modifiers } = binding;
let modifiers = modifiers_to_string(*modifiers);
let key = match key {
KeyCode::Enter => "enter".to_string(),
KeyCode::Up => "".to_string(),
KeyCode::Down => "".to_string(),
KeyCode::Left => "".to_string(),
KeyCode::Right => "".to_string(),
KeyCode::PageUp => "pgup".to_string(),
KeyCode::PageDown => "pgdn".to_string(),
_ => format!("{key}").to_ascii_lowercase(),
};
Span::styled(format!("{modifiers}{key}"), key_hint_style())
}
}
#[cfg(test)]
const ALT_PREFIX: &str = "";
#[cfg(all(not(test), target_os = "macos"))]
const ALT_PREFIX: &str = "";
#[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "Alt+";
fn key_hint_style() -> Style {
Style::default().dim()
Style::default().bold()
}
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style())
}
pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key)
}

View File

@@ -9,7 +9,6 @@ use codex_app_server_protocol::AuthMode;
use codex_core::AuthManager;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::CodexAuth;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -362,7 +361,7 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&config);
let auth_manager = AuthManager::shared(config.codex_home.clone(), false);
let auth_manager = AuthManager::shared(config.codex_home.clone());
let login_status = get_login_status(&config);
let should_show_onboarding =
should_show_onboarding(login_status, &config, should_show_trust_screen);
@@ -394,14 +393,7 @@ async fn run_ratatui_app(
}
}
} else if cli.resume_last {
match RolloutRecorder::list_conversations(
&config.codex_home,
1,
None,
INTERACTIVE_SESSION_SOURCES,
)
.await
{
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
Ok(page) => page
.items
.first()

View File

@@ -3,16 +3,18 @@ use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::HistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::renderable::Renderable;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -59,40 +61,23 @@ impl Overlay {
}
}
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t'));
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
// Common pager navigation hints rendered on the first line
const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[
(&[KEY_UP, KEY_DOWN], "to scroll"),
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"),
(&[KEY_HOME, KEY_END], "to jump"),
const PAGER_KEY_HINTS: &[(&str, &str)] = &[
("↑/↓", "scroll"),
("PgUp/PgDn", "page"),
("Home/End", "jump"),
];
// Render a single line of key hints from (key(s), description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
// Render a single line of key hints from (key, description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
let key_hint_style = Style::default().fg(Color::Cyan);
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
for (key, desc) in pairs {
if !first {
spans.push(" ".into());
}
for (i, key) in keys.iter().enumerate() {
if i > 0 {
spans.push("/".into());
}
spans.push(Span::from(key));
}
spans.push(Span::from(key.to_string()).set_style(key_hint_style));
spans.push(" ".into());
spans.push(Span::from(desc.to_string()));
first = false;
@@ -229,24 +214,48 @@ impl PagerView {
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
match key_event {
e if KEY_UP.is_press(e) => {
KeyEvent {
code: KeyCode::Up,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
e if KEY_DOWN.is_press(e) => {
KeyEvent {
code: KeyCode::Down,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
e if KEY_PAGE_UP.is_press(e) => {
KeyEvent {
code: KeyCode::PageUp,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
}
e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => {
KeyEvent {
code: KeyCode::PageDown | KeyCode::Char(' '),
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
}
e if KEY_HOME.is_press(e) => {
KeyEvent {
code: KeyCode::Home,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = 0;
}
e if KEY_END.is_press(e) => {
KeyEvent {
code: KeyCode::End,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.scroll_offset = usize::MAX;
}
_ => {
@@ -425,11 +434,9 @@ impl TranscriptOverlay {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let mut pairs: Vec<(&[KeyBinding], &str)> =
vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")];
let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")];
if self.highlight_cell.is_some() {
pairs.push((&[KEY_ENTER], "to edit message"));
pairs.push(("", "edit message"));
}
render_key_hints(line2, buf, &pairs);
}
@@ -447,7 +454,23 @@ impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true;
Ok(())
}
@@ -493,7 +516,7 @@ impl StaticOverlay {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
let pairs = [("q", "quit")];
render_key_hints(line2, buf, &pairs);
}
@@ -510,7 +533,17 @@ impl StaticOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.is_done = true;
Ok(())
}

View File

@@ -8,7 +8,6 @@ use chrono::Utc;
use codex_core::ConversationItem;
use codex_core::ConversationsPage;
use codex_core::Cursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
@@ -25,7 +24,6 @@ use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
use unicode_width::UnicodeWidthStr;
use crate::key_hint;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
use crate::tui::Tui;
@@ -78,7 +76,6 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
&request.codex_home,
PAGE_SIZE,
request.cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {
@@ -326,13 +323,7 @@ impl PickerState {
}
async fn load_initial_page(&mut self) -> Result<()> {
let page = RolloutRecorder::list_conversations(
&self.codex_home,
PAGE_SIZE,
None,
INTERACTIVE_SESSION_SOURCES,
)
.await?;
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, None).await?;
self.reset_pagination();
self.all_rows.clear();
self.filtered_rows.clear();
@@ -687,18 +678,16 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
// Hint line
let hint_line: Line = vec![
key_hint::plain(KeyCode::Enter).into(),
" to resume ".dim(),
" ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to start new ".dim(),
" ".dim(),
key_hint::ctrl(KeyCode::Char('c')).into(),
" to quit ".dim(),
" ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
"Enter".bold(),
" to resume ".into(),
" ".dim(),
"Esc".bold(),
" to start new ".into(),
" ".dim(),
"Ctrl+C".into(),
" to quit ".into(),
" ".dim(),
"↑/↓".into(),
" to browse".dim(),
]
.into();

View File

@@ -9,6 +9,6 @@ expression: term.backend()
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit "
" ↑/↓ scroll PgUp/PgDn page Home/End "
" q quit "
" "

View File

@@ -11,5 +11,5 @@ expression: snapshot
1 +hello
2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ to scroll pgup/pgdn to page home/end to jump
q to quit esc to edit prev
↑/↓ scroll PgUp/PgDn page Home/End jump
q quit Esc edit prev

View File

@@ -9,6 +9,6 @@ expression: term.backend()
" "
"gamma "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" ↑/↓ scroll PgUp/PgDn page Home/End "
" q quit Esc edit prev "
" "

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • esc "
" Working (0s • Esc "
" "

View File

@@ -2,11 +2,11 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • esc to interrupt) "
" Working (0s • Esc to interrupt) "
" "
" ↳ first "
" ↳ second "
" alt + ↑ edit "
" ↑ edit "
" "
" "
" "

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • esc to interrupt) "
" Working (0s • Esc to interrupt) "
" "

View File

@@ -152,7 +152,7 @@ impl StatusHistoryCell {
Span::from(format!("{percent}% left")),
Span::from(" (").dim(),
Span::from(used_fmt).dim(),
Span::from(" used / ").dim(),
Span::from(" / ").dim(),
Span::from(window_fmt).dim(),
Span::from(")").dim(),
])
@@ -302,10 +302,7 @@ impl HistoryCell for StatusHistoryCell {
}
lines.push(Line::from(Vec::<Span<'static>>::new()));
// Hide token usage only for ChatGPT subscribers
if !matches!(self.account, Some(StatusAccountDisplay::ChatGpt { .. })) {
lines.push(formatter.line("Token usage", self.token_usage_spans()));
}
lines.push(formatter.line("Token usage", self.token_usage_spans()));
if let Some(spans) = self.context_window_spans() {
lines.push(formatter.line("Context window", spans));

View File

@@ -14,6 +14,6 @@ expression: sanitized
│ Agents.md: <none> │
│ │
│ Token usage: 1.2K total (800 input + 400 output) │
│ Context window: 100% left (1.2K used / 272K) │
│ Context window: 100% left (1.2K / 272K)
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
╰────────────────────────────────────────────────────────────────────────────╯

View File

@@ -14,7 +14,7 @@ expression: sanitized
│ Agents.md: <none> │
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
│ Context window: 100% left (2.1K used / 272K) │
│ Context window: 100% left (2.1K / 272K)
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
╰─────────────────────────────────────────────────────────────────────╯

View File

@@ -14,6 +14,6 @@ expression: sanitized
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Context window: 100% left (750 / 272K)
│ Limits: data not available yet │
╰─────────────────────────────────────────────────────────────────╯

View File

@@ -14,6 +14,6 @@ expression: sanitized
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Context window: 100% left (750 / 272K)
│ Limits: send a message to load usage data │
╰─────────────────────────────────────────────────────────────────╯

View File

@@ -14,7 +14,7 @@ expression: sanitized
│ Agents.md: <none> │
│ │
│ Token usage: 1.9K total (1K input + │
│ Context window: 100% left (2.1K used /
│ Context window: 100% left (2.1K / 272K)
│ 5h limit: [███████████████░░░░░] │
│ (resets 03:14) │
╰────────────────────────────────────────────╯

View File

@@ -314,7 +314,7 @@ fn status_context_window_uses_last_usage() {
.expect("context line");
assert!(
context_line.contains("13.7K used / 272K"),
context_line.contains("13.7K / 272K"),
"expected context line to reflect last usage tokens, got: {context_line}"
);
assert!(

View File

@@ -5,7 +5,6 @@ use std::time::Duration;
use std::time::Instant;
use codex_core::protocol::Op;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
@@ -165,7 +164,7 @@ impl WidgetRef for StatusIndicatorWidget {
spans.extend(vec![
" ".into(),
format!("({pretty_elapsed}").dim(),
key_hint::plain(KeyCode::Esc).into(),
"Esc".dim().bold(),
" to interrupt)".dim(),
]);
@@ -189,14 +188,8 @@ impl WidgetRef for StatusIndicatorWidget {
}
}
if !self.queued_messages.is_empty() {
lines.push(
Line::from(vec![
" ".into(),
key_hint::alt(KeyCode::Up).into(),
" edit".into(),
])
.dim(),
);
let shortcut = key_hint::alt("");
lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim());
}
let paragraph = Paragraph::new(lines);

View File

@@ -1,5 +1,51 @@
## Advanced
## Non-interactive / CI mode
Run Codex head-less in pipelines. Example GitHub Action step:
```yaml
- name: Update changelog via Codex
run: |
npm install -g @openai/codex
codex login --api-key "${{ secrets.OPENAI_KEY }}"
codex exec --full-auto "update CHANGELOG for next release"
```
### Resuming non-interactive sessions
You can resume a previous headless run to continue the same conversation context and append to the same rollout file.
Interactive TUI equivalent:
```shell
codex resume # picker
codex resume --last # most recent
codex resume <SESSION_ID>
```
Compatibility:
- Latest source builds include `codex exec resume` (examples below).
- Current released CLI may not include this yet. If `codex exec --help` shows no `resume`, use the workaround in the next subsection.
```shell
# Resume the most recent recorded session and run with a new prompt (source builds)
codex exec "ship a release draft changelog" resume --last
# Alternatively, pass the prompt via stdin (source builds)
# Note: omit the trailing '-' to avoid it being parsed as a SESSION_ID
echo "ship a release draft changelog" | codex exec resume --last
# Or resume a specific session by id (UUID) (source builds)
codex exec resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc "continue the task"
```
Notes:
- When using `--last`, Codex picks the newest recorded session; if none exist, it behaves like starting fresh.
- Resuming appends new events to the existing session file and maintains the same conversation id.
## Tracing / verbose logging
Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior.

View File

@@ -1,105 +0,0 @@
## Non-interactive mode
Use Codex in non-interactive mode to automate common workflows.
```shell
codex exec "count the total number of lines of code in this project"
```
In non-interactive mode, Codex does not ask for command or edit approvals. By default it runs in `read-only` mode, so it cannot edit files or run commands that require network access.
Use `codex exec --full-auto` to allow file edits. Use `codex exec --sandbox danger-full-access` to allow edits and networked commands.
### JSON output mode
`codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs.
Supported event types:
- `thread.started` - when a thread is started or resumed.
- `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response.
- `turn.completed` - when a turn completes; includes token usage.
- `turn.failed` - when a turn fails; includes error details.
- `item.started`/`item.updated`/`item.completed` - when a thread item is added/updated/completed.
Supported item types:
- `assistant_message` - assistant message.
- `reasoning` - a summary of the assistant's thinking.
- `command_execution` - assistant executing a command.
- `file_change` - assistant making file changes.
- `mcp_tool_call` - assistant calling an MCP tool.
- `web_search` - assistant performing a web search.
Typically, an `assistant_message` is added at the end of the turn.
Sample output:
```jsonl
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","item_type":"reasoning","text":"**Searching for README files**"}}
{"type":"item.started","item":{"id":"item_1","item_type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_1","item_type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"item_2","item_type":"reasoning","text":"**Checking repository root for README**"}}
{"type":"item.completed","item":{"id":"item_3","item_type":"assistant_message","text":"Yep — theres a `README.md` in the repository root."}}
{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}
```
### Structured output
By default, the agent responds with natural language. Use `--output-schema` to provide a JSON Schema that defines the expected JSON output.
The JSON Schema must follow the [strict schema rules](https://platform.openai.com/docs/guides/structured-outputs).
Sample schema:
```json
{
"type": "object",
"properties": {
"project_name": { "type": "string" },
"programming_languages": { "type": "array", "items": { "type": "string" } }
},
"required": ["project_name", "programming_languages"],
"additionalProperties": false
}
```
```shell
codex exec "Extract details of the project" --output-schema ~/schema.json
...
{"project_name":"Codex CLI","programming_languages":["Rust","TypeScript","Shell"]}
```
Combine `--output-schema` with `-o` to only print the final JSON output. You can also pass a file path to `-o` to save the JSON output to a file.
### Git repository requirement
Codex requires a Git repository to avoid destructive changes. To disable this check, use `codex exec --skip-git-repo-check`.
### Resuming non-interactive sessions
Resume a previous non-interactive session with `codex exec resume <SESSION_ID>` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent.
```shell
codex exec "Review the change, look for use-after-free issues"
codex exec resume --last "Fix use-after-free issues"
```
Only the conversation context is preserved; you must still provide flags to customize Codex behavior.
```shell
codex exec --model gpt-5-codex --json "Review the change, look for use-after-free issues"
codex exec --model gpt-5 --json resume --last "Fix use-after-free issues"
```
## Authentication
By default, `codex exec` will use the same authentication method as Codex CLI and VSCode extension. You can override the api key by setting the `CODEX_API_KEY` environment variable.
```shell
CODEX_API_KEY=your-api-key-here codex exec "Fix merge conflict"
```
NOTE: `CODEX_API_KEY` is only supported in `codex exec`.

View File

@@ -51,21 +51,3 @@ const result = await thread.run("Implement the fix");
console.log(result);
```
### Working directory
By default, Codex will run in the current working directory. You can change the working directory by passing the `workingDirectory` option to the when creating a thread.
```typescript
const thread = codex.startThread({
workingDirectory: "/path/to/working/directory",
});
```
To avoid unrecoverable errors, Codex requires the working directory to be a git repository. You can skip the git repository check by passing the `skipGitRepoCheck` option to the when creating a thread.
```typescript
const thread = codex.startThread({
skipGitRepoCheck: true,
});
```

View File

@@ -1,7 +1,6 @@
import { CodexOptions } from "./codexOptions";
import { CodexExec } from "./exec";
import { Thread } from "./thread";
import { ThreadOptions } from "./threadOptions";
/**
* Codex is the main class for interacting with the Codex agent.
@@ -21,8 +20,8 @@ export class Codex {
* Starts a new conversation with an agent.
* @returns A new thread instance.
*/
startThread(options: ThreadOptions = {}): Thread {
return new Thread(this.exec, this.options, options);
startThread(): Thread {
return new Thread(this.exec, this.options);
}
/**
@@ -32,7 +31,7 @@ export class Codex {
* @param id The id of the thread to resume.
* @returns A new thread instance.
*/
resumeThread(id: string, options: ThreadOptions = {}): Thread {
return new Thread(this.exec, this.options, options, id);
resumeThread(id: string): Thread {
return new Thread(this.exec, this.options, id);
}
}

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import readline from "node:readline";
import { SandboxMode } from "./threadOptions";
import { SandboxMode } from "./turnOptions";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -58,7 +58,7 @@ export class CodexExec {
env.OPENAI_BASE_URL = args.baseUrl;
}
if (args.apiKey) {
env.CODEX_API_KEY = args.apiKey;
env.OPENAI_API_KEY = args.apiKey;
}
const child = spawn(this.executablePath, commandArgs, {

View File

@@ -29,4 +29,4 @@ export { Codex } from "./codex";
export type { CodexOptions } from "./codexOptions";
export type { ThreadOptions as TheadOptions, ApprovalMode, SandboxMode } from "./threadOptions";
export type { TurnOptions, ApprovalMode, SandboxMode } from "./turnOptions";

View File

@@ -2,7 +2,7 @@ import { CodexOptions } from "./codexOptions";
import { ThreadEvent } from "./events";
import { CodexExec } from "./exec";
import { ThreadItem } from "./items";
import { ThreadOptions } from "./threadOptions";
import { TurnOptions } from "./turnOptions";
/** Completed turn. */
export type Turn = {
@@ -29,33 +29,27 @@ export class Thread {
private _exec: CodexExec;
private _options: CodexOptions;
private _id: string | null;
private _threadOptions: ThreadOptions;
/** Returns the ID of the thread. Populated after the first turn starts. */
public get id(): string | null {
return this._id;
}
/* @internal */
constructor(
exec: CodexExec,
options: CodexOptions,
threadOptions: ThreadOptions,
id: string | null = null,
) {
constructor(exec: CodexExec, options: CodexOptions, id: string | null = null) {
this._exec = exec;
this._options = options;
this._id = id;
this._threadOptions = threadOptions;
}
/** Provides the input to the agent and streams events as they are produced during the turn. */
async runStreamed(input: string): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(input) };
async runStreamed(input: string, options?: TurnOptions): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(input, options) };
}
private async *runStreamedInternal(input: string): AsyncGenerator<ThreadEvent> {
const options = this._threadOptions;
private async *runStreamedInternal(
input: string,
options?: TurnOptions,
): AsyncGenerator<ThreadEvent> {
const generator = this._exec.run({
input,
baseUrl: this._options.baseUrl,
@@ -81,8 +75,8 @@ export class Thread {
}
/** Provides the input to the agent and returns the completed turn. */
async run(input: string): Promise<Turn> {
const generator = this.runStreamedInternal(input);
async run(input: string, options?: TurnOptions): Promise<Turn> {
const generator = this.runStreamedInternal(input, options);
const items: ThreadItem[] = [];
let finalResponse: string = "";
for await (const event of generator) {

View File

@@ -2,7 +2,7 @@ export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted";
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type ThreadOptions = {
export type TurnOptions = {
model?: string;
sandboxMode?: SandboxMode;
workingDirectory?: string;

View File

@@ -5,8 +5,7 @@ jest.mock("node:child_process", () => {
return { ...actual, spawn: jest.fn(actual.spawn) };
});
const actualChildProcess =
jest.requireActual<typeof import("node:child_process")>("node:child_process");
const actualChildProcess = jest.requireActual<typeof import("node:child_process")>("node:child_process");
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
export function codexExecSpy(): { args: string[][]; restore: () => void } {

View File

@@ -109,7 +109,9 @@ describe("Codex", () => {
const thread = client.startThread();
await thread.run("first input");
await thread.run("second input");
await thread.run("second input", {
model: "gpt-test-1",
});
// Check second request continues the same thread
expect(requests.length).toBeGreaterThanOrEqual(2);
@@ -117,7 +119,7 @@ describe("Codex", () => {
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
expect(payload.input.at(-1)!.content![0]!.text).toBe("second input");
expect(payload.model).toBe("gpt-test-1");
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
@@ -195,11 +197,11 @@ describe("Codex", () => {
try {
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread({
const thread = client.startThread();
await thread.run("apply options", {
model: "gpt-test-1",
sandboxMode: "workspace-write",
});
await thread.run("apply options");
const payload = requests[0];
expect(payload).toBeDefined();
@@ -238,11 +240,11 @@ describe("Codex", () => {
apiKey: "test",
});
const thread = client.startThread({
const thread = client.startThread();
await thread.run("use custom working directory", {
workingDirectory,
skipGitRepoCheck: true,
});
await thread.run("use custom working directory");
const commandArgs = spawnArgs[0];
expectPair(commandArgs, ["--cd", workingDirectory]);
@@ -272,12 +274,12 @@ describe("Codex", () => {
apiKey: "test",
});
const thread = client.startThread({
workingDirectory,
});
await expect(thread.run("use custom working directory")).rejects.toThrow(
/Not inside a trusted directory/,
);
const thread = client.startThread();
await expect(
thread.run("use custom working directory", {
workingDirectory,
}),
).rejects.toThrow(/Not inside a trusted directory/);
} finally {
await close();
}