core-agent-ide/codex-rs/tui/src/app.rs
Michael Bolin bfff0c729f
config: enforce enterprise feature requirements (#13388)
## Why

Enterprises can already constrain approvals, sandboxing, and web search
through `requirements.toml` and MDM, but feature flags were still only
configurable as managed defaults. That meant an enterprise could suggest
feature values, but it could not actually pin them.

This change closes that gap and makes enterprise feature requirements
behave like the other constrained settings. The effective feature set
now stays consistent with enterprise requirements during config load,
when config writes are validated, and when runtime code mutates feature
flags later in the session.

It also tightens the runtime API for managed features. `ManagedFeatures`
now follows the same constraint-oriented shape as `Constrained<T>`
instead of exposing panic-prone mutation helpers, and production code
can no longer construct it through an unconstrained `From<Features>`
path.

The PR also hardens the `compact_resume_fork` integration coverage on
Windows. After the feature-management changes,
`compact_resume_after_second_compaction_preserves_history` was
overflowing the libtest/Tokio thread stacks on Windows, so the test now
uses an explicit larger-stack harness as a pragmatic mitigation. That
may not be the ideal root-cause fix, and it merits a parallel
investigation into whether part of the async future chain should be
boxed to reduce stack pressure instead.

## What Changed

Enterprises can now pin feature values in `requirements.toml` with the
requirements-side `features` table:

```toml
[features]
personality = true
unified_exec = false
```

Only canonical feature keys are allowed in the requirements `features`
table; omitted keys remain unconstrained.

- Added a requirements-side pinned feature map to
`ConfigRequirementsToml`, threaded it through source-preserving
requirements merge and normalization in `codex-config`, and made the
TOML surface use `[features]` (while still accepting legacy
`[feature_requirements]` for compatibility).
- Exposed `featureRequirements` from `configRequirements/read`,
regenerated the JSON/TypeScript schema artifacts, and updated the
app-server README.
- Wrapped the effective feature set in `ManagedFeatures`, backed by
`ConstrainedWithSource<Features>`, and changed its API to mirror
`Constrained<T>`: `can_set(...)`, `set(...) -> ConstraintResult<()>`,
and result-returning `enable` / `disable` / `set_enabled` helpers.
- Removed the legacy-usage and bulk-map passthroughs from
`ManagedFeatures`; callers that need those behaviors now mutate a plain
`Features` value and reapply it through `set(...)`, so the constrained
wrapper remains the enforcement boundary.
- Removed the production loophole for constructing unconstrained
`ManagedFeatures`. Non-test code now creates it through the configured
feature-loading path, and `impl From<Features> for ManagedFeatures` is
restricted to `#[cfg(test)]`.
- Rejected legacy feature aliases in enterprise feature requirements,
and return a load error when a pinned combination cannot survive
dependency normalization.
- Validated config writes against enterprise feature requirements before
persisting changes, including explicit conflicting writes and
profile-specific feature states that normalize into invalid
combinations.
- Updated runtime and TUI feature-toggle paths to use the constrained
setter API and to persist or apply the effective post-constraint value
rather than the requested value.
- Updated the `core_test_support` Bazel target to include the bundled
core model-catalog fixtures in its runtime data, so helper code that
resolves `core/models.json` through runfiles works in remote Bazel test
environments.
- Renamed the core config test coverage to emphasize that effective
feature values are normalized at runtime, while conflicting persisted
config writes are rejected.
- Ran `compact_resume_after_second_compaction_preserves_history` inside
an explicit 8 MiB test thread and Tokio runtime worker stack, following
the existing larger-stack integration-test pattern, to keep the Windows
`compact_resume_fork` test slice from aborting while a parallel
investigation continues into whether some of the underlying async
futures should be boxed.

## Verification

- `cargo test -p codex-config`
- `cargo test -p codex-core feature_requirements_ -- --nocapture`
- `cargo test -p codex-core
load_requirements_toml_produces_expected_constraints -- --nocapture`
- `cargo test -p codex-core
compact_resume_after_second_compaction_preserves_history -- --nocapture`
- `cargo test -p codex-core compact_resume_fork -- --nocapture`
- Re-ran the built `codex-core` `tests/all` binary with
`RUST_MIN_STACK=262144` for
`compact_resume_after_second_compaction_preserves_history` to confirm
the explicit-stack harness fixes the deterministic low-stack repro.
- `cargo test -p codex-core`
- This still fails locally in unrelated integration areas that expect
the `codex` / `test_stdio_server` binaries or hit existing `search_tool`
wiremock mismatches.

## Docs

`developers.openai.com/codex` should document the requirements-side
`[features]` table for enterprise and MDM-managed configuration,
including that it only accepts canonical feature keys and that
conflicting config writes are rejected.
2026-03-04 04:40:22 +00:00

5592 lines
224 KiB
Rust

use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::chatwidget::ChatWidget;
use crate::chatwidget::ExternalEditorState;
use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_editor;
use crate::file_search::FileSearchManager;
use crate::history_cell;
use crate::history_cell::HistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::model_migration::ModelMigrationOutcome;
use crate::model_migration::migration_copy_for_models;
use crate::model_migration::run_model_migration_prompt;
use crate::multi_agents::AgentPickerThreadEntry;
use crate::multi_agents::agent_picker_status_dot_spans;
use crate::multi_agents::format_agent_picker_item_name;
use crate::multi_agents::sort_agent_picker_threads;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_protocol::ConfigLayerSource;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
#[cfg(target_os = "windows")]
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_otel::OtelManager;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::config_types::Personality;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillErrorInfo;
use codex_protocol::protocol::TokenUsage;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tokio::select;
use tokio::sync::Mutex;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
mod pending_interactive_replay;
use self::pending_interactive_replay::PendingInteractiveReplayState;
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
/// Baseline cadence for periodic stream commit animation ticks.
///
/// Smooth-mode streaming drains one line per tick, so this interval controls
/// perceived typing speed for non-backlogged output.
const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
#[derive(Debug, Clone)]
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub thread_id: Option<ThreadId>,
pub thread_name: Option<String>,
pub update_action: Option<UpdateAction>,
pub exit_reason: ExitReason,
}
impl AppExitInfo {
pub fn fatal(message: impl Into<String>) -> Self {
Self {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::Fatal(message.into()),
}
}
}
#[derive(Debug)]
pub(crate) enum AppRunControl {
Continue,
Exit(ExitReason),
}
#[derive(Debug, Clone)]
pub enum ExitReason {
UserRequested,
Fatal(String),
}
fn session_summary(
token_usage: TokenUsage,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
) -> Option<SessionSummary> {
if token_usage.is_zero() {
return None;
}
let usage_line = FinalOutput::from(token_usage).to_string();
let resume_command = codex_core::util::resume_command(thread_name.as_deref(), thread_id);
Some(SessionSummary {
usage_line,
resume_command,
})
}
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
response
.skills
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.errors.clone())
.unwrap_or_default()
}
fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) {
if errors.is_empty() {
return;
}
let error_count = errors.len();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!(
"Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
)),
)));
for error in errors {
let path = error.path.display();
let message = error.message.as_str();
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
crate::history_cell::new_warning_event(format!("{path}: {message}")),
)));
}
}
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
let mut disabled_folders = Vec::new();
for layer in config
.config_layer_stack
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
{
let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else {
continue;
};
if layer.disabled_reason.is_none() {
continue;
}
disabled_folders.push((
dot_codex_folder.as_path().display().to_string(),
layer
.disabled_reason
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "config.toml is disabled.".to_string()),
));
}
if disabled_folders.is_empty() {
return;
}
let mut message = concat!(
"Project config.toml files are disabled in the following folders. ",
"Settings in those files are ignored, but skills and exec policies still load.\n",
)
.to_string();
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
let display_index = index + 1;
message.push_str(&format!(" {display_index}. {folder}\n"));
message.push_str(&format!(" {reason}\n"));
}
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(message),
)));
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionSummary {
usage_line: String,
resume_command: Option<String>,
}
#[derive(Debug, Clone)]
struct ThreadEventSnapshot {
session_configured: Option<Event>,
events: Vec<Event>,
}
#[derive(Debug)]
struct ThreadEventStore {
session_configured: Option<Event>,
buffer: VecDeque<Event>,
user_message_ids: HashSet<String>,
pending_interactive_replay: PendingInteractiveReplayState,
capacity: usize,
active: bool,
}
impl ThreadEventStore {
fn new(capacity: usize) -> Self {
Self {
session_configured: None,
buffer: VecDeque::new(),
user_message_ids: HashSet::new(),
pending_interactive_replay: PendingInteractiveReplayState::default(),
capacity,
active: false,
}
}
fn new_with_session_configured(capacity: usize, event: Event) -> Self {
let mut store = Self::new(capacity);
store.session_configured = Some(event);
store
}
fn push_event(&mut self, event: Event) {
self.pending_interactive_replay.note_event(&event);
match &event.msg {
EventMsg::SessionConfigured(_) => {
self.session_configured = Some(event);
return;
}
EventMsg::ItemCompleted(completed) => {
if let TurnItem::UserMessage(item) = &completed.item {
if !event.id.is_empty() && self.user_message_ids.contains(&event.id) {
return;
}
let legacy = Event {
id: event.id,
msg: item.as_legacy_event(),
};
self.push_legacy_event(legacy);
return;
}
}
_ => {}
}
self.push_legacy_event(event);
}
fn push_legacy_event(&mut self, event: Event) {
if let EventMsg::UserMessage(_) = &event.msg
&& !event.id.is_empty()
&& !self.user_message_ids.insert(event.id.clone())
{
return;
}
self.buffer.push_back(event);
if self.buffer.len() > self.capacity
&& let Some(removed) = self.buffer.pop_front()
{
self.pending_interactive_replay.note_evicted_event(&removed);
if matches!(removed.msg, EventMsg::UserMessage(_)) && !removed.id.is_empty() {
self.user_message_ids.remove(&removed.id);
}
}
}
fn snapshot(&self) -> ThreadEventSnapshot {
ThreadEventSnapshot {
session_configured: self.session_configured.clone(),
// Thread switches replay buffered events into a rebuilt ChatWidget. Only replay
// interactive prompts that are still pending, or answered approvals/input will reappear.
events: self
.buffer
.iter()
.filter(|event| {
self.pending_interactive_replay
.should_replay_snapshot_event(event)
})
.cloned()
.collect(),
}
}
fn note_outbound_op(&mut self, op: &Op) {
self.pending_interactive_replay.note_outbound_op(op);
}
fn op_can_change_pending_replay_state(op: &Op) -> bool {
PendingInteractiveReplayState::op_can_change_state(op)
}
fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
PendingInteractiveReplayState::event_can_change_pending_thread_approvals(event)
}
fn has_pending_thread_approvals(&self) -> bool {
self.pending_interactive_replay
.has_pending_thread_approvals()
}
}
#[derive(Debug)]
struct ThreadEventChannel {
sender: mpsc::Sender<Event>,
receiver: Option<mpsc::Receiver<Event>>,
store: Arc<Mutex<ThreadEventStore>>,
}
impl ThreadEventChannel {
fn new(capacity: usize) -> Self {
let (sender, receiver) = mpsc::channel(capacity);
Self {
sender,
receiver: Some(receiver),
store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))),
}
}
fn new_with_session_configured(capacity: usize, event: Event) -> Self {
let (sender, receiver) = mpsc::channel(capacity);
Self {
sender,
receiver: Some(receiver),
store: Arc::new(Mutex::new(ThreadEventStore::new_with_session_configured(
capacity, event,
))),
}
}
}
fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
seen_migrations: &BTreeMap<String, String>,
available_models: &[ModelPreset],
) -> bool {
if target_model == current_model {
return false;
}
if let Some(seen_target) = seen_migrations.get(current_model)
&& seen_target == target_model
{
return false;
}
if !available_models
.iter()
.any(|preset| preset.model == target_model && preset.show_in_picker)
{
return false;
}
if available_models
.iter()
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
{
return true;
}
if available_models
.iter()
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
{
return true;
}
false
}
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
match migration_config_key {
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
.notices
.hide_gpt_5_1_codex_max_migration_prompt
.unwrap_or(false),
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
}
_ => false,
}
}
fn target_preset_for_upgrade<'a>(
available_models: &'a [ModelPreset],
target_model: &str,
) -> Option<&'a ModelPreset> {
available_models
.iter()
.find(|preset| preset.model == target_model && preset.show_in_picker)
}
const MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT: u32 = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
struct StartupTooltipOverride {
model_slug: String,
message: String,
}
fn select_model_availability_nux(
available_models: &[ModelPreset],
nux_config: &ModelAvailabilityNuxConfig,
) -> Option<StartupTooltipOverride> {
available_models.iter().find_map(|preset| {
let ModelAvailabilityNux { message } = preset.availability_nux.as_ref()?;
let shown_count = nux_config
.shown_count
.get(&preset.model)
.copied()
.unwrap_or_default();
(shown_count < MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT).then(|| StartupTooltipOverride {
model_slug: preset.model.clone(),
message: message.clone(),
})
})
}
async fn prepare_startup_tooltip_override(
config: &mut Config,
available_models: &[ModelPreset],
is_first_run: bool,
) -> Option<String> {
if is_first_run || !config.show_tooltips {
return None;
}
let tooltip_override =
select_model_availability_nux(available_models, &config.model_availability_nux)?;
let shown_count = config
.model_availability_nux
.shown_count
.get(&tooltip_override.model_slug)
.copied()
.unwrap_or_default();
let next_count = shown_count.saturating_add(1);
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
.set_model_availability_nux_count(&updated_shown_count)
.apply()
.await
{
tracing::error!(
error = %err,
model = %tooltip_override.model_slug,
"failed to persist model availability nux count"
);
return Some(tooltip_override.message);
}
config.model_availability_nux.shown_count = updated_shown_count;
Some(tooltip_override.message)
}
async fn handle_model_migration_prompt_if_needed(
tui: &mut tui::Tui,
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
available_models: &[ModelPreset],
) -> Option<AppExitInfo> {
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
.and_then(|preset| preset.upgrade.as_ref());
if let Some(ModelUpgrade {
id: target_model,
reasoning_effort_mapping,
migration_config_key,
model_link,
upgrade_copy,
migration_markdown,
}) = upgrade
{
if migration_prompt_hidden(config, migration_config_key.as_str()) {
return None;
}
let target_model = target_model.to_string();
if !should_show_model_migration_prompt(
model,
&target_model,
&config.notices.model_migrations,
available_models,
) {
return None;
}
let current_preset = available_models.iter().find(|preset| preset.model == model);
let target_preset = target_preset_for_upgrade(available_models, &target_model);
let target_preset = target_preset?;
let target_display_name = target_preset.display_name.clone();
let heading_label = if target_display_name == model {
target_model.clone()
} else {
target_display_name.clone()
};
let target_description =
(!target_preset.description.is_empty()).then(|| target_preset.description.clone());
let can_opt_out = current_preset.is_some();
let prompt_copy = migration_copy_for_models(
model,
&target_model,
model_link.clone(),
upgrade_copy.clone(),
migration_markdown.clone(),
heading_label,
target_description,
can_opt_out,
);
match run_model_migration_prompt(tui, prompt_copy).await {
ModelMigrationOutcome::Accepted => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
&& let Some(reasoning_effort) = config.model_reasoning_effort
{
reasoning_effort_mapping
.get(&reasoning_effort)
.cloned()
.or(config.model_reasoning_effort)
} else {
config.model_reasoning_effort
};
config.model = Some(target_model.clone());
config.model_reasoning_effort = mapped_effort;
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
app_event_tx.send(AppEvent::PersistModelSelection {
model: target_model.clone(),
effort: mapped_effort,
});
}
ModelMigrationOutcome::Rejected => {
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
from_model: model.to_string(),
to_model: target_model.clone(),
});
}
ModelMigrationOutcome::Exit => {
return Some(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
}
}
None
}
pub(crate) struct App {
pub(crate) server: Arc<ThreadManager>,
pub(crate) otel_manager: OtelManager,
pub(crate) app_event_tx: AppEventSender,
pub(crate) chat_widget: ChatWidget,
pub(crate) auth_manager: Arc<AuthManager>,
/// Config is stored here so we can recreate ChatWidgets as needed.
pub(crate) config: Config,
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_sandbox_policy_override: Option<SandboxPolicy>,
pub(crate) file_search: FileSearchManager,
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
pub(crate) enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid status-line config warnings only emit once.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Esc-backtracking state grouped
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
/// When set, the next draw re-renders the transcript into terminal scrollback once.
///
/// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed
/// transcript cells.
pub(crate) backtrack_render_pending: bool,
pub(crate) feedback: codex_feedback::CodexFeedback,
feedback_audience: FeedbackAudience,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
/// One-shot guard used while switching threads.
///
/// We set this when intentionally stopping the current thread before moving
/// to another one, then ignore exactly one `ShutdownComplete` so it is not
/// misclassified as an unexpected sub-agent death.
suppress_shutdown_complete: bool,
/// Tracks the thread we intentionally shut down while exiting the app.
///
/// When this matches the active thread, its `ShutdownComplete` should lead to
/// process exit instead of being treated as an unexpected sub-agent death that
/// triggers failover to the primary thread.
///
/// This is thread-scoped state (`Option<ThreadId>`) instead of a global bool
/// so shutdown events from other threads still take the normal failover path.
pending_shutdown_exit_thread_id: Option<ThreadId>,
windows_sandbox: WindowsSandboxState,
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
agent_picker_threads: HashMap<ThreadId, AgentPickerThreadEntry>,
active_thread_id: Option<ThreadId>,
active_thread_rx: Option<mpsc::Receiver<Event>>,
primary_thread_id: Option<ThreadId>,
primary_session_configured: Option<SessionConfiguredEvent>,
pending_primary_events: VecDeque<Event>,
}
#[derive(Default)]
struct WindowsSandboxState {
setup_started_at: Option<Instant>,
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
}
fn normalize_harness_overrides_for_cwd(
mut overrides: ConfigOverrides,
base_cwd: &Path,
) -> Result<ConfigOverrides> {
if overrides.additional_writable_roots.is_empty() {
return Ok(overrides);
}
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
for root in overrides.additional_writable_roots.drain(..) {
let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?;
normalized.push(absolute.into_path_buf());
}
overrides.additional_writable_roots = normalized;
Ok(overrides)
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
tui: &mut tui::Tui,
cfg: codex_core::config::Config,
) -> crate::chatwidget::ChatWidgetInit {
crate::chatwidget::ChatWidgetInit {
config: cfg,
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
// Fork/resume bootstraps here don't carry any prefilled message content.
initial_user_message: None,
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
feedback_audience: self.feedback_audience,
model: Some(self.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
otel_manager: self.otel_manager.clone(),
}
}
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
let mut overrides = self.harness_overrides.clone();
overrides.cwd = Some(cwd.clone());
let cwd_display = cwd.display().to_string();
ConfigBuilder::default()
.codex_home(self.config.codex_home.clone())
.cli_overrides(self.cli_kv_overrides.clone())
.harness_overrides(overrides)
.build()
.await
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
}
async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> {
let mut config = self.rebuild_config_for_cwd(self.config.cwd.clone()).await?;
self.apply_runtime_policy_overrides(&mut config);
self.config = config;
Ok(())
}
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(*policy)
{
tracing::warn!(%err, "failed to carry forward approval policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward approval policy override: {err}"
));
}
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
&& let Err(err) = config.permissions.sandbox_policy.set(policy.clone())
{
tracing::warn!(%err, "failed to carry forward sandbox policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward sandbox policy override: {err}"
));
}
}
fn open_url_in_browser(&mut self, url: String) {
if let Err(err) = webbrowser::open(&url) {
self.chat_widget
.add_error_message(format!("Failed to open browser for {url}: {err}"));
return;
}
self.chat_widget
.add_info_message(format!("Opened {url} in your browser."), None);
}
fn clear_ui_header_lines_with_version(
&self,
width: u16,
version: &'static str,
) -> Vec<Line<'static>> {
history_cell::SessionHeaderHistoryCell::new(
self.chat_widget.current_model().to_string(),
self.chat_widget.current_reasoning_effort(),
self.config.cwd.clone(),
version,
)
.display_lines(width)
}
fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION)
}
fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) {
let width = tui.terminal.last_known_screen_size.width;
let header_lines = self.clear_ui_header_lines(width);
if !header_lines.is_empty() {
tui.insert_history_lines(header_lines);
self.has_emitted_history_lines = true;
}
}
fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> {
let is_alt_screen_active = tui.is_alt_screen_active();
// Drop queued history insertions so stale transcript lines cannot be flushed after /clear.
tui.clear_pending_history_lines();
if is_alt_screen_active {
tui.terminal.clear_visible_screen()?;
} else {
// Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and
// clear are emitted as separate backend commands. Prefer a single ANSI sequence.
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
let mut area = tui.terminal.viewport_area;
if area.y > 0 {
// After a full clear, anchor the inline viewport at the top and redraw a fresh header
// box. `insert_history_lines()` will shift the viewport down by the rendered height.
area.y = 0;
tui.terminal.set_viewport_area(area);
}
self.has_emitted_history_lines = false;
if redraw_header {
self.queue_clear_ui_header(tui);
}
Ok(())
}
fn reset_app_ui_state_after_clear(&mut self) {
self.overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
}
async fn shutdown_current_thread(&mut self) {
if let Some(thread_id) = self.chat_widget.thread_id() {
// Clear any in-flight rollback guard when switching threads.
self.backtrack.pending_rollback = None;
self.suppress_shutdown_complete = true;
self.chat_widget.submit_op(Op::Shutdown);
self.server.remove_thread(&thread_id).await;
self.abort_thread_event_listener(thread_id);
}
}
fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) {
handle.abort();
}
}
fn abort_all_thread_event_listeners(&mut self) {
for handle in self
.thread_event_listener_tasks
.drain()
.map(|(_, handle)| handle)
{
handle.abort();
}
}
fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel {
self.thread_event_channels
.entry(thread_id)
.or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY))
}
async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) {
if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
let mut store = channel.store.lock().await;
store.active = active;
}
}
async fn activate_thread_channel(&mut self, thread_id: ThreadId) {
if self.active_thread_id.is_some() {
return;
}
self.set_thread_active(thread_id, true).await;
let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
channel.receiver.take()
} else {
None
};
self.active_thread_id = Some(thread_id);
self.active_thread_rx = receiver;
self.refresh_pending_thread_approvals().await;
}
async fn store_active_thread_receiver(&mut self) {
let Some(active_id) = self.active_thread_id else {
return;
};
let Some(receiver) = self.active_thread_rx.take() else {
return;
};
if let Some(channel) = self.thread_event_channels.get_mut(&active_id) {
let mut store = channel.store.lock().await;
store.active = false;
channel.receiver = Some(receiver);
}
}
async fn activate_thread_for_replay(
&mut self,
thread_id: ThreadId,
) -> Option<(mpsc::Receiver<Event>, ThreadEventSnapshot)> {
let channel = self.thread_event_channels.get_mut(&thread_id)?;
let receiver = channel.receiver.take()?;
let mut store = channel.store.lock().await;
store.active = true;
let snapshot = store.snapshot();
Some((receiver, snapshot))
}
async fn clear_active_thread(&mut self) {
if let Some(active_id) = self.active_thread_id.take() {
self.set_thread_active(active_id, false).await;
}
self.active_thread_rx = None;
self.refresh_pending_thread_approvals().await;
}
async fn note_thread_outbound_op(&mut self, thread_id: ThreadId, op: &Op) {
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
return;
};
let mut store = channel.store.lock().await;
store.note_outbound_op(op);
}
async fn note_active_thread_outbound_op(&mut self, op: &Op) {
if !ThreadEventStore::op_can_change_pending_replay_state(op) {
return;
}
let Some(thread_id) = self.active_thread_id else {
return;
};
self.note_thread_outbound_op(thread_id, op).await;
}
fn thread_label(&self, thread_id: ThreadId) -> String {
let is_primary = self.primary_thread_id == Some(thread_id);
let fallback_label = if is_primary {
"Main [default]".to_string()
} else {
let thread_id = thread_id.to_string();
let short_id: String = thread_id.chars().take(8).collect();
format!("Agent ({short_id})")
};
if let Some(entry) = self.agent_picker_threads.get(&thread_id) {
let label = format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
);
if label == "Agent" {
let thread_id = thread_id.to_string();
let short_id: String = thread_id.chars().take(8).collect();
format!("{label} ({short_id})")
} else {
label
}
} else {
fallback_label
}
}
async fn thread_cwd(&self, thread_id: ThreadId) -> Option<PathBuf> {
let channel = self.thread_event_channels.get(&thread_id)?;
let store = channel.store.lock().await;
match store.session_configured.as_ref().map(|event| &event.msg) {
Some(EventMsg::SessionConfigured(session)) => Some(session.cwd.clone()),
_ => None,
}
}
async fn approval_request_for_thread_event(
&self,
thread_id: ThreadId,
event: &Event,
) -> Option<ApprovalRequest> {
let thread_label = Some(self.thread_label(thread_id));
match &event.msg {
EventMsg::ExecApprovalRequest(ev) => Some(ApprovalRequest::Exec {
thread_id,
thread_label,
id: ev.effective_approval_id(),
command: ev.command.clone(),
reason: ev.reason.clone(),
available_decisions: ev.effective_available_decisions(),
network_approval_context: ev.network_approval_context.clone(),
additional_permissions: ev.additional_permissions.clone(),
}),
EventMsg::ApplyPatchApprovalRequest(ev) => Some(ApprovalRequest::ApplyPatch {
thread_id,
thread_label,
id: ev.call_id.clone(),
reason: ev.reason.clone(),
cwd: self
.thread_cwd(thread_id)
.await
.unwrap_or_else(|| self.config.cwd.clone()),
changes: ev.changes.clone(),
}),
EventMsg::ElicitationRequest(ev) => Some(ApprovalRequest::McpElicitation {
thread_id,
thread_label,
server_name: ev.server_name.clone(),
request_id: ev.id.clone(),
message: ev.message.clone(),
}),
_ => None,
}
}
async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: Op) {
let replay_state_op =
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
let submitted = if self.active_thread_id == Some(thread_id) {
self.chat_widget.submit_op(op)
} else {
crate::session_log::log_outbound_op(&op);
match self.server.get_thread(thread_id).await {
Ok(thread) => match thread.submit(op).await {
Ok(_) => true,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to submit op to thread {thread_id}: {err}"
));
false
}
},
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to find thread {thread_id} for approval response: {err}"
));
false
}
}
};
if submitted && let Some(op) = replay_state_op.as_ref() {
self.note_thread_outbound_op(thread_id, op).await;
self.refresh_pending_thread_approvals().await;
}
}
async fn refresh_pending_thread_approvals(&mut self) {
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
.thread_event_channels
.iter()
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
.collect();
let mut pending_thread_ids = Vec::new();
for (thread_id, store) in channels {
if Some(thread_id) == self.active_thread_id {
continue;
}
let store = store.lock().await;
if store.has_pending_thread_approvals() {
pending_thread_ids.push(thread_id);
}
}
pending_thread_ids.sort_by_key(ThreadId::to_string);
let threads = pending_thread_ids
.into_iter()
.map(|thread_id| self.thread_label(thread_id))
.collect();
self.chat_widget.set_pending_thread_approvals(threads);
}
async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> {
let refresh_pending_thread_approvals =
ThreadEventStore::event_can_change_pending_thread_approvals(&event);
let inactive_approval_request = if self.active_thread_id != Some(thread_id) {
self.approval_request_for_thread_event(thread_id, &event)
.await
} else {
None
};
let (sender, store) = {
let channel = self.ensure_thread_channel(thread_id);
(channel.sender.clone(), Arc::clone(&channel.store))
};
let should_send = {
let mut guard = store.lock().await;
guard.push_event(event.clone());
guard.active
};
if should_send {
// Never await a bounded channel send on the main TUI loop: if the receiver falls behind,
// `send().await` can block and the UI stops drawing. If the channel is full, wait in a
// spawned task instead.
match sender.try_send(event) {
Ok(()) => {}
Err(TrySendError::Full(event)) => {
tokio::spawn(async move {
if let Err(err) = sender.send(event).await {
tracing::warn!("thread {thread_id} event channel closed: {err}");
}
});
}
Err(TrySendError::Closed(_)) => {
tracing::warn!("thread {thread_id} event channel closed");
}
}
} else if let Some(request) = inactive_approval_request {
self.chat_widget.push_approval_request(request);
}
if refresh_pending_thread_approvals {
self.refresh_pending_thread_approvals().await;
}
Ok(())
}
async fn handle_routed_thread_event(
&mut self,
thread_id: ThreadId,
event: Event,
) -> Result<()> {
if !self.thread_event_channels.contains_key(&thread_id) {
tracing::debug!("dropping stale event for untracked thread {thread_id}");
return Ok(());
}
self.enqueue_thread_event(thread_id, event).await
}
async fn enqueue_primary_event(&mut self, event: Event) -> Result<()> {
if let Some(thread_id) = self.primary_thread_id {
return self.enqueue_thread_event(thread_id, event).await;
}
if let EventMsg::SessionConfigured(session) = &event.msg {
let thread_id = session.session_id;
self.primary_thread_id = Some(thread_id);
self.primary_session_configured = Some(session.clone());
self.ensure_thread_channel(thread_id);
self.activate_thread_channel(thread_id).await;
self.enqueue_thread_event(thread_id, event).await?;
let pending = std::mem::take(&mut self.pending_primary_events);
for pending_event in pending {
self.enqueue_thread_event(thread_id, pending_event).await?;
}
} else {
self.pending_primary_events.push_back(event);
}
Ok(())
}
async fn open_agent_picker(&mut self) {
let thread_ids: Vec<ThreadId> = self.thread_event_channels.keys().cloned().collect();
for thread_id in thread_ids {
match self.server.get_thread(thread_id).await {
Ok(thread) => {
let session_source = thread.config_snapshot().await.session_source;
self.upsert_agent_picker_thread(
thread_id,
session_source.get_nickname(),
session_source.get_agent_role(),
false,
);
}
Err(_) => {
self.mark_agent_picker_thread_closed(thread_id);
}
}
}
let has_non_primary_agent_thread = self
.agent_picker_threads
.keys()
.any(|thread_id| Some(*thread_id) != self.primary_thread_id);
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
self.chat_widget.open_multi_agent_enable_prompt();
return;
}
if self.agent_picker_threads.is_empty() {
self.chat_widget
.add_info_message("No agents available yet.".to_string(), None);
return;
}
let mut agent_threads: Vec<(ThreadId, AgentPickerThreadEntry)> = self
.agent_picker_threads
.iter()
.map(|(thread_id, entry)| (*thread_id, entry.clone()))
.collect();
sort_agent_picker_threads(&mut agent_threads);
let mut initial_selected_idx = None;
let items: Vec<SelectionItem> = agent_threads
.iter()
.enumerate()
.map(|(idx, (thread_id, entry))| {
if self.active_thread_id == Some(*thread_id) {
initial_selected_idx = Some(idx);
}
let id = *thread_id;
let is_primary = self.primary_thread_id == Some(*thread_id);
let name = format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
);
let uuid = thread_id.to_string();
SelectionItem {
name: name.clone(),
name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed),
description: Some(uuid.clone()),
is_current: self.active_thread_id == Some(*thread_id),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SelectAgentThread(id));
})],
dismiss_on_select: true,
search_value: Some(format!("{name} {uuid}")),
..Default::default()
}
})
.collect();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Multi-agents".to_string()),
subtitle: Some("Select an agent to watch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
});
}
fn upsert_agent_picker_thread(
&mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
agent_role: Option<String>,
is_closed: bool,
) {
self.agent_picker_threads.insert(
thread_id,
AgentPickerThreadEntry {
agent_nickname,
agent_role,
is_closed,
},
);
}
fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) {
if let Some(entry) = self.agent_picker_threads.get_mut(&thread_id) {
entry.is_closed = true;
} else {
self.upsert_agent_picker_thread(thread_id, None, None, true);
}
}
async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> {
if self.active_thread_id == Some(thread_id) {
return Ok(());
}
let live_thread = match self.server.get_thread(thread_id).await {
Ok(thread) => Some(thread),
Err(err) => {
if self.thread_event_channels.contains_key(&thread_id) {
self.mark_agent_picker_thread_closed(thread_id);
None
} else {
self.chat_widget.add_error_message(format!(
"Failed to attach to agent thread {thread_id}: {err}"
));
return Ok(());
}
}
};
let is_replay_only = live_thread.is_none();
let previous_thread_id = self.active_thread_id;
self.store_active_thread_receiver().await;
self.active_thread_id = None;
let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else {
self.chat_widget
.add_error_message(format!("Agent thread {thread_id} is already active."));
if let Some(previous_thread_id) = previous_thread_id {
self.activate_thread_channel(previous_thread_id).await;
}
return Ok(());
};
self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver);
let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
let codex_op_tx = if let Some(thread) = live_thread {
crate::chatwidget::spawn_op_forwarder(thread)
} else {
let (tx, _rx) = unbounded_channel();
tx
};
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot);
if is_replay_only {
self.chat_widget.add_info_message(
format!("Agent thread {thread_id} is closed. Replaying saved transcript."),
None,
);
}
self.drain_active_thread_events(tui).await?;
self.refresh_pending_thread_approvals().await;
Ok(())
}
fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
self.overlay = None;
self.transcript_cells.clear();
self.deferred_history_lines.clear();
self.has_emitted_history_lines = false;
self.backtrack = BacktrackState::default();
self.backtrack_render_pending = false;
tui.terminal.clear_scrollback()?;
tui.terminal.clear()?;
Ok(())
}
fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.thread_event_channels.clear();
self.agent_picker_threads.clear();
self.active_thread_id = None;
self.active_thread_rx = None;
self.primary_thread_id = None;
self.pending_primary_events.clear();
self.chat_widget.set_pending_thread_approvals(Vec::new());
}
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
// Start a fresh in-memory session while preserving resumability via persisted rollout
// history.
let model = self.chat_widget.current_model().to_string();
let config = self.fresh_session_config();
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
self.shutdown_current_thread().await;
if let Err(err) = self.server.remove_and_close_all_threads().await {
tracing::warn!(error = %err, "failed to close all threads");
}
let init = crate::chatwidget::ChatWidgetInit {
config,
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
// New sessions start without prefilled message content.
initial_user_message: None,
enhanced_keys_supported: self.enhanced_keys_supported,
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
is_first_run: false,
feedback_audience: self.feedback_audience,
model: Some(model),
startup_tooltip_override: None,
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
otel_manager: self.otel_manager.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
tui.frame_requester().schedule_frame();
}
fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.current_service_tier();
config
}
async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
let Some(mut rx) = self.active_thread_rx.take() else {
return Ok(());
};
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(event) => self.handle_codex_event_now(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if !disconnected {
self.active_thread_rx = Some(rx);
} else {
self.clear_active_thread().await;
}
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
}
Ok(())
}
/// Returns `(closed_thread_id, primary_thread_id)` when a non-primary active
/// thread has died and we should fail over to the primary thread.
///
/// A user-requested shutdown (`ExitMode::ShutdownFirst`) sets
/// `pending_shutdown_exit_thread_id`; matching shutdown completions are ignored
/// here so Ctrl+C-like exits don't accidentally resurrect the main thread.
///
/// Failover is only eligible when all of these are true:
/// 1. the event is `ShutdownComplete`;
/// 2. the active thread differs from the primary thread;
/// 3. the active thread is not the pending shutdown-exit thread.
fn active_non_primary_shutdown_target(&self, msg: &EventMsg) -> Option<(ThreadId, ThreadId)> {
if !matches!(msg, EventMsg::ShutdownComplete) {
return None;
}
let active_thread_id = self.active_thread_id?;
let primary_thread_id = self.primary_thread_id?;
if self.pending_shutdown_exit_thread_id == Some(active_thread_id) {
return None;
}
(active_thread_id != primary_thread_id).then_some((active_thread_id, primary_thread_id))
}
fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) {
if let Some(event) = snapshot.session_configured {
self.handle_codex_event_replay(event);
}
for event in snapshot.events {
self.handle_codex_event_replay(event);
}
self.refresh_status_line();
}
fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
matches!(
session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
)
}
fn should_handle_active_thread_events(
waiting_for_initial_session_configured: bool,
has_active_thread_receiver: bool,
) -> bool {
has_active_thread_receiver && !waiting_for_initial_session_configured
}
fn should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured: bool,
primary_thread_id: Option<ThreadId>,
) -> bool {
waiting_for_initial_session_configured && primary_thread_id.is_some()
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
tui: &mut tui::Tui,
auth_manager: Arc<AuthManager>,
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
tui.set_notification_method(config.tui_notification_method);
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let thread_manager = Arc::new(ThreadManager::new(
config.codex_home.clone(),
auth_manager.clone(),
SessionSource::Cli,
config.model_catalog.clone(),
CollaborationModesConfig {
default_mode_request_user_input: config
.features
.enabled(codex_core::features::Feature::DefaultModeRequestUserInput),
},
));
let mut model = thread_manager
.get_models_manager()
.get_default_model(&config.model, RefreshStrategy::Offline)
.await;
let available_models = thread_manager
.get_models_manager()
.list_models(RefreshStrategy::Offline)
.await;
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
&available_models,
)
.await;
if let Some(exit_info) = exit_info {
return Ok(exit_info);
}
if let Some(updated_model) = config.model.clone() {
model = updated_model;
}
let auth = auth_manager.auth().await;
let auth_ref = auth.as_ref();
// Determine who should see internal Slack routing. We treat
// `@openai.com` emails as employees and default to `External` when the
// email is unavailable (for example, API key auth).
let feedback_audience = if auth_ref
.and_then(CodexAuth::get_account_email)
.is_some_and(|email| email.ends_with("@openai.com"))
{
FeedbackAudience::OpenAiEmployee
} else {
FeedbackAudience::External
};
let auth_mode = auth_ref
.map(CodexAuth::auth_mode)
.map(TelemetryAuthMode::from);
let otel_manager = OtelManager::new(
ThreadId::new(),
model.as_str(),
model.as_str(),
auth_ref.and_then(CodexAuth::get_account_id),
auth_ref.and_then(CodexAuth::get_account_email),
auth_mode,
codex_core::default_client::originator().value,
config.otel.log_user_prompt,
codex_core::terminal::user_agent(),
SessionSource::Cli,
);
if config
.tui_status_line
.as_ref()
.is_some_and(|cmd| !cmd.is_empty())
{
otel_manager.counter("codex.status_line", 1, &[]);
}
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
let enhanced_keys_supported = tui.enhanced_keys_supported();
let wait_for_initial_session_configured =
Self::should_wait_for_initial_session(&session_selection);
let mut chat_widget = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
let startup_tooltip_override =
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
auth_manager: auth_manager.clone(),
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
feedback_audience,
model: Some(model.clone()),
startup_tooltip_override,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new(init, thread_manager.clone())
}
SessionSelection::Resume(target_session) => {
let resumed = thread_manager
.resume_thread_from_rollout(
config.clone(),
target_session.path.clone(),
auth_manager.clone(),
)
.await
.wrap_err_with(|| {
let path_display = target_session.path.display();
format!("Failed to resume session from {path_display}")
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
auth_manager: auth_manager.clone(),
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
feedback_audience,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
}
SessionSelection::Fork(target_session) => {
otel_manager.counter("codex.thread.fork", 1, &[("source", "cli_subcommand")]);
let forked = thread_manager
.fork_thread(
usize::MAX,
config.clone(),
target_session.path.clone(),
false,
)
.await
.wrap_err_with(|| {
let path_display = target_session.path.display();
format!("Failed to fork session from {path_display}")
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_user_message: crate::chatwidget::create_initial_user_message(
initial_prompt.clone(),
initial_images.clone(),
// CLI prompt args are plain strings, so they don't provide element ranges.
Vec::new(),
),
enhanced_keys_supported,
auth_manager: auth_manager.clone(),
models_manager: thread_manager.get_models_manager(),
feedback: feedback.clone(),
is_first_run,
feedback_audience,
model: config.model.clone(),
startup_tooltip_override: None,
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
otel_manager: otel_manager.clone(),
};
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
}
};
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let mut app = Self {
server: thread_manager.clone(),
otel_manager: otel_manager.clone(),
app_event_tx,
chat_widget,
auth_manager: auth_manager.clone(),
config,
active_profile,
cli_kv_overrides,
harness_overrides,
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
enhanced_keys_supported,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: feedback.clone(),
feedback_audience,
pending_update_action: None,
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_picker_threads: HashMap::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
};
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let should_check = WindowsSandboxLevel::from_config(&app.config)
!= WindowsSandboxLevel::Disabled
&& matches!(
app.config.permissions.sandbox_policy.get(),
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::ReadOnly { .. }
)
&& !app
.config
.notices
.hide_world_writable_warning
.unwrap_or(false);
if should_check {
let cwd = app.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
let logs_base_dir = app.config.codex_home.clone();
let sandbox_policy = app.config.permissions.sandbox_policy.get().clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
}
}
#[cfg(not(debug_assertions))]
if let Some(latest_version) = upgrade_version {
let control = app
.handle_event(
tui,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
))),
)
.await?;
if let AppRunControl::Exit(exit_reason) = control {
return Ok(AppExitInfo {
token_usage: app.token_usage(),
thread_id: app.chat_widget.thread_id(),
thread_name: app.chat_widget.thread_name(),
update_action: app.pending_update_action,
exit_reason,
});
}
}
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
tui.frame_requester().schedule_frame();
let mut thread_created_rx = thread_manager.subscribe_thread_created();
let mut listen_for_threads = true;
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
let exit_reason = loop {
let control = select! {
Some(event) = app_event_rx.recv() => {
app.handle_event(tui, event).await?
}
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) => {
if let Some(event) = active {
app.handle_active_thread_event(tui, event).await?;
} else {
app.clear_active_thread().await;
}
AppRunControl::Continue
}
Some(event) = tui_events.next() => {
app.handle_tui_event(tui, event).await?
}
// Listen on new thread creation due to collab tools.
created = thread_created_rx.recv(), if listen_for_threads => {
match created {
Ok(thread_id) => {
app.handle_thread_created(thread_id).await?;
}
Err(broadcast::error::RecvError::Lagged(_)) => {
tracing::warn!("thread_created receiver lagged; skipping resync");
}
Err(broadcast::error::RecvError::Closed) => {
listen_for_threads = false;
}
}
AppRunControl::Continue
}
};
if App::should_stop_waiting_for_initial_session(
waiting_for_initial_session_configured,
app.primary_thread_id,
) {
waiting_for_initial_session_configured = false;
}
match control {
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => break reason,
}
};
tui.terminal.clear()?;
Ok(AppExitInfo {
token_usage: app.token_usage(),
thread_id: app.chat_widget.thread_id(),
thread_name: app.chat_widget.thread_name(),
update_action: app.pending_update_action,
exit_reason,
})
}
pub(crate) async fn handle_tui_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<AppRunControl> {
if matches!(event, TuiEvent::Draw) {
let size = tui.terminal.size()?;
if size != tui.terminal.last_known_screen_size {
self.refresh_status_line();
}
}
if self.overlay.is_some() {
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else {
match event {
TuiEvent::Key(key_event) => {
self.handle_key_event(tui, key_event).await;
}
TuiEvent::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
self.chat_widget.handle_paste(pasted);
}
TuiEvent::Draw => {
if self.backtrack_render_pending {
self.backtrack_render_pending = false;
self.render_transcript_once(tui);
}
self.chat_widget.maybe_post_pending_notification(tui);
if self
.chat_widget
.handle_paste_burst_tick(tui.frame_requester())
{
return Ok(AppRunControl::Continue);
}
// Allow widgets to process any pending timers before rendering.
self.chat_widget.pre_draw_tick();
tui.draw(
self.chat_widget.desired_height(tui.terminal.size()?.width),
|frame| {
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
},
)?;
if self.chat_widget.external_editor_state() == ExternalEditorState::Requested {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Active);
self.app_event_tx.send(AppEvent::LaunchExternalEditor);
}
}
}
}
Ok(AppRunControl::Continue)
}
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
match event {
AppEvent::NewSession => {
self.start_fresh_session_with_summary_hint(tui).await;
}
AppEvent::ClearUi => {
self.clear_terminal_ui(tui, false)?;
self.reset_app_ui_state_after_clear();
self.start_fresh_session_with_summary_hint(tui).await;
}
AppEvent::OpenResumePicker => {
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
SessionSelection::Resume(target_session) => {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
&target_session.path,
CwdPromptAction::Resume,
true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
};
let mut resume_config = if crate::cwds_differ(&current_cwd, &resume_cwd) {
match self.rebuild_config_for_cwd(resume_cwd).await {
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
}
} else {
// No rebuild needed: current_cwd comes from self.config.cwd.
self.config.clone()
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
match self
.server
.resume_thread_from_rollout(
resume_config.clone(),
target_session.path.clone(),
self.auth_manager.clone(),
)
.await
{
Ok(resumed) => {
self.shutdown_current_thread().await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
resumed.thread,
resumed.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
let path_display = target_session.path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Fork(_) => {}
}
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::ForkCurrentSession => {
self.otel_manager
.counter("codex.thread.fork", 1, &[("source", "slash_command")]);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(path) = self.chat_widget.rollout_path() {
// Fresh threads expose a precomputed path, but the file is
// materialized lazily on first user message.
if path.exists() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone(), false)
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
// part of an ongoing stream. Streaming continuations should not
// accrue extra blank lines between chunks.
if !cell.is_stream_continuation() {
if self.has_emitted_history_lines {
display.insert(0, Line::from(""));
} else {
self.has_emitted_history_lines = true;
}
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
tui.insert_history_lines(display);
}
}
}
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StartCommitAnimation => {
if self
.commit_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.commit_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(COMMIT_ANIMATION_TICK);
tx.send(AppEvent::CommitTick);
}
});
}
}
AppEvent::StopCommitAnimation => {
self.commit_anim_running.store(false, Ordering::Release);
}
AppEvent::CommitTick => {
self.chat_widget.on_commit_tick();
}
AppEvent::CodexEvent(event) => {
self.enqueue_primary_event(event).await?;
}
AppEvent::ThreadEvent { thread_id, event } => {
self.handle_routed_thread_event(thread_id, event).await?;
}
AppEvent::Exit(mode) => {
return Ok(self.handle_exit_mode(mode));
}
AppEvent::FatalExitRequest(message) => {
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
}
AppEvent::CodexOp(op) => {
let replay_state_op =
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
let submitted = self.chat_widget.submit_op(op);
if submitted && let Some(op) = replay_state_op.as_ref() {
self.note_active_thread_outbound_op(op).await;
self.refresh_pending_thread_approvals().await;
}
}
AppEvent::SubmitThreadOp { thread_id, op } => {
self.submit_op_to_thread(thread_id, op).await;
}
AppEvent::DiffResult(text) => {
// Clear the in-progress state in the bottom pane
self.chat_widget.on_diff_complete();
// Enter alternate screen using TUI helper and build pager lines
let _ = tui.enter_alt_screen();
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
vec!["No changes detected.".italic().into()]
} else {
text.lines().map(ansi_escape_line).collect()
};
self.overlay = Some(Overlay::new_static_with_lines(
pager_lines,
"D I F F".to_string(),
));
tui.frame_requester().schedule_frame();
}
AppEvent::OpenAppLink {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
} => {
self.chat_widget
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
});
}
AppEvent::OpenUrlInBrowser { url } => {
self.open_url_in_browser(url);
}
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
AppEvent::FileSearchResult { query, matches } => {
self.chat_widget.apply_file_search_result(query, matches);
}
AppEvent::RateLimitSnapshotFetched(snapshot) => {
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
AppEvent::UpdateReasoningEffort(effort) => {
self.on_update_reasoning_effort(effort);
self.refresh_status_line();
}
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(&model);
self.refresh_status_line();
}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
self.refresh_status_line();
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
AppEvent::OpenPlanReasoningScopePrompt { model, effort } => {
self.chat_widget
.open_plan_reasoning_scope_prompt(model, effort);
}
AppEvent::OpenAllModelsPopup { models } => {
self.chat_widget.open_all_models_popup(models);
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
} => {
self.chat_widget
.open_full_access_confirmation(preset, return_to_permissions);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
sample_paths,
extra_count,
failed_scan,
);
}
AppEvent::OpenFeedbackNote {
category,
include_logs,
} => {
self.chat_widget.open_feedback_note(category, include_logs);
}
AppEvent::OpenFeedbackConsent { category } => {
self.chat_widget.open_feedback_consent(category);
}
AppEvent::LaunchExternalEditor => {
if self.chat_widget.external_editor_state() == ExternalEditorState::Active {
self.launch_external_editor(tui).await;
}
}
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
}
AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => {
self.otel_manager
.counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]);
self.chat_widget.clear_windows_sandbox_setup_status();
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
self.otel_manager.record_duration(
"codex.windows_sandbox.elevated_setup_duration_ms",
started_at.elapsed(),
&[("result", "failure")],
);
}
self.chat_widget
.open_windows_sandbox_fallback_prompt(preset);
}
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
#[cfg(target_os = "windows")]
{
let policy = preset.sandbox.clone();
let policy_cwd = self.config.cwd.clone();
let command_cwd = policy_cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
// If the elevated setup already ran on this machine, don't prompt for
// elevation again - just flip the config to use the elevated path.
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
{
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
});
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
self.windows_sandbox.setup_started_at = Some(Instant::now());
let otel_manager = self.otel_manager.clone();
tokio::task::spawn_blocking(move || {
let result = codex_core::windows_sandbox::run_elevated_setup(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
);
let event = match result {
Ok(()) => {
otel_manager.counter(
"codex.windows_sandbox.elevated_setup_success",
1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
}
}
Err(err) => {
let mut code_tag: Option<String> = None;
let mut message_tag: Option<String> = None;
if let Some((code, message)) =
codex_core::windows_sandbox::elevated_setup_failure_details(
&err,
)
{
code_tag = Some(code);
message_tag = Some(message);
}
let mut tags: Vec<(&str, &str)> = Vec::new();
if let Some(code) = code_tag.as_deref() {
tags.push(("code", code));
}
if let Some(message) = message_tag.as_deref() {
tags.push(("message", message));
}
otel_manager.counter(
codex_core::windows_sandbox::elevated_setup_failure_metric_name(
&err,
),
1,
&tags,
);
tracing::error!(
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt { preset }
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::BeginWindowsSandboxLegacySetup { preset } => {
#[cfg(target_os = "windows")]
{
let policy = preset.sandbox.clone();
let policy_cwd = self.config.cwd.clone();
let command_cwd = policy_cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
let otel_manager = self.otel_manager.clone();
self.chat_widget.show_windows_sandbox_setup_status();
tokio::task::spawn_blocking(move || {
if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
) {
otel_manager.counter(
"codex.windows_sandbox.legacy_setup_preflight_failed",
1,
&[],
);
tracing::warn!(
error = %err,
"failed to preflight non-admin Windows sandbox setup"
);
}
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Legacy,
});
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
}
}
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
#[cfg(target_os = "windows")]
{
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Granting sandbox read access to {path} ..."),
None,
));
let policy = self.config.permissions.sandbox_policy.get().clone();
let policy_cwd = self.config.cwd.clone();
let command_cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
tokio::task::spawn_blocking(move || {
let requested_path = PathBuf::from(path);
let event = match codex_core::windows_sandbox_read_grants::grant_read_root_non_elevated(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
requested_path.as_path(),
) {
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: canonical_path,
error: None,
},
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
path: requested_path,
error: Some(err.to_string()),
},
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
}
}
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
Some(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
}
None => {
self.chat_widget
.add_to_history(history_cell::new_info_event(
format!("Sandbox read access granted for {}", path.display()),
None,
));
}
},
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
self.otel_manager.record_duration(
"codex.windows_sandbox.elevated_setup_duration_ms",
started_at.elapsed(),
&[("result", "success")],
);
}
let profile = self.active_profile.as_deref();
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_windows_sandbox_mode(if elevated_enabled {
"elevated"
} else {
"unelevated"
})
.clear_legacy_windows_sandbox_keys();
match builder.apply().await {
Ok(()) => {
if elevated_enabled {
self.config.set_windows_sandbox_enabled(false);
self.config.set_windows_elevated_sandbox_enabled(true);
} else {
self.config.set_windows_sandbox_enabled(true);
self.config.set_windows_elevated_sandbox_enabled(false);
}
self.chat_widget.set_windows_sandbox_mode(
self.config.permissions.windows_sandbox_mode,
);
let windows_sandbox_level =
WindowsSandboxLevel::from_config(&self.config);
if let Some((sample_paths, extra_count, failed_scan)) =
self.chat_widget.world_writable_warning_details()
{
self.app_event_tx.send(AppEvent::CodexOp(
Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
},
));
self.app_event_tx.send(
AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset.clone()),
sample_paths,
extra_count,
failed_scan,
},
);
} else {
self.app_event_tx.send(AppEvent::CodexOp(
Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(preset.approval),
sandbox_policy: Some(preset.sandbox.clone()),
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
},
));
self.app_event_tx
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
self.app_event_tx
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
let _ = mode;
self.chat_widget.add_plain_history_lines(vec![
Line::from(vec!["".dim(), "Sandbox ready".into()]),
Line::from(vec![
" ".into(),
"Codex can now safely edit files and execute commands in your computer"
.dark_gray(),
]),
]);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to enable Windows sandbox feature"
);
self.chat_widget.add_error_message(format!(
"Failed to enable the Windows sandbox feature: {err}"
));
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode);
}
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_model(Some(model.as_str()), effort)
.apply()
.await
{
Ok(()) => {
let effort_label = effort
.map(|selected_effort| selected_effort.to_string())
.unwrap_or_else(|| "default".to_string());
tracing::info!("Selected model: {model}, Selected effort: {effort_label}");
let mut message = format!("Model changed to {model}");
if let Some(label) = Self::reasoning_label_for(&model, effort) {
message.push(' ');
message.push_str(label);
}
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist model selection"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save model for profile `{profile}`: {err}"
));
} else {
self.chat_widget
.add_error_message(format!("Failed to save default model: {err}"));
}
}
}
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_personality(Some(personality))
.apply()
.await
{
Ok(()) => {
let label = Self::personality_label(personality);
let mut message = format!("Personality set to {label}");
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist personality selection"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save personality for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default personality: {err}"
));
}
}
}
}
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_service_tier(service_tier)
.apply()
.await
{
Ok(()) => {
let status = if service_tier.is_some() { "on" } else { "off" };
let mut message = format!("Fast mode set to {status}");
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, None);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist fast mode selection");
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save Fast mode for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default Fast mode: {err}"
));
}
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_speaker(name.as_deref())
}
};
match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());
if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.permissions.approval_policy.set(policy) {
tracing::warn!(%err, "failed to set approval policy on app config");
self.chat_widget
.add_error_message(format!("Failed to set approval policy: {err}"));
return Ok(AppRunControl::Continue);
}
self.chat_widget.set_approval_policy(policy);
}
AppEvent::UpdateSandboxPolicy(policy) => {
#[cfg(target_os = "windows")]
let policy_is_workspace_write_or_ro = matches!(
&policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::ReadOnly { .. }
);
let policy_for_chat = policy.clone();
if let Err(err) = self.config.permissions.sandbox_policy.set(policy) {
tracing::warn!(%err, "failed to set sandbox policy on app config");
self.chat_widget
.add_error_message(format!("Failed to set sandbox policy: {err}"));
return Ok(AppRunControl::Continue);
}
if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) {
tracing::warn!(%err, "failed to set sandbox policy on chat config");
self.chat_widget
.add_error_message(format!("Failed to set sandbox policy: {err}"));
return Ok(AppRunControl::Continue);
}
self.runtime_sandbox_policy_override =
Some(self.config.permissions.sandbox_policy.get().clone());
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
#[cfg(target_os = "windows")]
{
// One-shot suppression if the user just confirmed continue.
if self.windows_sandbox.skip_world_writable_scan_once {
self.windows_sandbox.skip_world_writable_scan_once = false;
return Ok(AppRunControl::Continue);
}
let should_check = WindowsSandboxLevel::from_config(&self.config)
!= WindowsSandboxLevel::Disabled
&& policy_is_workspace_write_or_ro
&& !self.chat_widget.world_writable_warning_hidden();
if should_check {
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let tx = self.app_event_tx.clone();
let logs_base_dir = self.config.codex_home.clone();
let sandbox_policy = self.config.permissions.sandbox_policy.get().clone();
Self::spawn_world_writable_scan(
cwd,
env_map,
logs_base_dir,
sandbox_policy,
tx,
);
}
}
}
AppEvent::UpdateFeatureFlags { updates } => {
if updates.is_empty() {
return Ok(AppRunControl::Continue);
}
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
matches!(
feature,
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
)
});
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in &updates {
let feature_key = feature.key();
if let Err(err) = self.config.features.set_enabled(*feature, *enabled) {
tracing::error!(
error = %err,
feature = feature_key,
"failed to update constrained feature flags"
);
self.chat_widget.add_error_message(format!(
"Failed to update experimental feature `{feature_key}`: {err}"
));
continue;
}
let effective_enabled = self.config.features.enabled(*feature);
self.chat_widget
.set_feature_enabled(*feature, effective_enabled);
if effective_enabled {
builder = builder.set_feature_enabled(feature_key, true);
} else if feature.default_enabled() {
builder = builder.set_feature_enabled(feature_key, false);
} else {
// If the feature already default to `false`, we drop the key
// in the config file so that the user does not miss the feature
// once it gets globally released.
builder = builder.with_edits(vec![ConfigEdit::ClearPath {
segments: vec!["features".to_string(), feature_key.to_string()],
}]);
}
}
if windows_sandbox_changed {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
self.app_event_tx
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}));
}
}
if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget.add_error_message(format!(
"Failed to update experimental features: {err}"
));
}
}
AppEvent::SkipNextWorldWritableScan => {
self.windows_sandbox.skip_world_writable_scan_once = true;
}
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
self.chat_widget.set_full_access_warning_acknowledged(ack);
}
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
}
AppEvent::UpdatePlanModeReasoningEffort(effort) => {
self.config.plan_mode_reasoning_effort = effort;
self.chat_widget.set_plan_mode_reasoning_effort(effort);
self.refresh_status_line();
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_full_access_warning(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist full access warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save full access confirmation preference: {err}"
));
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_world_writable_warning(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist world-writable warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save Agent mode warning preference: {err}"
));
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_rate_limit_model_nudge(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist rate limit switch prompt preference"
);
self.chat_widget.add_error_message(format!(
"Failed to save rate limit reminder preference: {err}"
));
}
}
AppEvent::PersistPlanModeReasoningEffort(effort) => {
let profile = self.active_profile.as_deref();
let segments = if let Some(profile) = profile {
vec![
"profiles".to_string(),
profile.to_string(),
"plan_mode_reasoning_effort".to_string(),
]
} else {
vec!["plan_mode_reasoning_effort".to_string()]
};
let edit = if let Some(effort) = effort {
ConfigEdit::SetPath {
segments,
value: effort.to_string().into(),
}
} else {
ConfigEdit::ClearPath { segments }
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist plan mode reasoning effort"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save Plan mode reasoning effort for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save Plan mode reasoning effort: {err}"
));
}
}
}
AppEvent::PersistModelMigrationPromptAcknowledged {
from_model,
to_model,
} => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist model migration prompt acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save model migration prompt preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker().await;
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread(tui, thread_id).await?;
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
AppEvent::SetSkillEnabled { path, enabled } => {
let edits = [ConfigEdit::SetSkillConfig {
path: path.clone(),
enabled,
}];
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_skill_enabled(path.clone(), enabled);
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after skill toggle"
);
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to update skill config for {path_display}: {err}"
));
}
}
}
AppEvent::SetAppEnabled { id, enabled } => {
let edits = if enabled {
vec![
ConfigEdit::ClearPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
},
ConfigEdit::ClearPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
},
]
} else {
vec![
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
value: false.into(),
},
ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
value: "user".into(),
},
]
};
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_connector_enabled(&id, enabled);
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(error = %err, "failed to refresh config after app toggle");
}
self.chat_widget.submit_op(Op::ReloadUserConfig);
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to update app config for {id}: {err}"
));
}
}
}
AppEvent::OpenPermissionsPopup => {
self.chat_widget.open_permissions_popup();
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
AppEvent::OpenReviewCommitPicker(cwd) => {
self.chat_widget.show_review_commit_picker(&cwd).await;
}
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::SubmitUserMessageWithMode {
text,
collaboration_mode,
} => {
self.chat_widget
.submit_user_message_with_mode(text, collaboration_mode);
}
AppEvent::ManageSkillsClosed => {
self.chat_widget.handle_manage_skills_closed();
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();
let diff_summary = DiffSummary::new(changes, cwd);
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
));
}
ApprovalRequest::Exec { command, .. } => {
let _ = tui.enter_alt_screen();
let full_cmd = strip_bash_lc_and_escape(&command);
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
));
}
ApprovalRequest::McpElicitation {
server_name,
message,
..
} => {
let _ = tui.enter_alt_screen();
let paragraph = Paragraph::new(vec![
Line::from(vec!["Server: ".into(), server_name.bold()]),
Line::from(""),
Line::from(message),
])
.wrap(Wrap { trim: false });
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(paragraph)],
"E L I C I T A T I O N".to_string(),
));
}
},
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionComplete { id, text } => {
self.chat_widget.replace_transcription(&id, &text);
}
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionFailed { id, error: _ } => {
self.chat_widget.remove_transcription_placeholder(&id);
}
#[cfg(not(target_os = "linux"))]
AppEvent::UpdateRecordingMeter { id, text } => {
// Update in place to preserve the element id for subsequent frames.
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
if updated {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = codex_core::config::edit::status_line_items_edit(&ids);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_status_line = Some(ids.clone());
self.chat_widget.setup_status_line(items);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist status line items; keeping previous selection");
self.chat_widget
.add_error_message(format!("Failed to save status line items: {err}"));
}
}
}
AppEvent::StatusLineBranchUpdated { cwd, branch } => {
self.chat_widget.set_status_line_branch(cwd, branch);
self.refresh_status_line();
}
AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup();
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = codex_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
// Ensure the selected theme is active in the current
// session. The preview callback covers arrow-key
// navigation, but if the user presses Enter without
// navigating, the runtime theme must still be applied.
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
&name,
Some(&self.config.codex_home),
) {
crate::render::highlight::set_syntax_theme(theme);
}
self.sync_tui_theme_selection(name);
}
Err(err) => {
self.restore_runtime_theme_from_config();
tracing::error!(error = %err, "failed to persist theme selection");
self.chat_widget
.add_error_message(format!("Failed to save theme: {err}"));
}
}
}
}
Ok(AppRunControl::Continue)
}
fn handle_exit_mode(&mut self, mode: ExitMode) -> AppRunControl {
match mode {
ExitMode::ShutdownFirst => {
// Mark the thread we are explicitly shutting down for exit so
// its shutdown completion does not trigger agent failover.
self.pending_shutdown_exit_thread_id =
self.active_thread_id.or(self.chat_widget.thread_id());
if self.chat_widget.submit_op(Op::Shutdown) {
AppRunControl::Continue
} else {
self.pending_shutdown_exit_thread_id = None;
AppRunControl::Exit(ExitReason::UserRequested)
}
}
ExitMode::Immediate => {
self.pending_shutdown_exit_thread_id = None;
AppRunControl::Exit(ExitReason::UserRequested)
}
}
}
fn handle_codex_event_now(&mut self, event: Event) {
let needs_refresh = matches!(
event.msg,
EventMsg::SessionConfigured(_) | EventMsg::TokenCount(_)
);
// This guard is only for intentional thread-switch shutdowns.
// App-exit shutdowns are tracked by `pending_shutdown_exit_thread_id`
// and resolved in `handle_active_thread_event`.
if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) {
self.suppress_shutdown_complete = false;
return;
}
if let EventMsg::ListSkillsResponse(response) = &event.msg {
let cwd = self.chat_widget.config_ref().cwd.clone();
let errors = errors_for_cwd(&cwd, response);
emit_skill_load_warnings(&self.app_event_tx, &errors);
}
self.handle_backtrack_event(&event.msg);
self.chat_widget.handle_codex_event(event);
if needs_refresh {
self.refresh_status_line();
}
}
fn handle_codex_event_replay(&mut self, event: Event) {
self.chat_widget.handle_codex_event_replay(event);
}
/// Handles an event emitted by the currently active thread.
///
/// This function enforces shutdown intent routing: unexpected non-primary
/// thread shutdowns fail over to the primary thread, while user-requested
/// app exits consume only the tracked shutdown completion and then proceed.
async fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> {
// Capture this before any potential thread switch: we only want to clear
// the exit marker when the currently active thread acknowledges shutdown.
let pending_shutdown_exit_completed = matches!(&event.msg, EventMsg::ShutdownComplete)
&& self.pending_shutdown_exit_thread_id == self.active_thread_id;
// Processing order matters:
//
// 1. handle unexpected non-primary shutdown failover first;
// 2. clear pending exit marker for matching shutdown;
// 3. forward the event through normal handling.
//
// This preserves the mental model that user-requested exits do not trigger
// failover, while true sub-agent deaths still do.
if let Some((closed_thread_id, primary_thread_id)) =
self.active_non_primary_shutdown_target(&event.msg)
{
self.mark_agent_picker_thread_closed(closed_thread_id);
self.select_agent_thread(tui, primary_thread_id).await?;
if self.active_thread_id == Some(primary_thread_id) {
self.chat_widget.add_info_message(
format!(
"Agent thread {closed_thread_id} closed. Switched back to main thread."
),
None,
);
} else {
self.clear_active_thread().await;
self.chat_widget.add_error_message(format!(
"Agent thread {closed_thread_id} closed. Failed to switch back to main thread {primary_thread_id}.",
));
}
return Ok(());
}
if pending_shutdown_exit_completed {
// Clear only after seeing the shutdown completion for the tracked
// thread, so unrelated shutdowns cannot consume this marker.
self.pending_shutdown_exit_thread_id = None;
}
self.handle_codex_event_now(event);
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
}
Ok(())
}
async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> {
if self.thread_event_channels.contains_key(&thread_id) {
return Ok(());
}
let thread = match self.server.get_thread(thread_id).await {
Ok(thread) => thread,
Err(err) => {
tracing::warn!("failed to attach listener for thread {thread_id}: {err}");
return Ok(());
}
};
let config_snapshot = thread.config_snapshot().await;
self.upsert_agent_picker_thread(
thread_id,
config_snapshot.session_source.get_nickname(),
config_snapshot.session_source.get_agent_role(),
false,
);
let event = Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: None,
model: config_snapshot.model,
model_provider_id: config_snapshot.model_provider_id,
service_tier: config_snapshot.service_tier,
approval_policy: config_snapshot.approval_policy,
sandbox_policy: config_snapshot.sandbox_policy,
cwd: config_snapshot.cwd,
reasoning_effort: config_snapshot.reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: thread.rollout_path(),
}),
};
let channel =
ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event);
let app_event_tx = self.app_event_tx.clone();
self.thread_event_channels.insert(thread_id, channel);
let listener_handle = tokio::spawn(async move {
loop {
let event = match thread.next_event().await {
Ok(event) => event,
Err(err) => {
tracing::debug!("external thread {thread_id} listener stopped: {err}");
break;
}
};
app_event_tx.send(AppEvent::ThreadEvent { thread_id, event });
}
});
self.thread_event_listener_tasks
.insert(thread_id, listener_handle);
Ok(())
}
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {
match reasoning_effort {
Some(ReasoningEffortConfig::Minimal) => "minimal",
Some(ReasoningEffortConfig::Low) => "low",
Some(ReasoningEffortConfig::Medium) => "medium",
Some(ReasoningEffortConfig::High) => "high",
Some(ReasoningEffortConfig::XHigh) => "xhigh",
None | Some(ReasoningEffortConfig::None) => "default",
}
}
fn reasoning_label_for(
model: &str,
reasoning_effort: Option<ReasoningEffortConfig>,
) -> Option<&'static str> {
(!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort))
}
pub(crate) fn token_usage(&self) -> codex_protocol::protocol::TokenUsage {
self.chat_widget.token_usage()
}
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
// TODO(aibrahim): Remove this and don't use config as a state object.
// Instead, explicitly pass the stored collaboration mode's effort into new sessions.
self.config.model_reasoning_effort = effort;
self.chat_widget.set_reasoning_effort(effort);
}
fn on_update_personality(&mut self, personality: Personality) {
self.config.personality = Some(personality);
self.chat_widget.set_personality(personality);
}
fn sync_tui_theme_selection(&mut self, name: String) {
self.config.tui_theme = Some(name.clone());
self.chat_widget.set_tui_theme(Some(name));
}
fn restore_runtime_theme_from_config(&self) {
if let Some(name) = self.config.tui_theme.as_deref()
&& let Some(theme) =
crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home))
{
crate::render::highlight::set_syntax_theme(theme);
return;
}
let auto_theme_name = crate::render::highlight::adaptive_default_theme_name();
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
auto_theme_name,
Some(&self.config.codex_home),
) {
crate::render::highlight::set_syntax_theme(theme);
}
}
fn personality_label(personality: Personality) -> &'static str {
match personality {
Personality::None => "None",
Personality::Friendly => "Friendly",
Personality::Pragmatic => "Pragmatic",
}
}
async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
let editor_cmd = match external_editor::resolve_editor_command() {
Ok(cmd) => cmd,
Err(external_editor::EditorError::MissingEditor) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(
"Cannot open external editor: set $VISUAL or $EDITOR before starting Codex."
.to_string(),
));
self.reset_external_editor_state(tui);
return;
}
Err(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to open editor: {err}",
)));
self.reset_external_editor_state(tui);
return;
}
};
let seed = self.chat_widget.composer_text_with_pending();
let editor_result = tui
.with_restored(tui::RestoreMode::KeepRaw, || async {
external_editor::run_editor(&seed, &editor_cmd).await
})
.await;
self.reset_external_editor_state(tui);
match editor_result {
Ok(new_text) => {
// Trim trailing whitespace
let cleaned = new_text.trim_end().to_string();
self.chat_widget.apply_external_edit(cleaned);
}
Err(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to open editor: {err}",
)));
}
}
tui.frame_requester().schedule_frame();
}
fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Requested);
self.chat_widget.set_footer_hint_override(Some(vec![(
EXTERNAL_EDITOR_HINT.to_string(),
String::new(),
)]));
tui.frame_requester().schedule_frame();
}
fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Closed);
self.chat_widget.set_footer_hint_override(None);
tui.frame_requester().schedule_frame();
}
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
KeyEvent {
code: KeyCode::Char('l'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
if !self.chat_widget.can_run_ctrl_l_clear_now() {
return;
}
if let Err(err) = self.clear_terminal_ui(tui, false) {
tracing::warn!(error = %err, "failed to clear terminal UI");
self.chat_widget
.add_error_message(format!("Failed to clear terminal UI: {err}"));
} else {
self.reset_app_ui_state_after_clear();
self.queue_clear_ui_header(tui);
tui.frame_requester().schedule_frame();
}
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
}
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty()
{
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} if self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty() =>
{
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
}
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
// Any non-Esc key press should cancel a primed backtrack.
// This avoids stale "Esc-primed" state after the user starts typing
// (even if they later backspace to empty).
if key_event.code != KeyCode::Esc && self.backtrack.primed {
self.reset_backtrack_state();
}
self.chat_widget.handle_key_event(key_event);
}
_ => {
self.chat_widget.handle_key_event(key_event);
}
};
}
fn refresh_status_line(&mut self) {
self.chat_widget.refresh_status_line();
}
#[cfg(target_os = "windows")]
fn spawn_world_writable_scan(
cwd: PathBuf,
env_map: std::collections::HashMap<String, String>,
logs_base_dir: PathBuf,
sandbox_policy: codex_protocol::protocol::SandboxPolicy,
tx: AppEventSender,
) {
tokio::task::spawn_blocking(move || {
let result = codex_windows_sandbox::apply_world_writable_scan_and_denies(
&logs_base_dir,
&cwd,
&env_map,
&sandbox_policy,
Some(logs_base_dir.as_path()),
);
if result.is_err() {
// Scan failed: warn without examples.
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths: Vec::new(),
extra_count: 0usize,
failed_scan: true,
});
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::file_search::FileSearchManager;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use assert_matches::assert_matches;
use codex_core::CodexAuth;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::Line;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
use tokio::time;
#[test]
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
let temp_dir = tempdir()?;
let base_cwd = temp_dir.path().join("base");
std::fs::create_dir_all(&base_cwd)?;
let overrides = ConfigOverrides {
additional_writable_roots: vec![PathBuf::from("rel")],
..Default::default()
};
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
assert_eq!(
normalized.additional_writable_roots,
vec![base_cwd.join("rel")]
);
Ok(())
}
#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::StartFresh),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Exit),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: PathBuf::from("/tmp/restore"),
thread_id: ThreadId::new(),
}
)),
false
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: PathBuf::from("/tmp/fork"),
thread_id: ThreadId::new(),
}
)),
false
);
}
#[test]
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
let mut wait_for_initial_session =
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
assert_eq!(wait_for_initial_session, true);
assert_eq!(
App::should_handle_active_thread_events(wait_for_initial_session, true),
false
);
assert_eq!(
App::should_stop_waiting_for_initial_session(wait_for_initial_session, None),
false
);
if App::should_stop_waiting_for_initial_session(
wait_for_initial_session,
Some(ThreadId::new()),
) {
wait_for_initial_session = false;
}
assert_eq!(wait_for_initial_session, false);
assert_eq!(
App::should_handle_active_thread_events(wait_for_initial_session, true),
true
);
}
#[test]
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: PathBuf::from("/tmp/restore"),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(wait_for_resume, true),
true
);
let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: PathBuf::from("/tmp/fork"),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(wait_for_fork, true),
true
);
}
#[tokio::test]
async fn enqueue_primary_event_delivers_session_configured_before_buffered_approval()
-> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let approval_event = Event {
id: "approval-event".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
cwd: PathBuf::from("/tmp/project"),
reason: Some("needs approval".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
};
let session_configured_event = Event {
id: "session-configured".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/tmp/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
};
app.enqueue_primary_event(approval_event.clone()).await?;
app.enqueue_primary_event(session_configured_event.clone())
.await?;
let rx = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active");
let first_event = time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for session configured event")
.expect("channel closed unexpectedly");
let second_event = time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for buffered approval event")
.expect("channel closed unexpectedly");
assert!(matches!(first_event.msg, EventMsg::SessionConfigured(_)));
assert!(matches!(second_event.msg, EventMsg::ExecApprovalRequest(_)));
app.handle_codex_event_now(first_event);
app.handle_codex_event_now(second_event);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
while let Ok(app_event) = app_event_rx.try_recv() {
if let AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
..
} = app_event
{
assert_eq!(op_thread_id, thread_id);
return Ok(());
}
}
panic!("expected approval action to submit a thread-scoped op");
}
#[tokio::test]
async fn routed_thread_event_does_not_recreate_channel_after_reset() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY),
);
app.reset_thread_event_state();
app.handle_routed_thread_event(
thread_id,
Event {
id: "stale-event".to_string(),
msg: EventMsg::ShutdownComplete,
},
)
.await?;
assert!(
!app.thread_event_channels.contains_key(&thread_id),
"stale routed events should not recreate cleared thread channels"
);
assert_eq!(app.active_thread_id, None);
assert_eq!(app.primary_thread_id, None);
Ok(())
}
#[tokio::test]
async fn reset_thread_event_state_aborts_listener_tasks() {
struct NotifyOnDrop(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for NotifyOnDrop {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
let _notify_on_drop = NotifyOnDrop(Some(dropped_tx));
let _ = started_tx.send(());
std::future::pending::<()>().await;
});
app.thread_event_listener_tasks.insert(thread_id, handle);
started_rx
.await
.expect("listener task should report it started");
app.reset_thread_event_state();
assert_eq!(app.thread_event_listener_tasks.is_empty(), true);
time::timeout(Duration::from_millis(50), dropped_rx)
.await
.expect("timed out waiting for listener task abort")
.expect("listener task drop notification should succeed");
}
#[tokio::test]
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.set_thread_active(thread_id, true).await;
let event = Event {
id: String::new(),
msg: EventMsg::ShutdownComplete,
};
app.enqueue_thread_event(thread_id, event.clone()).await?;
time::timeout(
Duration::from_millis(50),
app.enqueue_thread_event(thread_id, event),
)
.await
.expect("enqueue_thread_event blocked on a full channel")?;
let mut rx = app
.thread_event_channels
.get_mut(&thread_id)
.expect("missing thread channel")
.receiver
.take()
.expect("missing receiver");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for first event")
.expect("channel closed unexpectedly");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for second event")
.expect("channel closed unexpectedly");
Ok(())
}
#[tokio::test]
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.open_agent_picker().await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_picker_threads.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: true,
})
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_keeps_cached_closed_threads() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.agent_picker_threads.insert(
thread_id,
AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: false,
},
);
app.open_agent_picker().await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_picker_threads.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: true,
})
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
{
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(1));
app.open_agent_picker().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
Ok(())
}
#[tokio::test]
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(1));
let agent_channel = ThreadEventChannel::new(1);
{
let mut store = agent_channel.store.lock().await;
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
});
}
app.thread_event_channels
.insert(agent_thread_id, agent_channel);
app.agent_picker_threads.insert(
agent_thread_id,
AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: false,
},
);
app.refresh_pending_thread_approvals().await;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
app.active_thread_id = Some(agent_thread_id);
app.refresh_pending_thread_approvals().await;
assert!(app.chat_widget.pending_thread_approvals().is_empty());
}
#[tokio::test]
async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(1));
app.thread_event_channels.insert(
agent_thread_id,
ThreadEventChannel::new_with_session_configured(
1,
Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: agent_thread_id,
forked_from_id: None,
thread_name: None,
model: "gpt-5".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
cwd: PathBuf::from("/tmp/agent"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")),
}),
},
),
);
app.agent_picker_threads.insert(
agent_thread_id,
AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: false,
},
);
app.enqueue_thread_event(
agent_thread_id,
Event {
id: "ev-approval".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-approval".to_string(),
approval_id: None,
turn_id: "turn-approval".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp/agent"),
reason: Some("need approval".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
},
)
.await?;
assert_eq!(app.chat_widget.has_active_view(), true);
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
Ok(())
}
#[test]
fn agent_picker_item_name_snapshot() {
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
let snapshot = [
format!(
"{} | {}",
format_agent_picker_item_name(Some("Robie"), Some("explorer"), true),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(Some("Robie"), Some("explorer"), false),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(Some("Robie"), None, false),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(None, Some("explorer"), false),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(None, None, false),
thread_id
),
]
.join("\n");
assert_snapshot!("agent_picker_item_name", snapshot);
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()>
{
let mut app = make_test_app().await;
app.active_thread_id = Some(ThreadId::new());
app.primary_thread_id = Some(ThreadId::new());
assert_eq!(
app.active_non_primary_shutdown_target(&EventMsg::SkillsUpdateAvailable),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown()
-> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.primary_thread_id = Some(thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()>
{
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
Some((active_thread_id, primary_thread_id))
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending()
-> Result<()> {
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
app.pending_shutdown_exit_thread_id = Some(active_thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread()
-> Result<()> {
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
app.pending_shutdown_exit_thread_id = Some(ThreadId::new());
assert_eq!(
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
Some((active_thread_id, primary_thread_id))
);
Ok(())
}
async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
let mut app = make_test_app().await;
app.config.cwd = PathBuf::from("/tmp/project");
app.chat_widget.set_model("gpt-test");
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \
nineteen years, and the children were told it was because the sea no longer wanted a \
guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \
heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \
mouth of the bay as if listening for a signal that never came. When an old brass key fell \
out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \
she decided to climb the hill and see what the town had forgotten.";
let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \
with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \
broken, only unfinished. Someone had removed the governor spring and hidden it in a false \
drawer, along with a letter from the last keeper admitting he had darkened the light on \
purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \
from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \
over the water and the harbor bells began to ring.";
let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \
blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \
great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \
whitecaps, and reached the boats just as they were drifting toward the rocks below the \
eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \
and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \
and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \
lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \
something in town to keep better time.";
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(AgentMessageCell::new(
vec![Line::from(text.to_string())],
true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| -> Arc<dyn HistoryCell> {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/tmp/project"),
reasoning_effort: Some(ReasoningEffortConfig::High),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
is_first,
None,
None,
)) as Arc<dyn HistoryCell>
};
app.transcript_cells = vec![
make_header(true),
Arc::new(crate::history_cell::new_info_event(
"startup tip that used to replay".to_string(),
None,
)) as Arc<dyn HistoryCell>,
user_cell("Tell me a long story about a town with a dark lighthouse."),
agent_cell(story_part_one),
user_cell("Continue the story and reveal why the light went out."),
agent_cell(story_part_two),
user_cell("Finish the story with a storm and a resolution."),
agent_cell(story_part_three),
];
app.has_emitted_history_lines = true;
let rendered = app
.clear_ui_header_lines_with_version(80, "<VERSION>")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!rendered.contains("startup tip that used to replay"),
"clear header should not replay startup notices"
);
assert!(
!rendered.contains("Bracken Ferry"),
"clear header should not replay prior conversation turns"
);
rendered
}
#[tokio::test]
async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() {
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
}
#[tokio::test]
async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() {
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let server = Arc::new(
codex_core::test_support::thread_manager_with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
),
);
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::from_api_key("Test API Key"),
);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let otel_manager = test_otel_manager(&config, model.as_str());
App {
server,
otel_manager,
app_event_tx,
chat_widget,
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
pending_update_action: None,
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_picker_threads: HashMap::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
}
}
async fn make_test_app_with_channels() -> (
App,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let server = Arc::new(
codex_core::test_support::thread_manager_with_models_provider(
CodexAuth::from_api_key("Test API Key"),
config.model_provider.clone(),
),
);
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::from_api_key("Test API Key"),
);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let otel_manager = test_otel_manager(&config, model.as_str());
(
App {
server,
otel_manager,
app_event_tx,
chat_widget,
auth_manager,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
pending_update_action: None,
suppress_shutdown_complete: false,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_picker_threads: HashMap::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
},
rx,
op_rx,
)
}
fn test_otel_manager(config: &Config, model: &str) -> OtelManager {
let model_info = codex_core::test_support::construct_model_info_offline(model, config);
OtelManager::new(
ThreadId::new(),
model,
model_info.slug.as_str(),
None,
None,
None,
"test_originator".to_string(),
false,
"test".to_string(),
SessionSource::Cli,
)
}
fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
config
.config_layer_stack
.effective_config()
.as_table()
.and_then(|table| table.get("apps"))
.and_then(TomlValue::as_table)
.and_then(|apps| apps.get(app_id))
.and_then(TomlValue::as_table)
.and_then(|app| app.get("enabled"))
.and_then(TomlValue::as_bool)
}
fn all_model_presets() -> Vec<ModelPreset> {
codex_core::test_support::all_model_presets().clone()
}
fn model_availability_nux_config(shown_count: &[(&str, u32)]) -> ModelAvailabilityNuxConfig {
ModelAvailabilityNuxConfig {
shown_count: shown_count
.iter()
.map(|(model, count)| ((*model).to_string(), *count))
.collect(),
}
}
fn model_migration_copy_to_plain_text(
copy: &crate::model_migration::ModelMigrationCopy,
) -> String {
if let Some(markdown) = copy.markdown.as_ref() {
return markdown.clone();
}
let mut s = String::new();
for span in &copy.heading {
s.push_str(&span.content);
}
s.push('\n');
s.push('\n');
for line in &copy.content {
for span in &line.spans {
s.push_str(&span.content);
}
s.push('\n');
}
s
}
#[tokio::test]
async fn model_migration_prompt_only_shows_for_deprecated_models() {
let seen = BTreeMap::new();
assert!(should_show_model_migration_prompt(
"gpt-5",
"gpt-5.2-codex",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex",
"gpt-5.2-codex",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5-codex-mini",
"gpt-5.2-codex",
&seen,
&all_model_presets()
));
assert!(should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.2-codex",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1-codex",
"gpt-5.1-codex",
&seen,
&all_model_presets()
));
}
#[test]
fn select_model_availability_nux_picks_only_eligible_model() {
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let target = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5")
.expect("target preset present");
target.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5 is available".to_string(),
});
let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
assert_eq!(
selected,
Some(StartupTooltipOverride {
model_slug: "gpt-5".to_string(),
message: "gpt-5 is available".to_string(),
})
);
}
#[test]
fn select_model_availability_nux_skips_missing_and_exhausted_models() {
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let gpt_5 = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5")
.expect("gpt-5 preset present");
gpt_5.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5 is available".to_string(),
});
let gpt_5_2 = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5.2")
.expect("gpt-5.2 preset present");
gpt_5_2.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5.2 is available".to_string(),
});
let selected = select_model_availability_nux(
&presets,
&model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
);
assert_eq!(
selected,
Some(StartupTooltipOverride {
model_slug: "gpt-5.2".to_string(),
message: "gpt-5.2 is available".to_string(),
})
);
}
#[test]
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let first = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5")
.expect("gpt-5 preset present");
first.availability_nux = Some(ModelAvailabilityNux {
message: "first".to_string(),
});
let second = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5.2")
.expect("gpt-5.2 preset present");
second.availability_nux = Some(ModelAvailabilityNux {
message: "second".to_string(),
});
let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
assert_eq!(
selected,
Some(StartupTooltipOverride {
model_slug: "gpt-5.2".to_string(),
message: "second".to_string(),
})
);
}
#[test]
fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let target = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5")
.expect("target preset present");
target.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5 is available".to_string(),
});
let selected = select_model_availability_nux(
&presets,
&model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
);
assert_eq!(selected, None);
}
#[tokio::test]
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
let mut seen = BTreeMap::new();
seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
assert!(!should_show_model_migration_prompt(
"gpt-5",
"gpt-5.1",
&seen,
&all_model_presets()
));
assert!(!should_show_model_migration_prompt(
"gpt-5.1",
"gpt-5.1",
&seen,
&all_model_presets()
));
}
#[tokio::test]
async fn model_migration_prompt_skips_when_target_missing_or_hidden() {
let mut available = all_model_presets();
let mut current = available
.iter()
.find(|preset| preset.model == "gpt-5-codex")
.cloned()
.expect("preset present");
current.upgrade = Some(ModelUpgrade {
id: "missing-target".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
model_link: None,
upgrade_copy: None,
migration_markdown: None,
});
available.retain(|preset| preset.model != "gpt-5-codex");
available.push(current.clone());
assert!(!should_show_model_migration_prompt(
&current.model,
"missing-target",
&BTreeMap::new(),
&available,
));
assert!(target_preset_for_upgrade(&available, "missing-target").is_none());
let mut with_hidden_target = all_model_presets();
let target = with_hidden_target
.iter_mut()
.find(|preset| preset.model == "gpt-5.2-codex")
.expect("target preset present");
target.show_in_picker = false;
assert!(!should_show_model_migration_prompt(
"gpt-5-codex",
"gpt-5.2-codex",
&BTreeMap::new(),
&with_hidden_target,
));
assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.2-codex").is_none());
}
#[tokio::test]
async fn model_migration_prompt_shows_for_hidden_model() {
let codex_home = tempdir().expect("temp codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let mut available_models = all_model_presets();
let current = available_models
.iter()
.find(|preset| preset.model == "gpt-5.1-codex")
.cloned()
.expect("gpt-5.1-codex preset present");
assert!(
!current.show_in_picker,
"expected gpt-5.1-codex to be hidden from picker for this test"
);
let upgrade = current.upgrade.as_ref().expect("upgrade configured");
// Test "hidden current model still prompts" even if bundled
// catalog data changes the target model's picker visibility.
available_models
.iter_mut()
.find(|preset| preset.model == upgrade.id)
.expect("upgrade target present")
.show_in_picker = true;
assert!(
should_show_model_migration_prompt(
&current.model,
&upgrade.id,
&config.notices.model_migrations,
&available_models,
),
"expected migration prompt to be eligible for hidden model"
);
let target = target_preset_for_upgrade(&available_models, &upgrade.id)
.expect("upgrade target present");
let target_description =
(!target.description.is_empty()).then(|| target.description.clone());
let can_opt_out = true;
let copy = migration_copy_for_models(
&current.model,
&upgrade.id,
upgrade.model_link.clone(),
upgrade.upgrade_copy.clone(),
upgrade.migration_markdown.clone(),
target.display_name.clone(),
target_description,
can_opt_out,
);
// Snapshot the copy we would show; rendering is covered by model_migration snapshots.
assert_snapshot!(
"model_migration_prompt_shows_for_hidden_model",
model_migration_copy_to_plain_text(&copy)
);
}
#[tokio::test]
async fn update_reasoning_effort_updates_collaboration_mode() {
let mut app = make_test_app().await;
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
app.config.model_reasoning_effort,
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> {
let mut app = make_test_app().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
let app_id = "unit_test_refresh_in_memory_config_connector".to_string();
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
ConfigEditsBuilder::new(&app.config.codex_home)
.with_edits([
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
value: false.into(),
},
ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
app_id.clone(),
"disabled_reason".to_string(),
],
value: "user".into(),
},
])
.apply()
.await
.expect("persist app toggle");
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
app.refresh_in_memory_config_from_disk().await?;
assert_eq!(
app_enabled_in_effective_config(&app.config, &app_id),
Some(false)
);
Ok(())
}
#[tokio::test]
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
app.sync_tui_theme_selection("dracula".to_string());
assert_eq!(app.config.tui_theme.as_deref(), Some("dracula"));
assert_eq!(
app.chat_widget.config_ref().tui_theme.as_deref(),
Some("dracula")
);
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;
app.chat_widget
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
let config = app.fresh_session_config();
assert_eq!(
config.service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)
);
}
#[tokio::test]
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let user_cell = |text: &str,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>|
-> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements,
local_image_paths,
remote_image_urls,
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(AgentMessageCell::new(
vec![Line::from(text.to_string())],
true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| {
let event = SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
app.chat_widget.current_model(),
event,
is_first,
None,
None,
)) as Arc<dyn HistoryCell>
};
let placeholder = "[Image #1]";
let edited_text = format!("follow-up (edited) {placeholder}");
let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len();
let edited_text_elements = vec![TextElement::new(edited_range.into(), None)];
let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")];
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
// and an edited turn appended after a session header boundary.
app.transcript_cells = vec![
make_header(true),
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer first"),
user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer follow-up"),
make_header(false),
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer first"),
user_cell(
&edited_text,
edited_text_elements.clone(),
edited_local_image_paths.clone(),
vec!["https://example.com/backtrack.png".to_string()],
),
agent_cell("answer edited"),
];
assert_eq!(user_count(&app.transcript_cells), 2);
let base_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: base_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
app.backtrack.base_id = Some(base_id);
app.backtrack.primed = true;
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
let selection = app
.confirm_backtrack_from_main()
.expect("backtrack selection");
assert_eq!(selection.nth_user_message, 1);
assert_eq!(selection.prefill, edited_text);
assert_eq!(selection.text_elements, edited_text_elements);
assert_eq!(selection.local_image_paths, edited_local_image_paths);
assert_eq!(
selection.remote_image_urls,
vec!["https://example.com/backtrack.png".to_string()]
);
app.apply_backtrack_rollback(selection);
assert_eq!(
app.chat_widget.remote_image_urls(),
vec!["https://example.com/backtrack.png".to_string()]
);
let mut rollback_turns = None;
while let Ok(op) = op_rx.try_recv() {
if let Op::ThreadRollback { num_turns } = op {
rollback_turns = Some(num_turns);
}
}
assert_eq!(rollback_turns, Some(1));
}
#[tokio::test]
async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "original".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>];
app.chat_widget
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
let remote_image_url = "https://example.com/remote-only.png".to_string();
app.apply_backtrack_rollback(BacktrackSelection {
nth_user_message: 0,
prefill: String::new(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![remote_image_url.clone()],
});
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
let mut rollback_turns = None;
while let Ok(op) = op_rx.try_recv() {
if let Op::ThreadRollback { num_turns } = op {
rollback_turns = Some(num_turns);
}
}
assert_eq!(rollback_turns, Some(1));
}
#[tokio::test]
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
let data_image_url = "data:image/png;base64,abc123".to_string();
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
}) as Arc<dyn HistoryCell>];
app.apply_backtrack_rollback(BacktrackSelection {
nth_user_message: 0,
prefill: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
});
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let mut saw_rollback = false;
let mut submitted_items: Option<Vec<UserInput>> = None;
while let Ok(op) = op_rx.try_recv() {
match op {
Op::ThreadRollback { .. } => saw_rollback = true,
Op::UserTurn { items, .. } => submitted_items = Some(items),
_ => {}
}
}
assert!(saw_rollback);
let items = submitted_items.expect("expected user turn after backtrack resubmit");
assert!(items.iter().any(|item| {
matches!(
item,
UserInput::Image { image_url } if image_url == &data_image_url
)
}));
}
#[tokio::test]
async fn replayed_initial_messages_apply_rollback_in_queue_order() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let session_id = ThreadId::new();
app.handle_codex_event_replay(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![
EventMsg::UserMessage(UserMessageEvent {
message: "first prompt".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "second prompt".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
EventMsg::UserMessage(UserMessageEvent {
message: "third prompt".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
]),
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
let mut saw_rollback = false;
while let Ok(event) = app_event_rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
app.transcript_cells.push(cell);
}
AppEvent::ApplyThreadRollback { num_turns } => {
saw_rollback = true;
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
&mut app.transcript_cells,
num_turns,
);
}
_ => {}
}
}
assert!(saw_rollback);
let user_messages: Vec<String> = app
.transcript_cells
.iter()
.filter_map(|cell| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
.map(|cell| cell.message.clone())
})
.collect();
assert_eq!(
user_messages,
vec!["first prompt".to_string(), "third prompt".to_string()]
);
}
#[tokio::test]
async fn live_rollback_during_replay_is_applied_in_app_event_order() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let session_id = ThreadId::new();
app.handle_codex_event_replay(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![
EventMsg::UserMessage(UserMessageEvent {
message: "first prompt".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "second prompt".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
]),
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
// Simulate a live rollback arriving before queued replay inserts are drained.
app.handle_codex_event_now(Event {
id: "live-rollback".to_string(),
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
});
let mut saw_rollback = false;
while let Ok(event) = app_event_rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
let cell: Arc<dyn HistoryCell> = cell.into();
app.transcript_cells.push(cell);
}
AppEvent::ApplyThreadRollback { num_turns } => {
saw_rollback = true;
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
&mut app.transcript_cells,
num_turns,
);
}
_ => {}
}
}
assert!(saw_rollback);
let user_messages: Vec<String> = app
.transcript_cells
.iter()
.filter_map(|cell| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
.map(|cell| cell.message.clone())
})
.collect();
assert_eq!(user_messages, vec!["first prompt".to_string()]);
}
#[tokio::test]
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
let mut app = make_test_app().await;
app.transcript_cells = vec![
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after first")],
false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after second")],
false,
)) as Arc<dyn HistoryCell>,
];
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
app.deferred_history_lines = vec![Line::from("stale buffered line")];
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 1;
let changed = app.apply_non_pending_thread_rollback(1);
assert!(changed);
assert!(app.backtrack_render_pending);
assert!(app.deferred_history_lines.is_empty());
assert_eq!(app.backtrack.nth_user_message, 0);
let user_messages: Vec<String> = app
.transcript_cells
.iter()
.filter_map(|cell| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
.map(|cell| cell.message.clone())
})
.collect();
assert_eq!(user_messages, vec!["first".to_string()]);
let overlay_cell_count = match app.overlay.as_ref() {
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
_ => panic!("expected transcript overlay"),
};
assert_eq!(overlay_cell_count, app.transcript_cells.len());
}
#[tokio::test]
async fn new_session_requests_shutdown_for_previous_conversation() {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let event = SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(event),
});
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
app.shutdown_current_thread().await;
match op_rx.try_recv() {
Ok(Op::Shutdown) => {}
Ok(other) => panic!("expected Op::Shutdown, got {other:?}"),
Err(_) => panic!("expected shutdown op to be sent"),
}
}
#[tokio::test]
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let control = app.handle_exit_mode(ExitMode::ShutdownFirst);
assert_eq!(app.pending_shutdown_exit_thread_id, None);
assert!(matches!(
control,
AppRunControl::Exit(ExitReason::UserRequested)
));
}
#[tokio::test]
async fn shutdown_first_exit_waits_for_shutdown_when_submit_succeeds() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let control = app.handle_exit_mode(ExitMode::ShutdownFirst);
assert_eq!(app.pending_shutdown_exit_thread_id, Some(thread_id));
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown));
}
#[tokio::test]
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: Some("keep me".to_string()),
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/tmp/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
app.chat_widget
.apply_external_edit("draft prompt".to_string());
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "old message".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>];
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
app.deferred_history_lines = vec![Line::from("stale buffered line")];
app.has_emitted_history_lines = true;
app.backtrack.primed = true;
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 0;
app.backtrack_render_pending = true;
app.reset_app_ui_state_after_clear();
assert!(app.overlay.is_none());
assert!(app.transcript_cells.is_empty());
assert!(app.deferred_history_lines.is_empty());
assert!(!app.has_emitted_history_lines);
assert!(!app.backtrack.primed);
assert!(!app.backtrack.overlay_preview_active);
assert!(app.backtrack.pending_rollback.is_none());
assert!(!app.backtrack_render_pending);
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
}
#[tokio::test]
async fn session_summary_skip_zero_usage() {
assert!(session_summary(TokenUsage::default(), None, None).is_none());
}
#[tokio::test]
async fn session_summary_includes_resume_hint() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let summary = session_summary(usage, Some(conversation), None).expect("summary");
assert_eq!(
summary.usage_line,
"Token usage: total=12 input=10 output=2"
);
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
#[tokio::test]
async fn session_summary_prefers_name_over_id() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let summary = session_summary(usage, Some(conversation), Some("my-session".to_string()))
.expect("summary");
assert_eq!(
summary.resume_command,
Some("codex resume my-session".to_string())
);
}
}