Refactor chatwidget settings surfaces into modules (phase 4) (#22518)

## Why

`chatwidget.rs` is still carrying too many unrelated responsibilities in
one file. #22269 started a five-phase cleanup to move coherent behavior
domains into focused modules while keeping `chatwidget.rs` as the
composition layer. #22407 completed phase 2 by extracting input and
submission flow, and #22433 completed phase 3 by extracting protocol,
replay, streaming, and tool lifecycle handling.

This PR is phase 4. It keeps moving high-churn UI coordination out of
the central widget by extracting settings, popups, and status surfaces
without changing the visible behavior those flows already provide. This
is once again a mechanical movement of existing functions. No functional
changes.

## What Changed

- Added focused modules for runtime settings/model coordination,
model/reasoning/collaboration popups,
settings/personality/theme/audio/experimental popups, permission
prompts, status setup/output controls, and Windows sandbox prompt flows.
- Moved the remaining rate-limit nudge/status helpers and connectors
popup/loading/update helpers into their existing focused modules.
- Preserved the existing picker flows, approval behavior, status/title
setup previews, rate-limit notices, and connectors/app list behavior
while shrinking `chatwidget.rs` back toward orchestration.
- Left `codex-rs/tui/src/chatwidget.rs` as the registration and
composition surface for these extracted behaviors.

## Cleanup Phases

The five-phase cleanup plan from #22269 is:

1. Phase 1: mechanical helper and state moves. Completed in #22269.
2. Phase 2: extract input and submission flow, including queued user
messages, shell prompt submission, pending steer restoration, and thread
input snapshot/restore behavior. Completed in #22407.
3. Phase 3: extract protocol, replay, streaming, and tool lifecycle
handling, while preserving active-cell grouping, transcript
invalidation, interrupt deferral, and final-message separator behavior.
Completed in #22433.
4. Phase 4: extract settings, popups, and status surfaces, including
model/reasoning/collaboration/personality popups, permission prompts,
rate-limit UI, and connectors helpers. This PR.
5. Phase 5: clean up the remaining constructor and orchestration code
once the larger behavior domains have moved out, leaving `chatwidget.rs`
as the composition layer.

## Verification

- `cargo check -p codex-tui`
- `cargo test -p codex-tui chatwidget::tests::permissions`
- `cargo test -p codex-tui chatwidget::tests::status_surface_previews`
- `cargo test -p codex-tui chatwidget::tests::popups_and_settings`
- `cargo test -p codex-tui chatwidget::tests::status_and_layout`

`cargo test -p codex-tui` also compiles and begins running, but aborts
in the unchanged app-side test
`app::tests::discard_side_thread_keeps_local_state_when_server_close_fails`
with a reproducible stack overflow.
This commit is contained in:
Eric Traut
2026-05-13 11:26:37 -07:00
committed by GitHub
Unverified
parent 4454e1411b
commit 1ae811ddb2
11 changed files with 3517 additions and 3450 deletions
File diff suppressed because it is too large Load Diff
+414 -1
View File
@@ -1,5 +1,6 @@
//! Connector list cache state for `ChatWidget`.
//! Connector cache, popup, and refresh handling for `ChatWidget`.
use super::*;
use crate::app_event::ConnectorsSnapshot;
#[derive(Debug, Clone, Default)]
@@ -18,3 +19,415 @@ pub(super) struct ConnectorsState {
pub(super) prefetch_in_flight: bool,
pub(super) force_refetch_pending: bool,
}
impl ChatWidget {
pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) {
self.prefetch_connectors_with_options(force_refetch);
}
pub(super) fn prefetch_connectors(&mut self) {
self.prefetch_connectors_with_options(/*force_refetch*/ false);
}
fn prefetch_connectors_with_options(&mut self, force_refetch: bool) {
if !self.connectors_enabled() {
return;
}
if self.connectors.prefetch_in_flight {
if force_refetch {
self.connectors.force_refetch_pending = true;
}
return;
}
self.connectors.prefetch_in_flight = true;
if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) {
self.connectors.cache = ConnectorsCacheState::Loading;
}
let config = self.config.clone();
let environment_manager = Arc::clone(&self.environment_manager);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let accessible_result =
match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
force_refetch,
&environment_manager,
)
.await
{
Ok(connectors) => connectors,
Err(err) => {
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Err(format!("Failed to load apps: {err}")),
is_final: true,
});
return;
}
};
let should_schedule_force_refetch =
!force_refetch && !accessible_result.codex_apps_ready;
let accessible_connectors = accessible_result.connectors;
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Ok(ConnectorsSnapshot {
connectors: accessible_connectors.clone(),
}),
is_final: false,
});
let result: Result<ConnectorsSnapshot, String> = async {
let all_connectors =
chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch)
.await?;
let connectors = chatgpt_connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
);
Ok(ConnectorsSnapshot { connectors })
}
.await
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded {
result,
is_final: true,
});
if should_schedule_force_refetch {
app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
}
});
}
pub(super) fn connectors_enabled(&self) -> bool {
self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account
}
pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> {
if !self.connectors_enabled() {
return None;
}
if let Some(snapshot) = &self.connectors.partial_snapshot {
return Some(snapshot.connectors.as_slice());
}
match &self.connectors.cache {
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
_ => None,
}
}
pub(crate) fn add_connectors_output(&mut self) {
if !self.connectors_enabled() {
self.add_info_message(
"Apps are disabled.".to_string(),
Some("Enable the apps feature to use $ or /apps.".to_string()),
);
return;
}
let connectors_cache = self.connectors.cache.clone();
let should_force_refetch = !self.connectors.prefetch_in_flight
|| matches!(connectors_cache, ConnectorsCacheState::Ready(_));
self.prefetch_connectors_with_options(should_force_refetch);
match connectors_cache {
ConnectorsCacheState::Ready(snapshot) => {
if snapshot.connectors.is_empty() {
self.add_info_message("No apps available.".to_string(), /*hint*/ None);
} else {
self.open_connectors_popup(&snapshot.connectors);
}
}
ConnectorsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
}
ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => {
self.open_connectors_loading_popup();
}
}
self.request_redraw();
}
fn open_connectors_loading_popup(&mut self) {
if !self.bottom_pane.replace_selection_view_if_active(
CONNECTORS_SELECTION_VIEW_ID,
self.connectors_loading_popup_params(),
) {
self.bottom_pane
.show_selection_view(self.connectors_loading_popup_params());
}
}
fn open_connectors_popup(&mut self, connectors: &[AppInfo]) {
self.bottom_pane.show_selection_view(
self.connectors_popup_params(connectors, /*selected_connector_id*/ None),
);
}
fn connectors_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from("Loading installed and available apps...".dim()));
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading apps...".to_string(),
description: Some("This updates when the full list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn connectors_popup_params(
&self,
connectors: &[AppInfo],
selected_connector_id: Option<&str>,
) -> SelectionViewParams {
let total = connectors.len();
let installed = connectors
.iter()
.filter(|connector| connector.is_accessible)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from(
"Use $ to insert an installed app into your prompt.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available apps.").dim(),
));
let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| {
connectors
.iter()
.position(|connector| connector.id == selected_connector_id)
});
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
for connector in connectors {
let connector_label = codex_connectors::metadata::connector_display_label(connector);
let connector_title = connector_label.clone();
let link_description = Self::connector_description(connector);
let description = Self::connector_brief_description(connector);
let status_label = Self::connector_status_label(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
description: Some(description),
search_value: Some(search_value),
..Default::default()
};
let is_installed = connector.is_accessible;
let selected_label = if is_installed {
format!(
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
)
} else {
format!("{status_label}. Press Enter to open the app page to install this app.")
};
let missing_label = format!("{status_label}. App link unavailable.");
let instructions = if connector.is_accessible {
"Manage this app in your browser."
} else {
"Install this app in your browser, then reload Codex."
};
if let Some(install_url) = connector.install_url.clone() {
let app_id = connector.id.clone();
let is_enabled = connector.is_enabled;
let title = connector_title.clone();
let instructions = instructions.to_string();
let description = link_description.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
app_id: app_id.clone(),
title: title.clone(),
description: description.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
is_installed,
is_enabled,
});
})];
item.dismiss_on_select = true;
item.selected_description = Some(selected_label);
} else {
let missing_label_for_action = missing_label.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
missing_label_for_action.clone(),
/*hint*/ None,
),
)));
})];
item.dismiss_on_select = true;
item.selected_description = Some(missing_label);
}
items.push(item);
}
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
initial_selected_idx,
..Default::default()
}
}
fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) {
let selected_connector_id =
if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = (
self.bottom_pane
.selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID),
&self.connectors.cache,
) {
snapshot
.connectors
.get(selected_index)
.map(|connector| connector.id.as_str())
} else {
None
};
let _ = self.bottom_pane.replace_selection_view_if_active(
CONNECTORS_SELECTION_VIEW_ID,
self.connectors_popup_params(connectors, selected_connector_id),
);
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = Self::connector_status_label(connector);
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_status_label(connector: &AppInfo) -> &'static str {
if connector.is_accessible {
if connector.is_enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
"Can be installed"
}
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
pub(crate) fn on_connectors_loaded(
&mut self,
result: Result<ConnectorsSnapshot, String>,
is_final: bool,
) {
let mut trigger_pending_force_refetch = false;
if is_final {
self.connectors.prefetch_in_flight = false;
if self.connectors.force_refetch_pending {
self.connectors.force_refetch_pending = false;
trigger_pending_force_refetch = true;
}
}
match result {
Ok(mut snapshot) => {
if !is_final {
snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible(
Vec::new(),
snapshot.connectors,
/*all_connectors_loaded*/ false,
);
}
snapshot.connectors =
chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config);
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
.connectors
.iter()
.map(|connector| (connector.id.as_str(), connector.is_enabled))
.collect();
for connector in &mut snapshot.connectors {
if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) {
connector.is_enabled = *is_enabled;
}
}
}
if is_final {
self.connectors.partial_snapshot = None;
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
} else {
self.connectors.partial_snapshot = Some(snapshot.clone());
}
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
Err(err) => {
let partial_snapshot = self.connectors.partial_snapshot.take();
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache {
warn!("failed to refresh apps list; retaining current apps snapshot: {err}");
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else if let Some(snapshot) = partial_snapshot {
warn!(
"failed to load full apps list; falling back to installed apps snapshot: {err}"
);
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
} else {
self.connectors.cache = ConnectorsCacheState::Failed(err);
self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None);
}
}
}
if trigger_pending_force_refetch {
self.prefetch_connectors_with_options(/*force_refetch*/ true);
}
}
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else {
return;
};
let mut changed = false;
for connector in &mut snapshot.connectors {
if connector.id == connector_id {
changed = connector.is_enabled != enabled;
connector.is_enabled = enabled;
break;
}
}
if !changed {
return;
}
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
}
+583
View File
@@ -0,0 +1,583 @@
//! Model, collaboration, and reasoning popups for `ChatWidget`.
//!
//! These surfaces are tightly related because changing one often redirects
//! into another, especially while Plan mode is active.
use super::*;
impl ChatWidget {
/// Open a popup to choose a quick auto model. Selecting "All models"
/// opens the full picker with every available preset.
pub(crate) fn open_model_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Model selection is disabled until startup completes.".to_string(),
/*hint*/ None,
);
return;
}
let presets: Vec<ModelPreset> = match self.model_catalog.try_list_models() {
Ok(models) => models,
Err(_) => {
self.add_info_message(
"Models are being updated; please try /model again in a moment.".to_string(),
/*hint*/ None,
);
return;
}
};
self.open_model_popup_with_presets(presets);
}
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
let title = title.to_string();
let subtitle = subtitle.to_string();
let mut header = ColumnRenderable::new();
header.push(Line::from(title.bold()));
header.push(Line::from(subtitle.dim()));
if let Some(warning) = self.model_menu_warning_line() {
header.push(warning);
}
Box::new(header)
}
fn model_menu_warning_line(&self) -> Option<Line<'static>> {
let base_url = self.custom_openai_base_url()?;
let warning = format!(
"Warning: OpenAI base URL is overridden to {base_url}. Selecting models may not be supported or work properly."
);
Some(Line::from(warning.red()))
}
fn custom_openai_base_url(&self) -> Option<String> {
if !self.config.model_provider.is_openai() {
return None;
}
let base_url = self.config.model_provider.base_url.as_ref()?;
let trimmed = base_url.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.trim_end_matches('/');
if normalized == DEFAULT_OPENAI_BASE_URL {
return None;
}
Some(trimmed.to_string())
}
pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec<ModelPreset>) {
let presets: Vec<ModelPreset> = presets
.into_iter()
.filter(|preset| preset.show_in_picker)
.collect();
let current_model = self.current_model();
let current_label = presets
.iter()
.find(|preset| preset.model.as_str() == current_model)
.map(|preset| preset.model.to_string())
.unwrap_or_else(|| self.model_display_name().to_string());
let (mut auto_presets, other_presets): (Vec<ModelPreset>, Vec<ModelPreset>) = presets
.into_iter()
.partition(|preset| Self::is_auto_model(&preset.model));
if auto_presets.is_empty() {
self.open_all_models_popup(other_presets);
return;
}
auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model));
let mut items: Vec<SelectionItem> = auto_presets
.into_iter()
.map(|preset| {
let description =
(!preset.description.is_empty()).then_some(preset.description.clone());
let model = preset.model.clone();
let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope(
model.as_str(),
Some(preset.default_reasoning_effort),
);
let actions = Self::model_selection_actions(
model.clone(),
Some(preset.default_reasoning_effort),
should_prompt_plan_mode_scope,
);
SelectionItem {
name: model.clone(),
description,
is_current: model.as_str() == current_model,
is_default: preset.is_default,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
if !other_presets.is_empty() {
let all_models = other_presets;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAllModelsPopup {
models: all_models.clone(),
});
})];
let is_current = !items.iter().any(|item| item.is_current);
let description = Some(format!(
"Choose a specific model and reasoning level (current: {current_label})"
));
items.push(SelectionItem {
name: "All models".to_string(),
description,
is_current,
actions,
dismiss_on_select: true,
..Default::default()
});
}
let header = self.model_menu_header(
"Select Model",
"Pick a quick auto mode or browse all models.",
);
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header,
..Default::default()
});
}
fn is_auto_model(model: &str) -> bool {
model.starts_with("codex-auto-")
}
fn auto_model_order(model: &str) -> usize {
match model {
"codex-auto-fast" => 0,
"codex-auto-balanced" => 1,
"codex-auto-thorough" => 2,
_ => 3,
}
}
pub(crate) fn open_all_models_popup(&mut self, presets: Vec<ModelPreset>) {
if presets.is_empty() {
self.add_info_message(
"No additional models are available right now.".to_string(),
/*hint*/ None,
);
return;
}
let mut items: Vec<SelectionItem> = Vec::new();
for preset in presets.into_iter() {
let description =
(!preset.description.is_empty()).then_some(preset.description.to_string());
let is_current = preset.model.as_str() == self.current_model();
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
let preset_for_action = preset.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
let preset_for_event = preset_for_action.clone();
tx.send(AppEvent::OpenReasoningPopup {
model: preset_for_event,
});
})];
items.push(SelectionItem {
name: preset.model.clone(),
description,
is_current,
is_default: preset.is_default,
actions,
dismiss_on_select: single_supported_effort,
dismiss_parent_on_child_accept: !single_supported_effort,
..Default::default()
});
}
let header = self.model_menu_header(
"Select Model and Effort",
"Access legacy models by running codex -m <model_name> or in your config.toml",
);
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
header,
..Default::default()
});
}
pub(crate) fn open_collaboration_modes_popup(&mut self) {
let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref());
if presets.is_empty() {
self.add_info_message(
"No collaboration modes are available right now.".to_string(),
/*hint*/ None,
);
return;
}
let current_kind = self
.active_collaboration_mask
.as_ref()
.and_then(|mask| mask.mode)
.or_else(|| {
collaboration_modes::default_mask(self.model_catalog.as_ref())
.and_then(|mask| mask.mode)
});
let items: Vec<SelectionItem> = presets
.into_iter()
.map(|mask| {
let name = mask.name.clone();
let is_current = current_kind == mask.mode;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::UpdateCollaborationMode(mask.clone()));
})];
SelectionItem {
name,
is_current,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Collaboration Mode".to_string()),
subtitle: Some("Pick a collaboration preset.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
fn model_selection_actions(
model_for_action: String,
effort_for_action: Option<ReasoningEffortConfig>,
should_prompt_plan_mode_scope: bool,
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
if should_prompt_plan_mode_scope {
tx.send(AppEvent::OpenPlanReasoningScopePrompt {
model: model_for_action.clone(),
effort: effort_for_action,
});
return;
}
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: effort_for_action,
});
})]
}
fn should_prompt_plan_mode_reasoning_scope(
&self,
selected_model: &str,
selected_effort: Option<ReasoningEffortConfig>,
) -> bool {
if !self.collaboration_modes_enabled()
|| self.active_mode_kind() != ModeKind::Plan
|| selected_model != self.current_model()
{
return false;
}
// Prompt whenever the selection is not a true no-op for both:
// 1) the active Plan-mode effective reasoning, and
// 2) the stored global defaults that would be updated by the fallback path.
selected_effort != self.effective_reasoning_effort()
|| selected_model != self.current_collaboration_mode.model()
|| selected_effort != self.current_collaboration_mode.reasoning_effort()
}
pub(crate) fn open_plan_reasoning_scope_prompt(
&mut self,
model: String,
effort: Option<ReasoningEffortConfig>,
) {
let reasoning_phrase = match effort {
Some(ReasoningEffortConfig::None) => "no reasoning".to_string(),
Some(selected_effort) => {
format!(
"{} reasoning",
Self::reasoning_effort_label(selected_effort).to_lowercase()
)
}
None => "the selected reasoning".to_string(),
};
let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode.");
let plan_reasoning_source = if let Some(plan_override) =
self.config.plan_mode_reasoning_effort
{
format!(
"user-chosen Plan override ({})",
Self::reasoning_effort_label(plan_override).to_lowercase()
)
} else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref())
{
match plan_mask.reasoning_effort.flatten() {
Some(plan_effort) => format!(
"built-in Plan default ({})",
Self::reasoning_effort_label(plan_effort).to_lowercase()
),
None => "built-in Plan default (no reasoning)".to_string(),
}
} else {
"built-in Plan default".to_string()
};
let all_modes_description = format!(
"Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}."
);
let subtitle = format!("Choose where to apply {reasoning_phrase}.");
let plan_only_actions: Vec<SelectionAction> = vec![Box::new({
let model = model.clone();
move |tx| {
tx.send(AppEvent::UpdateModel(model.clone()));
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
}
})];
let all_modes_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::UpdateModel(model.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistModelSelection {
model: model.clone(),
effort,
});
})];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()),
subtitle: Some(subtitle),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
SelectionItem {
name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(),
description: Some(plan_only_description),
actions: plan_only_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(),
description: Some(all_modes_description),
actions: all_modes_actions,
dismiss_on_select: true,
..Default::default()
},
],
..Default::default()
});
self.notify(Notification::PlanModePrompt {
title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(),
});
}
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) {
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
let supported = preset.supported_reasoning_efforts;
let in_plan_mode =
self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan;
let warn_effort = if supported
.iter()
.any(|option| option.effort == ReasoningEffortConfig::XHigh)
{
Some(ReasoningEffortConfig::XHigh)
} else if supported
.iter()
.any(|option| option.effort == ReasoningEffortConfig::High)
{
Some(ReasoningEffortConfig::High)
} else {
None
};
let warning_text = warn_effort.map(|effort| {
let effort_label = Self::reasoning_effort_label(effort);
format!("{effort_label} reasoning effort can quickly consume Plus plan rate limits.")
});
let warn_for_model = preset.model.starts_with("gpt-5.1-codex")
|| preset.model.starts_with("gpt-5.1-codex-max")
|| preset.model.starts_with("gpt-5.2");
struct EffortChoice {
stored: Option<ReasoningEffortConfig>,
display: ReasoningEffortConfig,
}
let mut choices: Vec<EffortChoice> = Vec::new();
for effort in ReasoningEffortConfig::iter() {
if supported.iter().any(|option| option.effort == effort) {
choices.push(EffortChoice {
stored: Some(effort),
display: effort,
});
}
}
if choices.is_empty() {
choices.push(EffortChoice {
stored: Some(default_effort),
display: default_effort,
});
}
if choices.len() == 1 {
let selected_effort = choices.first().and_then(|c| c.stored);
let selected_model = preset.model;
if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) {
self.app_event_tx
.send(AppEvent::OpenPlanReasoningScopePrompt {
model: selected_model,
effort: selected_effort,
});
} else {
self.apply_model_and_effort(selected_model, selected_effort);
}
return;
}
let default_choice: Option<ReasoningEffortConfig> = choices
.iter()
.any(|choice| choice.stored == Some(default_effort))
.then_some(Some(default_effort))
.flatten()
.or_else(|| choices.iter().find_map(|choice| choice.stored))
.or(Some(default_effort));
let model_slug = preset.model.to_string();
let is_current_model = self.current_model() == preset.model.as_str();
let highlight_choice = if is_current_model {
if in_plan_mode {
self.config
.plan_mode_reasoning_effort
.or(self.effective_reasoning_effort())
} else {
self.effective_reasoning_effort()
}
} else {
default_choice
};
let selection_choice = highlight_choice.or(default_choice);
let initial_selected_idx = choices
.iter()
.position(|choice| choice.stored == selection_choice)
.or_else(|| {
selection_choice
.and_then(|effort| choices.iter().position(|choice| choice.display == effort))
});
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.display;
let mut effort_label = Self::reasoning_effort_label(effort).to_string();
if choice.stored == default_choice {
effort_label.push_str(" (default)");
}
let description = choice
.stored
.and_then(|effort| {
supported
.iter()
.find(|option| option.effort == effort)
.map(|option| option.description.to_string())
})
.filter(|text| !text.is_empty());
let show_warning = warn_for_model && warn_effort == Some(effort);
let selected_description = if show_warning {
warning_text.as_ref().map(|warning_message| {
description.as_ref().map_or_else(
|| warning_message.clone(),
|d| format!("{d}\n{warning_message}"),
)
})
} else {
None
};
let model_for_action = model_slug.clone();
let choice_effort = choice.stored;
let should_prompt_plan_mode_scope =
self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort);
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
if should_prompt_plan_mode_scope {
tx.send(AppEvent::OpenPlanReasoningScopePrompt {
model: model_for_action.clone(),
effort: choice_effort,
});
} else {
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(choice_effort));
tx.send(AppEvent::PersistModelSelection {
model: model_for_action.clone(),
effort: choice_effort,
});
}
})];
items.push(SelectionItem {
name: effort_label,
description,
selected_description,
is_current: is_current_model && choice.stored == highlight_choice,
actions,
dismiss_on_select: true,
..Default::default()
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from(
format!("Select Reasoning Level for {model_slug}").bold(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
});
}
pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str {
match effort {
ReasoningEffortConfig::None => "None",
ReasoningEffortConfig::Minimal => "Minimal",
ReasoningEffortConfig::Low => "Low",
ReasoningEffortConfig::Medium => "Medium",
ReasoningEffortConfig::High => "High",
ReasoningEffortConfig::XHigh => "Extra high",
}
}
pub(super) fn apply_model_and_effort_without_persist(
&self,
model: String,
effort: Option<ReasoningEffortConfig>,
) {
self.app_event_tx.send(AppEvent::UpdateModel(model));
self.app_event_tx
.send(AppEvent::UpdateReasoningEffort(effort));
}
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
self.apply_model_and_effort_without_persist(model.clone(), effort);
self.app_event_tx
.send(AppEvent::PersistModelSelection { model, effort });
}
}
@@ -0,0 +1,463 @@
//! Permission and approval popup flows for `ChatWidget`.
//!
//! This module owns the generic permission pickers and confirmation surfaces;
//! Windows-specific sandbox prompting lives beside it in
//! `windows_sandbox_prompts`.
use super::*;
impl ChatWidget {
/// Open the permissions popup.
pub(crate) fn open_approvals_popup(&mut self) {
self.open_permissions_popup();
}
/// Open a popup to choose the permissions mode.
pub(crate) fn open_permissions_popup(&mut self) {
let include_read_only = cfg!(target_os = "windows");
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
let current_permission_profile = self.config.permissions.permission_profile();
let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval);
let current_review_policy = self.config.approvals_reviewer;
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
#[cfg(target_os = "windows")]
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
#[cfg(not(target_os = "windows"))]
let windows_degraded_sandbox_enabled = false;
let show_elevate_sandbox_hint =
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& windows_degraded_sandbox_enabled
&& presets.iter().any(|preset| preset.id == "auto");
let guardian_disabled_reason = |enabled: bool| {
let mut next_features = self.config.features.get().clone();
next_features.set_enabled(Feature::GuardianApproval, enabled);
self.config
.features
.can_set(&next_features)
.err()
.map(|err| err.to_string())
};
for preset in presets.into_iter() {
if !include_read_only && preset.id == "read-only" {
continue;
}
let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Default (non-admin sandbox)".to_string()
} else {
preset.label.to_string()
};
let preset_approval = AskForApproval::from(preset.approval);
let base_description =
Some(preset.description.replace(" (Identical to Agent mode)", ""));
let approval_disabled_reason = match self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
Ok(()) => None,
Err(err) => Some(err.to_string()),
};
let default_disabled_reason = approval_disabled_reason
.clone()
.or_else(|| guardian_disabled_reason(false));
let requires_confirmation = preset.id == "full-access"
&& !self
.config
.notices
.hide_full_access_warning
.unwrap_or(false);
let default_actions: Vec<SelectionAction> = if requires_confirmation {
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenFullAccessConfirmation {
preset: preset_clone.clone(),
return_to_permissions: !include_read_only,
});
})]
} else if preset.id == "auto" {
#[cfg(target_os = "windows")]
{
if WindowsSandboxLevel::from_config(&self.config)
== WindowsSandboxLevel::Disabled
{
let preset_clone = preset.clone();
if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& crate::legacy_core::windows_sandbox::sandbox_setup_is_complete(
self.config.codex_home.as_path(),
)
{
vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Elevated,
});
})]
} else {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
}
} else if let Some((sample_paths, extra_count, failed_scan)) =
self.world_writable_warning_details()
{
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})]
} else {
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
}
} else {
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
};
if preset.id == "auto" {
items.push(SelectionItem {
name: base_name.clone(),
description: base_description.clone(),
is_current: current_review_policy == ApprovalsReviewer::User
&& Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: default_actions,
dismiss_on_select: true,
disabled_reason: default_disabled_reason,
..Default::default()
});
if guardian_approval_enabled {
items.push(SelectionItem {
name: "Auto-review".to_string(),
description: Some(
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."
.to_string(),
),
is_current: current_review_policy == ApprovalsReviewer::AutoReview
&& Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
"Auto-review".to_string(),
ApprovalsReviewer::AutoReview,
),
dismiss_on_select: true,
disabled_reason: approval_disabled_reason
.or_else(|| guardian_disabled_reason(true)),
..Default::default()
});
}
} else {
items.push(SelectionItem {
name: base_name,
description: base_description,
is_current: Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: default_actions,
dismiss_on_select: true,
disabled_reason: default_disabled_reason,
..Default::default()
});
}
}
let footer_note = show_elevate_sandbox_hint.then(|| {
vec![
"The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(),
"/setup-default-sandbox".cyan(),
".".dim(),
]
.into()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Update Model Permissions".to_string()),
footer_note,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
..Default::default()
});
}
pub(crate) fn open_auto_review_denials_popup(&mut self) {
if self.review.recent_auto_review_denials.is_empty() {
self.add_info_message(
"No recent auto-review denials in this thread.".to_string(),
Some("Denials are recorded after auto-review rejects an action.".to_string()),
);
return;
}
let Some(thread_id) = self.thread_id() else {
self.add_error_message("That thread is no longer available.".to_string());
return;
};
let mut items = vec![SelectionItem {
name: "Command".to_string(),
description: Some("Rationale".to_string()),
is_disabled: true,
search_value: Some(String::new()),
..Default::default()
}];
items.extend(
self.review
.recent_auto_review_denials
.entries()
.map(|event| {
let id = event.id.clone();
let summary = auto_review_denials::action_summary(&event.action);
let rationale = event
.rationale
.as_deref()
.unwrap_or("Auto-review did not include a rationale.");
SelectionItem {
name: summary.clone(),
description: Some(rationale.to_string()),
selected_description: Some(rationale.to_string()),
search_value: Some(format!("{summary} {rationale}")),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ApproveRecentAutoReviewDenial {
thread_id,
id: id.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
}
}),
);
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Auto-review Denials".to_string()),
subtitle: Some("Select a denied action to approve.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
});
self.request_redraw();
}
pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) {
let Some(event) = self.review.recent_auto_review_denials.take(&id) else {
self.add_error_message("That auto-review denial is no longer available.".to_string());
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::approve_guardian_denied_action(event),
});
self.add_info_message(
"Approval recorded for one retry of the selected auto-review denial.".to_string(),
Some(
"The model will see the approval context; the retry still goes through auto-review."
.to_string(),
),
);
}
pub(super) fn approval_preset_actions(
approval: AskForApproval,
permission_profile: PermissionProfile,
label: String,
approvals_reviewer: ApprovalsReviewer,
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
let permission_profile_clone = permission_profile.clone();
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
Some(approval),
Some(approvals_reviewer),
Some(permission_profile_clone.clone()),
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone));
tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
format!("Permissions updated to {label}"),
/*hint*/ None,
),
)));
})]
}
pub(super) fn preset_matches_current(
current_approval: AskForApproval,
current_permission_profile: &PermissionProfile,
cwd: &std::path::Path,
preset: &ApprovalPreset,
) -> bool {
let preset_approval = AskForApproval::from(preset.approval);
if current_approval != preset_approval {
return false;
}
match preset.id {
"full-access" => matches!(current_permission_profile, PermissionProfile::Disabled),
"read-only" => {
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
matches!(
current_permission_profile,
PermissionProfile::Managed { .. }
) && !file_system_policy.has_full_disk_write_access()
&& file_system_policy
.get_writable_roots_with_cwd(cwd)
.is_empty()
&& current_permission_profile.network_sandbox_policy()
== preset.permission_profile.network_sandbox_policy()
}
"auto" => {
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
matches!(
current_permission_profile,
PermissionProfile::Managed { .. }
) && file_system_policy.can_write_path_with_cwd(cwd, cwd)
&& !file_system_policy.has_full_disk_write_access()
&& current_permission_profile.network_sandbox_policy()
== preset.permission_profile.network_sandbox_policy()
}
_ => current_permission_profile == &preset.permission_profile,
}
}
pub(crate) fn open_full_access_confirmation(
&mut self,
preset: ApprovalPreset,
return_to_permissions: bool,
) {
let selected_name = preset.label.to_string();
let approval = AskForApproval::from(preset.approval);
let permission_profile = preset.permission_profile;
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let title_line = Line::from("Enable full access?").bold();
let info_line = Line::from(vec![
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
.into(),
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
.fg(Color::Red),
]);
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions = Self::approval_preset_actions(
approval,
permission_profile.clone(),
selected_name.clone(),
ApprovalsReviewer::User,
);
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions = Self::approval_preset_actions(
approval,
permission_profile,
selected_name,
ApprovalsReviewer::User,
);
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
}));
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
if return_to_permissions {
tx.send(AppEvent::OpenPermissionsPopup);
} else {
tx.send(AppEvent::OpenApprovalsPopup);
}
})];
let items = vec![
SelectionItem {
name: "Yes, continue anyway".to_string(),
description: Some("Apply full access for this session".to_string()),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Yes, and don't ask again".to_string(),
description: Some("Enable full access and remember this choice".to_string()),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Go back without enabling full access".to_string()),
actions: deny_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
}
+326 -1
View File
@@ -1,5 +1,6 @@
//! Rate-limit warning and prompt state for `ChatWidget`.
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
use super::*;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
@@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind(
pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
}
impl ChatWidget {
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
if let Some(mut snapshot) = snapshot {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
let limit_label = snapshot
.limit_name
.clone()
.unwrap_or_else(|| limit_id.clone());
if snapshot.credits.is_none() {
snapshot.credits = self
.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.credits.as_ref())
.map(|credits| CreditsSnapshot {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance.clone(),
});
}
self.plan_type = snapshot.plan_type.or(self.plan_type);
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
if is_codex_limit
&& let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type
{
self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);
}
let warnings = if is_codex_limit {
self.rate_limit_warnings.take_warnings(
snapshot
.secondary
.as_ref()
.map(|window| f64::from(window.used_percent)),
snapshot
.secondary
.as_ref()
.and_then(|window| window.window_duration_mins),
snapshot
.primary
.as_ref()
.map(|window| f64::from(window.used_percent)),
snapshot
.primary
.as_ref()
.and_then(|window| window.window_duration_mins),
)
} else {
vec![]
};
let high_usage = is_codex_limit
&& (snapshot
.secondary
.as_ref()
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
.unwrap_or(false)
|| snapshot
.primary
.as_ref()
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
.unwrap_or(false));
let has_workspace_credits = snapshot
.credits
.as_ref()
.map(|credits| credits.has_credits)
.unwrap_or(false);
if high_usage
&& !has_workspace_credits
&& !self.rate_limit_switch_prompt_hidden()
&& self.current_model() != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
)
{
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
}
let display =
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
self.rate_limit_snapshots_by_limit_id
.insert(limit_id, display);
if !warnings.is_empty() {
for warning in warnings {
self.add_to_history(history_cell::new_warning_event(warning));
}
self.request_redraw();
}
} else {
self.rate_limit_snapshots_by_limit_id.clear();
self.codex_rate_limit_reached_type = None;
}
self.refresh_status_line();
}
pub(super) fn stop_rate_limit_poller(&mut self) {}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn prefetch_rate_limits(&mut self) {
self.stop_rate_limit_poller();
}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn should_prefetch_rate_limits(&self) -> bool {
self.config.model_provider.requires_openai_auth && self.has_chatgpt_account
}
fn lower_cost_preset(&self) -> Option<ModelPreset> {
let models = self.model_catalog.try_list_models().ok()?;
models
.iter()
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
.cloned()
}
fn rate_limit_switch_prompt_hidden(&self) -> bool {
self.config
.notices
.hide_rate_limit_model_nudge
.unwrap_or(false)
}
pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {
if self.rate_limit_switch_prompt_hidden() {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
return;
}
if !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
) {
return;
}
if let Some(preset) = self.lower_cost_preset() {
self.open_rate_limit_switch_prompt(preset);
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;
} else {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {
let switch_model = preset.model;
let switch_model_for_events = switch_model.clone();
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*windows_sandbox_level*/ None,
Some(switch_model_for_events.clone()),
Some(Some(default_effort)),
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)));
tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
})];
let keep_actions: Vec<SelectionAction> = Vec::new();
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
})];
let description = if preset.description.is_empty() {
Some("Uses fewer credits for upcoming turns.".to_string())
} else {
Some(preset.description)
};
let items = vec![
SelectionItem {
name: format!("Switch to {switch_model}"),
description,
selected_description: None,
is_current: false,
actions: switch_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model".to_string(),
description: None,
selected_description: None,
is_current: false,
actions: keep_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model (never show again)".to_string(),
description: Some(
"Hide future rate limit reminders about switching models.".to_string(),
),
selected_description: None,
is_current: false,
actions: never_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Approaching rate limits".to_string()),
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(super) fn open_workspace_owner_nudge_prompt(
&mut self,
credit_type: AddCreditsNudgeCreditType,
) {
if self.add_credits_nudge_email_in_flight.is_some() {
return;
}
let (title, prompt) = match credit_type {
AddCreditsNudgeCreditType::Credits => (
"You've reached your workspace credit limit",
"Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?",
),
AddCreditsNudgeCreditType::UsageLimit => (
"Usage limit reached",
"Request a limit increase from your owner to continue using codex. Request increase?",
),
};
let send_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type });
})];
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
actions: send_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
is_default: true,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(title.to_string()),
subtitle: Some(prompt.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx: Some(1),
..Default::default()
});
}
pub(crate) fn start_add_credits_nudge_email_request(
&mut self,
credit_type: AddCreditsNudgeCreditType,
) -> bool {
self.add_credits_nudge_email_in_flight = Some(credit_type);
true
}
pub(crate) fn finish_add_credits_nudge_email_request(
&mut self,
result: Result<AddCreditsNudgeEmailStatus, String>,
) {
let credit_type = self
.add_credits_nudge_email_in_flight
.take()
.unwrap_or(AddCreditsNudgeCreditType::Credits);
let message = match (credit_type, result) {
(AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
"Workspace owner notified."
}
(
AddCreditsNudgeCreditType::Credits,
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
) => "Workspace owner was already notified recently.",
(AddCreditsNudgeCreditType::Credits, Err(_)) => {
"Could not notify your workspace owner. Please try again."
}
(AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
"Limit increase requested."
}
(
AddCreditsNudgeCreditType::UsageLimit,
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
) => "A limit increase was already requested recently.",
(AddCreditsNudgeCreditType::UsageLimit, Err(_)) => {
"Could not request a limit increase. Please try again."
}
};
self.add_to_history(history_cell::new_info_event(
message.to_string(),
/*hint*/ None,
));
self.request_redraw();
}
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
if hidden {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
}
+611
View File
@@ -0,0 +1,611 @@
//! Runtime settings state and model/collaboration coordination for `ChatWidget`.
use super::*;
impl ChatWidget {
/// Set the approval policy in the widget's config copy.
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
if let Err(err) = self
.config
.permissions
.approval_policy
.set(policy.to_core())
{
tracing::warn!(%err, "failed to set approval_policy on chat config");
} else {
self.refresh_status_surfaces();
}
}
/// Set the permission profile in the widget's config copy.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_permission_profile(
&mut self,
profile: PermissionProfile,
) -> ConstraintResult<()> {
self.config.permissions.set_permission_profile(profile)?;
self.refresh_status_surfaces();
Ok(())
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option<WindowsSandboxModeToml>) {
self.config.permissions.windows_sandbox_mode = mode;
#[cfg(target_os = "windows")]
self.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&self.config),
WindowsSandboxLevel::RestrictedToken
),
);
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool {
if let Err(err) = self.config.features.set_enabled(feature, enabled) {
tracing::warn!(
error = %err,
feature = feature.key(),
"failed to update constrained chat widget feature state"
);
}
let enabled = self.config.features.enabled(feature);
if feature == Feature::RealtimeConversation {
let realtime_conversation_enabled = self.realtime_conversation_enabled();
self.bottom_pane
.set_realtime_conversation_enabled(realtime_conversation_enabled);
self.bottom_pane
.set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled());
if !realtime_conversation_enabled && self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(Some(
"Realtime voice mode was closed because the feature was disabled.".to_string(),
));
}
}
if feature == Feature::FastMode {
self.sync_service_tier_commands();
}
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
if feature == Feature::Plugins {
self.sync_plugins_command_enabled();
self.refresh_plugin_mentions();
}
if feature == Feature::Goals {
self.sync_goal_command_enabled();
if !enabled {
self.current_goal_status_indicator = None;
self.current_goal_status = None;
self.turn_lifecycle.goal_status_active_turn_started_at = None;
self.turn_lifecycle.budget_limited_turn_ids.clear();
self.update_collaboration_mode_indicator();
}
}
if feature == Feature::MentionsV2 {
self.sync_mentions_v2_enabled();
}
if feature == Feature::PreventIdleSleep {
self.turn_lifecycle.set_prevent_idle_sleep(enabled);
}
#[cfg(target_os = "windows")]
if matches!(
feature,
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
) {
self.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&self.config),
WindowsSandboxLevel::RestrictedToken
),
);
}
enabled
}
pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) {
self.config.approvals_reviewer = policy;
self.refresh_status_surfaces();
}
pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) {
self.config.notices.hide_full_access_warning = Some(acknowledged);
}
pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) {
self.config.notices.hide_world_writable_warning = Some(acknowledged);
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn world_writable_warning_hidden(&self) -> bool {
self.config
.notices
.hide_world_writable_warning
.unwrap_or(false)
}
/// Override the reasoning effort used when Plan mode is active.
///
/// When the active mask is already Plan, the override is applied immediately
/// so the footer reflects it without waiting for the next mode switch.
/// Passing `None` resets to the Plan-mode preset default.
pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.config.plan_mode_reasoning_effort = effort;
if self.collaboration_modes_enabled()
&& let Some(mask) = self.active_collaboration_mask.as_mut()
&& mask.mode == Some(ModeKind::Plan)
{
if let Some(effort) = effort {
mask.reasoning_effort = Some(Some(effort));
} else if let Some(plan_mask) =
collaboration_modes::plan_mask(self.model_catalog.as_ref())
{
mask.reasoning_effort = plan_mask.reasoning_effort;
}
}
self.refresh_model_dependent_surfaces();
}
/// Set the reasoning effort for the non-Plan collaboration mode.
///
/// Does not touch the active Plan mask — Plan reasoning is controlled
/// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`.
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
/*model*/ None,
Some(effort),
/*developer_instructions*/ None,
);
if self.collaboration_modes_enabled()
&& let Some(mask) = self.active_collaboration_mask.as_mut()
&& mask.mode != Some(ModeKind::Plan)
{
// Generic "global default" updates should not mutate the active Plan mask.
// Plan reasoning is controlled by the Plan preset and Plan-only override updates.
mask.reasoning_effort = Some(effort);
}
self.refresh_model_dependent_surfaces();
}
/// Set the personality in the widget's config copy.
pub(crate) fn set_personality(&mut self, personality: Personality) {
self.config.personality = Some(personality);
}
pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> {
self.status_account_display.as_ref()
}
pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> {
self.runtime_model_provider_base_url.as_deref()
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn model_catalog(&self) -> Arc<ModelCatalog> {
self.model_catalog.clone()
}
pub(crate) fn current_plan_type(&self) -> Option<PlanType> {
self.plan_type
}
pub(crate) fn has_chatgpt_account(&self) -> bool {
self.has_chatgpt_account
}
pub(crate) fn update_account_state(
&mut self,
status_account_display: Option<StatusAccountDisplay>,
plan_type: Option<PlanType>,
has_chatgpt_account: bool,
) {
self.status_account_display = status_account_display;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
self.bottom_pane
.set_connectors_enabled(self.connectors_enabled());
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
name: Option<String>,
) {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name,
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name,
}
}
/// Set the syntax theme override in the widget's config copy.
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
self.config.tui_theme = theme;
}
/// Set the model in the widget's config copy and stored collaboration mode.
pub(crate) fn set_model(&mut self, model: &str) {
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model.to_string()),
/*effort*/ None,
/*developer_instructions*/ None,
);
if self.collaboration_modes_enabled()
&& let Some(mask) = self.active_collaboration_mask.as_mut()
{
mask.model = Some(model.to_string());
}
self.refresh_model_dependent_surfaces();
}
pub(crate) fn current_model(&self) -> &str {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.model();
}
self.active_collaboration_mask
.as_ref()
.and_then(|mask| mask.model.as_deref())
.unwrap_or_else(|| self.current_collaboration_mode.model())
}
pub(crate) fn realtime_conversation_is_live(&self) -> bool {
self.realtime_conversation.is_live()
}
pub(super) fn current_realtime_audio_device_name(
&self,
kind: RealtimeAudioDeviceKind,
) -> Option<String> {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(),
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(),
}
}
pub(super) fn current_realtime_audio_selection_label(
&self,
kind: RealtimeAudioDeviceKind,
) -> String {
self.current_realtime_audio_device_name(kind)
.unwrap_or_else(|| "System default".to_string())
}
pub(super) fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
}
pub(super) fn sync_plugins_command_enabled(&mut self) {
self.bottom_pane
.set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins));
}
pub(super) fn sync_goal_command_enabled(&mut self) {
self.bottom_pane
.set_goal_command_enabled(self.config.features.enabled(Feature::Goals));
}
pub(super) fn sync_mentions_v2_enabled(&mut self) {
self.bottom_pane
.set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2));
}
pub(super) fn current_model_supports_personality(&self) -> bool {
let model = self.current_model();
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.supports_personality)
})
.unwrap_or(false)
}
/// Return whether the effective model currently advertises image-input support.
///
/// We intentionally default to `true` when model metadata cannot be read so transient catalog
/// failures do not hard-block user input in the UI.
pub(super) fn current_model_supports_images(&self) -> bool {
let model = self.current_model();
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.input_modalities.contains(&InputModality::Image))
})
.unwrap_or(true)
}
pub(super) fn sync_image_paste_enabled(&mut self) {
let enabled = self.current_model_supports_images();
self.bottom_pane.set_image_paste_enabled(enabled);
}
pub(super) fn image_inputs_not_supported_message(&self) -> String {
format!(
"Model {} does not support image inputs. Remove images or switch models.",
self.current_model()
)
}
#[allow(dead_code)] // Used in tests
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
&self.current_collaboration_mode
}
pub(crate) fn current_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
self.effective_reasoning_effort()
}
#[cfg(test)]
pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind {
self.active_mode_kind()
}
pub(super) fn is_session_configured(&self) -> bool {
self.thread_id.is_some()
}
pub(super) fn collaboration_modes_enabled(&self) -> bool {
true
}
/// Returns the dismissal scope that applies to the currently visible draft.
fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope {
self.thread_id
.map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread)
}
/// Returns whether the current draft should replace the normal footer with the Plan-mode nudge.
///
/// `ChatWidget` owns this policy because it can combine lexical draft matching with mode
/// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders
/// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a
/// mode switch while the user is intentionally composing another local command.
pub(super) fn should_show_plan_mode_nudge(&self) -> bool {
let text = self.bottom_pane.composer_text();
let trimmed = text.trim_start();
self.collaboration_modes_enabled()
&& collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some()
&& self.active_mode_kind() != ModeKind::Plan
&& self.bottom_pane.composer_input_enabled()
&& !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& !trimmed.starts_with('/')
&& !trimmed.starts_with('!')
&& contains_plan_keyword(&text)
&& !self
.dismissed_plan_mode_nudge_scopes
.contains(&self.plan_mode_nudge_scope())
}
/// Synchronizes the footer presentation with the current Plan-mode nudge policy.
pub(super) fn refresh_plan_mode_nudge(&mut self) {
self.bottom_pane
.set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge());
}
/// Hides the nudge for the current thread scope until the user changes conversation context.
pub(super) fn dismiss_plan_mode_nudge(&mut self) {
self.dismissed_plan_mode_nudge_scopes
.insert(self.plan_mode_nudge_scope());
self.refresh_plan_mode_nudge();
}
pub(super) fn initial_collaboration_mask(
_config: &Config,
model_catalog: &ModelCatalog,
model_override: Option<&str>,
) -> Option<CollaborationModeMask> {
let mut mask = collaboration_modes::default_mask(model_catalog)?;
if let Some(model_override) = model_override {
mask.model = Some(model_override.to_string());
}
Some(mask)
}
pub(super) fn active_mode_kind(&self) -> ModeKind {
self.active_collaboration_mask
.as_ref()
.and_then(|mask| mask.mode)
.unwrap_or(ModeKind::Default)
}
pub(super) fn effective_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.reasoning_effort();
}
let current_effort = self.current_collaboration_mode.reasoning_effort();
self.active_collaboration_mask
.as_ref()
.and_then(|mask| mask.reasoning_effort)
.unwrap_or(current_effort)
}
pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.clone();
}
self.active_collaboration_mask.as_ref().map_or_else(
|| self.current_collaboration_mode.clone(),
|mask| self.current_collaboration_mode.apply_mask(mask),
)
}
pub(super) fn refresh_model_display(&mut self) {
let effective = self.effective_collaboration_mode();
self.session_header.set_model(effective.model());
// Keep composer paste affordances aligned with the currently effective model.
self.sync_image_paste_enabled();
self.sync_service_tier_commands();
self.refresh_terminal_title();
}
/// Refresh every UI surface that depends on the effective model, reasoning
/// effort, or collaboration mode.
///
/// Call this at the end of any setter that mutates `current_collaboration_mode`,
/// `active_collaboration_mask`, or per-mode reasoning-effort overrides.
/// Consolidating both refreshes here prevents the bug where callers update the
/// header/title (`refresh_model_display`) but forget the footer status line
/// (`refresh_status_line`).
pub(super) fn refresh_model_dependent_surfaces(&mut self) {
self.refresh_model_display();
self.refresh_status_line();
}
pub(super) fn model_display_name(&self) -> &str {
let model = self.current_model();
if model.is_empty() {
DEFAULT_MODEL_DISPLAY_NAME
} else {
model
}
}
/// Get the label for the current collaboration mode.
pub(super) fn collaboration_mode_label(&self) -> Option<&'static str> {
if !self.collaboration_modes_enabled() {
return None;
}
let active_mode = self.active_mode_kind();
active_mode
.is_tui_visible()
.then_some(active_mode.display_name())
}
fn collaboration_mode_indicator(&self) -> Option<CollaborationModeIndicator> {
if !self.collaboration_modes_enabled() {
return None;
}
match self.active_mode_kind() {
ModeKind::Plan => Some(CollaborationModeIndicator::Plan),
ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None,
}
}
pub(super) fn update_collaboration_mode_indicator(&mut self) {
let indicator = self.collaboration_mode_indicator();
let goal_indicator = if indicator.is_none() {
self.goal_status_indicator(Instant::now())
} else {
None
};
self.current_goal_status_indicator = goal_indicator.clone();
self.bottom_pane.set_collaboration_mode_indicator(indicator);
self.bottom_pane.set_goal_status_indicator(goal_indicator);
}
pub(super) fn refresh_goal_status_indicator_for_time_tick(&mut self) {
if self.collaboration_mode_indicator().is_some() {
return;
}
let goal_indicator = self.goal_status_indicator(Instant::now());
if goal_indicator != self.current_goal_status_indicator {
self.current_goal_status_indicator = goal_indicator.clone();
self.bottom_pane.set_goal_status_indicator(goal_indicator);
}
}
fn goal_status_indicator(&self, now: Instant) -> Option<GoalStatusIndicator> {
if !self.config.features.enabled(Feature::Goals) {
return None;
}
self.current_goal_status.as_ref().and_then(|state| {
state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at)
})
}
pub(super) fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option<String>) {
if let Some(active_thread_id) = self.thread_id
&& active_thread_id.to_string() != goal.thread_id
{
return;
}
if !self.config.features.enabled(Feature::Goals) {
self.current_goal_status_indicator = None;
self.current_goal_status = None;
self.update_collaboration_mode_indicator();
return;
}
if goal.status == AppThreadGoalStatus::BudgetLimited
&& let Some(turn_id) = turn_id
{
self.turn_lifecycle.mark_budget_limited(turn_id);
}
self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now()));
self.update_collaboration_mode_indicator();
}
/// Cycle to the next collaboration mode variant (Plan -> Default -> Plan).
pub(super) fn cycle_collaboration_mode(&mut self) {
if !self.collaboration_modes_enabled() {
return;
}
if let Some(next_mask) = collaboration_modes::next_mask(
self.model_catalog.as_ref(),
self.active_collaboration_mask.as_ref(),
) {
self.set_collaboration_mask(next_mask);
}
}
/// Update the active collaboration mask.
///
/// When collaboration modes are enabled and a preset is selected,
/// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`.
pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) {
if !self.collaboration_modes_enabled() {
return;
}
let previous_mode = self.active_mode_kind();
let previous_model = self.current_model().to_string();
let previous_effort = self.effective_reasoning_effort();
if mask.mode == Some(ModeKind::Plan)
&& let Some(effort) = self.config.plan_mode_reasoning_effort
{
mask.reasoning_effort = Some(Some(effort));
}
if mask.mode == Some(ModeKind::Plan) {
self.dismissed_plan_mode_nudge_scopes
.insert(self.plan_mode_nudge_scope());
}
self.active_collaboration_mask = Some(mask);
self.update_collaboration_mode_indicator();
self.refresh_plan_mode_nudge();
self.refresh_model_dependent_surfaces();
let next_mode = self.active_mode_kind();
let next_model = self.current_model();
let next_effort = self.effective_reasoning_effort();
if previous_mode != next_mode
&& (previous_model != next_model || previous_effort != next_effort)
{
let mut message = format!("Model changed to {next_model}");
if !next_model.starts_with("codex-auto-") {
let reasoning_label = match next_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",
};
message.push(' ');
message.push_str(reasoning_label);
}
message.push_str(" for ");
message.push_str(next_mode.display_name());
message.push_str(" mode.");
self.add_info_message(message, /*hint*/ None);
}
self.request_redraw();
}
}
@@ -0,0 +1,285 @@
//! Settings-adjacent popup surfaces for `ChatWidget`.
//!
//! This keeps theme, personality, audio-device, and experimental-feature UI
//! out of the main orchestration module without changing their event wiring.
use super::*;
impl ChatWidget {
pub(super) fn open_theme_picker(&mut self) {
let codex_home = crate::legacy_core::config::find_codex_home().ok();
let terminal_width = self
.last_rendered_width
.get()
.and_then(|width| u16::try_from(width).ok());
let params = crate::theme_picker::build_theme_picker_params(
self.config.tui_theme.as_deref(),
codex_home.as_deref(),
terminal_width,
);
self.bottom_pane.show_selection_view(params);
}
pub(crate) fn open_personality_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Personality selection is disabled until startup completes.".to_string(),
/*hint*/ None,
);
return;
}
if !self.current_model_supports_personality() {
let current_model = self.current_model();
self.add_error_message(format!(
"Current model ({current_model}) doesn't support personalities. Try /model to pick a different model."
));
return;
}
self.open_personality_popup_for_current_model();
}
fn open_personality_popup_for_current_model(&mut self) {
let current_personality = self.config.personality.unwrap_or(Personality::Friendly);
let personalities = [Personality::Friendly, Personality::Pragmatic];
let supports_personality = self.current_model_supports_personality();
let items: Vec<SelectionItem> = personalities
.into_iter()
.map(|personality| {
let name = Self::personality_label(personality).to_string();
let description = Some(Self::personality_description(personality).to_string());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
Some(personality),
)));
tx.send(AppEvent::UpdatePersonality(personality));
tx.send(AppEvent::PersistPersonalitySelection { personality });
})];
SelectionItem {
name,
description,
is_current: current_personality == personality,
is_disabled: !supports_personality,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
let mut header = ColumnRenderable::new();
header.push(Line::from("Select Personality".bold()));
header.push(Line::from("Choose a communication style for Codex.".dim()));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_realtime_audio_popup(&mut self) {
let items = [
RealtimeAudioDeviceKind::Microphone,
RealtimeAudioDeviceKind::Speaker,
]
.into_iter()
.map(|kind| {
let description = Some(format!(
"Current: {}",
self.current_realtime_audio_selection_label(kind)
));
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind });
})];
SelectionItem {
name: kind.title().to_string(),
description,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Settings".to_string()),
subtitle: Some("Configure settings for Codex.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
match list_realtime_audio_device_names(kind) {
Ok(device_names) => {
self.open_realtime_audio_device_selection_with_names(kind, device_names);
}
Err(err) => {
self.add_error_message(format!(
"Failed to load realtime {} devices: {err}",
kind.noun()
));
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
pub(super) fn open_realtime_audio_device_selection_with_names(
&mut self,
kind: RealtimeAudioDeviceKind,
device_names: Vec<String>,
) {
let current_selection = self.current_realtime_audio_device_name(kind);
let current_available = current_selection
.as_deref()
.is_some_and(|name| device_names.iter().any(|device_name| device_name == name));
let mut items = vec![SelectionItem {
name: "System default".to_string(),
description: Some("Use your operating system default device.".to_string()),
is_current: current_selection.is_none(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
})],
dismiss_on_select: true,
..Default::default()
}];
if let Some(selection) = current_selection.as_deref()
&& !current_available
{
items.push(SelectionItem {
name: format!("Unavailable: {selection}"),
description: Some("Configured device is not currently available.".to_string()),
is_current: true,
is_disabled: true,
disabled_reason: Some("Reconnect the device or choose another one.".to_string()),
..Default::default()
});
}
items.extend(device_names.into_iter().map(|device_name| {
let persisted_name = device_name.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
kind,
name: Some(persisted_name.clone()),
});
})];
SelectionItem {
is_current: current_selection.as_deref() == Some(device_name.as_str()),
name: device_name,
actions,
dismiss_on_select: true,
..Default::default()
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Select {}", kind.title()).bold()));
header.push(Line::from(
"Saved devices apply to realtime voice only.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) {
let restart_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::RestartRealtimeAudioDevice { kind });
})];
let items = vec![
SelectionItem {
name: "Restart now".to_string(),
description: Some(format!("Restart local {} audio now.", kind.noun())),
actions: restart_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Apply later".to_string(),
description: Some(format!(
"Keep the current {} until local audio starts again.",
kind.noun()
)),
dismiss_on_select: true,
..Default::default()
},
];
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Restart {} now?", kind.title()).bold()));
header.push(Line::from(
"Configuration is saved. Restart local audio to use it immediately.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_experimental_popup(&mut self) {
let features: Vec<ExperimentalFeatureItem> = FEATURES
.iter()
.filter_map(|spec| {
let name = spec.stage.experimental_menu_name()?;
let description = spec.stage.experimental_menu_description()?;
Some(ExperimentalFeatureItem {
feature: spec.id,
name: name.to_string(),
description: description.to_string(),
enabled: self.config.features.enabled(spec.id),
})
})
.collect();
let view = ExperimentalFeaturesView::new(
features,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
fn personality_label(personality: Personality) -> &'static str {
match personality {
Personality::None => "None",
Personality::Friendly => "Friendly",
Personality::Pragmatic => "Pragmatic",
}
}
fn personality_description(personality: Personality) -> &'static str {
match personality {
Personality::None => "No personality instructions.",
Personality::Friendly => "Warm, collaborative, and helpful.",
Personality::Pragmatic => "Concise, task-focused, and direct.",
}
}
}
@@ -0,0 +1,380 @@
//! Status output and setup controls for `ChatWidget`.
//!
//! Rendering details live in `status_surfaces`; this module owns the mutable
//! widget entrypoints that apply status state, open setup views, and update the
//! history-facing `/status` surface.
use super::*;
impl ChatWidget {
/// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
pub(super) fn set_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) {
let details = details
.filter(|details| !details.is_empty())
.map(|details| {
let trimmed = details.trim_start();
match details_capitalization {
StatusDetailsCapitalization::CapitalizeFirst => {
crate::text_formatting::capitalize_first(trimmed)
}
StatusDetailsCapitalization::Preserve => trimmed.to_string(),
}
});
self.status_state.set_status(StatusIndicatorState {
header: header.clone(),
details: details.clone(),
details_max_lines,
});
self.bottom_pane.update_status(
header,
details,
StatusDetailsCapitalization::Preserve,
details_max_lines,
);
let title_uses_status = self
.config
.tui_terminal_title
.as_ref()
.is_some_and(|items| {
items
.iter()
.any(|item| item == "run-state" || item == "status")
});
if title_uses_status {
self.refresh_status_surfaces();
}
}
/// Convenience wrapper around [`Self::set_status`];
/// updates the status indicator header and clears any existing details.
pub(super) fn set_status_header(&mut self, header: String) {
self.set_status(
header,
/*details*/ None,
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
}
/// Sets the currently rendered footer status-line value.
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
self.bottom_pane.set_status_line(status_line);
}
/// Sets the terminal hyperlink target for the currently rendered footer status line.
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
self.bottom_pane.set_status_line_hyperlink(url);
}
/// Forwards the contextual active-agent label into the bottom-pane footer pipeline.
///
/// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the
/// user actually looking at?" and the footer stack remains a pure renderer of that decision.
pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option<String>) {
self.bottom_pane.set_active_agent_label(active_agent_label);
}
/// Recomputes footer status-line content from config and current runtime state.
///
/// This method is the status-line orchestrator: it parses configured item identifiers,
/// warns once per session about invalid items, updates whether status-line mode is enabled,
/// schedules async git-branch lookup when needed, and renders only values that are currently
/// available.
///
/// The omission behavior is intentional. If selected items are unavailable (for example before
/// a session id exists or before branch lookup completes), those items are skipped without
/// placeholders so the line remains compact and stable.
pub(crate) fn refresh_status_line(&mut self) {
self.refresh_status_surfaces();
}
/// Records that status-line setup was canceled.
///
/// Cancellation is intentionally side-effect free for config state; the existing configuration
/// remains active and no persistence is attempted.
pub(crate) fn cancel_status_line_setup(&self) {
tracing::info!("Status line setup canceled by user");
}
/// Applies status-line item selection from the setup view to in-memory config.
///
/// An empty selection persists as an explicit empty list.
pub(crate) fn setup_status_line(&mut self, items: Vec<StatusLineItem>, use_theme_colors: bool) {
tracing::info!(
"status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}"
);
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.config.tui_status_line = Some(ids);
self.config.tui_status_line_use_colors = use_theme_colors;
self.refresh_status_line();
}
/// Applies a temporary terminal-title selection while the setup UI is open.
pub(crate) fn preview_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
if self.terminal_title_setup_original_items.is_none() {
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
}
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.config.tui_terminal_title = Some(ids);
self.refresh_terminal_title();
}
/// Restores the terminal-title config that was active before the setup UI
/// opened, undoing any preview changes. No-op if no setup session is active.
pub(crate) fn revert_terminal_title_setup_preview(&mut self) {
let Some(original_items) = self.terminal_title_setup_original_items.take() else {
return;
};
self.config.tui_terminal_title = original_items;
self.refresh_terminal_title();
}
/// Dismisses the terminal-title setup UI and reverts to the pre-setup config.
pub(crate) fn cancel_terminal_title_setup(&mut self) {
tracing::info!("Terminal title setup canceled by user");
self.revert_terminal_title_setup_preview();
}
/// Commits a confirmed terminal-title selection, ending the setup session.
///
/// After this call, `revert_terminal_title_setup_preview` becomes a no-op
/// because the original config snapshot is discarded.
pub(crate) fn setup_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
tracing::info!("terminal title setup confirmed with items: {items:#?}");
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
self.terminal_title_setup_original_items = None;
self.config.tui_terminal_title = Some(ids);
self.refresh_terminal_title();
}
/// Stores async git-branch lookup results for the current status-line cwd.
///
/// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch
/// names after directory changes.
pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option<String>) {
if self.status_line_branch_cwd.as_ref() != Some(&cwd) {
self.status_line_branch_pending = false;
return;
}
self.status_line_branch = branch;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = true;
self.refresh_status_surfaces();
}
/// Stores async Git summary lookup results for the current status-line cwd.
pub(crate) fn set_status_line_git_summary(
&mut self,
cwd: PathBuf,
summary: StatusLineGitSummary,
) {
if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) {
self.status_line_git_summary_pending = false;
return;
}
self.status_line_git_summary = Some(summary);
self.status_line_git_summary_pending = false;
self.status_line_git_summary_lookup_complete = true;
self.refresh_status_surfaces();
}
pub(crate) fn add_status_output(
&mut self,
refreshing_rate_limits: bool,
request_id: Option<u64>,
) {
let default_usage = TokenUsage::default();
let token_info = self.token_info.as_ref();
let total_usage = token_info
.map(|ti| &ti.total_token_usage)
.unwrap_or(&default_usage);
let collaboration_mode = self.collaboration_mode_label();
let model = self.current_model().to_string();
let model_default_reasoning_effort =
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.default_reasoning_effort)
});
let reasoning_effort_override = Some(
self.effective_reasoning_effort()
.or(self.config.model_reasoning_effort)
.or(model_default_reasoning_effort),
);
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
.rate_limit_snapshots_by_limit_id
.values()
.cloned()
.collect();
let agents_summary =
crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths);
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config,
self.runtime_model_provider_base_url.as_deref(),
self.status_account_display.as_ref(),
token_info,
total_usage,
&self.thread_id,
self.thread_name.clone(),
self.forked_from,
rate_limit_snapshots.as_slice(),
self.plan_type,
Local::now(),
self.model_display_name(),
collaboration_mode,
reasoning_effort_override,
agents_summary,
refreshing_rate_limits,
);
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
}
self.add_to_history(cell);
}
pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) {
if self.refreshing_status_outputs.is_empty() {
return;
}
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
.rate_limit_snapshots_by_limit_id
.values()
.cloned()
.collect();
let now = Local::now();
let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len());
let mut updated_any = false;
for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) {
if pending_request_id == request_id {
updated_any = true;
handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now);
} else {
remaining.push((pending_request_id, handle));
}
}
self.refreshing_status_outputs = remaining;
if updated_any {
self.request_redraw();
}
}
pub(super) fn open_status_line_setup(&mut self) {
let configured_status_line_items = self.configured_status_line_items();
let view = StatusLineSetupView::new(
Some(configured_status_line_items.as_slice()),
self.config.tui_status_line_use_colors,
self.status_surface_preview_data(),
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(super) fn open_terminal_title_setup(&mut self) {
let configured_terminal_title_items = self.configured_terminal_title_items();
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
let view = TerminalTitleSetupView::new(
Some(configured_terminal_title_items.as_slice()),
self.terminal_title_preview_data(),
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData {
StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| {
self.status_surface_preview_value_for_item(item)
.map(|value| (item, value))
}))
}
pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData {
let mut preview_data = self.status_surface_preview_data();
let now = Instant::now();
for item in TerminalTitleItem::iter() {
let Some(preview_item) = item.preview_item() else {
continue;
};
let Some(value) = self.terminal_title_value_for_item(item, now) else {
continue;
};
preview_data.set_live(preview_item, value);
}
preview_data
}
pub(super) fn status_line_context_window_size(&self) -> Option<i64> {
self.token_info
.as_ref()
.and_then(|info| info.model_context_window)
.or(self.config.model_context_window)
}
pub(super) fn status_line_context_remaining_percent(&self) -> Option<i64> {
let Some(context_window) = self.status_line_context_window_size() else {
return Some(100);
};
let default_usage = TokenUsage::default();
let usage = self
.token_info
.as_ref()
.map(|info| &info.last_token_usage)
.unwrap_or(&default_usage);
Some(
usage
.percent_of_context_window_remaining(context_window)
.clamp(0, 100),
)
}
pub(super) fn status_line_context_used_percent(&self) -> Option<i64> {
let remaining = self.status_line_context_remaining_percent().unwrap_or(100);
Some((100 - remaining).clamp(0, 100))
}
pub(super) fn status_line_total_usage(&self) -> TokenUsage {
self.token_info
.as_ref()
.map(|info| info.total_token_usage.clone())
.unwrap_or_default()
}
pub(super) fn status_line_limit_display(
&self,
window: Option<&RateLimitWindowDisplay>,
label: &str,
) -> Option<String> {
let window = window?;
let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64);
Some(format!("{label} {remaining:.0}%"))
}
pub(super) fn status_line_reasoning_effort_label(
effort: Option<ReasoningEffortConfig>,
) -> &'static str {
match 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",
}
}
}
@@ -1,4 +1,6 @@
use super::*;
use crate::app_event::ConnectorsSnapshot;
use crate::chatwidget::connectors::ConnectorsCacheState;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HooksListEntry;
@@ -1,5 +1,6 @@
use super::*;
use crate::bottom_pane::goal_status_indicator_line;
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
use pretty_assertions::assert_eq;
use ratatui::backend::TestBackend;
use serial_test::serial;
@@ -0,0 +1,446 @@
//! Windows sandbox prompts and warning surfaces for `ChatWidget`.
use super::*;
impl ChatWidget {
#[cfg(target_os = "windows")]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
if self
.config
.notices
.hide_world_writable_warning
.unwrap_or(false)
{
return None;
}
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let Ok(policy) = self
.config
.permissions
.permission_profile()
.to_legacy_sandbox_policy(self.config.cwd.as_path())
else {
return Some((Vec::new(), 0, true));
};
match codex_windows_sandbox::apply_world_writable_scan_and_denies(
self.config.codex_home.as_path(),
cwd.as_path(),
&env_map,
&policy,
Some(self.config.codex_home.as_path()),
) {
Ok(_) => None,
Err(_) => Some((Vec::new(), 0, true)),
}
}
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
None
}
#[cfg(target_os = "windows")]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
preset: Option<ApprovalPreset>,
sample_paths: Vec<String>,
extra_count: usize,
failed_scan: bool,
) {
let (approval, permission_profile) = match &preset {
Some(p) => (
Some(AskForApproval::from(p.approval)),
Some(p.permission_profile.clone()),
),
None => (None, None),
};
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let describe_profile = |profile: &PermissionProfile| {
if matches!(profile, PermissionProfile::Disabled) {
"Full Access mode"
} else if profile
.file_system_sandbox_policy()
.can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path())
{
"Agent mode"
} else {
"Read-Only mode"
}
};
let mode_label = preset
.as_ref()
.map(|p| describe_profile(&p.permission_profile))
.unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile()));
let info_line = if failed_scan {
Line::from(vec![
"We couldn't complete the world-writable scan, so protections cannot be verified. "
.into(),
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
.fg(Color::Red),
])
} else {
Line::from(vec![
"The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(),
" Consider removing write access for Everyone from the following folders:".into(),
])
};
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
if !sample_paths.is_empty() {
// Show up to three examples and optionally an "and X more" line.
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for p in &sample_paths {
lines.push(Line::from(format!(" - {p}")));
}
if extra_count > 0 {
lines.push(Line::from(format!("and {extra_count} more")));
}
header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
}
let header = ColumnRenderable::with(header_children);
// Build actions ensuring acknowledgement happens before applying the
// new permission profile, so downstream policy-change hooks don't
// re-trigger the warning.
let mut accept_actions: Vec<SelectionAction> = Vec::new();
// Suppress the immediate re-scan only when a preset will be applied via
// /permissions, to avoid duplicate warnings from the ensuing policy change.
if preset.is_some() {
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::SkipNextWorldWritableScan);
}));
}
if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) {
accept_actions.extend(Self::approval_preset_actions(
approval,
permission_profile,
mode_label.to_string(),
ApprovalsReviewer::User,
));
}
let mut accept_and_remember_actions: Vec<SelectionAction> = Vec::new();
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
}));
if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) {
accept_and_remember_actions.extend(Self::approval_preset_actions(
approval,
permission_profile,
mode_label.to_string(),
ApprovalsReviewer::User,
));
}
let items = vec![
SelectionItem {
name: "Continue".to_string(),
description: Some(format!("Apply {mode_label} for this session")),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Continue and don't warn again".to_string(),
description: Some(format!("Enable {mode_label} and remember this choice")),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
_preset: Option<ApprovalPreset>,
_sample_paths: Vec<String>,
_extra_count: usize,
_failed_scan: bool,
) {
}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) {
use ratatui_macros::line;
if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED {
// Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it
// directly (no elevation prompts).
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()],
line!["Learn more: https://developers.openai.com/codex/windows"],
])
.wrap(Wrap { trim: false }),
));
let preset_clone = preset;
let items = vec![
SelectionItem {
name: "Enable experimental sandbox".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Legacy,
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Go back".to_string(),
description: None,
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
return;
}
self.session_telemetry.counter(
"codex.windows_sandbox.elevated_prompt_shown",
/*inc*/ 1,
&[],
);
let mut header = ColumnRenderable::new();
header.push(*Box::new(
Paragraph::new(vec![
line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more <https://developers.openai.com/codex/windows>"],
])
.wrap(Wrap { trim: false }),
));
let accept_otel = self.session_telemetry.clone();
let legacy_otel = self.session_telemetry.clone();
let legacy_preset = preset.clone();
let quit_otel = self.session_telemetry.clone();
let items = vec![
SelectionItem {
name: "Set up default sandbox (requires Administrator permissions)".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
accept_otel.counter(
"codex.windows_sandbox.elevated_prompt_accept",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
legacy_otel.counter(
"codex.windows_sandbox.elevated_prompt_use_legacy",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
preset: legacy_preset.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Quit".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
quit_otel.counter(
"codex.windows_sandbox.elevated_prompt_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) {
use ratatui_macros::line;
let mut lines = Vec::new();
lines.push(line![
"Couldn't set up your sandbox with Administrator permissions".bold()
]);
lines.push(line![""]);
lines.push(line![
"You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected."
]);
lines.push(line![
"Learn more <https://developers.openai.com/codex/windows>"
]);
let mut header = ColumnRenderable::new();
header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
let elevated_preset = preset.clone();
let legacy_preset = preset;
let quit_otel = self.session_telemetry.clone();
let items = vec![
SelectionItem {
name: "Try setting up admin sandbox again".to_string(),
description: None,
actions: vec![Box::new({
let otel = self.session_telemetry.clone();
let preset = elevated_preset;
move |tx| {
otel.counter(
"codex.windows_sandbox.fallback_retry_elevated",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
}
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Use Codex with non-admin sandbox".to_string(),
description: None,
actions: vec![Box::new({
let otel = self.session_telemetry.clone();
let preset = legacy_preset;
move |tx| {
otel.counter(
"codex.windows_sandbox.fallback_use_legacy",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
preset: preset.clone(),
});
}
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Quit".to_string(),
description: None,
actions: vec![Box::new(move |tx| {
quit_otel.counter(
"codex.windows_sandbox.fallback_prompt_quit",
/*inc*/ 1,
&[],
);
tx.send(AppEvent::Exit(ExitMode::ShutdownFirst));
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: None,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {}
#[cfg(target_os = "windows")]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) {
if show_now
&& WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled
&& let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
{
self.open_windows_sandbox_enable_prompt(preset);
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {}
#[cfg(target_os = "windows")]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
// While elevated sandbox setup runs, prevent typing so the user doesn't
// accidentally queue messages that will run under an unexpected mode.
self.bottom_pane.set_composer_input_enabled(
/*enabled*/ false,
Some("Input disabled until setup completes.".to_string()),
);
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ false);
self.set_status(
"Setting up sandbox...".to_string(),
Some("Hang tight, this may take a few minutes".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
self.request_redraw();
}
#[cfg(not(target_os = "windows"))]
#[allow(dead_code)]
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
#[cfg(target_os = "windows")]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
self.bottom_pane
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
self.bottom_pane.hide_status_indicator();
self.request_redraw();
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
}