Merge a265928c96 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-01-14 15:55:45 -08:00
committed by GitHub
109 changed files with 4820 additions and 1241 deletions
+8 -1
View File
@@ -40,11 +40,18 @@ body:
description: |
For MacOS and Linux: copy the output of `uname -mprs`
For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console
- type: input
id: terminal
attributes:
label: What terminal emulator and version are you using (if applicable)?
description: Also note any multiplexer in use (screen / tmux / zellij)
description: |
E.g, VSCode, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell)
- type: textarea
id: actual
attributes:
label: What issue are you seeing?
description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot.
description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot.
validations:
required: true
- type: textarea
+51 -27
View File
@@ -88,7 +88,7 @@ jobs:
# --- CI to validate on different os/targets --------------------------------
lint_build:
name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runner }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
needs: changed
# Keep job-level if to avoid spinning up runners when not needed
@@ -106,47 +106,74 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-14
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: macos-14
- runner: macos-15-xlarge
target: x86_64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
- runner: windows-latest
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
- runner: windows-11-arm
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
# Also run representative release builds on Mac and Linux because
# there could be release-only build errors we want to catch.
# Hopefully this also pre-populates the build cache to speed up
# releases.
- runner: macos-14
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
profile: release
- runner: windows-latest
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: release
- runner: windows-11-arm
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: release
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@v6
@@ -336,7 +363,7 @@ jobs:
tests:
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 30
needs: changed
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
@@ -353,40 +380,37 @@ jobs:
fail-fast: false
matrix:
include:
- runner: macos-14
- runner: macos-15-xlarge
target: aarch64-apple-darwin
profile: dev
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
profile: dev
runs_on:
group: codex-runners
labels: codex-linux-x64
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
profile: dev
- runner: windows-latest
runs_on:
group: codex-runners
labels: codex-linux-arm64
- runner: windows-x64
target: x86_64-pc-windows-msvc
profile: dev
- runner: windows-11-arm
runs_on:
group: codex-runners
labels: codex-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
profile: dev
runs_on:
group: codex-runners
labels: codex-windows-arm64
steps:
- uses: actions/checkout@v6
# We have been running out of space when running this job on Linux for
# x86_64-unknown-linux-gnu, so remove some unnecessary dependencies.
- name: Remove unnecessary dependencies to save space
if: ${{ startsWith(matrix.runner, 'ubuntu') }}
shell: bash
run: |
set -euo pipefail
sudo rm -rf \
/usr/local/lib/android \
/usr/share/dotnet \
/usr/local/share/boost \
/usr/local/lib/node_modules \
/opt/ghc
sudo apt-get remove -y docker.io docker-compose podman buildah
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
+2
View File
@@ -1938,8 +1938,10 @@ dependencies = [
"anyhow",
"filedescriptor",
"lazy_static",
"libc",
"log",
"portable-pty",
"pretty_assertions",
"shared_library",
"tokio",
"winapi",
@@ -8,6 +8,7 @@ use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::ResponseItem;
@@ -327,6 +328,7 @@ pub struct ProfileV2 {
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub web_search: Option<WebSearchMode>,
pub chatgpt_base_url: Option<String>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
@@ -355,6 +357,7 @@ pub struct Config {
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub web_search: Option<WebSearchMode>,
pub tools: Option<ToolsV2>,
pub profile: Option<String>,
#[serde(default)]
@@ -178,6 +178,7 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::select;
use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use toml::Value as TomlValue;
use tracing::error;
@@ -229,6 +230,7 @@ pub(crate) struct CodexMessageProcessor {
config: Arc<Config>,
cli_overrides: Vec<(String, TomlValue)>,
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
listener_thread_ids_by_subscription: HashMap<Uuid, ThreadId>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
pending_interrupts: PendingInterrupts,
@@ -286,6 +288,7 @@ impl CodexMessageProcessor {
config,
cli_overrides,
conversation_listeners: HashMap::new(),
listener_thread_ids_by_subscription: HashMap::new(),
active_login: Arc::new(Mutex::new(None)),
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
pending_rollbacks: Arc::new(Mutex::new(HashMap::new())),
@@ -1271,7 +1274,11 @@ impl CodexMessageProcessor {
});
}
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
async fn process_new_conversation(
&mut self,
request_id: RequestId,
params: NewConversationParams,
) {
let NewConversationParams {
model,
model_provider,
@@ -1669,6 +1676,31 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
self.thread_manager.subscribe_thread_created()
}
/// Best-effort: attach a listener for thread_id if missing.
pub(crate) async fn try_attach_thread_listener(&mut self, thread_id: ThreadId) {
if self
.listener_thread_ids_by_subscription
.values()
.any(|entry| *entry == thread_id)
{
return;
}
if let Err(err) = self
.attach_conversation_listener(thread_id, false, ApiVersion::V2)
.await
{
warn!(
"failed to attach listener for thread {thread_id}: {message}",
message = err.message
);
}
}
async fn thread_resume(&mut self, request_id: RequestId, params: ThreadResumeParams) {
let ThreadResumeParams {
thread_id,
@@ -3389,7 +3421,9 @@ impl CodexMessageProcessor {
})?;
let mut config = self.config.as_ref().clone();
config.model = Some(self.config.review_model.clone());
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
}
let NewThread {
thread_id,
@@ -3563,6 +3597,12 @@ impl CodexMessageProcessor {
Some(sender) => {
// Signal the spawned task to exit and acknowledge.
let _ = sender.send(());
if let Some(thread_id) = self
.listener_thread_ids_by_subscription
.remove(&subscription_id)
{
info!("removed listener for thread {thread_id}");
}
let response = RemoveConversationSubscriptionResponse {};
self.outgoing.send_response(request_id, response).await;
}
@@ -3598,6 +3638,8 @@ impl CodexMessageProcessor {
let (cancel_tx, mut cancel_rx) = oneshot::channel();
self.conversation_listeners
.insert(subscription_id, cancel_tx);
self.listener_thread_ids_by_subscription
.insert(subscription_id, conversation_id);
let outgoing_for_task = self.outgoing.clone();
let pending_interrupts = self.pending_interrupts.clone();
+1 -1
View File
@@ -135,7 +135,7 @@ mod tests {
CoreSandboxModeRequirement::ReadOnly,
CoreSandboxModeRequirement::ExternalSandbox,
]),
mcp_server_requirements: None,
mcp_servers: None,
};
let mapped = map_requirements_toml_to_api(requirements);
+33 -6
View File
@@ -24,6 +24,7 @@ use toml::Value as TomlValue;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::warn;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
@@ -175,13 +176,39 @@ pub async fn run_main(
feedback.clone(),
config_warnings,
);
let mut thread_created_rx = processor.thread_created_receiver();
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e),
let mut listen_for_threads = true;
loop {
tokio::select! {
msg = incoming_rx.recv() => {
let Some(msg) = msg else {
break;
};
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e),
}
}
created = thread_created_rx.recv(), if listen_for_threads => {
match created {
Ok(thread_id) => {
processor.try_attach_thread_listener(thread_id).await;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
// TODO(jif) handle lag.
// Assumes thread creation volume is low enough that lag never happens.
// If it does, we log and continue without resyncing to avoid attaching
// listeners for threads that should remain unsubscribed.
warn!("thread_created receiver lagged; skipping resync");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
listen_for_threads = false;
}
}
}
}
}
+15 -1
View File
@@ -28,7 +28,9 @@ use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use codex_core::default_client::set_default_originator;
use codex_feedback::CodexFeedback;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use tokio::sync::broadcast;
use toml::Value as TomlValue;
pub(crate) struct MessageProcessor {
@@ -159,7 +161,6 @@ impl MessageProcessor {
self.outgoing.send_response(request_id, response).await;
self.initialized = true;
if !self.config_warnings.is_empty() {
for notification in self.config_warnings.drain(..) {
self.outgoing
@@ -214,6 +215,19 @@ impl MessageProcessor {
tracing::info!("<- notification: {:?}", notification);
}
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
self.codex_message_processor.thread_created_receiver()
}
pub(crate) async fn try_attach_thread_listener(&mut self, thread_id: ThreadId) {
if !self.initialized {
return;
}
self.codex_message_processor
.try_attach_thread_listener(thread_id)
.await;
}
/// Handle a standalone JSON-RPC response originating from the peer.
pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) {
tracing::info!("<- response: {:?}", response);
+3 -3
View File
@@ -691,11 +691,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
.parse_overrides()
.map_err(anyhow::Error::msg)?;
// Honor `--search` via the new feature toggle.
// Honor `--search` via the canonical web_search mode.
if interactive.web_search {
cli_kv_overrides.push((
"features.web_search_request".to_string(),
toml::Value::Boolean(true),
"web_search".to_string(),
toml::Value::String("live".to_string()),
));
}
+50 -17
View File
@@ -290,7 +290,6 @@ pub async fn process_sse(
telemetry: Option<Arc<dyn SseTelemetry>>,
) {
let mut stream = stream.eventsource();
let mut response_completed: Option<ResponseEvent> = None;
let mut response_error: Option<ApiError> = None;
loop {
@@ -307,17 +306,10 @@ pub async fn process_sse(
return;
}
Ok(None) => {
match response_completed.take() {
Some(event) => {
let _ = tx_event.send(Ok(event)).await;
}
None => {
let error = response_error.unwrap_or(ApiError::Stream(
"stream closed before response.completed".into(),
));
let _ = tx_event.send(Err(error)).await;
}
}
let error = response_error.unwrap_or(ApiError::Stream(
"stream closed before response.completed".into(),
));
let _ = tx_event.send(Err(error)).await;
return;
}
Err(_) => {
@@ -328,8 +320,7 @@ pub async fn process_sse(
}
};
let raw = sse.data.clone();
trace!("SSE event: {raw}");
trace!("SSE event: {}", &sse.data);
let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
@@ -341,9 +332,11 @@ pub async fn process_sse(
match process_responses_event(event) {
Ok(Some(event)) => {
if matches!(event, ResponseEvent::Completed { .. }) {
response_completed = Some(event);
} else if tx_event.send(Ok(event)).await.is_err() {
let is_completed = matches!(event, ResponseEvent::Completed { .. });
if tx_event.send(Ok(event)).await.is_err() {
return;
}
if is_completed {
return;
}
}
@@ -405,7 +398,9 @@ fn rate_limit_regex() -> &'static regex_lite::Regex {
mod tests {
use super::*;
use assert_matches::assert_matches;
use bytes::Bytes;
use codex_protocol::models::ResponseItem;
use futures::stream;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::sync::mpsc;
@@ -607,6 +602,44 @@ mod tests {
}
}
#[tokio::test]
async fn emits_completed_without_stream_end() {
let completed = json!({
"type": "response.completed",
"response": { "id": "resp1" }
})
.to_string();
let sse1 = format!("event: response.completed\ndata: {completed}\n\n");
let stream = stream::iter(vec![Ok(Bytes::from(sse1))]).chain(stream::pending());
let stream: ByteStream = Box::pin(stream);
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent, ApiError>>(8);
tokio::spawn(process_sse(stream, tx, idle_timeout(), None));
let events = tokio::time::timeout(Duration::from_millis(1000), async {
let mut events = Vec::new();
while let Some(ev) = rx.recv().await {
events.push(ev);
}
events
})
.await
.expect("timed out collecting events");
assert_eq!(events.len(), 1);
match &events[0] {
Ok(ResponseEvent::Completed {
response_id,
token_usage,
}) => {
assert_eq!(response_id, "resp1");
assert!(token_usage.is_none());
}
other => panic!("unexpected event: {other:?}"),
}
}
#[tokio::test]
async fn error_when_error_event() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#;
+1 -15
View File
@@ -8,7 +8,6 @@ use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::time::Duration;
use tracing::Span;
@@ -116,12 +115,11 @@ impl CodexRequestBuilder {
match self.builder.headers(headers).send().await {
Ok(response) => {
let request_ids = Self::extract_request_ids(&response);
tracing::debug!(
method = %self.method,
url = %self.url,
status = %response.status(),
request_ids = ?request_ids,
headers = ?response.headers(),
version = ?response.version(),
"Request completed"
);
@@ -141,18 +139,6 @@ impl CodexRequestBuilder {
}
}
}
fn extract_request_ids(response: &Response) -> HashMap<String, String> {
["cf-ray", "x-request-id", "x-oai-request-id"]
.iter()
.filter_map(|&name| {
let header_name = HeaderName::from_static(name);
let value = response.headers().get(header_name)?;
let value = value.to_str().ok()?.to_owned();
Some((name.to_owned(), value))
})
.collect()
}
}
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
+4
View File
@@ -10,6 +10,10 @@ Note that `codex-core` makes some assumptions about certain helper utilities bei
Expects `/usr/bin/sandbox-exec` to be present.
When using the workspace-write sandbox policy, the Seatbelt profile allows
writes under the configured writable roots while keeping `.git` (directory or
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
### Linux
Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.
+21 -2
View File
@@ -99,7 +99,7 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"hierarchical_agents": {
"child_agents_md": {
"type": "boolean"
},
"include_apply_patch_tool": {
@@ -395,6 +395,14 @@
}
]
},
"web_search": {
"description": "Controls the web search tool mode: disabled, cached, or live.",
"allOf": [
{
"$ref": "#/definitions/WebSearchMode"
}
]
},
"windows_wsl_setup_acknowledged": {
"description": "Tracks whether the Windows onboarding screen has been acknowledged.",
"type": "boolean"
@@ -559,7 +567,7 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"hierarchical_agents": {
"child_agents_md": {
"type": "boolean"
},
"include_apply_patch_tool": {
@@ -634,6 +642,9 @@
},
"tools_web_search": {
"type": "boolean"
},
"web_search": {
"$ref": "#/definitions/WebSearchMode"
}
},
"additionalProperties": false
@@ -1420,6 +1431,14 @@
"high"
]
},
"WebSearchMode": {
"type": "string",
"enum": [
"disabled",
"cached",
"live"
]
},
"WireApi": {
"description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.",
"oneOf": [
+8 -33
View File
@@ -1,10 +1,8 @@
use crate::CodexThread;
use crate::agent::AgentStatus;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::thread_manager::ThreadManagerState;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use std::sync::Arc;
@@ -29,21 +27,18 @@ impl AgentControl {
}
/// Spawn a new agent thread and submit the initial prompt.
///
/// If `headless` is true, a background drain task is spawned to prevent unbounded event growth
/// of the channel queue when there is no client actively reading the thread events.
pub(crate) async fn spawn_agent(
&self,
config: crate::config::Config,
prompt: String,
headless: bool,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let new_thread = state.spawn_new_thread(config, self.clone()).await?;
if headless {
spawn_headless_drain(Arc::clone(&new_thread.thread));
}
// Notify a new thread has been created. This notification will be processed by clients
// to subscribe or drain this newly created thread.
// TODO(jif) add helper for drain
state.notify_thread_created(new_thread.thread_id);
self.send_prompt(new_thread.thread_id, prompt).await?;
@@ -110,38 +105,18 @@ impl AgentControl {
}
}
/// When an agent is spawned "headless" (no UI/view attached), there may be no consumer polling
/// `CodexThread::next_event()`. The underlying event channel is unbounded, so the producer can
/// accumulate events indefinitely. This drain task prevents that memory growth by polling and
/// discarding events until shutdown.
fn spawn_headless_drain(thread: Arc<CodexThread>) {
tokio::spawn(async move {
loop {
match thread.next_event().await {
Ok(event) => {
if matches!(event.msg, EventMsg::ShutdownComplete) {
break;
}
}
Err(err) => {
tracing::warn!("failed to receive event from agent: {err:?}");
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CodexAuth;
use crate::CodexThread;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::config::Config;
use crate::config::ConfigBuilder;
use assert_matches::assert_matches;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
@@ -262,7 +237,7 @@ mod tests {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.spawn_agent(config, "hello".to_string(), false)
.spawn_agent(config, "hello".to_string())
.await
.expect_err("spawn_agent should fail without a manager");
assert_eq!(
@@ -363,7 +338,7 @@ mod tests {
let harness = AgentControlHarness::new().await;
let thread_id = harness
.control
.spawn_agent(harness.config.clone(), "spawned".to_string(), false)
.spawn_agent(harness.config.clone(), "spawned".to_string())
.await
.expect("spawn_agent should succeed");
let _thread = harness
+40 -12
View File
@@ -34,6 +34,7 @@ use async_channel::Receiver;
use async_channel::Sender;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::FileChange;
@@ -363,7 +364,7 @@ impl Codex {
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
conversation_id: ThreadId,
pub(crate) conversation_id: ThreadId,
tx_event: Sender<Event>,
agent_status: watch::Sender<AgentStatus>,
state: Mutex<SessionState>,
@@ -537,14 +538,27 @@ impl Session {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &per_turn_config.features,
web_search_mode: per_turn_config.web_search_mode,
});
let base_instructions = if per_turn_config.features.enabled(Feature::Collab) {
const COLLAB_INSTRUCTIONS: &str =
include_str!("../templates/collab/experimental_prompt.md");
let base = session_configuration
.base_instructions
.as_deref()
.unwrap_or(model_info.base_instructions.as_str());
Some(format!("{base}\n\n{COLLAB_INSTRUCTIONS}"))
} else {
session_configuration.base_instructions.clone()
};
TurnContext {
sub_id,
client,
cwd: session_configuration.cwd.clone(),
developer_instructions: session_configuration.developer_instructions.clone(),
base_instructions: session_configuration.base_instructions.clone(),
base_instructions,
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration.user_instructions.clone(),
approval_policy: session_configuration.approval_policy.value(),
@@ -687,7 +701,7 @@ impl Session {
// Create the mutable state for the Session.
if config.features.enabled(Feature::ShellSnapshot) {
default_shell.shell_snapshot =
ShellSnapshot::try_new(&config.codex_home, &default_shell)
ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell)
.await
.map(Arc::new);
}
@@ -2383,7 +2397,10 @@ async fn spawn_review_thread(
sub_id: String,
resolved: crate::review_prompts::ResolvedReviewRequest,
) {
let model = config.review_model.clone();
let model = config
.review_model
.clone()
.unwrap_or_else(|| parent_turn_context.client.get_model());
let review_model_info = sess
.services
.models_manager
@@ -2394,9 +2411,11 @@ async fn spawn_review_thread(
review_features
.disable(crate::features::Feature::WebSearchRequest)
.disable(crate::features::Feature::WebSearchCached);
let review_web_search_mode = WebSearchMode::Disabled;
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &review_model_info,
features: &review_features,
web_search_mode: review_web_search_mode,
});
let base_instructions = REVIEW_PROMPT.to_string();
@@ -2407,14 +2426,14 @@ async fn spawn_review_thread(
// Build per‑turn client with the requested model/family.
let mut per_turn_config = (*config).clone();
per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low);
per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed;
per_turn_config.model = Some(model.clone());
per_turn_config.features = review_features.clone();
per_turn_config.web_search_mode = review_web_search_mode;
let otel_manager = parent_turn_context.client.get_otel_manager().with_model(
config.review_model.as_str(),
review_model_info.slug.as_str(),
);
let otel_manager = parent_turn_context
.client
.get_otel_manager()
.with_model(model.as_str(), review_model_info.slug.as_str());
let per_turn_config = Arc::new(per_turn_config);
let client = ModelClient::new(
@@ -2629,9 +2648,18 @@ pub(crate) async fn run_turn(
Err(CodexErr::InvalidImageRequest()) => {
let mut state = sess.state.lock().await;
error_or_panic(
"Invalid image detected, replacing it in the last turn to prevent poisoning",
"Invalid image detected; sanitizing tool output to prevent poisoning",
);
state.history.replace_last_turn_images("Invalid image");
if state.history.replace_last_turn_images("Invalid image") {
continue;
}
let event = EventMsg::Error(ErrorEvent {
message: "Invalid image in your last message. Please remove it and try again."
.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
});
sess.send_event(&turn_context, event).await;
break;
}
Err(e) => {
info!("Turn error: {e:#}");
+1
View File
@@ -1007,6 +1007,7 @@ hide_rate_limit_model_nudge = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() {
let tmp = tempdir().expect("tmpdir");
+85 -29
View File
@@ -42,6 +42,7 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -76,8 +77,6 @@ pub use constraint::ConstraintResult;
pub use service::ConfigService;
pub use service::ConfigServiceError;
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max";
pub use codex_git::GhostSnapshotConfig;
/// Maximum number of bytes of the documentation that will be embedded. Larger
@@ -108,8 +107,8 @@ pub struct Config {
/// Optional override of model selection.
pub model: Option<String>,
/// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max".
pub review_model: String,
/// Model used specifically for review sessions.
pub review_model: Option<String>,
/// Size of the context window for the model, in tokens.
pub model_context_window: Option<i64>,
@@ -338,7 +337,7 @@ pub struct Config {
/// model info's default preference.
pub include_apply_patch_tool: bool,
pub tools_web_search_request: bool,
pub web_search_mode: WebSearchMode,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -896,6 +895,9 @@ pub struct ConfigToml {
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
pub web_search: Option<WebSearchMode>,
/// Nested tools section for feature toggles
pub tools: Option<ToolsToml>,
@@ -1180,6 +1182,26 @@ pub fn resolve_oss_provider(
}
}
/// Resolve the web search mode from the config, profile, and features.
fn resolve_web_search_mode(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
features: &Features,
) -> WebSearchMode {
// Enum gets precedence over features flags
if let Some(mode) = config_profile.web_search.or(config_toml.web_search) {
return mode;
}
if features.enabled(Feature::WebSearchCached) {
return WebSearchMode::Cached;
}
if features.enabled(Feature::WebSearchRequest) {
return WebSearchMode::Live;
}
// Fall back to default
WebSearchMode::default()
}
impl Config {
#[cfg(test)]
fn load_from_base_config_with_overrides(
@@ -1244,6 +1266,7 @@ impl Config {
};
let features = Features::from_config(&cfg, &config_profile, feature_overrides);
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features);
#[cfg(target_os = "windows")]
{
// Base flag controls sandbox on/off; elevated only applies when base is enabled.
@@ -1363,7 +1386,6 @@ impl Config {
};
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
let tools_web_search_request = features.enabled(Feature::WebSearchRequest);
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let forced_chatgpt_workspace_id =
@@ -1413,10 +1435,7 @@ impl Config {
)?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
// Default review model when not set in config; allow CLI override to take precedence.
let review_model = override_review_model
.or(cfg.review_model)
.unwrap_or_else(default_review_model);
let review_model = override_review_model.or(cfg.review_model);
let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true);
@@ -1425,7 +1444,7 @@ impl Config {
let ConfigRequirements {
approval_policy: mut constrained_approval_policy,
sandbox_policy: mut constrained_sandbox_policy,
mcp_server_requirements,
mcp_servers,
} = requirements;
constrained_approval_policy
@@ -1435,11 +1454,8 @@ impl Config {
.set(sandbox_policy)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
let mcp_servers =
constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_server_requirements.as_ref())
.map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}"))
})?;
let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
let config = Self {
model,
@@ -1510,7 +1526,7 @@ impl Config {
forced_chatgpt_workspace_id,
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,
tools_web_search_request,
web_search_mode,
use_experimental_unified_exec_tool,
ghost_snapshot,
features,
@@ -1647,10 +1663,6 @@ impl Config {
}
}
fn default_review_model() -> String {
OPENAI_DEFAULT_REVIEW_MODEL.to_string()
}
/// Returns the path to the Codex configuration directory, which can be
/// specified by the `CODEX_HOME` environment variable. If not set, defaults to
/// `~/.codex`.
@@ -2189,6 +2201,50 @@ trust_level = "trusted"
Ok(())
}
#[test]
fn web_search_mode_uses_default_if_unset() {
let cfg = ConfigToml::default();
let profile = ConfigProfile::default();
let features = Features::with_defaults();
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
WebSearchMode::default()
);
}
#[test]
fn web_search_mode_prefers_profile_over_legacy_flags() {
let cfg = ConfigToml::default();
let profile = ConfigProfile {
web_search: Some(WebSearchMode::Live),
..Default::default()
};
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
WebSearchMode::Live
);
}
#[test]
fn web_search_mode_disabled_overrides_legacy_request() {
let cfg = ConfigToml {
web_search: Some(WebSearchMode::Disabled),
..Default::default()
};
let profile = ConfigProfile::default();
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchRequest);
assert_eq!(
resolve_web_search_mode(&cfg, &profile, &features),
WebSearchMode::Disabled
);
}
#[test]
fn profile_legacy_toggles_override_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -3486,7 +3542,7 @@ model_verbosity = "high"
assert_eq!(
Config {
model: Some("o3".to_string()),
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
@@ -3525,7 +3581,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
web_search_mode: WebSearchMode::default(),
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -3573,7 +3629,7 @@ model_verbosity = "high"
)?;
let expected_gpt3_profile_config = Config {
model: Some("gpt-3.5-turbo".to_string()),
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_provider_id: "openai-chat-completions".to_string(),
@@ -3612,7 +3668,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
web_search_mode: WebSearchMode::default(),
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -3675,7 +3731,7 @@ model_verbosity = "high"
)?;
let expected_zdr_profile_config = Config {
model: Some("o3".to_string()),
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
@@ -3714,7 +3770,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
web_search_mode: WebSearchMode::default(),
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -3763,7 +3819,7 @@ model_verbosity = "high"
)?;
let expected_gpt5_profile_config = Config {
model: Some("gpt-5.1".to_string()),
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
review_model: None,
model_context_window: None,
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
@@ -3802,7 +3858,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
web_search_mode: WebSearchMode::default(),
use_experimental_unified_exec_tool: false,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
+2
View File
@@ -7,6 +7,7 @@ use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
/// Collection of common configuration options that a user can define as a unit
@@ -31,6 +32,7 @@ pub struct ConfigProfile {
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_web_search: Option<bool>,
pub tools_view_image: Option<bool>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
/// Optional feature toggles scoped to this profile.
#[serde(default)]
+1
View File
@@ -4,6 +4,7 @@
// definitions that do not contain business logic.
pub use codex_protocol::config_types::AltScreenMode;
pub use codex_protocol::config_types::WebSearchMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -44,7 +44,7 @@ impl fmt::Display for RequirementSource {
pub struct ConfigRequirements {
pub approval_policy: Constrained<AskForApproval>,
pub sandbox_policy: Constrained<SandboxPolicy>,
pub mcp_server_requirements: Option<BTreeMap<String, McpServerRequirement>>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
}
impl Default for ConfigRequirements {
@@ -52,7 +52,7 @@ impl Default for ConfigRequirements {
Self {
approval_policy: Constrained::allow_any_from_default(),
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly),
mcp_server_requirements: None,
mcp_servers: None,
}
}
}
@@ -74,7 +74,7 @@ pub struct McpServerRequirement {
pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub mcp_server_requirements: Option<BTreeMap<String, McpServerRequirement>>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
}
/// Value paired with the requirement source it came from, for better error
@@ -103,7 +103,7 @@ impl<T> std::ops::Deref for Sourced<T> {
pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub mcp_server_requirements: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
}
impl ConfigRequirementsWithSources {
@@ -134,7 +134,7 @@ impl ConfigRequirementsWithSources {
{
allowed_approval_policies,
allowed_sandbox_modes,
mcp_server_requirements,
mcp_servers,
}
);
}
@@ -143,12 +143,12 @@ impl ConfigRequirementsWithSources {
let ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_sandbox_modes,
mcp_server_requirements,
mcp_servers,
} = self;
ConfigRequirementsToml {
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
}
}
}
@@ -184,7 +184,7 @@ impl ConfigRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allowed_approval_policies.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.mcp_server_requirements.is_none()
&& self.mcp_servers.is_none()
}
}
@@ -195,7 +195,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
let ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_sandbox_modes,
mcp_server_requirements,
mcp_servers,
} = toml;
let approval_policy: Constrained<AskForApproval> = match allowed_approval_policies {
@@ -273,7 +273,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
Ok(ConfigRequirements {
approval_policy,
sandbox_policy,
mcp_server_requirements: mcp_server_requirements.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
})
}
}
@@ -291,15 +291,14 @@ mod tests {
let ConfigRequirementsToml {
allowed_approval_policies,
allowed_sandbox_modes,
mcp_server_requirements,
mcp_servers,
} = toml;
ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_sandbox_modes: allowed_sandbox_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_server_requirements: mcp_server_requirements
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
}
}
@@ -319,7 +318,7 @@ mod tests {
let other = ConfigRequirementsToml {
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
mcp_server_requirements: None,
mcp_servers: None,
};
target.merge_unset_fields(source.clone(), other);
@@ -332,7 +331,7 @@ mod tests {
source.clone()
)),
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)),
mcp_server_requirements: None,
mcp_servers: None,
}
);
}
@@ -360,7 +359,7 @@ mod tests {
source_location,
)),
allowed_sandbox_modes: None,
mcp_server_requirements: None,
mcp_servers: None,
}
);
Ok(())
@@ -396,7 +395,7 @@ mod tests {
existing_source,
)),
allowed_sandbox_modes: None,
mcp_server_requirements: None,
mcp_servers: None,
}
);
Ok(())
@@ -561,17 +560,17 @@ mod tests {
#[test]
fn deserialize_mcp_server_requirements() -> Result<()> {
let toml_str = r#"
[mcp_server_requirements.docs.identity]
[mcp_servers.docs.identity]
command = "codex-mcp"
[mcp_server_requirements.remote.identity]
[mcp_servers.remote.identity]
url = "https://example.com/mcp"
"#;
let requirements: ConfigRequirements =
with_unknown_source(from_str(toml_str)?).try_into()?;
assert_eq!(
requirements.mcp_server_requirements,
requirements.mcp_servers,
Some(BTreeMap::from([
(
"docs".to_string(),
+17 -16
View File
@@ -124,34 +124,35 @@ impl ContextManager {
self.items = items;
}
pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) {
let Some(last_item) = self.items.last_mut() else {
return;
/// Replace image content in the last turn if it originated from a tool output.
/// Returns true when a tool image was replaced, false otherwise.
pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) -> bool {
let Some(index) = self.items.iter().rposition(|item| {
matches!(item, ResponseItem::FunctionCallOutput { .. })
|| matches!(item, ResponseItem::Message { role, .. } if role == "user")
}) else {
return false;
};
match last_item {
ResponseItem::Message { role, content, .. } if role == "user" => {
for item in content.iter_mut() {
if matches!(item, ContentItem::InputImage { .. }) {
*item = ContentItem::InputText {
text: placeholder.to_string(),
};
}
}
}
match &mut self.items[index] {
ResponseItem::FunctionCallOutput { output, .. } => {
let Some(content_items) = output.content_items.as_mut() else {
return;
return false;
};
let mut replaced = false;
let placeholder = placeholder.to_string();
for item in content_items.iter_mut() {
if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) {
*item = FunctionCallOutputContentItem::InputText {
text: placeholder.to_string(),
text: placeholder.clone(),
};
replaced = true;
}
}
replaced
}
_ => {}
ResponseItem::Message { role, .. } if role == "user" => false,
_ => false,
}
}
@@ -3,6 +3,7 @@ use crate::truncate;
use crate::truncate::TruncationPolicy;
use codex_git::GhostCommit;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::LocalShellExecAction;
@@ -209,6 +210,58 @@ fn remove_first_item_removes_matching_call_for_output() {
assert_eq!(h.raw_items(), vec![]);
}
#[test]
fn replace_last_turn_images_replaces_tool_output_images() {
let items = vec![
user_input_text_msg("hi"),
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload {
content: "ok".to_string(),
content_items: Some(vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
}]),
success: Some(true),
},
},
];
let mut history = create_history_with_items(items);
assert!(history.replace_last_turn_images("Invalid image"));
assert_eq!(
history.raw_items(),
vec![
user_input_text_msg("hi"),
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload {
content: "ok".to_string(),
content_items: Some(vec![FunctionCallOutputContentItem::InputText {
text: "Invalid image".to_string(),
}]),
success: Some(true),
},
},
]
);
}
#[test]
fn replace_last_turn_images_does_not_touch_user_images() {
let items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
}],
}];
let mut history = create_history_with_items(items.clone());
assert!(!history.replace_last_turn_images("Invalid image"));
assert_eq!(history.raw_items(), items);
}
#[test]
fn remove_first_item_handles_local_shell_pair() {
let items = vec![
+1 -32
View File
@@ -32,6 +32,7 @@ use crate::sandboxing::SandboxPermissions;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use codex_utils_pty::process_group::kill_child_process_group;
pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000;
@@ -750,38 +751,6 @@ fn synthetic_exit_status(code: i32) -> ExitStatus {
std::process::ExitStatus::from_raw(code as u32)
}
#[cfg(unix)]
fn kill_child_process_group(child: &mut Child) -> io::Result<()> {
use std::io::ErrorKind;
if let Some(pid) = child.id() {
let pid = pid as libc::pid_t;
let pgid = unsafe { libc::getpgid(pid) };
if pgid == -1 {
let err = std::io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
return Ok(());
}
let result = unsafe { libc::killpg(pgid, libc::SIGKILL) };
if result == -1 {
let err = std::io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
}
}
Ok(())
}
#[cfg(not(unix))]
fn kill_child_process_group(_: &mut Child) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+3 -3
View File
@@ -89,7 +89,7 @@ pub enum Feature {
/// Experimental shell snapshotting.
ShellSnapshot,
/// Append additional AGENTS.md guidance to user instructions.
HierarchicalAgents,
ChildAgentsMd,
/// Experimental TUI v2 (viewport) implementation.
Tui2,
/// Enforce UTF8 output in Powershell.
@@ -359,8 +359,8 @@ pub const FEATURES: &[FeatureSpec] = &[
default_enabled: false,
},
FeatureSpec {
id: Feature::HierarchicalAgents,
key: "hierarchical_agents",
id: Feature::ChildAgentsMd,
key: "child_agents_md",
stage: Stage::Experimental,
default_enabled: false,
},
+1 -1
View File
@@ -29,7 +29,7 @@ use crate::models_manager::model_presets::builtin_model_presets;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300);
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.1-codex-max";
const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.2-codex";
const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex";
const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced";
@@ -39,7 +39,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
is_default: true,
upgrade: None,
show_in_picker: true,
supported_in_api: false,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.1-codex-max".to_string(),
+1 -1
View File
@@ -69,7 +69,7 @@ pub(crate) async fn get_user_instructions(
output.push_str(&skills_section);
}
if config.features.enabled(Feature::HierarchicalAgents) {
if config.features.enabled(Feature::ChildAgentsMd) {
if !output.is_empty() {
output.push_str("\n\n");
}
+9 -1
View File
@@ -90,6 +90,14 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable => false,
| EventMsg::SkillsUpdateAvailable
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => false,
}
}
+20 -8
View File
@@ -1,10 +1,12 @@
//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
use std::fs::File;
use std::fs::FileTimes;
use std::fs::{self};
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use codex_protocol::ThreadId;
use serde_json::Value;
@@ -151,14 +153,17 @@ impl RolloutRecorder {
}),
)
}
RolloutRecorderParams::Resume { path } => (
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
path,
None,
),
RolloutRecorderParams::Resume { path } => {
touch_rollout_file(&path)?;
(
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
path,
None,
)
}
};
// Clone the cwd for the spawned task to collect git info asynchronously
@@ -343,6 +348,13 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul
})
}
fn touch_rollout_file(path: &Path) -> std::io::Result<()> {
let file = fs::OpenOptions::new().append(true).open(path)?;
let times = FileTimes::new().set_modified(SystemTime::now());
file.set_times(times)?;
Ok(())
}
async fn rollout_writer(
file: tokio::fs::File,
mut rx: mpsc::Receiver<RolloutCmd>,
+103 -11
View File
@@ -174,6 +174,16 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
fn assert_seatbelt_denied(stderr: &[u8], path: &Path) {
let stderr = String::from_utf8_lossy(stderr);
let expected = format!("bash: {}: Operation not permitted\n", path.display());
assert!(
stderr == expected
|| stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"),
"unexpected stderr: {stderr}"
);
}
#[test]
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
// Create a temporary workspace with two writable roots: one containing
@@ -290,10 +300,7 @@ mod tests {
"command to write {} should fail under seatbelt",
&config_toml.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!("bash: {}: Operation not permitted\n", config_toml.display()),
);
assert_seatbelt_denied(&output.stderr, &config_toml);
// Create a similar Seatbelt command that tries to write to a file in
// the .git folder, which should also be blocked.
@@ -324,13 +331,7 @@ mod tests {
"command to write {} should fail under seatbelt",
&pre_commit_hook.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!(
"bash: {}: Operation not permitted\n",
pre_commit_hook.display()
),
);
assert_seatbelt_denied(&output.stderr, &pre_commit_hook);
// Verify that writing a file to the folder containing .git and .codex is allowed.
let allowed_file = vulnerable_root_canonical.join("allowed.txt");
@@ -351,6 +352,12 @@ mod tests {
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success()
&& stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted")
{
return;
}
assert!(
output.status.success(),
"command to write {} should succeed under seatbelt",
@@ -364,6 +371,91 @@ mod tests {
);
}
#[test]
fn create_seatbelt_args_with_read_only_git_pointer_file() {
let tmp = TempDir::new().expect("tempdir");
let worktree_root = tmp.path().join("worktree_root");
fs::create_dir_all(&worktree_root).expect("create worktree_root");
let gitdir = worktree_root.join("actual-gitdir");
fs::create_dir_all(&gitdir).expect("create gitdir");
let gitdir_config = gitdir.join("config");
let gitdir_config_contents = "[core]\n";
fs::write(&gitdir_config, gitdir_config_contents).expect("write gitdir config");
let dot_git = worktree_root.join(".git");
let dot_git_contents = format!("gitdir: {}\n", gitdir.to_string_lossy());
fs::write(&dot_git, &dot_git_contents).expect("write .git pointer");
let cwd = tmp.path().join("cwd");
fs::create_dir_all(&cwd).expect("create cwd");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let shell_command: Vec<String> = [
"bash",
"-c",
"echo 'pwned!' > \"$1\"",
"bash",
dot_git.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert_eq!(
dot_git_contents,
String::from_utf8_lossy(&fs::read(&dot_git).expect("read .git pointer")),
".git pointer file should not be modified under seatbelt"
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
dot_git.display()
);
assert_seatbelt_denied(&output.stderr, &dot_git);
let shell_command_gitdir: Vec<String> = [
"bash",
"-c",
"echo 'pwned!' > \"$1\"",
"bash",
gitdir_config.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let gitdir_args = create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&gitdir_args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert_eq!(
gitdir_config_contents,
String::from_utf8_lossy(&fs::read(&gitdir_config).expect("read gitdir config")),
"gitdir config should contain its original contents because it should not have been modified"
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
gitdir_config.display()
);
assert_seatbelt_denied(&output.stderr, &gitdir_config);
}
#[test]
fn create_seatbelt_args_for_cwd_as_git_repo() {
// Create a temporary workspace with two writable roots: one containing
+209 -10
View File
@@ -1,7 +1,10 @@
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::SystemTime;
use crate::rollout::list::find_thread_path_by_id_str;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell::get_shell;
@@ -9,10 +12,10 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_protocol::ThreadId;
use tokio::fs;
use tokio::process::Command;
use tokio::time::timeout;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShellSnapshot {
@@ -20,17 +23,30 @@ pub struct ShellSnapshot {
}
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days retention.
const SNAPSHOT_DIR: &str = "shell_snapshots";
impl ShellSnapshot {
pub async fn try_new(codex_home: &Path, shell: &Shell) -> Option<Self> {
pub async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option<Self> {
// File to store the snapshot
let extension = match shell.shell_type {
ShellType::PowerShell => "ps1",
_ => "sh",
};
let path =
codex_home
.join("shell_snapshots")
.join(format!("{}.{}", Uuid::new_v4(), extension));
let path = codex_home
.join(SNAPSHOT_DIR)
.join(format!("{session_id}.{extension}"));
// Clean the (unlikely) leaked snapshot files.
let codex_home = codex_home.to_path_buf();
let cleanup_session_id = session_id;
tokio::spawn(async move {
if let Err(err) = cleanup_stale_snapshots(&codex_home, cleanup_session_id).await {
tracing::warn!("Failed to clean up shell snapshots: {err:?}");
}
});
// Make the new snapshot.
match write_shell_snapshot(shell.shell_type.clone(), &path).await {
Ok(path) => {
tracing::info!("Shell snapshot successfully created: {}", path.display());
@@ -135,7 +151,13 @@ async fn run_shell_script_with_timeout(
}
fn zsh_snapshot_script() -> &'static str {
r##"print '# Snapshot file'
r##"if [[ -n "$ZDOTDIR" ]]; then
rc="$ZDOTDIR/.zshrc"
else
rc="$HOME/.zshrc"
fi
[[ -r "$rc" ]] && . "$rc"
print '# Snapshot file'
print '# Unset all aliases to avoid conflicts with functions'
print 'unalias -a 2>/dev/null || true'
print '# Functions'
@@ -156,7 +178,10 @@ export -p
}
fn bash_snapshot_script() -> &'static str {
r##"echo '# Snapshot file'
r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
@@ -180,7 +205,10 @@ export -p
}
fn sh_snapshot_script() -> &'static str {
r##"echo '# Snapshot file'
r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then
. "$ENV"
fi
echo '# Snapshot file'
echo '# Unset all aliases to avoid conflicts with functions'
unalias -a 2>/dev/null || true
echo '# Functions'
@@ -249,10 +277,82 @@ $envVars | ForEach-Object {
"##
}
/// Removes shell snapshots that either lack a matching session rollout file or
/// whose rollouts have not been updated within the retention window.
/// The active session id is exempt from cleanup.
pub async fn cleanup_stale_snapshots(codex_home: &Path, active_session_id: ThreadId) -> Result<()> {
let snapshot_dir = codex_home.join(SNAPSHOT_DIR);
let mut entries = match fs::read_dir(&snapshot_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
let now = SystemTime::now();
let active_session_id = active_session_id.to_string();
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let path = entry.path();
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
let (session_id, _) = match file_name.rsplit_once('.') {
Some((stem, ext)) => (stem, ext),
None => {
remove_snapshot_file(&path).await;
continue;
}
};
if session_id == active_session_id {
continue;
}
let rollout_path = find_thread_path_by_id_str(codex_home, session_id).await?;
let Some(rollout_path) = rollout_path else {
remove_snapshot_file(&path).await;
continue;
};
let modified = match fs::metadata(&rollout_path).await.and_then(|m| m.modified()) {
Ok(modified) => modified,
Err(err) => {
tracing::warn!(
"Failed to check rollout age for snapshot {}: {err:?}",
path.display()
);
continue;
}
};
if now
.duration_since(modified)
.ok()
.is_some_and(|age| age >= SNAPSHOT_RETENTION)
{
remove_snapshot_file(&path).await;
}
}
Ok(())
}
async fn remove_snapshot_file(path: &Path) {
if let Err(err) = fs::remove_file(path).await {
tracing::warn!("Failed to delete shell snapshot at {:?}: {err:?}", path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(target_os = "linux")]
use std::os::unix::fs::PermissionsExt;
#[cfg(target_os = "linux")]
@@ -303,7 +403,7 @@ mod tests {
shell_snapshot: None,
};
let snapshot = ShellSnapshot::try_new(dir.path(), &shell)
let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), &shell)
.await
.expect("snapshot should be created");
let path = snapshot.path.clone();
@@ -413,4 +513,103 @@ mod tests {
assert!(snapshot.contains("exports "));
Ok(())
}
async fn write_rollout_stub(codex_home: &Path, session_id: ThreadId) -> Result<PathBuf> {
let dir = codex_home
.join("sessions")
.join("2025")
.join("01")
.join("01");
fs::create_dir_all(&dir).await?;
let path = dir.join(format!("rollout-2025-01-01T00-00-00-{session_id}.jsonl"));
fs::write(&path, "").await?;
Ok(path)
}
#[tokio::test]
async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()> {
let dir = tempdir()?;
let codex_home = dir.path();
let snapshot_dir = codex_home.join(SNAPSHOT_DIR);
fs::create_dir_all(&snapshot_dir).await?;
let live_session = ThreadId::new();
let orphan_session = ThreadId::new();
let live_snapshot = snapshot_dir.join(format!("{live_session}.sh"));
let orphan_snapshot = snapshot_dir.join(format!("{orphan_session}.sh"));
let invalid_snapshot = snapshot_dir.join("not-a-snapshot.txt");
write_rollout_stub(codex_home, live_session).await?;
fs::write(&live_snapshot, "live").await?;
fs::write(&orphan_snapshot, "orphan").await?;
fs::write(&invalid_snapshot, "invalid").await?;
cleanup_stale_snapshots(codex_home, ThreadId::new()).await?;
assert_eq!(live_snapshot.exists(), true);
assert_eq!(orphan_snapshot.exists(), false);
assert_eq!(invalid_snapshot.exists(), false);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> {
let dir = tempdir()?;
let codex_home = dir.path();
let snapshot_dir = codex_home.join(SNAPSHOT_DIR);
fs::create_dir_all(&snapshot_dir).await?;
let stale_session = ThreadId::new();
let stale_snapshot = snapshot_dir.join(format!("{stale_session}.sh"));
let rollout_path = write_rollout_stub(codex_home, stale_session).await?;
fs::write(&stale_snapshot, "stale").await?;
set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;
cleanup_stale_snapshots(codex_home, ThreadId::new()).await?;
assert_eq!(stale_snapshot.exists(), false);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> {
let dir = tempdir()?;
let codex_home = dir.path();
let snapshot_dir = codex_home.join(SNAPSHOT_DIR);
fs::create_dir_all(&snapshot_dir).await?;
let active_session = ThreadId::new();
let active_snapshot = snapshot_dir.join(format!("{active_session}.sh"));
let rollout_path = write_rollout_stub(codex_home, active_session).await?;
fs::write(&active_snapshot, "active").await?;
set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?;
cleanup_stale_snapshots(codex_home, active_session).await?;
assert_eq!(active_snapshot.exists(), true);
Ok(())
}
#[cfg(unix)]
fn set_file_mtime(path: &Path, age: Duration) -> Result<()> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs()
.saturating_sub(age.as_secs());
let tv_sec = now
.try_into()
.map_err(|_| anyhow!("Snapshot mtime is out of range for libc::timespec"))?;
let ts = libc::timespec { tv_sec, tv_nsec: 0 };
let times = [ts, ts];
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?;
let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
}
+3 -14
View File
@@ -70,8 +70,8 @@ pub(crate) async fn spawn_child_async(
#[cfg(target_os = "linux")]
let parent_pid = libc::getpid();
cmd.pre_exec(move || {
if set_process_group && libc::setpgid(0, 0) == -1 {
return Err(std::io::Error::last_os_error());
if set_process_group {
codex_utils_pty::process_group::set_process_group()?;
}
// This relies on prctl(2), so it only works on Linux.
@@ -79,18 +79,7 @@ pub(crate) async fn spawn_child_async(
{
// This prctl call effectively requests, "deliver SIGTERM when my
// current parent dies."
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(std::io::Error::last_os_error());
}
// Though if there was a race condition and this pre_exec() block is
// run _after_ the parent (i.e., the Codex process) has already
// exited, then parent will be the closest configured "subreaper"
// ancestor process, or PID 1 (init). If the Codex process has exited
// already, so should the child process.
if libc::getppid() != parent_pid {
libc::raise(libc::SIGTERM);
}
codex_utils_pty::process_group::set_parent_death_signal(parent_pid)?;
}
Ok(())
});
+7 -4
View File
@@ -1,6 +1,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -85,14 +86,16 @@ async fn start_review_conversation(
let mut sub_agent_config = config.as_ref().clone();
// Carry over review-only feature restrictions so the delegate cannot
// re-enable blocked tools (web search, view image).
sub_agent_config
.features
.disable(crate::features::Feature::WebSearchRequest);
sub_agent_config.web_search_mode = WebSearchMode::Disabled;
// Set explicit review rubric for the sub-agent
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
sub_agent_config.model = Some(config.review_model.clone());
let model = config
.review_model
.clone()
.unwrap_or_else(|| ctx.client.get_model());
sub_agent_config.model = Some(model);
(run_codex_thread_one_shot(
sub_agent_config,
session.auth_manager(),
+16
View File
@@ -31,8 +31,11 @@ use std::sync::Arc;
#[cfg(any(test, feature = "test-support"))]
use tempfile::TempDir;
use tokio::sync::RwLock;
use tokio::sync::broadcast;
use tracing::warn;
const THREAD_CREATED_CHANNEL_CAPACITY: usize = 1024;
/// Represents a newly created Codex thread (formerly called a conversation), including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct NewThread {
@@ -54,6 +57,7 @@ pub struct ThreadManager {
/// function to require an `Arc<&Self>`.
pub(crate) struct ThreadManagerState {
threads: Arc<RwLock<HashMap<ThreadId, Arc<CodexThread>>>>,
thread_created_tx: broadcast::Sender<ThreadId>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
@@ -70,9 +74,11 @@ impl ThreadManager {
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
) -> Self {
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
thread_created_tx,
models_manager: Arc::new(ModelsManager::new(
codex_home.clone(),
auth_manager.clone(),
@@ -108,9 +114,11 @@ impl ThreadManager {
codex_home: PathBuf,
) -> Self {
let auth_manager = AuthManager::from_auth_for_testing(auth);
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
thread_created_tx,
models_manager: Arc::new(ModelsManager::with_provider(
codex_home.clone(),
auth_manager.clone(),
@@ -174,6 +182,10 @@ impl ThreadManager {
}
}
pub fn subscribe_thread_created(&self) -> broadcast::Receiver<ThreadId> {
self.state.thread_created_tx.subscribe()
}
pub async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> {
self.state.get_thread(thread_id).await
}
@@ -348,6 +360,10 @@ impl ThreadManagerState {
session_configured,
})
}
pub(crate) fn notify_thread_created(&self, thread_id: ThreadId) {
let _ = self.thread_created_tx.send(thread_id);
}
}
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
+223 -52
View File
@@ -1,4 +1,5 @@
use crate::agent::AgentStatus;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::error::CodexErr;
@@ -11,6 +12,14 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_protocol::ThreadId;
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
use codex_protocol::protocol::CollabCloseBeginEvent;
use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use serde::Deserialize;
use serde::Serialize;
@@ -40,6 +49,7 @@ impl ToolHandler for CollabHandler {
turn,
tool_name,
payload,
call_id,
..
} = invocation;
@@ -53,10 +63,10 @@ impl ToolHandler for CollabHandler {
};
match tool_name.as_str() {
"spawn_agent" => spawn::handle(session, turn, arguments).await,
"send_input" => send_input::handle(session, arguments).await,
"wait" => wait::handle(session, arguments).await,
"close_agent" => close_agent::handle(session, arguments).await,
"spawn_agent" => spawn::handle(session, turn, call_id, arguments).await,
"send_input" => send_input::handle(session, turn, call_id, arguments).await,
"wait" => wait::handle(session, turn, call_id, arguments).await,
"close_agent" => close_agent::handle(session, turn, call_id, arguments).await,
other => Err(FunctionCallError::RespondToModel(format!(
"unsupported collab tool {other}"
))),
@@ -66,7 +76,6 @@ impl ToolHandler for CollabHandler {
mod spawn {
use super::*;
use crate::codex::Session;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
@@ -82,24 +91,58 @@ mod spawn {
pub async fn handle(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: String,
) -> Result<ToolOutput, FunctionCallError> {
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
if args.message.trim().is_empty() {
let prompt = args.message;
if prompt.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"Empty message can't be sent to an agent".to_string(),
));
}
session
.send_event(
&turn,
CollabAgentSpawnBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let config = build_agent_spawn_config(turn.as_ref())?;
let result = session
.services
.agent_control
.spawn_agent(config, args.message, true)
.spawn_agent(config, prompt.clone())
.await
.map_err(collab_spawn_error)?;
.map_err(collab_spawn_error);
let (new_thread_id, status) = match &result {
Ok(thread_id) => (
Some(*thread_id),
session.services.agent_control.get_status(*thread_id).await,
),
Err(_) => (None, AgentStatus::NotFound),
};
session
.send_event(
&turn,
CollabAgentSpawnEndEvent {
call_id,
sender_thread_id: session.conversation_id,
new_thread_id,
prompt,
status,
}
.into(),
)
.await;
let new_thread_id = result?;
let content = serde_json::to_string(&SpawnAgentResult {
agent_id: result.to_string(),
agent_id: new_thread_id.to_string(),
})
.map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize spawn_agent result: {err}"))
@@ -115,7 +158,6 @@ mod spawn {
mod send_input {
use super::*;
use crate::codex::Session;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
@@ -131,22 +173,55 @@ mod send_input {
pub async fn handle(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: String,
) -> Result<ToolOutput, FunctionCallError> {
let args: SendInputArgs = parse_arguments(&arguments)?;
let agent_id = agent_id(&args.id)?;
if args.message.trim().is_empty() {
let receiver_thread_id = agent_id(&args.id)?;
let prompt = args.message;
if prompt.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"Empty message can't be sent to an agent".to_string(),
));
}
let agent_id_for_err = agent_id;
let submission_id = session
session
.send_event(
&turn,
CollabAgentInteractionBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt: prompt.clone(),
}
.into(),
)
.await;
let result = session
.services
.agent_control
.send_prompt(agent_id, args.message)
.send_prompt(receiver_thread_id, prompt.clone())
.await
.map_err(|err| collab_agent_error(agent_id_for_err, err))?;
.map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabAgentInteractionEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id,
prompt,
status,
}
.into(),
)
.await;
let submission_id = result?;
let content = serde_json::to_string(&SendInputResult { submission_id }).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize send_input result: {err}"))
@@ -163,7 +238,6 @@ mod send_input {
mod wait {
use super::*;
use crate::agent::status::is_final;
use crate::codex::Session;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;
@@ -183,10 +257,12 @@ mod wait {
pub async fn handle(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: String,
) -> Result<ToolOutput, FunctionCallError> {
let args: WaitArgs = parse_arguments(&arguments)?;
let agent_id = agent_id(&args.id)?;
let receiver_thread_id = agent_id(&args.id)?;
// Validate timeout.
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
@@ -199,14 +275,85 @@ mod wait {
ms => ms.min(MAX_WAIT_TIMEOUT_MS),
};
let agent_id_for_err = agent_id;
let mut status_rx = session
session
.send_event(
&turn,
CollabWaitingBeginEvent {
sender_thread_id: session.conversation_id,
receiver_thread_id,
call_id: call_id.clone(),
}
.into(),
)
.await;
let status_rx = match session
.services
.agent_control
.subscribe_status(agent_id)
.subscribe_status(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(agent_id_for_err, err))?;
{
Ok(status_rx) => status_rx,
Err(err) => {
let status = session
.services
.agent_control
.get_status(receiver_thread_id)
.await;
session
.send_event(
&turn,
CollabWaitingEndEvent {
sender_thread_id: session.conversation_id,
receiver_thread_id,
call_id: call_id.clone(),
status,
}
.into(),
)
.await;
return Err(collab_agent_error(receiver_thread_id, err));
}
};
let result =
wait_for_status(session.as_ref(), receiver_thread_id, timeout_ms, status_rx).await;
session
.send_event(
&turn,
CollabWaitingEndEvent {
sender_thread_id: session.conversation_id,
receiver_thread_id,
call_id,
status: result.status.clone(),
}
.into(),
)
.await;
if matches!(result.status, AgentStatus::NotFound) {
return Err(FunctionCallError::RespondToModel(format!(
"agent with id {receiver_thread_id} not found"
)));
}
let content = serde_json::to_string(&result).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize wait result: {err}"))
})?;
let success = !result.timed_out && !matches!(result.status, AgentStatus::Errored(_));
Ok(ToolOutput::Function {
content,
success: Some(success),
content_items: None,
})
}
async fn wait_for_status(
session: &Session,
agent_id: ThreadId,
timeout_ms: i64,
mut status_rx: tokio::sync::watch::Receiver<AgentStatus>,
) -> WaitResult {
// Get last known status.
let mut status = status_rx.borrow_and_update().clone();
let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
@@ -231,31 +378,12 @@ mod wait {
}
};
if matches!(status, AgentStatus::NotFound) {
return Err(FunctionCallError::RespondToModel(format!(
"agent with id {agent_id} not found"
)));
}
let result = WaitResult { status, timed_out };
let content = serde_json::to_string(&result).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize wait result: {err}"))
})?;
let success = !result.timed_out && !matches!(result.status, AgentStatus::Errored(_));
Ok(ToolOutput::Function {
content,
success: Some(success),
content_items: None,
})
WaitResult { status, timed_out }
}
}
pub mod close_agent {
use super::*;
use crate::codex::Session;
use std::sync::Arc;
#[derive(Debug, Deserialize, Serialize)]
@@ -265,28 +393,71 @@ pub mod close_agent {
pub async fn handle(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: String,
) -> Result<ToolOutput, FunctionCallError> {
let args: CloseAgentArgs = parse_arguments(&arguments)?;
let agent_id = agent_id(&args.id)?;
let agent_id_for_err = agent_id;
let mut status_rx = session
session
.send_event(
&turn,
CollabCloseBeginEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id: agent_id,
}
.into(),
)
.await;
let status = match session
.services
.agent_control
.subscribe_status(agent_id)
.await
.map_err(|err| collab_agent_error(agent_id_for_err, err))?;
let status = status_rx.borrow_and_update().clone();
if !matches!(status, AgentStatus::Shutdown) {
let agent_id_for_err = agent_id;
let _ = session
{
Ok(mut status_rx) => status_rx.borrow_and_update().clone(),
Err(err) => {
let status = session.services.agent_control.get_status(agent_id).await;
session
.send_event(
&turn,
CollabCloseEndEvent {
call_id: call_id.clone(),
sender_thread_id: session.conversation_id,
receiver_thread_id: agent_id,
status,
}
.into(),
)
.await;
return Err(collab_agent_error(agent_id, err));
}
};
let result = if !matches!(status, AgentStatus::Shutdown) {
session
.services
.agent_control
.shutdown_agent(agent_id)
.await
.map_err(|err| collab_agent_error(agent_id_for_err, err))?;
}
.map_err(|err| collab_agent_error(agent_id, err))
.map(|_| ())
} else {
Ok(())
};
session
.send_event(
&turn,
CollabCloseEndEvent {
call_id,
sender_thread_id: session.conversation_id,
receiver_thread_id: agent_id,
status: status.clone(),
}
.into(),
)
.await;
result?;
let content = serde_json::to_string(&CloseAgentResult { status }).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize close_agent result: {err}"))
@@ -33,6 +33,8 @@ struct ExecCommandArgs {
shell: Option<String>,
#[serde(default = "default_login")]
login: bool,
#[serde(default = "default_tty")]
tty: bool,
#[serde(default = "default_exec_yield_time_ms")]
yield_time_ms: u64,
#[serde(default)]
@@ -67,6 +69,10 @@ fn default_login() -> bool {
true
}
fn default_tty() -> bool {
false
}
#[async_trait]
impl ToolHandler for UnifiedExecHandler {
fn kind(&self) -> ToolKind {
@@ -124,6 +130,7 @@ impl ToolHandler for UnifiedExecHandler {
let ExecCommandArgs {
workdir,
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
@@ -173,6 +180,7 @@ impl ToolHandler for UnifiedExecHandler {
yield_time_ms,
max_output_tokens,
workdir,
tty,
sandbox_permissions,
justification,
},
+4
View File
@@ -58,6 +58,10 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
return command.to_vec();
};
if !snapshot.path.exists() {
return command.to_vec();
}
if command.len() < 3 {
return command.to_vec();
}
@@ -37,6 +37,7 @@ pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
@@ -46,6 +47,7 @@ pub struct UnifiedExecRequest {
pub struct UnifiedExecApprovalKey {
pub command: Vec<String>,
pub cwd: PathBuf,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
}
@@ -58,6 +60,7 @@ impl UnifiedExecRequest {
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
tty: bool,
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
exec_approval_requirement: ExecApprovalRequirement,
@@ -66,6 +69,7 @@ impl UnifiedExecRequest {
command,
cwd,
env,
tty,
sandbox_permissions,
justification,
exec_approval_requirement,
@@ -96,6 +100,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
vec![UnifiedExecApprovalKey {
command: req.command.clone(),
cwd: req.cwd.clone(),
tty: req.tty,
sandbox_permissions: req.sandbox_permissions,
}]
}
@@ -189,7 +194,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(spec)
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env)
.open_session_with_exec_env(&exec_env, req.tty)
.await
.map_err(|err| match err {
UnifiedExecError::SandboxDenied { output, .. } => {
+73 -42
View File
@@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -23,8 +24,7 @@ use std::collections::HashMap;
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
pub web_search_cached: bool,
pub web_search_mode: WebSearchMode,
pub collab_tools: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -32,6 +32,7 @@ pub(crate) struct ToolsConfig {
pub(crate) struct ToolsConfigParams<'a> {
pub(crate) model_info: &'a ModelInfo,
pub(crate) features: &'a Features,
pub(crate) web_search_mode: WebSearchMode,
}
impl ToolsConfig {
@@ -39,10 +40,9 @@ impl ToolsConfig {
let ToolsConfigParams {
model_info,
features,
web_search_mode,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_web_search_request = features.enabled(Feature::WebSearchRequest);
let include_web_search_cached = features.enabled(Feature::WebSearchCached);
let include_collab_tools = features.enabled(Feature::Collab);
let shell_type = if !features.enabled(Feature::ShellTool) {
@@ -73,8 +73,7 @@ impl ToolsConfig {
Self {
shell_type,
apply_patch_tool_type,
web_search_request: include_web_search_request,
web_search_cached: include_web_search_cached,
web_search_mode: *web_search_mode,
collab_tools: include_collab_tools,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
}
@@ -157,7 +156,7 @@ fn create_exec_command_tool() -> ToolSpec {
(
"shell".to_string(),
JsonSchema::String {
description: Some("Shell binary to launch. Defaults to /bin/bash.".to_string()),
description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()),
},
),
(
@@ -168,6 +167,15 @@ fn create_exec_command_tool() -> ToolSpec {
),
},
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
}
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
@@ -1216,15 +1224,18 @@ pub(crate) fn build_specs(
builder.register_handler("test_sync_tool", test_sync_handler);
}
// Prefer web_search_cached flag over web_search_request
if config.web_search_cached {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(false),
});
} else if config.web_search_request {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(true),
});
match config.web_search_mode {
WebSearchMode::Disabled => {}
WebSearchMode::Cached => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(false),
});
}
WebSearchMode::Live => {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(true),
});
}
}
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
@@ -1365,10 +1376,10 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&config, None).build();
@@ -1430,6 +1441,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None).build();
assert_contains_tool_names(
@@ -1438,12 +1450,18 @@ mod tests {
);
}
fn assert_model_tools(model_slug: &str, features: &Features, expected_tools: &[&str]) {
fn assert_model_tools(
model_slug: &str,
features: &Features,
web_search_mode: WebSearchMode,
expected_tools: &[&str],
) {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline(model_slug, &config);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features,
web_search_mode,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
let tool_names = tools.iter().map(|t| t.spec.name()).collect::<Vec<_>>();
@@ -1451,15 +1469,15 @@ mod tests {
}
#[test]
fn web_search_cached_sets_external_web_access_false() {
fn web_search_mode_cached_sets_external_web_access_false() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None).build();
@@ -1473,16 +1491,15 @@ mod tests {
}
#[test]
fn web_search_cached_takes_precedence_over_web_search_request() {
fn web_search_mode_live_sets_external_web_access_true() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
features.enable(Feature::WebSearchRequest);
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&tools_config, None).build();
@@ -1490,7 +1507,7 @@ mod tests {
assert_eq!(
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(false),
external_web_access: Some(true),
}
);
}
@@ -1500,6 +1517,7 @@ mod tests {
assert_model_tools(
"gpt-5-codex",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1507,6 +1525,7 @@ mod tests {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
@@ -1517,6 +1536,7 @@ mod tests {
assert_model_tools(
"gpt-5.1-codex",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1524,6 +1544,7 @@ mod tests {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
@@ -1533,9 +1554,8 @@ mod tests {
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
assert_model_tools(
"gpt-5-codex",
Features::with_defaults()
.enable(Feature::UnifiedExec)
.enable(Feature::WebSearchRequest),
Features::with_defaults().enable(Feature::UnifiedExec),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1554,9 +1574,8 @@ mod tests {
fn test_build_specs_gpt51_codex_unified_exec_web_search() {
assert_model_tools(
"gpt-5.1-codex",
Features::with_defaults()
.enable(Feature::UnifiedExec)
.enable(Feature::WebSearchRequest),
Features::with_defaults().enable(Feature::UnifiedExec),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1576,12 +1595,14 @@ mod tests {
assert_model_tools(
"codex-mini-latest",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"local_shell",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"web_search",
"view_image",
],
);
@@ -1592,6 +1613,7 @@ mod tests {
assert_model_tools(
"gpt-5.1-codex-mini",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1599,6 +1621,7 @@ mod tests {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
@@ -1609,12 +1632,14 @@ mod tests {
assert_model_tools(
"gpt-5",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"shell",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"web_search",
"view_image",
],
);
@@ -1625,6 +1650,7 @@ mod tests {
assert_model_tools(
"gpt-5.1",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"shell_command",
"list_mcp_resources",
@@ -1632,6 +1658,7 @@ mod tests {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
@@ -1642,6 +1669,7 @@ mod tests {
assert_model_tools(
"exp-5.1",
&Features::with_defaults(),
WebSearchMode::Cached,
&[
"exec_command",
"write_stdin",
@@ -1650,6 +1678,7 @@ mod tests {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
@@ -1659,9 +1688,8 @@ mod tests {
fn test_codex_mini_unified_exec_web_search() {
assert_model_tools(
"codex-mini-latest",
Features::with_defaults()
.enable(Feature::UnifiedExec)
.enable(Feature::WebSearchRequest),
Features::with_defaults().enable(Feature::UnifiedExec),
WebSearchMode::Live,
&[
"exec_command",
"write_stdin",
@@ -1680,11 +1708,11 @@ mod tests {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchRequest);
features.enable(Feature::UnifiedExec);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
@@ -1706,6 +1734,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None).build();
@@ -1724,6 +1753,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(&tools_config, None).build();
@@ -1751,10 +1781,10 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Live,
});
let (tools, _) = build_specs(
&tools_config,
@@ -1849,6 +1879,7 @@ mod tests {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
// Intentionally construct a map with keys that would sort alphabetically.
@@ -1922,10 +1953,10 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -1979,10 +2010,10 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2032,11 +2063,11 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
features.enable(Feature::ApplyPatchFreeform);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2089,10 +2120,10 @@ mod tests {
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
@@ -2201,10 +2232,10 @@ Examples of valid command strings:
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: WebSearchMode::Cached,
});
let (tools, _) = build_specs(
&tools_config,
+4 -2
View File
@@ -45,6 +45,8 @@ pub(crate) use errors::UnifiedExecError;
pub(crate) use process::UnifiedExecProcess;
pub(crate) const MIN_YIELD_TIME_MS: u64 = 250;
// Minimum yield time for an empty `write_stdin`.
pub(crate) const MIN_EMPTY_YIELD_TIME_MS: u64 = 5_000;
pub(crate) const MAX_YIELD_TIME_MS: u64 = 30_000;
pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB
@@ -77,6 +79,7 @@ pub(crate) struct ExecCommandRequest {
pub yield_time_ms: u64,
pub max_output_tokens: Option<usize>,
pub workdir: Option<PathBuf>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
}
@@ -130,8 +133,6 @@ impl Default for UnifiedExecProcessManager {
struct ProcessEntry {
process: Arc<UnifiedExecProcess>,
session_ref: Arc<Session>,
turn_ref: Arc<TurnContext>,
call_id: String,
process_id: String,
command: Vec<String>,
@@ -200,6 +201,7 @@ mod tests {
yield_time_ms,
max_output_tokens: None,
workdir: None,
tty: true,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
},
@@ -10,12 +10,7 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::bash::extract_bash_command;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec_env::create_env;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::ExecEnv;
use crate::sandboxing::SandboxPermissions;
@@ -31,6 +26,8 @@ use crate::truncate::approx_token_count;
use crate::truncate::formatted_truncate_text;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::MAX_YIELD_TIME_MS;
use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
use crate::unified_exec::ProcessEntry;
use crate::unified_exec::ProcessStore;
use crate::unified_exec::UnifiedExecContext;
@@ -74,8 +71,6 @@ struct PreparedProcessHandles {
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
cancellation_token: CancellationToken,
session_ref: Arc<Session>,
turn_ref: Arc<TurnContext>,
command: Vec<String>,
process_id: String,
}
@@ -131,6 +126,7 @@ impl UnifiedExecProcessManager {
cwd.clone(),
request.sandbox_permissions,
request.justification,
request.tty,
context,
)
.await;
@@ -224,8 +220,6 @@ impl UnifiedExecProcessManager {
Arc::clone(&transcript),
)
.await;
Self::emit_waiting_status(&context.session, &context.turn, &request.command).await;
};
let original_token_count = approx_token_count(&text);
@@ -259,8 +253,6 @@ impl UnifiedExecProcessManager {
output_buffer,
output_notify,
cancellation_token,
session_ref,
turn_ref,
command: session_command,
process_id,
..
@@ -274,7 +266,14 @@ impl UnifiedExecProcessManager {
}
let max_tokens = resolve_max_tokens(request.max_output_tokens);
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
let yield_time_ms = {
let time_ms = clamp_yield_time(request.yield_time_ms);
if request.input.is_empty() {
time_ms.clamp(MIN_EMPTY_YIELD_TIME_MS, MAX_YIELD_TIME_MS)
} else {
time_ms
}
};
let start = Instant::now();
let deadline = start + Duration::from_millis(yield_time_ms);
let collected = Self::collect_output_until_deadline(
@@ -325,10 +324,6 @@ impl UnifiedExecProcessManager {
session_command: Some(session_command.clone()),
};
if response.process_id.is_some() {
Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await;
}
Ok(response)
}
@@ -382,8 +377,6 @@ impl UnifiedExecProcessManager {
output_buffer,
output_notify,
cancellation_token,
session_ref: Arc::clone(&entry.session_ref),
turn_ref: Arc::clone(&entry.turn_ref),
command: entry.command.clone(),
process_id: entry.process_id.clone(),
})
@@ -412,8 +405,6 @@ impl UnifiedExecProcessManager {
) {
let entry = ProcessEntry {
process: Arc::clone(&process),
session_ref: Arc::clone(&context.session),
turn_ref: Arc::clone(&context.turn),
call_id: context.call_id.clone(),
process_id: process_id.clone(),
command: command.to_vec(),
@@ -449,43 +440,37 @@ impl UnifiedExecProcessManager {
);
}
async fn emit_waiting_status(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
command: &[String],
) {
let command_display = if let Some((_, script)) = extract_bash_command(command) {
script.to_string()
} else {
command.join(" ")
};
let message = format!("Waiting for `{command_display}`");
session
.send_event(
turn.as_ref(),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }),
)
.await;
}
pub(crate) async fn open_session_with_exec_env(
&self,
env: &ExecEnv,
tty: bool,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = env
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let spawned = codex_utils_pty::spawn_pty_process(
program,
args,
env.cwd.as_path(),
&env.env,
&env.arg0,
)
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
let spawn_result = if tty {
codex_utils_pty::pty::spawn_process(
program,
args,
env.cwd.as_path(),
&env.env,
&env.arg0,
)
.await
} else {
codex_utils_pty::pipe::spawn_process(
program,
args,
env.cwd.as_path(),
&env.env,
&env.arg0,
)
.await
};
let spawned =
spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
UnifiedExecProcess::from_spawned(spawned, env.sandbox).await
}
@@ -495,6 +480,7 @@ impl UnifiedExecProcessManager {
cwd: PathBuf,
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
tty: bool,
context: &UnifiedExecContext,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy));
@@ -517,6 +503,7 @@ impl UnifiedExecProcessManager {
command.to_vec(),
cwd,
env,
tty,
sandbox_permissions,
justification,
exec_approval_requirement,
@@ -0,0 +1,18 @@
## Multi agents
You have the possibility to spawn and user other agents to complete a task. For example, this can be use for:
* Very large tasks with multiple well-defined scopes
* When you want a review from another agent. This can review your own work or the work of another agent.
* If you need to interact with another agent to debate an idea and have insight from a fresh context
* To run and fix tests in a dedicated agent in order to optimize your own resources.
This feature must be used wisely. For simple or straightforward tasks, you don't need to spawn a new agent.
**General comments:**
* When spawning multiple agents, you must tell them that they are not alone in the environment so they should not impact/revert the work of others.
* Running tests or some config commands can output a large amount of logs. In order to optimize your own context, you can spawn an agent and ask it to do it for you. In such cases, you must tell this agent that it can't spawn another agent himself (to prevent infinite recursion)
* When you're done with a sub-agent, don't forget to close it using `close_agent`.
* Be careful on the `timeout_ms` parameter you choose for `wait`. It should be wisely scaled.
* Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not.
## Global
You're name is Batman
@@ -17,7 +17,7 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::HierarchicalAgents);
config.features.enable(Feature::ChildAgentsMd);
std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md");
});
let test = builder.build(&server).await.expect("build test codex");
@@ -52,7 +52,7 @@ async fn hierarchical_agents_emits_when_no_project_doc() {
let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::HierarchicalAgents);
config.features.enable(Feature::ChildAgentsMd);
});
let test = builder.build(&server).await.expect("build test codex");
+3 -2
View File
@@ -49,6 +49,7 @@ async fn list_models_returns_chatgpt_models() -> Result<()> {
fn expected_models_for_api_key() -> Vec<ModelPreset> {
vec![
gpt_52_codex(),
gpt_5_1_codex_max(),
gpt_5_1_codex_mini(),
gpt_5_2(),
@@ -108,7 +109,7 @@ fn gpt_52_codex() -> ModelPreset {
is_default: true,
upgrade: None,
show_in_picker: true,
supported_in_api: false,
supported_in_api: true,
}
}
@@ -137,7 +138,7 @@ fn gpt_5_1_codex_max() -> ModelPreset {
"Extra high reasoning depth for complex problems",
),
],
is_default: true,
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: true,
supported_in_api: true,
+11 -1
View File
@@ -1,5 +1,6 @@
#![allow(clippy::unwrap_used)]
use codex_protocol::config_types::WebSearchMode;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses;
use core_test_support::responses::start_mock_server;
@@ -32,7 +33,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
let sse = sse_completed(model);
let resp_mock = responses::mount_sse_once(&server, sse).await;
let mut builder = test_codex().with_model(model);
let mut builder = test_codex()
.with_model(model)
// Keep tool expectations stable when the default web_search mode changes.
.with_config(|config| config.web_search_mode = WebSearchMode::Cached);
let test = builder
.build(&server)
.await
@@ -58,6 +62,7 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"codex-mini-latest should expose the local shell tool",
@@ -73,6 +78,7 @@ async fn model_selects_expected_tools() {
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"gpt-5-codex should expose the apply_patch tool",
@@ -88,6 +94,7 @@ async fn model_selects_expected_tools() {
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"gpt-5.1-codex should expose the apply_patch tool",
@@ -102,6 +109,7 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"gpt-5 should expose the apply_patch tool",
@@ -117,6 +125,7 @@ async fn model_selects_expected_tools() {
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"gpt-5.1 should expose the apply_patch tool",
@@ -132,6 +141,7 @@ async fn model_selects_expected_tools() {
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string(),
"web_search".to_string(),
"view_image".to_string()
],
"exp-5.1 should expose the apply_patch tool",
+11 -1
View File
@@ -11,6 +11,7 @@ use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::shell::Shell;
use codex_core::shell::default_user_shell;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -52,7 +53,13 @@ fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.map(|t| {
t.get("name")
.and_then(|value| value.as_str())
.or_else(|| t.get("type").and_then(|value| value.as_str()))
.unwrap()
.to_string()
})
.collect::<Vec<_>>(),
expected_names
);
@@ -80,6 +87,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.model = Some("gpt-5.1-codex-max".to_string());
// Keep tool expectations stable when the default web_search mode changes.
config.web_search_mode = WebSearchMode::Cached;
})
.build(&server)
.await?;
@@ -122,6 +131,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
];
let body0 = req1.single_request().body_json();
+51 -1
View File
@@ -393,7 +393,7 @@ async fn review_uses_custom_review_model_from_config() {
// Choose a review model different from the main model; ensure it is used.
let codex = new_conversation_for_server(&server, &codex_home, |cfg| {
cfg.model = Some("gpt-4.1".to_string());
cfg.review_model = "gpt-5.1".to_string();
cfg.review_model = Some("gpt-5.1".to_string());
})
.await;
@@ -431,6 +431,56 @@ async fn review_uses_custom_review_model_from_config() {
server.verify().await;
}
/// Ensure that when `review_model` is not set in the config, the review request
/// uses the session model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn review_uses_session_model_when_review_model_unset() {
skip_if_no_network!();
// Minimal stream: just a completed event
let sse_raw = r#"[
{"type":"response.completed", "response": {"id": "__ID__"}}
]"#;
let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await;
let codex_home = TempDir::new().unwrap();
let codex = new_conversation_for_server(&server, &codex_home, |cfg| {
cfg.model = Some("gpt-4.1".to_string());
cfg.review_model = None;
})
.await;
codex
.submit(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: "use session model".to_string(),
},
user_facing_hint: None,
},
})
.await
.unwrap();
let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await;
let _closed = wait_for_event(&codex, |ev| {
matches!(
ev,
EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None
})
)
})
.await;
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = request_log.single_request();
assert_eq!(request.path(), "/v1/responses");
let body = request.body_json();
assert_eq!(body["model"].as_str().unwrap(), "gpt-4.1");
server.verify().await;
}
/// When a review session begins, it must not prepend prior chat history from
/// the parent session. The request `input` should contain only the review
/// prompt from the user.
+14 -2
View File
@@ -677,7 +677,13 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
.await;
let expected_env_value = "propagated-env-http";
let rmcp_http_server_bin = cargo_bin("test_streamable_http_server")?;
let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") {
Ok(path) => path,
Err(err) => {
eprintln!("test_streamable_http_server binary not available, skipping test: {err}");
return Ok(());
}
};
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
@@ -844,7 +850,13 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
let expected_token = "initial-access-token";
let client_id = "test-client-id";
let refresh_token = "initial-refresh-token";
let rmcp_http_server_bin = cargo_bin("test_streamable_http_server")?;
let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") {
Ok(path) => path,
Err(err) => {
eprintln!("test_streamable_http_server binary not available, skipping test: {err}");
return Ok(());
}
};
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
+183 -4
View File
@@ -37,6 +37,7 @@ use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use tokio::time::Duration;
use which::which;
fn extract_output_text(item: &Value) -> Option<&str> {
item.get("output").and_then(|value| match value {
@@ -902,21 +903,21 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
// and a final long poll to capture the second marker.
let first_poll_call_id = "uexec-delayed-poll-1";
let first_poll_args = json!({
"chars": "",
"chars": "x",
"session_id": 1000,
"yield_time_ms": 10,
});
let second_poll_call_id = "uexec-delayed-poll-2";
let second_poll_args = json!({
"chars": "",
"chars": "x",
"session_id": 1000,
"yield_time_ms": 4000,
});
let third_poll_call_id = "uexec-delayed-poll-3";
let third_poll_args = json!({
"chars": "",
"chars": "x",
"session_id": 1000,
"yield_time_ms": 6000,
});
@@ -1037,7 +1038,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
.iter()
.map(|ev| ev.stdin.as_str())
.collect::<Vec<_>>(),
vec!["", "", ""],
vec!["x", "x", "x"],
"terminal interactions should reflect the three stdin polls"
);
@@ -1287,6 +1288,180 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_defaults_to_pipe() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_windows!(Ok(()));
let python = match which("python").or_else(|_| which("python3")) {
Ok(path) => path,
Err(_) => {
eprintln!("python not found in PATH, skipping tty default test.");
return Ok(());
}
};
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let call_id = "uexec-default-pipe";
let args = serde_json::json!({
"cmd": format!("{} -c \"import sys; print(sys.stdin.isatty())\"", python.display()),
"yield_time_ms": 1500,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let request_log = mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "check default pipe mode".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = request_log.requests();
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.into_iter()
.map(|request| request.body_json())
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
.get(call_id)
.expect("missing default pipe unified exec output");
let normalized = output.output.replace("\r\n", "\n");
assert!(
normalized.contains("False"),
"stdin should not be a tty by default: {normalized:?}"
);
assert_eq!(output.exit_code, Some(0));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_can_enable_tty() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_windows!(Ok(()));
let python = match which("python").or_else(|_| which("python3")) {
Ok(path) => path,
Err(_) => {
eprintln!("python not found in PATH, skipping tty enable test.");
return Ok(());
}
};
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let call_id = "uexec-tty-enabled";
let args = serde_json::json!({
"cmd": format!("{} -c \"import sys; print(sys.stdin.isatty())\"", python.display()),
"yield_time_ms": 1500,
"tty": true,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let request_log = mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "check tty enabled".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = request_log.requests();
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.into_iter()
.map(|request| request.body_json())
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
.get(call_id)
.expect("missing tty-enabled unified exec output");
let normalized = output.output.replace("\r\n", "\n");
assert!(
normalized.contains("True"),
"stdin should be a tty when tty=true: {normalized:?}"
);
assert_eq!(output.exit_code, Some(0));
assert!(output.process_id.is_none(), "process should have exited");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1404,6 +1579,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
let start_args = serde_json::json!({
"cmd": "/bin/cat",
"yield_time_ms": 500,
"tty": true,
});
let send_args = serde_json::json!({
"chars": "hello unified exec\n",
@@ -1563,6 +1739,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
let start_args = serde_json::json!({
"cmd": "/bin/cat",
"yield_time_ms": 200,
"tty": true,
});
let echo_call_id = "uexec-end-on-exit-echo";
@@ -1903,6 +2080,7 @@ PY
let first_args = serde_json::json!({
"cmd": script,
"yield_time_ms": 25,
"tty": true,
});
let second_call_id = "uexec-lag-poll";
@@ -2285,6 +2463,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
let startup_args = serde_json::json!({
"cmd": format!("{} -i", python.display()),
"yield_time_ms": 1_500,
"tty": true,
});
let exit_call_id = "uexec-python-exit";
@@ -1,6 +1,7 @@
#![allow(clippy::unwrap_used)]
use codex_core::features::Feature;
use codex_protocol::config_types::WebSearchMode;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses;
use core_test_support::responses::start_mock_server;
@@ -24,7 +25,7 @@ fn find_web_search_tool(body: &Value) -> &Value {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn web_search_cached_sets_external_web_access_false_in_request_body() {
async fn web_search_mode_cached_sets_external_web_access_false_in_request_body() {
skip_if_no_network!();
let server = start_mock_server().await;
@@ -34,7 +35,7 @@ async fn web_search_cached_sets_external_web_access_false_in_request_body() {
let mut builder = test_codex()
.with_model("gpt-5-codex")
.with_config(|config| {
config.features.enable(Feature::WebSearchCached);
config.web_search_mode = WebSearchMode::Cached;
});
let test = builder
.build(&server)
@@ -50,12 +51,12 @@ async fn web_search_cached_sets_external_web_access_false_in_request_body() {
assert_eq!(
tool.get("external_web_access").and_then(Value::as_bool),
Some(false),
"web_search_cached should force external_web_access=false"
"web_search cached mode should force external_web_access=false"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn web_search_cached_takes_precedence_over_web_search_request_in_request_body() {
async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() {
skip_if_no_network!();
let server = start_mock_server().await;
@@ -66,7 +67,7 @@ async fn web_search_cached_takes_precedence_over_web_search_request_in_request_b
.with_model("gpt-5-codex")
.with_config(|config| {
config.features.enable(Feature::WebSearchRequest);
config.features.enable(Feature::WebSearchCached);
config.web_search_mode = WebSearchMode::Cached;
});
let test = builder
.build(&server)
@@ -82,6 +83,6 @@ async fn web_search_cached_takes_precedence_over_web_search_request_in_request_b
assert_eq!(
tool.get("external_web_access").and_then(Value::as_bool),
Some(false),
"web_search_cached should win over web_search_request"
"web_search mode should win over legacy web_search_request"
);
}
@@ -571,6 +571,16 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ContextCompacted(_) => {
ts_msg!(self, "context compacted");
}
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
}
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::WebSearchBegin(_)
| EventMsg::ExecApprovalRequest(_)
+49
View File
@@ -6,3 +6,52 @@ This crate is responsible for producing:
- a lib crate that exposes the business logic of the executable as `run_main()` so that
- the `codex-exec` CLI can check if its arg0 is `codex-linux-sandbox` and, if so, execute as if it were `codex-linux-sandbox`
- this should also be true of the `codex` multitool CLI
## Git safety mounts (Linux)
When the sandbox policy allows workspace writes, the Linux sandbox uses a user
namespace plus a mount namespace to bind-mount sensitive subpaths read-only
before applying Landlock rules. This keeps Git and Codex metadata immutable
while still allowing writes to other workspace files, including worktree setups
where `.git` is a pointer file.
Protected subpaths under each writable root include:
- `.git` (directory or pointer file)
- the resolved `gitdir:` target when `.git` is a pointer file
- `.codex` when present
### How this plays with Landlock
Mount permissions and Landlock intersect: if a bind mount is read-only, writes
are denied even if Landlock would allow them. For that reason, the sandbox sets
up the read-only mounts *before* calling `landlock_restrict_self()` and then
applies Landlock rules on top.
### Quick manual test
Run the sandbox directly with a workspace-write policy (from a Git repository
root):
```bash
codex-linux-sandbox \
--sandbox-policy-cwd "$PWD" \
--sandbox-policy '{"type":"workspace-write"}' \
-- bash -lc '
set -euo pipefail
echo "should fail" > .git/config && exit 1 || true
echo "should fail" > .git/hooks/pre-commit && exit 1 || true
echo "should fail" > .git/index.lock && exit 1 || true
echo "should fail" > .codex/config.toml && exit 1 || true
echo "ok" > sandbox-write-test.txt
'
```
Expected behavior:
- Writes to `.git/config` fail with `Read-only file system`.
- Creating or modifying files under `.git/hooks/` fails.
- Writing `.git/index.lock` fails (since `.git` is read-only).
- Writes under `.codex/` fail when the directory exists.
- Writing a normal repo file succeeds.
+6
View File
@@ -7,6 +7,8 @@ use codex_core::error::SandboxErr;
use codex_core::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::mounts::apply_read_only_mounts;
use landlock::ABI;
use landlock::Access;
use landlock::AccessFs;
@@ -31,6 +33,10 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Result<()> {
if !sandbox_policy.has_full_disk_write_access() {
apply_read_only_mounts(sandbox_policy, cwd)?;
}
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}
+2
View File
@@ -2,6 +2,8 @@
mod landlock;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(target_os = "linux")]
mod mounts;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {
+255
View File
@@ -0,0 +1,255 @@
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use codex_core::error::CodexErr;
use codex_core::error::Result;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::WritableRoot;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Apply read-only bind mounts for protected subpaths before Landlock.
///
/// This unshares mount namespaces (and user namespaces for non-root) so the
/// read-only remounts do not affect the host, then bind-mounts each protected
/// target onto itself and remounts it read-only.
pub(crate) fn apply_read_only_mounts(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result<()> {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let mount_targets = collect_read_only_mount_targets(&writable_roots)?;
if mount_targets.is_empty() {
return Ok(());
}
// Root can unshare the mount namespace directly; non-root needs a user
// namespace to gain capabilities for remounting.
if is_running_as_root() {
unshare_mount_namespace()?;
} else {
let original_euid = unsafe { libc::geteuid() };
let original_egid = unsafe { libc::getegid() };
unshare_user_and_mount_namespaces()?;
write_user_namespace_maps(original_euid, original_egid)?;
}
make_mounts_private()?;
for target in mount_targets {
// Bind and remount read-only works for both files and directories.
bind_mount_read_only(target.as_path())?;
}
// Drop ambient capabilities acquired from the user namespace so the
// sandboxed command cannot remount or create new bind mounts.
if !is_running_as_root() {
drop_caps()?;
}
Ok(())
}
/// Collect read-only mount targets, resolving worktree `.git` pointer files.
fn collect_read_only_mount_targets(
writable_roots: &[WritableRoot],
) -> Result<Vec<AbsolutePathBuf>> {
let mut targets = Vec::new();
for writable_root in writable_roots {
for ro_subpath in &writable_root.read_only_subpaths {
// The policy expects these paths to exist; surface actionable errors
// rather than silently skipping protections.
if !ro_subpath.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Sandbox expected to protect {path}, but it does not exist. Ensure the repository contains this path or create it before running Codex.",
path = ro_subpath.as_path().display()
)));
}
targets.push(ro_subpath.clone());
// Worktrees and submodules store `.git` as a pointer file; add the
// referenced gitdir as an extra read-only target.
if is_git_pointer_file(ro_subpath) {
let gitdir = resolve_gitdir_from_file(ro_subpath)?;
if !targets
.iter()
.any(|target| target.as_path() == gitdir.as_path())
{
targets.push(gitdir);
}
}
}
}
Ok(targets)
}
/// Detect a `.git` pointer file used by worktrees and submodules.
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(std::ffi::OsStr::new(".git"))
}
/// Resolve a worktree `.git` pointer file to its gitdir path.
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Result<AbsolutePathBuf> {
let contents = std::fs::read_to_string(dot_git.as_path()).map_err(CodexErr::from)?;
let trimmed = contents.trim();
let (_, gitdir_raw) = trimmed.split_once(':').ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
))
})?;
// `gitdir: <path>` may be relative to the directory containing `.git`.
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
return Err(CodexErr::UnsupportedOperation(format!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
)));
}
let base = dot_git.as_path().parent().ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
))
})?;
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base)?;
if !gitdir_path.as_path().exists() {
return Err(CodexErr::UnsupportedOperation(format!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
)));
}
Ok(gitdir_path)
}
/// Unshare the mount namespace so mount changes are isolated to the sandboxed process.
fn unshare_mount_namespace() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Unshare user + mount namespaces so the process can remount read-only without privileges.
fn unshare_user_and_mount_namespaces() -> Result<()> {
let result = unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
fn is_running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[repr(C)]
struct CapUserHeader {
version: u32,
pid: i32,
}
#[repr(C)]
struct CapUserData {
effective: u32,
permitted: u32,
inheritable: u32,
}
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
/// Map the provided uid/gid to root inside the user namespace.
fn write_user_namespace_maps(uid: libc::uid_t, gid: libc::gid_t) -> Result<()> {
write_proc_file("/proc/self/setgroups", "deny\n")?;
write_proc_file("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
write_proc_file("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
Ok(())
}
/// Drop all capabilities in the current user namespace.
fn drop_caps() -> Result<()> {
let mut header = CapUserHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let data = [
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
CapUserData {
effective: 0,
permitted: 0,
inheritable: 0,
},
];
// Use syscall directly to avoid libc capability symbols that are missing on musl.
let result = unsafe { libc::syscall(libc::SYS_capset, &mut header, data.as_ptr()) };
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Write a small procfs file, returning a sandbox error on failure.
fn write_proc_file(path: &str, contents: impl AsRef<[u8]>) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
/// Ensure mounts are private so remounting does not propagate outside the namespace.
fn make_mounts_private() -> Result<()> {
let root = CString::new("/").map_err(|_| {
CodexErr::UnsupportedOperation("Sandbox mount path contains NUL byte: /".to_string())
})?;
let result = unsafe {
libc::mount(
std::ptr::null(),
root.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
};
if result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
/// Bind-mount a path onto itself and remount read-only.
fn bind_mount_read_only(path: &Path) -> Result<()> {
let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
CodexErr::UnsupportedOperation(format!(
"Sandbox mount path contains NUL byte: {path}",
path = path.display()
))
})?;
let bind_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
};
if bind_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
let remount_result = unsafe {
libc::mount(
c_path.as_ptr(),
c_path.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REMOUNT | libc::MS_RDONLY,
std::ptr::null(),
)
};
if remount_result != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
@@ -1,4 +1,5 @@
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)]
use codex_core::config::types::ShellEnvironmentPolicy;
use codex_core::error::CodexErr;
use codex_core::error::SandboxErr;
@@ -11,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use tokio::process::Command;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
@@ -79,6 +81,51 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
}
}
#[expect(clippy::expect_used)]
async fn assert_write_blocked(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots
.iter()
.map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap())
.collect(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
&sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
None,
)
.await;
match result {
Ok(output) => {
if output.exit_code == 0 {
panic!("expected command to fail, but exit code was 0");
}
}
Err(CodexErr::Sandbox(SandboxErr::Denied { .. })) => {}
Err(err) => panic!("expected sandbox denial, got: {err:?}"),
}
}
#[tokio::test]
async fn test_root_read() {
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
@@ -127,6 +174,134 @@ async fn test_writable_root() {
.await;
}
#[tokio::test]
async fn test_git_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_config = repo_root.join(".git").join("config");
let git_index_lock = repo_root.join(".git").join("index.lock");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", git_index_lock.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_dir_move_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
Command::new("git")
.arg("init")
.arg(".")
.current_dir(repo_root)
.output()
.await
.expect("git init .");
let git_dir = repo_root.join(".git");
let git_dir_backup = repo_root.join(".git.bak");
assert_write_blocked(
&[
"bash",
"-lc",
&format!(
"mv {} {}",
git_dir.to_string_lossy(),
git_dir_backup.to_string_lossy()
),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_codex_dir_write_blocked() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
std::fs::create_dir_all(repo_root.join(".codex")).unwrap();
let codex_config = repo_root.join(".codex").join("config.toml");
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", codex_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
async fn test_git_pointer_file_blocks_gitdir_writes() {
let tmpdir = tempfile::tempdir().unwrap();
let repo_root = tmpdir.path();
let gitdir = repo_root.join("actual-gitdir");
std::fs::create_dir_all(&gitdir).unwrap();
let gitdir_config = gitdir.join("config");
std::fs::write(&gitdir_config, "[core]\n\trepositoryformatversion = 0\n").unwrap();
std::fs::write(
repo_root.join(".git"),
format!("gitdir: {}\n", gitdir.to_string_lossy()),
)
.unwrap();
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", gitdir_config.to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
assert_write_blocked(
&[
"bash",
"-lc",
&format!("echo pwned > {}", repo_root.join(".git").to_string_lossy()),
],
&[repo_root.to_path_buf()],
LONG_TIMEOUT_MS,
)
.await;
}
#[tokio::test]
#[should_panic(expected = "Sandbox(Timeout")]
async fn test_timeout() {
@@ -349,6 +349,14 @@ async fn run_codex_tool_session_inner(
| EventMsg::ExitedReviewMode(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::DeprecationNotice(_) => {
// For now, we do not do anything extra for these
// events. Note that
+12
View File
@@ -63,6 +63,18 @@ pub enum SandboxMode {
DangerFullAccess,
}
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum WebSearchMode {
#[default]
Disabled,
Cached,
Live,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
+246 -2
View File
@@ -4,6 +4,7 @@
//! between user and agent.
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
@@ -505,12 +506,26 @@ impl SandboxPolicy {
roots
.into_iter()
.map(|writable_root| {
let mut subpaths = Vec::new();
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
#[allow(clippy::expect_used)]
let top_level_git = writable_root
.join(".git")
.expect(".git is a valid relative path");
if top_level_git.as_path().is_dir() {
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
&& !subpaths
.iter()
.any(|subpath| subpath.as_path() == gitdir.as_path())
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
#[allow(clippy::expect_used)]
@@ -531,6 +546,71 @@ impl SandboxPolicy {
}
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
Ok(path) => path,
Err(err) => {
error!(
"Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}
/// Event Queue Entry - events from agent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {
@@ -693,6 +773,71 @@ pub enum EventMsg {
AgentMessageContentDelta(AgentMessageContentDeltaEvent),
ReasoningContentDelta(ReasoningContentDeltaEvent),
ReasoningRawContentDelta(ReasoningRawContentDeltaEvent),
/// Collab interaction: agent spawn begin.
CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent),
/// Collab interaction: agent spawn end.
CollabAgentSpawnEnd(CollabAgentSpawnEndEvent),
/// Collab interaction: agent interaction begin.
CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent),
/// Collab interaction: agent interaction end.
CollabAgentInteractionEnd(CollabAgentInteractionEndEvent),
/// Collab interaction: waiting begin.
CollabWaitingBegin(CollabWaitingBeginEvent),
/// Collab interaction: waiting end.
CollabWaitingEnd(CollabWaitingEndEvent),
/// Collab interaction: close begin.
CollabCloseBegin(CollabCloseBeginEvent),
/// Collab interaction: close end.
CollabCloseEnd(CollabCloseEndEvent),
}
impl From<CollabAgentSpawnBeginEvent> for EventMsg {
fn from(event: CollabAgentSpawnBeginEvent) -> Self {
EventMsg::CollabAgentSpawnBegin(event)
}
}
impl From<CollabAgentSpawnEndEvent> for EventMsg {
fn from(event: CollabAgentSpawnEndEvent) -> Self {
EventMsg::CollabAgentSpawnEnd(event)
}
}
impl From<CollabAgentInteractionBeginEvent> for EventMsg {
fn from(event: CollabAgentInteractionBeginEvent) -> Self {
EventMsg::CollabAgentInteractionBegin(event)
}
}
impl From<CollabAgentInteractionEndEvent> for EventMsg {
fn from(event: CollabAgentInteractionEndEvent) -> Self {
EventMsg::CollabAgentInteractionEnd(event)
}
}
impl From<CollabWaitingBeginEvent> for EventMsg {
fn from(event: CollabWaitingBeginEvent) -> Self {
EventMsg::CollabWaitingBegin(event)
}
}
impl From<CollabWaitingEndEvent> for EventMsg {
fn from(event: CollabWaitingEndEvent) -> Self {
EventMsg::CollabWaitingEnd(event)
}
}
impl From<CollabCloseBeginEvent> for EventMsg {
fn from(event: CollabCloseBeginEvent) -> Self {
EventMsg::CollabCloseBegin(event)
}
}
impl From<CollabCloseEndEvent> for EventMsg {
fn from(event: CollabCloseEndEvent) -> Self {
EventMsg::CollabCloseEnd(event)
}
}
/// Agent lifecycle status, derived from emitted events.
@@ -1943,6 +2088,105 @@ pub enum TurnAbortReason {
ReviewEnded,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabAgentSpawnBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
/// beginning.
pub prompt: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabAgentSpawnEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the newly spawned agent, if it was created.
pub new_thread_id: Option<ThreadId>,
/// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
/// beginning.
pub prompt: String,
/// Last known status of the new agent reported to the sender agent.
pub status: AgentStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabAgentInteractionBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
/// Prompt sent from the sender to the receiver. Can be empty to prevent CoT
/// leaking at the beginning.
pub prompt: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabAgentInteractionEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
/// Prompt sent from the sender to the receiver. Can be empty to prevent CoT
/// leaking at the beginning.
pub prompt: String,
/// Last known status of the receiver agent reported to the sender agent.
pub status: AgentStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabWaitingBeginEvent {
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
/// ID of the waiting call.
pub call_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabWaitingEndEvent {
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
/// ID of the waiting call.
pub call_id: String,
/// Last known status of the receiver agent reported to the sender agent.
pub status: AgentStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabCloseBeginEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct CollabCloseEndEvent {
/// Identifier for the collab tool call.
pub call_id: String,
/// Thread ID of the sender.
pub sender_thread_id: ThreadId,
/// Thread ID of the receiver.
pub receiver_thread_id: ThreadId,
/// Last known status of the receiver agent reported to the sender agent before
/// the close.
pub status: AgentStatus,
}
#[cfg(test)]
mod tests {
use super::*;
+13 -9
View File
@@ -1,5 +1,6 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(target_os = "windows")]
@@ -417,7 +418,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new(init, thread_manager.clone())
}
@@ -440,7 +441,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
}
@@ -463,7 +464,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
}
@@ -640,7 +641,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.current_model = model_info.slug.clone();
@@ -690,7 +691,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
@@ -759,7 +760,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
@@ -858,9 +859,12 @@ impl App {
}
self.chat_widget.handle_codex_event(event);
}
AppEvent::ExitRequest => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
AppEvent::Exit(mode) => match mode {
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
ExitMode::Immediate => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
},
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
+33 -2
View File
@@ -1,3 +1,13 @@
//! Application-level events used to coordinate UI actions.
//!
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
//! Widgets emit events to request actions that must be handled at the app layer (like opening
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
//! `App` internals.
//!
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf;
use codex_common::approval_presets::ApprovalPreset;
@@ -41,8 +51,13 @@ pub(crate) enum AppEvent {
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Request to exit the application gracefully.
ExitRequest,
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
/// background tasks, rollout flush, or child process cleanup).
Exit(ExitMode),
/// Request to exit the application due to a fatal error.
FatalExitRequest(String),
@@ -215,6 +230,22 @@ pub(crate) enum AppEvent {
LaunchExternalEditor,
}
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
/// already completed (or is being bypassed) and the UI loop should terminate right away.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitMode {
/// Shutdown core and exit after completion.
ShutdownFirst,
/// Exit the UI loop immediately without waiting for shutdown.
///
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
/// cleanup that normally runs before `ShutdownComplete` can be missed.
Immediate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
+54 -20
View File
@@ -57,7 +57,8 @@
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -168,7 +169,8 @@ pub(crate) struct ChatComposer {
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
quit_shortcut_expires_at: Option<Instant>,
quit_shortcut_key: KeyBinding,
esc_backtrack_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
@@ -193,6 +195,7 @@ pub(crate) struct ChatComposer {
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
}
@@ -222,7 +225,8 @@ impl ChatComposer {
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
quit_shortcut_expires_at: None,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
esc_backtrack_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
@@ -255,6 +259,12 @@ impl ChatComposer {
self.skills = skills;
}
/// Enables or disables "Steer" behavior for submission keys.
///
/// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and
/// `Tab` produces [`InputResult::Queued`] (eligible to queue if a task is running).
/// When steer is disabled, `Enter` produces [`InputResult::Queued`], preserving the default
/// "queue while a task is running" behavior.
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.steer_enabled = enabled;
}
@@ -578,16 +588,37 @@ impl ChatComposer {
}
}
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = FooterMode::CtrlCReminder;
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
/// Show the transient "press again to quit" hint for `key`.
///
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
/// even when the UI is otherwise idle.
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = key;
self.footer_mode = FooterMode::QuitShortcutReminder;
self.set_has_focus(has_focus);
}
/// Clear the "press again to quit" hint immediately.
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
self.quit_shortcut_expires_at = None;
self.footer_mode = reset_mode_after_activity(self.footer_mode);
self.set_has_focus(has_focus);
}
/// Whether the quit shortcut hint should currently be shown.
///
/// This is time-based rather than event-based: it may become false without
/// any additional user input, so the UI schedules a redraw when the hint
/// expires.
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
@@ -1497,10 +1528,7 @@ impl ChatComposer {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} if self.is_empty() => {
self.app_event_tx.send(AppEvent::ExitRequest);
(InputResult::None, true)
}
} if self.is_empty() => (InputResult::None, false),
// -------------------------------------------------------------
// History navigation (Up / Down) – only when the composer is not
// empty or when the cursor is at the correct position, to avoid
@@ -1801,7 +1829,7 @@ impl ChatComposer {
return false;
}
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
@@ -1813,6 +1841,7 @@ impl ChatComposer {
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
quit_shortcut_key: self.quit_shortcut_key,
steer_enabled: self.steer_enabled,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
@@ -1823,8 +1852,13 @@ impl ChatComposer {
match self.footer_mode {
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
other => other,
}
@@ -2365,16 +2399,16 @@ mod tests {
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
+48 -25
View File
@@ -1,3 +1,13 @@
//! The bottom-pane footer renders transient hints and context indicators.
//!
//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.
//! It intentionally does not decide *which* footer content should be shown; that is owned by the
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
//! `ChatWidget` (which decides when quit/interrupt is allowed).
//!
//! Some footer content is time-based rather than event-based, such as the "press again to quit"
//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is
//! otherwise idle.
#[cfg(target_os = "linux")]
use crate::clipboard_paste::is_probably_wsl;
use crate::key_hint;
@@ -14,6 +24,12 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
/// The rendering inputs for the footer area under the composer.
///
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as
/// authoritative and does not attempt to infer missing state (for example, it does not query
/// whether a task is running).
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
@@ -21,13 +37,22 @@ pub(crate) struct FooterProps {
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
pub(crate) steer_enabled: bool,
/// Which key the user must press again to quit.
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
}
/// Selects which footer content is rendered.
///
/// The current mode is owned by `ChatComposer`, which may override it based on transient state
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
QuitShortcutReminder,
ShortcutSummary,
ShortcutOverlay,
EscHint,
@@ -35,12 +60,14 @@ pub(crate) enum FooterMode {
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) {
return current;
}
match current {
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => {
FooterMode::ShortcutSummary
}
_ => FooterMode::ShortcutOverlay,
}
}
@@ -57,7 +84,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint
| FooterMode::ShortcutOverlay
| FooterMode::CtrlCReminder
| FooterMode::QuitShortcutReminder
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
other => other,
}
@@ -82,9 +109,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
// the shortcut hint is hidden). Hide it only for the multi-line
// ShortcutOverlay.
match props.mode {
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})],
FooterMode::QuitShortcutReminder => {
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
}
FooterMode::ShortcutSummary => {
let mut line = context_window_line(
props.context_window_percent,
@@ -126,11 +153,6 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
}
}
#[derive(Clone, Copy, Debug)]
struct CtrlCReminderState {
is_task_running: bool,
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
@@ -138,17 +160,8 @@ struct ShortcutsState {
is_wsl: bool,
}
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
let action = if state.is_task_running {
"interrupt"
} else {
"quit"
};
Line::from(vec![
key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
Line::from(vec![key.into(), " again to quit".into()]).dim()
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
@@ -487,6 +500,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -500,6 +514,7 @@ mod tests {
use_shift_enter_hint: true,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -508,11 +523,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -521,11 +537,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_running",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -539,6 +556,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -552,6 +570,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -565,6 +584,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(72),
context_window_used_tokens: None,
},
@@ -578,6 +598,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: Some(123_456),
},
@@ -591,6 +612,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
@@ -604,6 +626,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: true,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
},
+95 -21
View File
@@ -1,9 +1,25 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
//! The bottom pane is the interactive footer of the chat UI.
//!
//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
//! interactions like selection lists.
//!
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
//! shortcut.
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -46,6 +62,27 @@ mod textarea;
mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView;
/// How long the "press again to quit" hint stays visible.
///
/// This is shared between:
/// - `ChatWidget`: arming the double-press quit shortcut.
/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.
///
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
/// Whether Ctrl+C/Ctrl+D require a second press to quit.
///
/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in
/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we
/// rethink a better quit/interrupt design.
pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false;
/// The result of offering a cancellation key to a bottom-pane surface.
///
/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss
/// themselves, and the caller can decide what higher-level action (if any) to take when the key is
/// not handled locally.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Handled,
@@ -63,6 +100,10 @@ pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
/// Pane displayed in the lower half of the chat UI.
///
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving
/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.
pub(crate) struct BottomPane {
/// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed.
@@ -76,7 +117,6 @@ pub(crate) struct BottomPane {
has_input_focus: bool,
is_task_running: bool,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -129,7 +169,6 @@ impl BottomPane {
frame_requester,
has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
queued_user_messages: QueuedUserMessages::new(),
@@ -218,8 +257,14 @@ impl BottomPane {
}
}
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
/// itself). If no view is active, Ctrl+C clears draft composer input.
///
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
/// quit/interrupt state machine and uses the result to decide what happens next.
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
if let Some(view) = self.view_stack.last_mut() {
let event = view.on_ctrl_c();
@@ -228,7 +273,7 @@ impl BottomPane {
self.view_stack.pop();
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
}
event
} else if self.composer_is_empty() {
@@ -236,7 +281,7 @@ impl BottomPane {
} else {
self.view_stack.pop();
self.clear_composer_for_ctrl_c();
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
CancellationEvent::Handled
}
}
@@ -314,25 +359,45 @@ impl BottomPane {
}
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
/// Show the transient "press again to quit" hint for `key`.
///
/// `ChatWidget` owns the quit shortcut state machine (it decides when quit is
/// allowed), while the bottom pane owns rendering. We also schedule a redraw
/// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user
/// stops typing and no other events trigger a draw.
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
return;
}
self.composer
.set_ctrl_c_quit_hint(true, self.has_input_focus);
.show_quit_shortcut_hint(key, self.has_input_focus);
let frame_requester = self.frame_requester.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
frame_requester.schedule_frame();
});
} else {
// In tests (and other non-Tokio contexts), fall back to a thread so
// the hint can still expire without requiring an explicit draw.
std::thread::spawn(move || {
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
frame_requester.schedule_frame();
});
}
self.request_redraw();
}
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
if self.ctrl_c_quit_hint {
self.ctrl_c_quit_hint = false;
self.composer
.set_ctrl_c_quit_hint(false, self.has_input_focus);
self.request_redraw();
}
/// Clear the "press again to quit" hint immediately.
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
self.ctrl_c_quit_hint
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.composer.quit_shortcut_hint_visible()
}
#[cfg(test)]
@@ -463,6 +528,15 @@ impl BottomPane {
self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Returns true when the bottom pane has no active modal view and no active composer popup.
///
/// This is the UI-level definition of "no modal/popup is active" for key routing decisions.
/// It intentionally does not include task state, since some actions are safe while a task is
/// running and some are not.
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.can_launch_external_editor()
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
@@ -632,7 +706,7 @@ mod tests {
}
#[test]
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
fn ctrl_c_on_modal_consumes_without_showing_quit_hint() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
@@ -648,7 +722,7 @@ mod tests {
});
pane.push_approval_request(exec_request(), &features);
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert!(pane.ctrl_c_quit_hint_visible());
assert!(!pane.quit_shortcut_hint_visible());
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ctrl + c again to interrupt "
" ctrl + c again to quit "
@@ -2,4 +2,4 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to interrupt "
" ctrl + c again to quit "
+392 -106
View File
@@ -26,7 +26,9 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AuthMode;
use codex_backend_client::Client as BackendClient;
use codex_core::config::Config;
@@ -54,6 +56,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandOutputDeltaEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::ListCustomPromptsResponseEvent;
@@ -98,6 +101,8 @@ use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -106,7 +111,10 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
@@ -116,8 +124,10 @@ use crate::bottom_pane::BetaFeatureItem;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
@@ -135,6 +145,8 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::markdown::append_markdown;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
@@ -199,6 +211,28 @@ impl UnifiedExecWaitState {
}
}
#[derive(Clone, Debug)]
struct UnifiedExecWaitStreak {
process_id: String,
command_display: Option<String>,
}
impl UnifiedExecWaitStreak {
fn new(process_id: String, command_display: Option<String>) -> Self {
Self {
process_id,
command_display: command_display.filter(|display| !display.is_empty()),
}
}
fn update_command_display(&mut self, command_display: Option<String>) {
if self.command_display.is_some() {
return;
}
self.command_display = command_display.filter(|display| !display.is_empty());
}
}
fn is_unified_exec_source(source: ExecCommandSource) -> bool {
matches!(
source,
@@ -316,7 +350,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) model: String,
pub(crate) model: Option<String>,
}
#[derive(Default)]
@@ -335,12 +369,18 @@ pub(crate) enum ExternalEditorState {
Active,
}
/// Maintains the per-session UI state for the chat screen.
/// Maintains the per-session UI state and interaction state machines for the chat screen.
///
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
/// sending `Op` requests back to codex-core.
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
/// intent (`Op` submissions and `AppEvent` requests).
///
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
/// and by sending requests back to codex-core.
///
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
@@ -357,7 +397,7 @@ pub(crate) struct ChatWidget {
/// where the overlay may briefly treat new tail content as already cached.
active_cell_revision: u64,
config: Config,
model: String,
model: Option<String>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
session_header: SessionHeader,
@@ -373,6 +413,7 @@ pub(crate) struct ChatWidget {
running_commands: HashMap<String, RunningCommand>,
suppressed_exec_calls: HashSet<String>,
last_unified_wait: Option<UnifiedExecWaitState>,
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
task_complete_pending: bool,
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
/// Tracks whether codex-core currently considers an agent turn to be in progress.
@@ -407,6 +448,14 @@ pub(crate) struct ChatWidget {
queued_user_messages: VecDeque<UserMessage>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// When `Some`, the user has pressed a quit shortcut and the second press
/// must occur before `quit_shortcut_expires_at`.
quit_shortcut_expires_at: Option<Instant>,
/// Tracks which quit shortcut key was pressed first.
///
/// We require the second press to match this key so `Ctrl+C` followed by
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
quit_shortcut_key: Option<KeyBinding>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
// Snapshot of token usage to restore after review mode exits.
@@ -486,6 +535,26 @@ impl ChatWidget {
self.bottom_pane
.set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some());
}
fn restore_reasoning_status_header(&mut self) {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.set_status_header(header);
} else if self.bottom_pane.is_task_running() {
self.set_status_header(String::from("Working"));
}
}
fn flush_unified_exec_wait_streak(&mut self) {
let Some(wait) = self.unified_exec_wait_streak.take() else {
return;
};
self.needs_final_message_separator = true;
let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new());
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
self.restore_reasoning_status_header();
}
fn flush_answer_stream_with_separator(&mut self) {
if let Some(mut controller) = self.stream_controller.take()
&& let Some(cell) = controller.finalize()
@@ -523,13 +592,16 @@ impl ChatWidget {
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.model = Some(model_for_header.clone());
self.session_header.set_model(&model_for_header);
self.add_to_history(history_cell::new_session_info(
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
event,
self.show_welcome_banner,
));
);
self.apply_session_info_cell(session_info_cell);
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
@@ -610,6 +682,12 @@ impl ChatWidget {
// (between **/**) as the chunk header. Show this header as status.
self.reasoning_buffer.push_str(&delta);
if self.unified_exec_wait_streak.is_some() {
// Unified exec waiting should take precedence over reasoning-derived status headers.
self.request_redraw();
return;
}
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.set_status_header(header);
@@ -643,7 +721,9 @@ impl ChatWidget {
fn on_task_started(&mut self) {
self.agent_turn_running = true;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.update_task_running_state();
self.retry_status_header = None;
self.bottom_pane.set_interrupt_hint_visible(true);
@@ -656,13 +736,14 @@ impl ChatWidget {
fn on_task_complete(&mut self, last_agent_message: Option<String>) {
// If a stream is currently active, finalize it.
self.flush_answer_stream_with_separator();
self.flush_wait_cell();
self.flush_unified_exec_wait_streak();
// Mark task stopped and request redraw now that all content is in history.
self.agent_turn_running = false;
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.unified_exec_wait_streak = None;
self.request_redraw();
// If there is a queued user message, send exactly one now to begin the next turn.
@@ -764,7 +845,7 @@ impl ChatWidget {
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
&& self.model != NUDGE_MODEL_SLUG
&& self.current_model() != Some(NUDGE_MODEL_SLUG)
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
@@ -967,11 +1048,19 @@ impl ChatWidget {
self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
}
fn on_exec_command_output_delta(
&mut self,
_ev: codex_core::protocol::ExecCommandOutputDeltaEvent,
) {
// TODO: Handle streaming exec output if/when implemented
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
let Some(cell) = self
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
else {
return;
};
if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) {
self.bump_active_cell_revision();
self.request_redraw();
}
}
fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) {
@@ -982,50 +1071,38 @@ impl ChatWidget {
.find(|process| process.key == ev.process_id)
.map(|process| process.command_display.clone());
if ev.stdin.is_empty() {
// Empty stdin means we are still waiting on background output; keep a live shimmer cell.
if let Some(wait_cell) = self.active_cell.as_mut().and_then(|cell| {
cell.as_any_mut()
.downcast_mut::<history_cell::UnifiedExecWaitCell>()
}) && wait_cell.matches(command_display.as_deref())
{
// Same process still waiting; update command display if it shows up late.
if wait_cell.update_command_display(command_display) {
self.bump_active_cell_revision();
// Empty stdin means we are polling for background output.
// Surface this in the status header (single "waiting" surface) instead of the transcript.
self.bottom_pane.ensure_status_indicator();
self.bottom_pane.set_interrupt_hint_visible(true);
let header = if let Some(command) = &command_display {
format!("Waiting for background terminal · {command}")
} else {
"Waiting for background terminal".to_string()
};
self.set_status_header(header);
match &mut self.unified_exec_wait_streak {
Some(wait) if wait.process_id == ev.process_id => {
wait.update_command_display(command_display);
}
Some(_) => {
self.flush_unified_exec_wait_streak();
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
}
None => {
self.unified_exec_wait_streak =
Some(UnifiedExecWaitStreak::new(ev.process_id, command_display));
}
self.request_redraw();
return;
}
let has_non_wait_active = matches!(
self.active_cell.as_ref(),
Some(active)
if active
.as_any()
.downcast_ref::<history_cell::UnifiedExecWaitCell>()
.is_none()
);
if has_non_wait_active {
// Do not preempt non-wait active cells with a wait entry.
return;
}
self.flush_wait_cell();
self.active_cell = Some(Box::new(history_cell::new_unified_exec_wait_live(
command_display,
self.config.animations,
)));
self.bump_active_cell_revision();
self.request_redraw();
} else {
if let Some(wait_cell) = self.active_cell.as_ref().and_then(|cell| {
cell.as_any()
.downcast_ref::<history_cell::UnifiedExecWaitCell>()
}) {
// Convert the live wait cell into a static "(waited)" entry before logging stdin.
let waited_command = wait_cell.command_display().or(command_display.clone());
self.active_cell = None;
self.add_to_history(history_cell::new_unified_exec_interaction(
waited_command,
String::new(),
));
if self
.unified_exec_wait_streak
.as_ref()
.is_some_and(|wait| wait.process_id == ev.process_id)
{
self.flush_unified_exec_wait_streak();
}
self.add_to_history(history_cell::new_unified_exec_interaction(
command_display,
@@ -1060,6 +1137,14 @@ impl ChatWidget {
fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) {
if is_unified_exec_source(ev.source) {
if let Some(process_id) = ev.process_id.as_deref()
&& self
.unified_exec_wait_streak
.as_ref()
.is_some_and(|wait| wait.process_id == process_id)
{
self.flush_unified_exec_wait_streak();
}
self.track_unified_exec_process_end(&ev);
if !self.bottom_pane.is_task_running() {
return;
@@ -1142,7 +1227,7 @@ impl ChatWidget {
}
fn on_shutdown_complete(&mut self) {
self.request_exit();
self.request_immediate_exit();
}
fn on_turn_diff(&mut self, unified_diff: String) {
@@ -1515,11 +1600,22 @@ impl ChatWidget {
model,
} = common;
let mut config = config;
config.model = Some(model.clone());
let model = model.filter(|m| !m.trim().is_empty());
config.model = model.clone();
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
let model_for_header = config
.model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let active_cell = if model.is_none() {
Some(Self::placeholder_session_header_cell(&config))
} else {
None
};
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
@@ -1534,13 +1630,13 @@ impl ChatWidget {
animations_enabled: config.animations,
skills: None,
}),
active_cell: None,
active_cell,
active_cell_revision: 0,
config,
model: model.clone(),
model,
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
session_header: SessionHeader::new(model_for_header),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1555,6 +1651,7 @@ impl ChatWidget {
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@@ -1569,6 +1666,8 @@ impl ChatWidget {
show_welcome_banner: is_first_run,
suppress_session_configured_redraw: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1605,9 +1704,12 @@ impl ChatWidget {
model,
..
} = common;
let model = model.filter(|m| !m.trim().is_empty());
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let header_model = model.unwrap_or_else(|| session_configured.model.clone());
let codex_op_tx =
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
@@ -1628,10 +1730,10 @@ impl ChatWidget {
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
model: Some(header_model.clone()),
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
session_header: SessionHeader::new(header_model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1646,6 +1748,7 @@ impl ChatWidget {
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@@ -1660,6 +1763,8 @@ impl ChatWidget {
show_welcome_banner: false,
suppress_session_configured_redraw: true,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1688,6 +1793,19 @@ impl ChatWidget {
self.on_ctrl_c();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
if self.on_ctrl_d() {
return;
}
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
@@ -1700,7 +1818,9 @@ impl ChatWidget {
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
_ => {}
}
@@ -1731,7 +1851,11 @@ impl ChatWidget {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.submit_user_message(user_message);
if !self.is_session_configured() {
self.queue_user_message(user_message);
} else {
self.submit_user_message(user_message);
}
}
InputResult::Queued(text) => {
// Tab queues the message if a task is running, otherwise submits immediately
@@ -1911,7 +2035,7 @@ impl ChatWidget {
self.open_experimental_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_exit();
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
if let Err(e) = codex_core::auth::logout(
@@ -1920,7 +2044,7 @@ impl ChatWidget {
) {
tracing::error!("failed to logout: {e}");
}
self.request_exit();
self.request_quit_without_confirmation();
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
@@ -2072,40 +2196,26 @@ impl ChatWidget {
}
fn flush_active_cell(&mut self) {
self.flush_wait_cell();
if let Some(active) = self.active_cell.take() {
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
}
// Only flush a live wait cell here; other active cells must finalize via their end events.
fn flush_wait_cell(&mut self) {
// Wait cells are transient: convert them into "(waited)" history entries if present.
// Leave non-wait active cells intact so their end events can finalize them.
let Some(active) = self.active_cell.take() else {
return;
};
let Some(wait_cell) = active
.as_any()
.downcast_ref::<history_cell::UnifiedExecWaitCell>()
else {
self.active_cell = Some(active);
return;
};
self.needs_final_message_separator = true;
let cell =
history_cell::new_unified_exec_interaction(wait_cell.command_display(), String::new());
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
self.add_boxed_history(Box::new(cell));
}
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
if !cell.display_lines(u16::MAX).is_empty() {
// Keep the placeholder session header as the active cell until real session info arrives,
// so we can merge headers instead of committing a duplicate box to history.
let keep_placeholder_header_active = !self.is_session_configured()
&& self
.active_cell
.as_ref()
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
self.needs_final_message_separator = true;
@@ -2114,7 +2224,10 @@ impl ChatWidget {
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() || self.is_review_mode {
if !self.is_session_configured()
|| self.bottom_pane.is_task_running()
|| self.is_review_mode
{
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
@@ -2327,6 +2440,16 @@ impl ChatWidget {
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
}
EventMsg::ThreadRolledBack(_) => {}
EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
@@ -2397,8 +2520,21 @@ impl ChatWidget {
}
}
fn request_exit(&self) {
self.app_event_tx.send(AppEvent::ExitRequest);
/// Exit the UI immediately without waiting for shutdown.
///
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
/// this is mainly a fallback for shutdown completion or emergency exits.
fn request_immediate_exit(&self) {
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
}
/// Request a shutdown-first quit.
///
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
fn request_quit_without_confirmation(&self) {
self.app_event_tx
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
}
fn request_redraw(&mut self) {
@@ -2483,7 +2619,7 @@ impl ChatWidget {
self.rate_limit_snapshot.as_ref(),
self.plan_type,
Local::now(),
&self.model,
self.model_display_name(),
));
}
@@ -2637,6 +2773,14 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Model selection is disabled until startup completes.".to_string(),
None,
);
return;
}
let presets: Vec<ModelPreset> = match self.models_manager.try_list_models(&self.config) {
Ok(models) => models,
Err(_) => {
@@ -2695,11 +2839,12 @@ impl ChatWidget {
.filter(|preset| preset.show_in_picker)
.collect();
let current_model = self.current_model();
let current_label = presets
.iter()
.find(|preset| preset.model == self.model)
.find(|preset| Some(preset.model.as_str()) == current_model)
.map(|preset| preset.display_name.to_string())
.unwrap_or_else(|| self.model.clone());
.unwrap_or_else(|| self.model_display_name().to_string());
let (mut auto_presets, other_presets): (Vec<ModelPreset>, Vec<ModelPreset>) = presets
.into_iter()
@@ -2725,7 +2870,7 @@ impl ChatWidget {
SelectionItem {
name: preset.display_name.clone(),
description,
is_current: model == self.model,
is_current: Some(model.as_str()) == current_model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
@@ -2795,7 +2940,7 @@ impl ChatWidget {
for preset in presets.into_iter() {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
let is_current = preset.model == self.model;
let is_current = Some(preset.model.as_str()) == self.current_model();
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let preset_for_action = preset.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
@@ -2921,7 +3066,7 @@ impl ChatWidget {
.or(Some(default_effort));
let model_slug = preset.model.to_string();
let is_current_model = self.model == preset.model;
let is_current_model = self.current_model() == Some(preset.model.as_str());
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
@@ -3754,7 +3899,55 @@ impl ChatWidget {
/// Set the model in the widget's config copy.
pub(crate) fn set_model(&mut self, model: &str) {
self.session_header.set_model(model);
self.model = model.to_string();
self.model = Some(model.to_string());
}
fn current_model(&self) -> Option<&str> {
self.model.as_deref()
}
fn model_display_name(&self) -> &str {
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
None,
config.cwd.clone(),
CODEX_CLI_VERSION,
))
}
/// Merge the real session info cell with any placeholder header to avoid double boxes.
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
let merged_header = if let Some(active) = self.active_cell.take() {
if active
.as_any()
.is::<history_cell::SessionHeaderHistoryCell>()
{
// Reuse the existing placeholder header to avoid rendering two boxes.
if let Some(cell) = session_info_cell.take() {
self.active_cell = Some(cell);
}
true
} else {
self.active_cell = Some(active);
false
}
} else {
false
};
self.flush_active_cell();
if !merged_header && let Some(cell) = session_info_cell {
self.add_boxed_history(cell);
}
}
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
@@ -3785,19 +3978,108 @@ impl ChatWidget {
self.bottom_pane.on_file_search_result(query, matches);
}
/// Handle Ctrl-C key press.
/// Handles a Ctrl+C press at the chat-widget layer.
///
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
///
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
/// quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
} else {
self.arm_quit_shortcut(key);
}
}
return;
}
if self.bottom_pane.is_task_running() {
self.bottom_pane.show_ctrl_c_quit_hint();
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);
} else {
self.request_quit_without_confirmation();
}
return;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return;
}
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);
return;
}
}
/// Handles a Ctrl+D press at the chat-widget layer.
///
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
/// Otherwise it should be routed to the active view and not attempt to quit.
fn on_ctrl_d(&mut self) -> bool {
let key = key_hint::ctrl(KeyCode::Char('d'));
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
{
return false;
}
self.request_quit_without_confirmation();
return true;
}
self.submit_op(Op::Shutdown);
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return true;
}
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
return false;
}
self.arm_quit_shortcut(key);
true
}
/// True if `key` matches the armed quit shortcut and the window has not expired.
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
self.quit_shortcut_key == Some(key)
&& self
.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
/// Arm the double-press quit shortcut and show the footer hint.
///
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = Some(key);
self.bottom_pane.show_quit_shortcut_hint(key);
}
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
fn is_cancellable_work_active(&self) -> bool {
self.bottom_pane.is_task_running() || self.is_review_mode
}
pub(crate) fn composer_is_empty(&self) -> bool {
@@ -4030,6 +4312,10 @@ impl ChatWidget {
self.thread_id
}
fn is_session_configured(&self) -> bool {
self.thread_id.is_some()
}
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
self.current_rollout_path.clone()
}
+1
View File
@@ -38,6 +38,7 @@ pub(crate) fn spawn_agent(
msg: EventMsg::Error(err.to_error_event(None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};
@@ -5,11 +5,12 @@ expression: popup
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.1-codex-max (default) Codex-optimized flagship for deep and fast
reasoning.
2. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but
less capable.
3. gpt-5.2 Latest frontier model with improvements
across knowledge, reasoning and coding
› 1. gpt-5.2-codex (default) Latest frontier agentic coding model.
2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
4. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
Press enter to select reasoning effort, or esc to dismiss.
@@ -4,5 +4,3 @@ expression: active_combined
---
↳ Interacted with background terminal · just fix
└ pwd
• Waiting for background terminal · just fix
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: active_blob(&chat)
---
• Waiting for background terminal · just fix
+52 -65
View File
@@ -6,6 +6,7 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
@@ -354,7 +355,7 @@ async fn helpers_are_available_and_do_not_panic() {
models_manager: thread_manager.get_models_manager(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
model: resolved_model,
model: Some(resolved_model),
};
let mut w = ChatWidget::new(init, thread_manager);
// Basic construction sanity.
@@ -399,7 +400,7 @@ async fn make_chatwidget_manual(
active_cell: None,
active_cell_revision: 0,
config: cfg,
model: resolved_model.clone(),
model: Some(resolved_model.clone()),
auth_manager: auth_manager.clone(),
models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)),
session_header: SessionHeader::new(resolved_model),
@@ -414,6 +415,7 @@ async fn make_chatwidget_manual(
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@@ -429,6 +431,8 @@ async fn make_chatwidget_manual(
queued_user_messages: VecDeque::new(),
suppress_session_configured_redraw: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1053,6 +1057,7 @@ async fn alt_up_edits_most_recent_queued_message() {
#[tokio::test]
async fn enqueueing_history_prompt_multiple_times_is_stable() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
// Submit an initial prompt to seed history.
chat.bottom_pane.set_composer_text("repeat me".to_string());
@@ -1079,6 +1084,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
#[tokio::test]
async fn streaming_final_answer_keeps_task_running_state() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
@@ -1103,19 +1109,34 @@ async fn streaming_final_answer_keeps_task_running_state() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_shutdown_ignores_caps_lock() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
async fn ctrl_c_shutdown_works_with_caps_lock() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Shutdown) => {}
other => panic!("expected Op::Shutdown, got {other:?}"),
}
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_with_modal_open_does_not_quit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_approvals_popup();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
@@ -1134,7 +1155,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let restored_text = chat.bottom_pane.composer_text();
@@ -1143,7 +1164,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
"expected placeholder {placeholder:?} after history recall"
);
assert!(restored_text.starts_with("draft message "));
assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
let images = chat.bottom_pane.take_recent_submission_images();
assert!(
@@ -1317,62 +1338,23 @@ async fn unified_exec_end_after_task_complete_is_suppressed() {
}
#[tokio::test]
async fn unified_exec_wait_cell_revision_updates_on_late_command_display() {
async fn unified_exec_wait_status_header_updates_on_late_command_display() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
None,
chat.config.animations,
)));
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
key: "proc-1".to_string(),
command_display: "sleep 5".to_string(),
});
let before = chat.active_cell_revision;
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-1".to_string(),
process_id: "proc-1".to_string(),
stdin: String::new(),
});
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
let lines = chat
.active_cell_transcript_lines(80)
.expect("active cell lines");
let blob = lines_to_single_string(&lines);
assert!(
blob.contains("sleep 5"),
"expected command display to render: {blob:?}"
);
}
#[tokio::test]
async fn unified_exec_wait_cell_revision_updates_on_replacement() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
Some("old command".to_string()),
chat.config.animations,
)));
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
key: "proc-2".to_string(),
command_display: "new command".to_string(),
});
let before = chat.active_cell_revision;
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-2".to_string(),
process_id: "proc-2".to_string(),
stdin: String::new(),
});
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
let lines = chat
.active_cell_transcript_lines(80)
.expect("active cell lines");
let blob = lines_to_single_string(&lines);
assert!(
blob.contains("new command"),
"expected replacement wait cell to render: {blob:?}"
assert!(chat.active_cell.is_none());
assert_eq!(
chat.current_status_header,
"Waiting for background terminal · sleep 5"
);
}
@@ -1383,9 +1365,9 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-1a", "proc-1", "");
terminal_interaction(&mut chat, "call-wait-1b", "proc-1", "");
assert_snapshot!(
"unified_exec_waiting_multiple_empty_active",
active_blob(&chat)
assert_eq!(
chat.current_status_header,
"Waiting for background terminal · just fix"
);
chat.handle_codex_event(Event {
@@ -1426,15 +1408,15 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n");
terminal_interaction(&mut chat, "call-wait-3b", "proc-3", "");
assert_eq!(
chat.current_status_header,
"Waiting for background terminal · just fix"
);
let pre_cells = drain_insert_history(&mut rx);
let mut active_combined = pre_cells
let active_combined = pre_cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
if !active_combined.is_empty() {
active_combined.push('\n');
}
active_combined.push_str(&active_blob(&chat));
assert_snapshot!("unified_exec_non_empty_then_empty_active", active_combined);
chat.handle_codex_event(Event {
@@ -1525,7 +1507,7 @@ async fn slash_quit_requests_exit() {
chat.dispatch_command(SlashCommand::Quit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
@@ -1534,7 +1516,7 @@ async fn slash_exit_requests_exit() {
chat.dispatch_command(SlashCommand::Exit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
@@ -2056,6 +2038,7 @@ async fn experimental_features_toggle_saves_on_exit() {
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
@@ -2065,6 +2048,7 @@ async fn model_selection_popup_snapshot() {
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
chat.thread_id = Some(ThreadId::new());
let preset = |slug: &str, show_in_picker: bool| ModelPreset {
id: slug.to_string(),
model: slug.to_string(),
@@ -2338,6 +2322,7 @@ async fn feedback_upload_consent_popup_snapshot() {
#[tokio::test]
async fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.thread_id = Some(ThreadId::new());
chat.open_model_popup();
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
@@ -3884,6 +3869,7 @@ printf 'fenced within fenced\n'
#[tokio::test]
async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
@@ -3909,6 +3895,7 @@ async fn chatwidget_tall() {
#[tokio::test]
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "review-1".into(),
+1 -1
View File
@@ -94,7 +94,7 @@ pub struct Cli {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval).
/// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval).
#[arg(long = "search", default_value_t = false)]
pub web_search: bool,
+12
View File
@@ -125,6 +125,18 @@ impl ExecCell {
self.calls.iter()
}
pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool {
if chunk.is_empty() {
return false;
}
let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {
return false;
};
let output = call.output.get_or_insert_with(CommandOutput::default);
output.aggregated_output.push_str(chunk);
true
}
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
!matches!(call.source, ExecCommandSource::UserShell)
&& !call.parsed.is_empty()
+21 -103
View File
@@ -25,7 +25,6 @@ use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
@@ -469,98 +468,6 @@ pub(crate) fn new_unified_exec_interaction(
UnifiedExecInteractionCell::new(command_display, stdin)
}
#[derive(Debug)]
// Live-only wait cell that shimmers while we poll; flushes into a static entry later.
pub(crate) struct UnifiedExecWaitCell {
command_display: Option<String>,
animations_enabled: bool,
start_time: Instant,
}
impl UnifiedExecWaitCell {
pub(crate) fn new(command_display: Option<String>, animations_enabled: bool) -> Self {
Self {
command_display: command_display.filter(|display| !display.is_empty()),
animations_enabled,
start_time: Instant::now(),
}
}
pub(crate) fn matches(&self, command_display: Option<&str>) -> bool {
let command_display = command_display.filter(|display| !display.is_empty());
match (self.command_display.as_deref(), command_display) {
(Some(current), Some(incoming)) => current == incoming,
_ => true,
}
}
/// Update the command display once.
///
/// Unified exec can start without a stable command string, and later correlate a process id to
/// a user-facing `command_display`. This method records that first non-empty command display and
/// returns whether it changed the cell; callers use the `true` case to invalidate any cached
/// transcript rendering (for example, the transcript overlay live tail).
pub(crate) fn update_command_display(&mut self, command_display: Option<String>) -> bool {
let command_display = command_display.filter(|display| !display.is_empty());
if self.command_display.is_some() || command_display.is_none() {
return false;
}
self.command_display = command_display;
true
}
pub(crate) fn command_display(&self) -> Option<String> {
self.command_display.clone()
}
}
impl HistoryCell for UnifiedExecWaitCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let wrap_width = width as usize;
let mut header_spans = vec!["• ".dim()];
if self.animations_enabled {
header_spans.extend(shimmer_spans("Waiting for background terminal"));
} else {
header_spans.push("Waiting for background terminal".bold());
}
if let Some(command) = &self.command_display
&& !command.is_empty()
{
header_spans.push(" · ".dim());
header_spans.push(command.clone().dim());
}
let header = Line::from(header_spans);
let mut out: Vec<Line<'static>> = Vec::new();
let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width));
push_owned_lines(&header_wrapped, &mut out);
out
}
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
}
// Match `App`'s frame scheduling cadence for transcript overlay live-tail animation.
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
pub(crate) fn new_unified_exec_wait_live(
command_display: Option<String>,
animations_enabled: bool,
) -> UnifiedExecWaitCell {
UnifiedExecWaitCell::new(command_display, animations_enabled)
}
#[derive(Debug)]
struct UnifiedExecProcessesCell {
processes: Vec<String>,
@@ -984,23 +891,41 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
}
#[derive(Debug)]
struct SessionHeaderHistoryCell {
pub(crate) struct SessionHeaderHistoryCell {
version: &'static str,
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
}
impl SessionHeaderHistoryCell {
fn new(
pub(crate) fn new(
model: String,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
) -> Self {
Self::new_with_style(
model,
Style::default(),
reasoning_effort,
directory,
version,
)
}
pub(crate) fn new_with_style(
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
) -> Self {
Self {
version,
model,
model_style,
reasoning_effort,
directory,
}
@@ -1073,7 +998,7 @@ impl HistoryCell for SessionHeaderHistoryCell {
let reasoning_label = self.reasoning_label();
let mut model_spans: Vec<Span<'static>> = vec![
Span::from(format!("{model_label} ")).dim(),
Span::from(self.model.clone()),
Span::styled(self.model.clone(), self.model_style),
];
if let Some(reasoning) = reasoning_label {
model_spans.push(Span::from(" "));
@@ -1862,13 +1787,6 @@ mod tests {
);
}
#[test]
fn unified_exec_wait_cell_renders_wait() {
let cell = new_unified_exec_wait_live(None, false);
let lines = render_transcript(&cell);
assert_eq!(lines, vec!["• Waiting for background terminal"],);
}
#[test]
fn ps_output_empty_snapshot() {
let cell = new_unified_exec_processes_output(Vec::new());
+2 -2
View File
@@ -123,11 +123,11 @@ pub async fn run_main(
)
};
// Map the legacy --search flag to the new feature toggle.
// Map the legacy --search flag to the canonical web_search mode.
if cli.web_search {
cli.config_overrides
.raw_overrides
.push("features.web_search_request=true".to_string());
.push("web_search=\"live\"".to_string());
}
// When using `--oss`, let the bootstrapper pick the model (defaulting to
+1 -1
View File
@@ -8037,5 +8037,5 @@
{"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"}
+13 -9
View File
@@ -1,5 +1,6 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(target_os = "windows")]
@@ -485,7 +486,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new(init, thread_manager.clone())
}
@@ -508,7 +509,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
}
@@ -531,7 +532,7 @@ impl App {
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
model: model.clone(),
model: config.model.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
}
@@ -1445,7 +1446,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
if let Some(summary) = summary {
@@ -1494,7 +1495,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
@@ -1562,7 +1563,7 @@ impl App {
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
model: self.current_model.clone(),
model: Some(self.current_model.clone()),
};
self.chat_widget = ChatWidget::new_from_existing(
init,
@@ -1645,9 +1646,12 @@ impl App {
}
self.chat_widget.handle_codex_event(event);
}
AppEvent::ExitRequest => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
AppEvent::Exit(mode) => match mode {
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
ExitMode::Immediate => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
},
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
+33 -2
View File
@@ -1,3 +1,13 @@
//! Application-level events used to coordinate UI actions.
//!
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
//! Widgets emit events to request actions that must be handled at the app layer (like opening
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
//! `App` internals.
//!
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf;
use codex_common::approval_presets::ApprovalPreset;
@@ -40,8 +50,13 @@ pub(crate) enum AppEvent {
/// Open the fork picker inside the running TUI session.
OpenForkPicker,
/// Request to exit the application gracefully.
ExitRequest,
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
/// background tasks, rollout flush, or child process cleanup).
Exit(ExitMode),
/// Request to exit the application due to a fatal error.
FatalExitRequest(String),
@@ -206,6 +221,22 @@ pub(crate) enum AppEvent {
},
}
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
/// already completed (or is being bypassed) and the UI loop should terminate right away.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitMode {
/// Shutdown core and exit after completion.
ShutdownFirst,
/// Exit the UI loop immediately without waiting for shutdown.
///
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
/// cleanup that normally runs before `ShutdownComplete` can be missed.
Immediate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
+57 -29
View File
@@ -170,7 +170,8 @@ pub(crate) struct ChatComposer {
active_popup: ActivePopup,
app_event_tx: AppEventSender,
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
quit_shortcut_expires_at: Option<Instant>,
quit_shortcut_key: KeyBinding,
esc_backtrack_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
@@ -200,6 +201,7 @@ pub(crate) struct ChatComposer {
transcript_copy_feedback: Option<TranscriptCopyFeedback>,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
}
@@ -229,7 +231,8 @@ impl ChatComposer {
active_popup: ActivePopup::None,
app_event_tx,
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
quit_shortcut_expires_at: None,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
esc_backtrack_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
@@ -267,6 +270,12 @@ impl ChatComposer {
self.skills = skills;
}
/// Enables or disables "Steer" behavior for submission keys.
///
/// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and
/// `Tab` produces [`InputResult::Queued`] (eligible to queue if a task is running).
/// When steer is disabled, `Enter` produces [`InputResult::Queued`], preserving the default
/// "queue while a task is running" behavior.
pub fn set_steer_enabled(&mut self, enabled: bool) {
self.steer_enabled = enabled;
}
@@ -511,16 +520,37 @@ impl ChatComposer {
}
}
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = FooterMode::CtrlCReminder;
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
/// Show the transient "press again to quit" hint for `key`.
///
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
/// even when the UI is otherwise idle.
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = key;
self.footer_mode = FooterMode::QuitShortcutReminder;
self.set_has_focus(has_focus);
}
/// Clear the "press again to quit" hint immediately.
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
self.quit_shortcut_expires_at = None;
self.footer_mode = reset_mode_after_activity(self.footer_mode);
self.set_has_focus(has_focus);
}
/// Whether the quit shortcut hint should currently be shown.
///
/// This is time-based rather than event-based: it may become false without
/// any additional user input, so the UI schedules a redraw when the hint
/// expires.
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
@@ -1431,10 +1461,7 @@ impl ChatComposer {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} if self.is_empty() => {
self.app_event_tx.send(AppEvent::ExitRequest);
(InputResult::None, true)
}
} if self.is_empty() => (InputResult::None, false),
// -------------------------------------------------------------
// History navigation (Up / Down) – only when the composer is not
// empty or when the cursor is at the correct position, to avoid
@@ -1473,20 +1500,14 @@ impl ChatComposer {
kind: KeyEventKind::Press,
..
} => self.handle_submission(true),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Tab queues the message instead of submitting immediately
self.handle_submission(true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.handle_submission(false),
} => {
let should_queue = !self.steer_enabled;
self.handle_submission(should_queue)
}
input => self.handle_input_basic(input),
}
}
@@ -1741,7 +1762,7 @@ impl ChatComposer {
return false;
}
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
@@ -1753,6 +1774,7 @@ impl ChatComposer {
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
quit_shortcut_key: self.quit_shortcut_key,
steer_enabled: self.steer_enabled,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
@@ -1768,8 +1790,13 @@ impl ChatComposer {
match self.footer_mode {
FooterMode::EscHint => FooterMode::EscHint,
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
FooterMode::QuitShortcutReminder
}
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
other => other,
}
@@ -2341,16 +2368,16 @@ mod tests {
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
@@ -2879,6 +2906,7 @@ mod tests {
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
+50 -25
View File
@@ -1,3 +1,13 @@
//! The bottom-pane footer renders transient hints and context indicators.
//!
//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.
//! It intentionally does not decide *which* footer content should be shown; that is owned by the
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
//! `ChatWidget` (which decides when quit/interrupt is allowed).
//!
//! Some footer content is time-based rather than event-based, such as the "press again to quit"
//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is
//! otherwise idle.
#[cfg(target_os = "linux")]
use crate::clipboard_paste::is_probably_wsl;
use crate::key_hint;
@@ -15,6 +25,12 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
/// The rendering inputs for the footer area under the composer.
///
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as
/// authoritative and does not attempt to infer missing state (for example, it does not query
/// whether a task is running).
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
@@ -22,6 +38,10 @@ pub(crate) struct FooterProps {
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
pub(crate) steer_enabled: bool,
/// Which key the user must press again to quit.
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) transcript_scrolled: bool,
@@ -31,9 +51,14 @@ pub(crate) struct FooterProps {
pub(crate) transcript_copy_feedback: Option<TranscriptCopyFeedback>,
}
/// Selects which footer content is rendered.
///
/// The current mode is owned by `ChatComposer`, which may override it based on transient state
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
QuitShortcutReminder,
ShortcutSummary,
ShortcutOverlay,
EscHint,
@@ -41,12 +66,14 @@ pub(crate) enum FooterMode {
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) {
return current;
}
match current {
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => {
FooterMode::ShortcutSummary
}
_ => FooterMode::ShortcutOverlay,
}
}
@@ -63,7 +90,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint
| FooterMode::ShortcutOverlay
| FooterMode::CtrlCReminder
| FooterMode::QuitShortcutReminder
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
other => other,
}
@@ -103,9 +130,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
// the shortcut hint is hidden). Hide it only for the multi-line
// ShortcutOverlay.
let mut lines = match props.mode {
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})],
FooterMode::QuitShortcutReminder => {
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
}
FooterMode::ShortcutSummary => {
let mut line = context_window_line(
props.context_window_percent,
@@ -170,11 +197,6 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
lines
}
#[derive(Clone, Copy, Debug)]
struct CtrlCReminderState {
is_task_running: bool,
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
@@ -182,17 +204,8 @@ struct ShortcutsState {
is_wsl: bool,
}
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
let action = if state.is_task_running {
"interrupt"
} else {
"quit"
};
Line::from(vec![
key_hint::ctrl(KeyCode::Char('c')).into(),
format!(" again to {action}").into(),
])
.dim()
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
Line::from(vec![key.into(), " again to quit".into()]).dim()
}
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
@@ -518,6 +531,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -536,6 +550,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: true,
@@ -554,6 +569,7 @@ mod tests {
use_shift_enter_hint: true,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -567,11 +583,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -585,11 +602,12 @@ mod tests {
snapshot_footer(
"footer_ctrl_c_quit_running",
FooterProps {
mode: FooterMode::CtrlCReminder,
mode: FooterMode::QuitShortcutReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -608,6 +626,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -626,6 +645,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -644,6 +664,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(72),
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -662,6 +683,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: Some(123_456),
transcript_scrolled: false,
@@ -680,6 +702,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -698,6 +721,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: true,
steer_enabled: true,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
@@ -716,6 +740,7 @@ mod tests {
use_shift_enter_hint: false,
is_task_running: false,
steer_enabled: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
transcript_scrolled: false,
+100 -21
View File
@@ -1,8 +1,24 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
//! The bottom pane is the interactive footer of the chat UI.
//!
//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
//! interactions like selection lists.
//!
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
//! shortcut.
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -43,6 +59,27 @@ mod selection_popup_common;
mod textarea;
pub(crate) use feedback_view::FeedbackNoteView;
/// How long the "press again to quit" hint stays visible.
///
/// This is shared between:
/// - `ChatWidget`: arming the double-press quit shortcut.
/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.
///
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
/// Whether Ctrl+C/Ctrl+D require a second press to quit.
///
/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in
/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we
/// rethink a better quit/interrupt design.
pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false;
/// The result of offering a cancellation key to a bottom-pane surface.
///
/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss
/// themselves, and the caller can decide what higher-level action (if any) to take when the key is
/// not handled locally.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Handled,
@@ -58,6 +95,10 @@ pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
/// Pane displayed in the lower half of the chat UI.
///
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving
/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.
pub(crate) struct BottomPane {
/// Composer is retained even when a BottomPaneView is displayed so the
/// input state is retained when the view is closed.
@@ -71,7 +112,6 @@ pub(crate) struct BottomPane {
has_input_focus: bool,
is_task_running: bool,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -122,7 +162,6 @@ impl BottomPane {
frame_requester,
has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
@@ -210,8 +249,14 @@ impl BottomPane {
}
}
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
/// itself). If no view is active, Ctrl+C clears draft composer input.
///
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
/// quit/interrupt state machine and uses the result to decide what happens next.
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
if let Some(view) = self.view_stack.last_mut() {
let event = view.on_ctrl_c();
@@ -220,7 +265,7 @@ impl BottomPane {
self.view_stack.pop();
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
}
event
} else if self.composer_is_empty() {
@@ -228,7 +273,7 @@ impl BottomPane {
} else {
self.view_stack.pop();
self.clear_composer_for_ctrl_c();
self.show_ctrl_c_quit_hint();
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
CancellationEvent::Handled
}
}
@@ -292,25 +337,45 @@ impl BottomPane {
}
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
/// Show the transient "press again to quit" hint for `key`.
///
/// `ChatWidget` owns the quit shortcut state machine (it decides when quit is
/// allowed), while the bottom pane owns rendering. We also schedule a redraw
/// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user
/// stops typing and no other events trigger a draw.
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
return;
}
self.composer
.set_ctrl_c_quit_hint(true, self.has_input_focus);
.show_quit_shortcut_hint(key, self.has_input_focus);
let frame_requester = self.frame_requester.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
frame_requester.schedule_frame();
});
} else {
// In tests (and other non-Tokio contexts), fall back to a thread so
// the hint can still expire without requiring an explicit draw.
std::thread::spawn(move || {
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
frame_requester.schedule_frame();
});
}
self.request_redraw();
}
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
if self.ctrl_c_quit_hint {
self.ctrl_c_quit_hint = false;
self.composer
.set_ctrl_c_quit_hint(false, self.has_input_focus);
self.request_redraw();
}
/// Clear the "press again to quit" hint immediately.
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
self.ctrl_c_quit_hint
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
self.composer.quit_shortcut_hint_visible()
}
#[cfg(test)]
@@ -450,6 +515,20 @@ impl BottomPane {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Return true when no popups or modal views are active, regardless of task state.
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Returns true when the bottom pane has no active modal view and no active composer popup.
///
/// This is the UI-level definition of "no modal/popup is active" for key routing decisions.
/// It intentionally does not include task state, since some actions are safe while a task is
/// running and some are not.
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.can_launch_external_editor()
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
@@ -613,7 +692,7 @@ mod tests {
}
#[test]
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
fn ctrl_c_on_modal_consumes_without_showing_quit_hint() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let features = Features::with_defaults();
@@ -629,7 +708,7 @@ mod tests {
});
pane.push_approval_request(exec_request(), &features);
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert!(pane.ctrl_c_quit_hint_visible());
assert!(!pane.quit_shortcut_hint_visible());
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}
@@ -10,4 +10,4 @@ expression: terminal.backend()
" "
" "
" "
" ctrl + c again to interrupt "
" ctrl + c again to quit "
@@ -2,4 +2,4 @@
source: tui2/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to interrupt "
" ctrl + c again to quit "
+300 -44
View File
@@ -26,6 +26,7 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::AuthMode;
use codex_backend_client::Client as BackendClient;
@@ -53,6 +54,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandOutputDeltaEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::ListCustomPromptsResponseEvent;
@@ -97,6 +99,8 @@ use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -106,6 +110,7 @@ use tokio::task::JoinHandle;
use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
@@ -114,7 +119,9 @@ use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
@@ -131,6 +138,8 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::markdown::append_markdown;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
@@ -150,6 +159,7 @@ use self::agent::spawn_agent_from_existing;
mod session_header;
use self::session_header::SessionHeader;
use crate::streaming::controller::StreamController;
use crate::version::CODEX_CLI_VERSION;
use std::path::Path;
use chrono::Local;
@@ -192,6 +202,7 @@ impl UnifiedExecWaitState {
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini";
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
#[derive(Default)]
struct RateLimitWarningState {
@@ -292,7 +303,7 @@ pub(crate) struct ChatWidgetInit {
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) model: String,
pub(crate) model: Option<String>,
}
#[derive(Default)]
@@ -303,12 +314,18 @@ enum RateLimitSwitchPromptState {
Shown,
}
/// Maintains the per-session UI state for the chat screen.
/// Maintains the per-session UI state and interaction state machines for the chat screen.
///
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
/// sending `Op` requests back to codex-core.
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
/// intent (`Op` submissions and `AppEvent` requests).
///
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
/// and by sending requests back to codex-core.
///
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
@@ -325,7 +342,7 @@ pub(crate) struct ChatWidget {
/// where the overlay may briefly treat new tail content as already cached.
active_cell_revision: u64,
config: Config,
model: String,
model: Option<String>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
session_header: SessionHeader,
@@ -374,6 +391,14 @@ pub(crate) struct ChatWidget {
queued_user_messages: VecDeque<UserMessage>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// When `Some`, the user has pressed a quit shortcut and the second press
/// must occur before `quit_shortcut_expires_at`.
quit_shortcut_expires_at: Option<Instant>,
/// Tracks which quit shortcut key was pressed first.
///
/// We require the second press to match this key so `Ctrl+C` followed by
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
quit_shortcut_key: Option<KeyBinding>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
// Snapshot of token usage to restore after review mode exits.
@@ -489,13 +514,16 @@ impl ChatWidget {
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.model = Some(model_for_header.clone());
self.session_header.set_model(&model_for_header);
self.add_to_history(history_cell::new_session_info(
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
event,
self.show_welcome_banner,
));
);
self.apply_session_info_cell(session_info_cell);
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
@@ -609,7 +637,9 @@ impl ChatWidget {
fn on_task_started(&mut self) {
self.agent_turn_running = true;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.update_task_running_state();
self.retry_status_header = None;
self.bottom_pane.set_interrupt_hint_visible(true);
@@ -729,7 +759,7 @@ impl ChatWidget {
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
&& self.model != NUDGE_MODEL_SLUG
&& self.current_model() != Some(NUDGE_MODEL_SLUG)
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
@@ -924,11 +954,19 @@ impl ChatWidget {
self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
}
fn on_exec_command_output_delta(
&mut self,
_ev: codex_core::protocol::ExecCommandOutputDeltaEvent,
) {
// TODO: Handle streaming exec output if/when implemented
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
let Some(cell) = self
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
else {
return;
};
if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) {
self.bump_active_cell_revision();
self.request_redraw();
}
}
fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) {
@@ -997,7 +1035,7 @@ impl ChatWidget {
}
fn on_shutdown_complete(&mut self) {
self.request_exit();
self.request_immediate_exit();
}
fn on_turn_diff(&mut self, unified_diff: String) {
@@ -1375,11 +1413,22 @@ impl ChatWidget {
model,
} = common;
let mut config = config;
config.model = Some(model.clone());
let model = model.filter(|m| !m.trim().is_empty());
config.model = model.clone();
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
let model_for_header = config
.model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let active_cell = if model.is_none() {
Some(Self::placeholder_session_header_cell(&config))
} else {
None
};
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
@@ -1394,13 +1443,13 @@ impl ChatWidget {
animations_enabled: config.animations,
skills: None,
}),
active_cell: None,
active_cell,
active_cell_revision: 0,
config,
model: model.clone(),
model,
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
session_header: SessionHeader::new(model_for_header),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1428,6 +1477,8 @@ impl ChatWidget {
show_welcome_banner: is_first_run,
suppress_session_configured_redraw: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1451,7 +1502,7 @@ impl ChatWidget {
session_configured: codex_core::protocol::SessionConfiguredEvent,
) -> Self {
let ChatWidgetInit {
config,
mut config,
frame_requester,
app_event_tx,
initial_prompt,
@@ -1463,9 +1514,13 @@ impl ChatWidget {
model,
..
} = common;
let model = model.filter(|m| !m.trim().is_empty());
config.model = model.clone();
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let header_model = model.unwrap_or_else(|| session_configured.model.clone());
let codex_op_tx =
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
@@ -1486,10 +1541,10 @@ impl ChatWidget {
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
model: Some(header_model.clone()),
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
session_header: SessionHeader::new(header_model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
@@ -1517,6 +1572,8 @@ impl ChatWidget {
show_welcome_banner: false,
suppress_session_configured_redraw: true,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1544,6 +1601,19 @@ impl ChatWidget {
self.on_ctrl_c();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
if self.on_ctrl_d() {
return;
}
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
@@ -1572,7 +1642,9 @@ impl ChatWidget {
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
_ => {}
}
@@ -1603,7 +1675,11 @@ impl ChatWidget {
text,
image_paths: self.bottom_pane.take_recent_submission_images(),
};
self.submit_user_message(user_message);
if !self.is_session_configured() {
self.queue_user_message(user_message);
} else {
self.submit_user_message(user_message);
}
}
InputResult::Queued(text) => {
// Tab queues the message if a task is running, otherwise submits immediately
@@ -1729,7 +1805,7 @@ impl ChatWidget {
};
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_exit();
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
if let Err(e) = codex_core::auth::logout(
@@ -1738,7 +1814,7 @@ impl ChatWidget {
) {
tracing::error!("failed to logout: {e}");
}
self.request_exit();
self.request_quit_without_confirmation();
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
@@ -1884,7 +1960,15 @@ impl ChatWidget {
}
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
if !cell.display_lines(u16::MAX).is_empty() {
// Keep the placeholder session header as the active cell until real session info arrives,
// so we can merge headers instead of committing a duplicate box to history.
let keep_placeholder_header_active = !self.is_session_configured()
&& self
.active_cell
.as_ref()
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
self.needs_final_message_separator = true;
@@ -1894,7 +1978,7 @@ impl ChatWidget {
#[allow(dead_code)] // Used in tests
fn queue_user_message(&mut self, user_message: UserMessage) {
if self.bottom_pane.is_task_running() {
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
@@ -2106,6 +2190,16 @@ impl ChatWidget {
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
}
EventMsg::RawResponseItem(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::ItemStarted(_)
@@ -2175,8 +2269,21 @@ impl ChatWidget {
self.needs_final_message_separator = false;
}
fn request_exit(&self) {
self.app_event_tx.send(AppEvent::ExitRequest);
/// Exit the UI immediately without waiting for shutdown.
///
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
/// this is mainly a fallback for shutdown completion or emergency exits.
fn request_immediate_exit(&self) {
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
}
/// Request a shutdown-first quit.
///
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
fn request_quit_without_confirmation(&self) {
self.app_event_tx
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
}
fn request_redraw(&mut self) {
@@ -2261,7 +2368,7 @@ impl ChatWidget {
self.rate_limit_snapshot.as_ref(),
self.plan_type,
Local::now(),
&self.model,
self.model_display_name(),
));
}
fn stop_rate_limit_poller(&mut self) {
@@ -2405,6 +2512,14 @@ impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Model selection is disabled until startup completes.".to_string(),
None,
);
return;
}
let presets: Vec<ModelPreset> = match self.models_manager.try_list_models(&self.config) {
Ok(models) => models,
Err(_) => {
@@ -2424,11 +2539,12 @@ impl ChatWidget {
.filter(|preset| preset.show_in_picker)
.collect();
let current_model = self.current_model();
let current_label = presets
.iter()
.find(|preset| preset.model == self.model)
.find(|preset| Some(preset.model.as_str()) == current_model)
.map(|preset| preset.display_name.to_string())
.unwrap_or_else(|| self.model.clone());
.unwrap_or_else(|| self.model_display_name().to_string());
let (mut auto_presets, other_presets): (Vec<ModelPreset>, Vec<ModelPreset>) = presets
.into_iter()
@@ -2454,7 +2570,7 @@ impl ChatWidget {
SelectionItem {
name: preset.display_name.clone(),
description,
is_current: model == self.model,
is_current: Some(model.as_str()) == current_model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
@@ -2521,7 +2637,7 @@ impl ChatWidget {
for preset in presets.into_iter() {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
let is_current = preset.model == self.model;
let is_current = Some(preset.model.as_str()) == self.current_model();
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let preset_for_action = preset.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
@@ -2647,7 +2763,7 @@ impl ChatWidget {
.or(Some(default_effort));
let model_slug = preset.model.to_string();
let is_current_model = self.model == preset.model;
let is_current_model = self.current_model() == Some(preset.model.as_str());
let highlight_choice = if is_current_model {
self.config.model_reasoning_effort
} else {
@@ -3457,7 +3573,54 @@ impl ChatWidget {
/// Set the model in the widget's config copy.
pub(crate) fn set_model(&mut self, model: &str) {
self.session_header.set_model(model);
self.model = model.to_string();
self.model = Some(model.to_string());
}
fn current_model(&self) -> Option<&str> {
self.model.as_deref()
}
fn model_display_name(&self) -> &str {
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
}
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
None,
config.cwd.clone(),
CODEX_CLI_VERSION,
))
}
/// Merge the real session info cell with any placeholder header to avoid double boxes.
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
let merged_header = if let Some(active) = self.active_cell.take() {
if active
.as_any()
.is::<history_cell::SessionHeaderHistoryCell>()
{
if let Some(cell) = session_info_cell.take() {
self.active_cell = Some(cell);
}
true
} else {
self.active_cell = Some(active);
false
}
} else {
false
};
self.flush_active_cell();
if !merged_header && let Some(cell) = session_info_cell {
self.add_boxed_history(cell);
}
}
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
@@ -3488,19 +3651,108 @@ impl ChatWidget {
self.bottom_pane.on_file_search_result(query, matches);
}
/// Handle Ctrl-C key press.
/// Handles a Ctrl+C press at the chat-widget layer.
///
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
///
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
/// quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
} else {
self.arm_quit_shortcut(key);
}
}
return;
}
if self.bottom_pane.is_task_running() {
self.bottom_pane.show_ctrl_c_quit_hint();
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);
} else {
self.request_quit_without_confirmation();
}
return;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return;
}
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);
return;
}
}
/// Handles a Ctrl+D press at the chat-widget layer.
///
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
/// Otherwise it should be routed to the active view and not attempt to quit.
fn on_ctrl_d(&mut self) -> bool {
let key = key_hint::ctrl(KeyCode::Char('d'));
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
{
return false;
}
self.request_quit_without_confirmation();
return true;
}
self.submit_op(Op::Shutdown);
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return true;
}
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
return false;
}
self.arm_quit_shortcut(key);
true
}
/// True if `key` matches the armed quit shortcut and the window has not expired.
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
self.quit_shortcut_key == Some(key)
&& self
.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
/// Arm the double-press quit shortcut and show the footer hint.
///
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = Some(key);
self.bottom_pane.show_quit_shortcut_hint(key);
}
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
fn is_cancellable_work_active(&self) -> bool {
self.bottom_pane.is_task_running() || self.is_review_mode
}
pub(crate) fn composer_is_empty(&self) -> bool {
@@ -3765,6 +4017,10 @@ impl ChatWidget {
self.current_rollout_path.clone()
}
fn is_session_configured(&self) -> bool {
self.conversation_id.is_some()
}
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
+1
View File
@@ -38,6 +38,7 @@ pub(crate) fn spawn_agent(
msg: EventMsg::Error(err.to_error_event(None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};
@@ -5,11 +5,12 @@ expression: popup
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.1-codex-max (default) Codex-optimized flagship for deep and fast
reasoning.
2. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but
less capable.
3. gpt-5.2 Latest frontier model with improvements
across knowledge, reasoning and coding
› 1. gpt-5.2-codex (default) Latest frontier agentic coding model.
2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
4. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
Press enter to select reasoning effort, or esc to dismiss.
+40 -15
View File
@@ -6,6 +6,7 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
@@ -326,14 +327,13 @@ async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let cfg = test_config().await;
let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref());
let thread_manager = Arc::new(ThreadManager::with_models_provider(
CodexAuth::from_api_key("test"),
cfg.model_provider.clone(),
));
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let init = ChatWidgetInit {
config: cfg,
config: cfg.clone(),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: tx,
initial_prompt: None,
@@ -343,7 +343,7 @@ async fn helpers_are_available_and_do_not_panic() {
models_manager: thread_manager.get_models_manager(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
model: resolved_model,
model: cfg.model,
};
let mut w = ChatWidget::new(init, thread_manager);
// Basic construction sanity.
@@ -388,7 +388,7 @@ async fn make_chatwidget_manual(
active_cell: None,
active_cell_revision: 0,
config: cfg,
model: resolved_model.clone(),
model: Some(resolved_model.clone()),
auth_manager: auth_manager.clone(),
models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)),
session_header: SessionHeader::new(resolved_model),
@@ -417,6 +417,8 @@ async fn make_chatwidget_manual(
queued_user_messages: VecDeque::new(),
suppress_session_configured_redraw: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -1004,6 +1006,8 @@ async fn alt_up_edits_most_recent_queued_message() {
#[tokio::test]
async fn enqueueing_history_prompt_multiple_times_is_stable() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.conversation_id = Some(ThreadId::new());
assert!(!chat.bottom_pane.is_task_running());
// Submit an initial prompt to seed history.
chat.bottom_pane.set_composer_text("repeat me".to_string());
@@ -1011,6 +1015,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
// Simulate an active task so further submissions are queued.
chat.bottom_pane.set_task_running(true);
assert!(chat.bottom_pane.is_task_running());
for _ in 0..3 {
// Recall the prompt from history and ensure it is what we expect.
@@ -1030,8 +1035,10 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
#[tokio::test]
async fn streaming_final_answer_keeps_task_running_state() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.conversation_id = Some(ThreadId::new());
chat.on_task_started();
assert!(chat.bottom_pane.is_task_running());
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
@@ -1054,19 +1061,34 @@ async fn streaming_final_answer_keeps_task_running_state() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_shutdown_ignores_caps_lock() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
async fn ctrl_c_shutdown_works_with_caps_lock() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Shutdown) => {}
other => panic!("expected Op::Shutdown, got {other:?}"),
}
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_d_with_modal_open_does_not_quit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_approvals_popup();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
@@ -1085,7 +1107,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let restored_text = chat.bottom_pane.composer_text();
@@ -1094,7 +1116,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
"expected placeholder {placeholder:?} after history recall"
);
assert!(restored_text.starts_with("draft message "));
assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
let images = chat.bottom_pane.take_recent_submission_images();
assert!(
@@ -1291,7 +1313,7 @@ async fn slash_quit_requests_exit() {
chat.dispatch_command(SlashCommand::Quit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
@@ -1300,7 +1322,7 @@ async fn slash_exit_requests_exit() {
chat.dispatch_command(SlashCommand::Exit);
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
@@ -1757,6 +1779,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
#[tokio::test]
async fn model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.conversation_id = Some(ThreadId::new());
chat.open_model_popup();
let popup = render_bottom_popup(&chat, 80);
@@ -2039,6 +2062,7 @@ async fn feedback_upload_consent_popup_snapshot() {
#[tokio::test]
async fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.conversation_id = Some(ThreadId::new());
chat.open_model_popup();
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
@@ -3449,6 +3473,7 @@ printf 'fenced within fenced\n'
#[tokio::test]
async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.conversation_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "t1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
+1 -1
View File
@@ -94,7 +94,7 @@ pub struct Cli {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Enable web search (off by default). When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval).
/// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval).
#[arg(long = "search", default_value_t = false)]
pub web_search: bool,
+12
View File
@@ -125,6 +125,18 @@ impl ExecCell {
self.calls.iter()
}
pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool {
if chunk.is_empty() {
return false;
}
let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else {
return false;
};
let output = call.output.get_or_insert_with(CommandOutput::default);
output.aggregated_output.push_str(chunk);
true
}
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
!matches!(call.source, ExecCommandSource::UserShell)
&& !call.parsed.is_empty()
+18 -3
View File
@@ -894,6 +894,7 @@ pub(crate) fn new_session_info(
// Header box rendered as history (so it appears at the very top)
let header = SessionHeaderHistoryCell::new(
model.clone(),
Style::default(),
reasoning_effort,
config.cwd.clone(),
CODEX_CLI_VERSION,
@@ -959,16 +960,28 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
}
#[derive(Debug)]
struct SessionHeaderHistoryCell {
pub(crate) struct SessionHeaderHistoryCell {
version: &'static str,
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
}
impl SessionHeaderHistoryCell {
fn new(
pub(crate) fn new(
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
) -> Self {
Self::new_with_style(model, model_style, reasoning_effort, directory, version)
}
pub(crate) fn new_with_style(
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
@@ -976,6 +989,7 @@ impl SessionHeaderHistoryCell {
Self {
version,
model,
model_style,
reasoning_effort,
directory,
}
@@ -1048,7 +1062,7 @@ impl HistoryCell for SessionHeaderHistoryCell {
let reasoning_label = self.reasoning_label();
let mut model_spans: Vec<Span<'static>> = vec![
Span::from(format!("{model_label} ")).dim(),
Span::from(self.model.clone()),
Span::styled(self.model.clone(), self.model_style),
];
if let Some(reasoning) = reasoning_label {
model_spans.push(Span::from(" "));
@@ -2297,6 +2311,7 @@ mod tests {
fn session_header_includes_reasoning_level_when_present() {
let cell = SessionHeaderHistoryCell::new(
"gpt-4o".to_string(),
Style::default(),
Some(ReasoningEffortConfig::High),
std::env::temp_dir(),
"test",
+1 -1
View File
@@ -138,7 +138,7 @@ pub async fn run_main(
if cli.web_search {
cli.config_overrides
.raw_overrides
.push("features.web_search_request=true".to_string());
.push("web_search=\"live\"".to_string());
}
// When using `--oss`, let the bootstrapper pick the model (defaulting to
+1 -1
View File
@@ -8037,5 +8037,5 @@
{"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"}
{"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"}
+6 -1
View File
@@ -10,7 +10,10 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
portable-pty = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
[target.'cfg(windows)'.dependencies]
filedescriptor = "0.8.3"
@@ -27,3 +30,5 @@ winapi = { version = "0.3.9", features = [
"winerror",
"winnt",
] }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
+56
View File
@@ -0,0 +1,56 @@
# codex-utils-pty
Lightweight helpers for spawning interactive processes either under a PTY (pseudo terminal) or regular pipes. The public API is minimal and mirrors both backends so callers can switch based on their needs (e.g., enabling or disabling TTY).
## API surface
- `spawn_pty_process(program, args, cwd, env, arg0)` → `SpawnedProcess`
- `spawn_pipe_process(program, args, cwd, env, arg0)` → `SpawnedProcess`
- `conpty_supported()` → `bool` (Windows only; always true elsewhere)
- `ProcessHandle` exposes:
- `writer_sender()` → `mpsc::Sender<Vec<u8>>` (stdin)
- `output_receiver()` → `broadcast::Receiver<Vec<u8>>` (stdout/stderr merged)
- `has_exited()`, `exit_code()`, `terminate()`
- `SpawnedProcess` bundles `handle`, `output_rx`, and `exit_rx` (oneshot exit code).
## Usage examples
```rust
use std::collections::HashMap;
use std::path::Path;
use codex_utils_pty::spawn_pty_process;
# tokio_test::block_on(async {
let env_map: HashMap<String, String> = std::env::vars().collect();
let spawned = spawn_pty_process(
"bash",
&["-lc".into(), "echo hello".into()],
Path::new("."),
&env_map,
&None,
).await?;
let writer = spawned.session.writer_sender();
writer.send(b"exit\n".to_vec()).await?;
// Collect output until the process exits.
let mut output_rx = spawned.output_rx;
let mut collected = Vec::new();
while let Ok(chunk) = output_rx.try_recv() {
collected.extend_from_slice(&chunk);
}
let exit_code = spawned.exit_rx.await.unwrap_or(-1);
# let _ = (collected, exit_code);
# anyhow::Ok(())
# });
```
Swap in `spawn_pipe_process` for a non-TTY subprocess; the rest of the API stays the same.
## Tests
Unit tests live in `src/lib.rs` and cover both backends (PTY Python REPL and pipe-based stdin roundtrip). Run with:
```
cargo test -p codex-utils-pty -- --nocapture
```
+20 -268
View File
@@ -1,271 +1,23 @@
use core::fmt;
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
pub mod pipe;
mod process;
pub mod process_group;
pub mod pty;
#[cfg(test)]
mod tests;
#[cfg(windows)]
mod win;
use anyhow::Result;
#[cfg(not(windows))]
use portable_pty::native_pty_system;
use portable_pty::CommandBuilder;
use portable_pty::MasterPty;
use portable_pty::PtySize;
use portable_pty::SlavePty;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
pub struct PtyPairWrapper {
pub _slave: Option<Box<dyn SlavePty + Send>>,
pub _master: Box<dyn MasterPty + Send>,
}
#[derive(Debug)]
pub struct ExecCommandSession {
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
killer: StdMutex<Option<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
reader_handle: StdMutex<Option<JoinHandle<()>>>,
writer_handle: StdMutex<Option<JoinHandle<()>>>,
wait_handle: StdMutex<Option<JoinHandle<()>>>,
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
// PtyPair must be preserved because the process will receive Control+C if the
// slave is closed
_pair: StdMutex<PtyPairWrapper>,
}
impl fmt::Debug for PtyPairWrapper {
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
Ok(())
}
}
impl ExecCommandSession {
#[allow(clippy::too_many_arguments)]
pub fn new(
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
initial_output_rx: broadcast::Receiver<Vec<u8>>,
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
reader_handle: JoinHandle<()>,
writer_handle: JoinHandle<()>,
wait_handle: JoinHandle<()>,
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
pair: PtyPairWrapper,
) -> (Self, broadcast::Receiver<Vec<u8>>) {
(
Self {
writer_tx,
output_tx,
killer: StdMutex::new(Some(killer)),
reader_handle: StdMutex::new(Some(reader_handle)),
writer_handle: StdMutex::new(Some(writer_handle)),
wait_handle: StdMutex::new(Some(wait_handle)),
exit_status,
exit_code,
_pair: StdMutex::new(pair),
},
initial_output_rx,
)
}
pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.writer_tx.clone()
}
pub fn output_receiver(&self) -> broadcast::Receiver<Vec<u8>> {
self.output_tx.subscribe()
}
pub fn has_exited(&self) -> bool {
self.exit_status.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn exit_code(&self) -> Option<i32> {
self.exit_code.lock().ok().and_then(|guard| *guard)
}
pub fn terminate(&self) {
if let Ok(mut killer_opt) = self.killer.lock() {
if let Some(mut killer) = killer_opt.take() {
let _ = killer.kill();
}
}
if let Ok(mut h) = self.reader_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
if let Ok(mut h) = self.writer_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
if let Ok(mut h) = self.wait_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
}
}
impl Drop for ExecCommandSession {
fn drop(&mut self) {
self.terminate();
}
}
#[derive(Debug)]
pub struct SpawnedPty {
pub session: ExecCommandSession,
pub output_rx: broadcast::Receiver<Vec<u8>>,
pub exit_rx: oneshot::Receiver<i32>,
}
#[allow(unreachable_code)]
pub fn conpty_supported() -> bool {
// Annotation required because `win` can't be compiled on other OS.
#[cfg(windows)]
return win::conpty_supported();
true
}
#[cfg(windows)]
fn platform_native_pty_system() -> Box<dyn portable_pty::PtySystem + Send> {
Box::new(win::ConPtySystem::default())
}
#[cfg(not(windows))]
fn platform_native_pty_system() -> Box<dyn portable_pty::PtySystem + Send> {
native_pty_system()
}
pub async fn spawn_pty_process(
program: &str,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
arg0: &Option<String>,
) -> Result<SpawnedPty> {
if program.is_empty() {
anyhow::bail!("missing program for PTY spawn");
}
let pty_system = platform_native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
command_builder.cwd(cwd);
command_builder.env_clear();
for arg in args {
command_builder.arg(arg);
}
for (key, value) in env {
command_builder.env(key, value);
}
let mut child = pair.slave.spawn_command(command_builder)?;
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
// Subscribe before starting the reader thread.
let initial_output_rx = output_tx.subscribe();
let mut reader = pair.master.try_clone_reader()?;
let output_tx_clone = output_tx.clone();
let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8_192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = output_tx_clone.send(buf[..n].to_vec());
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(_) => break,
}
}
});
let writer = pair.master.take_writer()?;
let writer = Arc::new(TokioMutex::new(writer));
let writer_handle: JoinHandle<()> = tokio::spawn({
let writer = Arc::clone(&writer);
async move {
while let Some(bytes) = writer_rx.recv().await {
let mut guard = writer.lock().await;
use std::io::Write;
let _ = guard.write_all(&bytes);
let _ = guard.flush();
}
}
});
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
let exit_status = Arc::new(AtomicBool::new(false));
let wait_exit_status = Arc::clone(&exit_status);
let exit_code = Arc::new(StdMutex::new(None));
let wait_exit_code = Arc::clone(&exit_code);
let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let code = match child.wait() {
Ok(status) => status.exit_code() as i32,
Err(_) => -1,
};
wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut guard) = wait_exit_code.lock() {
*guard = Some(code);
}
let _ = exit_tx.send(code);
});
let pair = PtyPairWrapper {
_slave: if cfg!(windows) {
// Keep the slave handle alive on Windows to prevent the process from receiving Control+C
Some(pair.slave)
} else {
None
},
_master: pair.master,
};
let (session, output_rx) = ExecCommandSession::new(
writer_tx,
output_tx,
initial_output_rx,
killer,
reader_handle,
writer_handle,
wait_handle,
exit_status,
exit_code,
pair,
);
Ok(SpawnedPty {
session,
output_rx,
exit_rx,
})
}
/// Spawn a non-interactive process using regular pipes for stdin/stdout/stderr.
pub use pipe::spawn_process as spawn_pipe_process;
/// Handle for interacting with a spawned process (PTY or pipe).
pub use process::ProcessHandle;
/// Bundle of process handles plus output and exit receivers returned by spawn helpers.
pub use process::SpawnedProcess;
/// Backwards-compatible alias for ProcessHandle.
pub type ExecCommandSession = ProcessHandle;
/// Backwards-compatible alias for SpawnedProcess.
pub type SpawnedPty = SpawnedProcess;
/// Report whether ConPTY is available on this platform (Windows only).
pub use pty::conpty_supported;
/// Spawn a process attached to a PTY for interactive use.
pub use pty::spawn_process as spawn_pty_process;
+232
View File
@@ -0,0 +1,232 @@
use std::collections::HashMap;
use std::io;
use std::io::ErrorKind;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use anyhow::Result;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::process::ChildTerminator;
use crate::process::ProcessHandle;
use crate::process::SpawnedProcess;
#[cfg(target_os = "linux")]
use libc;
struct PipeChildTerminator {
#[cfg(windows)]
pid: u32,
#[cfg(unix)]
process_group_id: u32,
}
impl ChildTerminator for PipeChildTerminator {
fn kill(&mut self) -> io::Result<()> {
#[cfg(unix)]
{
crate::process_group::kill_process_group(self.process_group_id)
}
#[cfg(windows)]
{
kill_process(self.pid)
}
#[cfg(not(any(unix, windows)))]
{
Ok(())
}
}
}
#[cfg(windows)]
fn kill_process(pid: u32) -> io::Result<()> {
unsafe {
let handle = winapi::um::processthreadsapi::OpenProcess(
winapi::um::winnt::PROCESS_TERMINATE,
0,
pid,
);
if handle.is_null() {
return Err(io::Error::last_os_error());
}
let success = winapi::um::processthreadsapi::TerminateProcess(handle, 1);
let err = io::Error::last_os_error();
winapi::um::handleapi::CloseHandle(handle);
if success == 0 {
Err(err)
} else {
Ok(())
}
}
}
async fn read_output_stream<R>(mut reader: R, output_tx: broadcast::Sender<Vec<u8>>)
where
R: AsyncRead + Unpin,
{
let mut buf = vec![0u8; 8_192];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let _ = output_tx.send(buf[..n].to_vec());
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit.
pub async fn spawn_process(
program: &str,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
arg0: &Option<String>,
) -> Result<SpawnedProcess> {
if program.is_empty() {
anyhow::bail!("missing program for pipe spawn");
}
let mut command = Command::new(program);
#[cfg(unix)]
if let Some(arg0) = arg0 {
command.arg0(arg0);
}
#[cfg(target_os = "linux")]
let parent_pid = unsafe { libc::getpid() };
#[cfg(unix)]
unsafe {
command.pre_exec(move || {
crate::process_group::set_process_group()?;
#[cfg(target_os = "linux")]
crate::process_group::set_parent_death_signal(parent_pid)?;
Ok(())
});
}
#[cfg(not(unix))]
let _ = arg0;
command.current_dir(cwd);
command.env_clear();
for (key, value) in env {
command.env(key, value);
}
for arg in args {
command.arg(arg);
}
command.stdin(Stdio::piped());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command.spawn()?;
let pid = child
.id()
.ok_or_else(|| io::Error::other("missing child pid"))?;
#[cfg(unix)]
let process_group_id = pid;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let initial_output_rx = output_tx.subscribe();
let writer_handle = tokio::spawn({
let writer = stdin.map(|w| Arc::new(tokio::sync::Mutex::new(w)));
async move {
while let Some(bytes) = writer_rx.recv().await {
if let Some(writer) = &writer {
let mut guard = writer.lock().await;
let _ = guard.write_all(&bytes).await;
let _ = guard.flush().await;
}
}
}
});
let stdout_handle = stdout.map(|stdout| {
let output_tx = output_tx.clone();
tokio::spawn(async move {
read_output_stream(BufReader::new(stdout), output_tx).await;
})
});
let stderr_handle = stderr.map(|stderr| {
let output_tx = output_tx.clone();
tokio::spawn(async move {
read_output_stream(BufReader::new(stderr), output_tx).await;
})
});
let mut reader_abort_handles = Vec::new();
if let Some(handle) = stdout_handle.as_ref() {
reader_abort_handles.push(handle.abort_handle());
}
if let Some(handle) = stderr_handle.as_ref() {
reader_abort_handles.push(handle.abort_handle());
}
let reader_handle = tokio::spawn(async move {
if let Some(handle) = stdout_handle {
let _ = handle.await;
}
if let Some(handle) = stderr_handle {
let _ = handle.await;
}
});
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
let exit_status = Arc::new(AtomicBool::new(false));
let wait_exit_status = Arc::clone(&exit_status);
let exit_code = Arc::new(StdMutex::new(None));
let wait_exit_code = Arc::clone(&exit_code);
let wait_handle: JoinHandle<()> = tokio::spawn(async move {
let code = match child.wait().await {
Ok(status) => status.code().unwrap_or(-1),
Err(_) => -1,
};
wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut guard) = wait_exit_code.lock() {
*guard = Some(code);
}
let _ = exit_tx.send(code);
});
let (handle, output_rx) = ProcessHandle::new(
writer_tx,
output_tx,
initial_output_rx,
Box::new(PipeChildTerminator {
#[cfg(windows)]
pid,
#[cfg(unix)]
process_group_id,
}),
reader_handle,
reader_abort_handles,
writer_handle,
wait_handle,
exit_status,
exit_code,
None,
);
Ok(SpawnedProcess {
session: handle,
output_rx,
exit_rx,
})
}
+147
View File
@@ -0,0 +1,147 @@
use core::fmt;
use std::io;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use portable_pty::MasterPty;
use portable_pty::SlavePty;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::AbortHandle;
use tokio::task::JoinHandle;
pub(crate) trait ChildTerminator: Send + Sync {
fn kill(&mut self) -> io::Result<()>;
}
pub struct PtyHandles {
pub _slave: Option<Box<dyn SlavePty + Send>>,
pub _master: Box<dyn MasterPty + Send>,
}
impl fmt::Debug for PtyHandles {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PtyHandles").finish()
}
}
/// Handle for driving an interactive process (PTY or pipe).
pub struct ProcessHandle {
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
reader_handle: StdMutex<Option<JoinHandle<()>>>,
reader_abort_handles: StdMutex<Vec<AbortHandle>>,
writer_handle: StdMutex<Option<JoinHandle<()>>>,
wait_handle: StdMutex<Option<JoinHandle<()>>>,
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
// PtyHandles must be preserved because the process will receive Control+C if the
// slave is closed
_pty_handles: StdMutex<Option<PtyHandles>>,
}
impl fmt::Debug for ProcessHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProcessHandle").finish()
}
}
impl ProcessHandle {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
initial_output_rx: broadcast::Receiver<Vec<u8>>,
killer: Box<dyn ChildTerminator>,
reader_handle: JoinHandle<()>,
reader_abort_handles: Vec<AbortHandle>,
writer_handle: JoinHandle<()>,
wait_handle: JoinHandle<()>,
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
pty_handles: Option<PtyHandles>,
) -> (Self, broadcast::Receiver<Vec<u8>>) {
(
Self {
writer_tx,
output_tx,
killer: StdMutex::new(Some(killer)),
reader_handle: StdMutex::new(Some(reader_handle)),
reader_abort_handles: StdMutex::new(reader_abort_handles),
writer_handle: StdMutex::new(Some(writer_handle)),
wait_handle: StdMutex::new(Some(wait_handle)),
exit_status,
exit_code,
_pty_handles: StdMutex::new(pty_handles),
},
initial_output_rx,
)
}
/// Returns a channel sender for writing raw bytes to the child stdin.
pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.writer_tx.clone()
}
/// Returns a broadcast receiver that yields stdout/stderr chunks.
pub fn output_receiver(&self) -> broadcast::Receiver<Vec<u8>> {
self.output_tx.subscribe()
}
/// True if the child process has exited.
pub fn has_exited(&self) -> bool {
self.exit_status.load(std::sync::atomic::Ordering::SeqCst)
}
/// Returns the exit code if known.
pub fn exit_code(&self) -> Option<i32> {
self.exit_code.lock().ok().and_then(|guard| *guard)
}
/// Attempts to kill the child and abort helper tasks.
pub fn terminate(&self) {
if let Ok(mut killer_opt) = self.killer.lock() {
if let Some(mut killer) = killer_opt.take() {
let _ = killer.kill();
}
}
if let Ok(mut h) = self.reader_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
if let Ok(mut handles) = self.reader_abort_handles.lock() {
for handle in handles.drain(..) {
handle.abort();
}
}
if let Ok(mut h) = self.writer_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
if let Ok(mut h) = self.wait_handle.lock() {
if let Some(handle) = h.take() {
handle.abort();
}
}
}
}
impl Drop for ProcessHandle {
fn drop(&mut self) {
self.terminate();
}
}
/// Return value from spawn helpers (PTY or pipe).
#[derive(Debug)]
pub struct SpawnedProcess {
pub session: ProcessHandle,
pub output_rx: broadcast::Receiver<Vec<u8>>,
pub exit_rx: oneshot::Receiver<i32>,
}
+135
View File
@@ -0,0 +1,135 @@
//! Process-group helpers shared by pipe/pty and shell command execution.
//!
//! This module centralizes the OS-specific pieces that ensure a spawned
//! command can be cleaned up reliably:
//! - `set_process_group` is called in `pre_exec` so the child starts its own
//! process group.
//! - `kill_process_group_by_pid` targets the whole group (children/grandchildren)
//! - `kill_process_group` targets a known process group ID directly
//! instead of a single PID.
//! - `set_parent_death_signal` (Linux only) arranges for the child to receive a
//! `SIGTERM` when the parent exits, and re-checks the parent PID to avoid
//! races during fork/exec.
//!
//! On non-Unix platforms these helpers are no-ops.
use std::io;
use tokio::process::Child;
#[cfg(target_os = "linux")]
/// Ensure the child receives SIGTERM when the original parent dies.
///
/// This should run in `pre_exec` and uses `parent_pid` captured before spawn to
/// avoid a race where the parent exits between fork and exec.
pub fn set_parent_death_signal(parent_pid: libc::pid_t) -> io::Result<()> {
if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) } == -1 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::getppid() } != parent_pid {
unsafe {
libc::raise(libc::SIGTERM);
}
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
/// No-op on non-Linux platforms.
pub fn set_parent_death_signal(_parent_pid: i32) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
/// Put the calling process into its own process group.
///
/// Intended for use in `pre_exec` so the child becomes the group leader.
pub fn set_process_group() -> io::Result<()> {
let result = unsafe { libc::setpgid(0, 0) };
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(unix))]
/// No-op on non-Unix platforms.
pub fn set_process_group() -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
/// Kill the process group for the given PID (best-effort).
///
/// This resolves the PGID for `pid` and sends SIGKILL to the whole group.
pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> {
use std::io::ErrorKind;
let pid = pid as libc::pid_t;
let pgid = unsafe { libc::getpgid(pid) };
if pgid == -1 {
let err = io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
return Ok(());
}
let result = unsafe { libc::killpg(pgid, libc::SIGKILL) };
if result == -1 {
let err = io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
}
Ok(())
}
#[cfg(not(unix))]
/// No-op on non-Unix platforms.
pub fn kill_process_group_by_pid(_pid: u32) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
/// Kill a specific process group ID (best-effort).
pub fn kill_process_group(process_group_id: u32) -> io::Result<()> {
use std::io::ErrorKind;
let pgid = process_group_id as libc::pid_t;
let result = unsafe { libc::killpg(pgid, libc::SIGKILL) };
if result == -1 {
let err = io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
}
Ok(())
}
#[cfg(not(unix))]
/// No-op on non-Unix platforms.
pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
/// Kill the process group for a tokio child (best-effort).
pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> {
if let Some(pid) = child.id() {
return kill_process_group_by_pid(pid);
}
Ok(())
}
#[cfg(not(unix))]
/// No-op on non-Unix platforms.
pub fn kill_child_process_group(_child: &mut Child) -> io::Result<()> {
Ok(())
}
+174
View File
@@ -0,0 +1,174 @@
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use anyhow::Result;
#[cfg(not(windows))]
use portable_pty::native_pty_system;
use portable_pty::CommandBuilder;
use portable_pty::PtySize;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::process::ChildTerminator;
use crate::process::ProcessHandle;
use crate::process::PtyHandles;
use crate::process::SpawnedProcess;
/// Returns true when ConPTY support is available (Windows only).
#[cfg(windows)]
pub fn conpty_supported() -> bool {
crate::win::conpty_supported()
}
/// Returns true when ConPTY support is available (non-Windows always true).
#[cfg(not(windows))]
pub fn conpty_supported() -> bool {
true
}
struct PtyChildTerminator {
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
}
impl ChildTerminator for PtyChildTerminator {
fn kill(&mut self) -> std::io::Result<()> {
self.killer.kill()
}
}
fn platform_native_pty_system() -> Box<dyn portable_pty::PtySystem + Send> {
#[cfg(windows)]
{
Box::new(crate::win::ConPtySystem::default())
}
#[cfg(not(windows))]
{
native_pty_system()
}
}
/// Spawn a process attached to a PTY, returning handles for stdin, output, and exit.
pub async fn spawn_process(
program: &str,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
arg0: &Option<String>,
) -> Result<SpawnedProcess> {
if program.is_empty() {
anyhow::bail!("missing program for PTY spawn");
}
let pty_system = platform_native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})?;
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
command_builder.cwd(cwd);
command_builder.env_clear();
for arg in args {
command_builder.arg(arg);
}
for (key, value) in env {
command_builder.env(key, value);
}
let mut child = pair.slave.spawn_command(command_builder)?;
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let initial_output_rx = output_tx.subscribe();
let mut reader = pair.master.try_clone_reader()?;
let output_tx_clone = output_tx.clone();
let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8_192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = output_tx_clone.send(buf[..n].to_vec());
}
Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(_) => break,
}
}
});
let writer = pair.master.take_writer()?;
let writer = Arc::new(tokio::sync::Mutex::new(writer));
let writer_handle: JoinHandle<()> = tokio::spawn({
let writer = Arc::clone(&writer);
async move {
while let Some(bytes) = writer_rx.recv().await {
let mut guard = writer.lock().await;
use std::io::Write;
let _ = guard.write_all(&bytes);
let _ = guard.flush();
}
}
});
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
let exit_status = Arc::new(AtomicBool::new(false));
let wait_exit_status = Arc::clone(&exit_status);
let exit_code = Arc::new(StdMutex::new(None));
let wait_exit_code = Arc::clone(&exit_code);
let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let code = match child.wait() {
Ok(status) => status.exit_code() as i32,
Err(_) => -1,
};
wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut guard) = wait_exit_code.lock() {
*guard = Some(code);
}
let _ = exit_tx.send(code);
});
let handles = PtyHandles {
_slave: if cfg!(windows) {
Some(pair.slave)
} else {
None
},
_master: pair.master,
};
let (handle, output_rx) = ProcessHandle::new(
writer_tx,
output_tx,
initial_output_rx,
Box::new(PtyChildTerminator { killer }),
reader_handle,
Vec::new(),
writer_handle,
wait_handle,
exit_status,
exit_code,
Some(handles),
);
Ok(SpawnedProcess {
session: handle,
output_rx,
exit_rx,
})
}

Some files were not shown because too many files have changed in this diff Show More