mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
4454e1411b
commit
1ae811ddb2
+6
-3448
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user