Merge branch 'etraut/next-turn-state-app-server' into etraut/next-turn-state-tui

# Conflicts:
#	codex-rs/tui/src/chatwidget.rs
#	codex-rs/tui/src/chatwidget/tests/plan_mode.rs
This commit is contained in:
Eric Traut
2026-05-14 10:44:52 -07:00
379 changed files with 22646 additions and 10873 deletions
-1
View File
@@ -154,7 +154,6 @@ codex-cli = { workspace = true }
codex-mcp = { workspace = true }
core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-utils-pty = { workspace = true }
assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
+6 -1
View File
@@ -125,9 +125,9 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_features::Feature;
use codex_model_provider::create_model_provider;
@@ -196,6 +196,7 @@ mod loaded_threads;
mod pending_interactive_replay;
mod pets;
mod platform_actions;
mod plugin_mentions;
mod replay_filter;
mod resize_reflow;
mod session_lifecycle;
@@ -465,6 +466,7 @@ pub(crate) struct App {
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_permission_profile_override: Option<PermissionProfile>,
@@ -629,6 +631,7 @@ impl App {
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -758,6 +761,7 @@ impl App {
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
let started = app_server.start_thread(&config).await?;
// Only count a startup tooltip once the fresh thread can actually render it.
let startup_tooltip_override =
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
.await;
@@ -899,6 +903,7 @@ See the Codex keymap documentation for supported actions and examples."
active_profile,
cli_kv_overrides,
harness_overrides,
loader_overrides,
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
+33 -21
View File
@@ -4,6 +4,7 @@
//! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so
//! the main event loop remains single-threaded.
use super::plugin_mentions::fetch_plugin_mentions;
use super::*;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
@@ -358,8 +359,9 @@ impl App {
});
}
pub(super) fn refresh_plugin_mentions(&mut self) {
pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) {
let config = self.config.clone();
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
if !config.features.enabled(Feature::Plugins) {
app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None });
@@ -367,15 +369,16 @@ impl App {
}
tokio::spawn(async move {
let plugins_input = config.plugins_config_input();
let plugins = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.to_vec();
app_event_tx.send(AppEvent::PluginMentionsLoaded {
plugins: Some(plugins),
});
match fetch_plugin_mentions(request_handle, config).await {
Ok(plugins) => {
app_event_tx.send(AppEvent::PluginMentionsLoaded {
plugins: Some(plugins),
});
}
Err(err) => {
tracing::warn!(error = %err, "plugin/list failed while refreshing plugin mention candidates");
}
}
});
}
@@ -635,18 +638,9 @@ pub(super) async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<PluginListResponse> {
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?;
let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4()));
let mut response = request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
marketplace_kinds: None,
},
})
let mut response = request_plugin_list(request_handle, cwd)
.await
.wrap_err("plugin/list failed in TUI")?;
.wrap_err("plugin/list failed while loading the plugins menu")?;
hide_cli_only_plugin_marketplaces(&mut response);
Ok(response)
}
@@ -659,6 +653,24 @@ pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListRespons
.retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str()));
}
pub(super) async fn request_plugin_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<PluginListResponse> {
let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?;
let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::PluginList {
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
marketplace_kinds: None,
},
})
.await
.wrap_err("plugin/list failed in TUI")
}
pub(super) async fn fetch_plugin_detail(
request_handle: AppServerRequestHandle,
params: PluginReadParams,
+4 -3
View File
@@ -15,6 +15,7 @@ impl App {
.codex_home(self.config.codex_home.to_path_buf())
.cli_overrides(self.cli_kv_overrides.clone())
.harness_overrides(overrides)
.loader_overrides(self.loader_overrides.clone())
.build()
.await
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
@@ -170,7 +171,7 @@ impl App {
(root_blocks_disable, profile_configured)
};
let mut permissions_history_label: Option<&'static str> = None;
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
let mut builder = ConfigEditsBuilder::for_config(&self.config)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in updates {
@@ -407,7 +408,7 @@ impl App {
},
];
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -591,7 +592,7 @@ mod tests {
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
ConfigEditsBuilder::new(&app.config.codex_home)
ConfigEditsBuilder::for_config(&app.config)
.with_edits([
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
+20 -23
View File
@@ -756,9 +756,6 @@ impl App {
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(&model);
}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
@@ -1082,7 +1079,7 @@ impl App {
}
let profile = self.active_profile.as_deref();
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
let builder = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_windows_sandbox_mode(if elevated_enabled {
"elevated"
@@ -1185,7 +1182,7 @@ impl App {
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_model(Some(model.as_str()), effort)
.apply()
@@ -1253,7 +1250,7 @@ impl App {
}
}
AppEvent::RefreshPluginMentions => {
self.refresh_plugin_mentions();
self.refresh_plugin_mentions(app_server);
}
AppEvent::PluginMentionsLoaded { mut plugins } => {
if !self.config.features.enabled(Feature::Plugins) {
@@ -1263,7 +1260,7 @@ impl App {
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_personality(Some(personality))
.apply()
@@ -1300,7 +1297,7 @@ impl App {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
self.config.service_tier = service_tier.clone();
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
let mut edits = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_service_tier(service_tier.clone());
if service_tier.is_none() {
@@ -1338,11 +1335,11 @@ impl App {
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_speaker(name.as_deref())
}
};
@@ -1479,7 +1476,7 @@ impl App {
} else {
vec!["approvals_reviewer".to_string()]
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.with_edits([ConfigEdit::SetPath {
segments,
@@ -1531,7 +1528,7 @@ impl App {
self.chat_widget.set_plan_mode_reasoning_effort(effort);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_full_access_warning(/*acknowledged*/ true)
.apply()
.await
@@ -1546,7 +1543,7 @@ impl App {
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_world_writable_warning(/*acknowledged*/ true)
.apply()
.await
@@ -1561,7 +1558,7 @@ impl App {
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
.apply()
.await
@@ -1594,7 +1591,7 @@ impl App {
} else {
ConfigEdit::ClearPath { segments }
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
@@ -1618,7 +1615,7 @@ impl App {
from_model,
to_model,
} => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
@@ -1661,7 +1658,7 @@ impl App {
path: path.to_path_buf(),
enabled,
}];
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -1713,7 +1710,7 @@ impl App {
},
]
};
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -1876,7 +1873,7 @@ impl App {
let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
let colors_edit =
crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([items_edit, colors_edit])
.apply()
.await;
@@ -1908,7 +1905,7 @@ impl App {
AppEvent::TerminalTitleSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await;
@@ -1934,7 +1931,7 @@ impl App {
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await;
@@ -2046,7 +2043,7 @@ impl App {
let edit =
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
@@ -2091,7 +2088,7 @@ impl App {
};
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
+147
View File
@@ -0,0 +1,147 @@
//! Plugin mention capability enrichment for the TUI.
//!
//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses
//! the older local bulk capability summaries. That keeps the feature app-server-shaped without
//! paying for a `plugin/read` per plugin.
use super::background_requests::request_plugin_list;
use super::*;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginSummary;
use codex_core_plugins::PluginsManager;
use codex_plugin::PluginCapabilitySummary;
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct PluginMentionEntry {
config_name: String,
display_name: String,
description: Option<String>,
}
impl PluginMentionEntry {
fn capability_summary(
self,
capabilities_by_config_name: &HashMap<String, PluginCapabilitySummary>,
) -> Option<PluginCapabilitySummary> {
let capabilities = capabilities_by_config_name.get(&self.config_name)?;
Some(PluginCapabilitySummary {
config_name: self.config_name,
display_name: self.display_name,
description: self.description,
has_skills: capabilities.has_skills,
mcp_server_names: capabilities.mcp_server_names.clone(),
app_connector_ids: capabilities.app_connector_ids.clone(),
})
}
}
pub(super) async fn fetch_plugin_mentions(
request_handle: AppServerRequestHandle,
config: crate::legacy_core::config::Config,
) -> Result<Vec<PluginCapabilitySummary>> {
let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?;
let mention_entries = plugin_mention_entries_from_list_response(response);
let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await;
Ok(mention_entries
.into_iter()
.filter_map(|entry| entry.capability_summary(&capabilities_by_config_name))
.collect())
}
async fn load_plugin_mention_capabilities(
config: &crate::legacy_core::config::Config,
) -> HashMap<String, PluginCapabilitySummary> {
let plugins_input = config.plugins_config_input();
PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.cloned()
.map(|summary| (summary.config_name.clone(), summary))
.collect()
}
fn plugin_mention_entries_from_list_response(
response: PluginListResponse,
) -> Vec<PluginMentionEntry> {
response
.marketplaces
.into_iter()
.flat_map(plugin_mention_entries_from_marketplace)
.collect()
}
fn plugin_mention_entries_from_marketplace(
marketplace: PluginMarketplaceEntry,
) -> Vec<PluginMentionEntry> {
let marketplace_name = marketplace.name;
marketplace
.plugins
.into_iter()
.filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin))
.collect()
}
fn plugin_mention_entry(
marketplace_name: &str,
plugin: PluginSummary,
) -> Option<PluginMentionEntry> {
if !plugin_is_eligible_for_mentions(&plugin) {
return None;
}
let config_name = plugin_mention_config_name(marketplace_name, &plugin)?;
Some(PluginMentionEntry {
config_name,
display_name: plugin_mention_display_name(&plugin),
description: plugin_mention_description(&plugin),
})
}
fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool {
plugin.installed && plugin.enabled
}
fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option<String> {
codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string())
.map(|plugin_id| plugin_id.as_key())
.map_err(|err| {
tracing::warn!(
plugin_name = plugin.name,
marketplace_name,
error = %err,
"skipping plugin mention with invalid identity"
);
})
.ok()
}
fn plugin_mention_display_name(plugin: &PluginSummary) -> String {
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.map(str::trim)
.filter(|display_name| !display_name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| plugin.name.clone())
}
fn plugin_mention_description(plugin: &PluginSummary) -> Option<String> {
plugin
.interface
.as_ref()
.and_then(|interface| {
interface
.short_description
.as_deref()
.or(interface.long_description.as_deref())
})
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
+1 -1
View File
@@ -209,7 +209,7 @@ pub(super) async fn prepare_startup_tooltip_override(
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(config)
.set_model_availability_nux_count(&updated_shown_count)
.apply()
.await
+1
View File
@@ -25,6 +25,7 @@ pub(super) async fn make_test_app() -> App {
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
File diff suppressed because it is too large Load Diff
@@ -174,6 +174,46 @@ fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
assert_eq!(selected, None);
}
#[tokio::test]
async fn prepare_startup_tooltip_override_persists_model_availability_nux_count() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let target = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5.4")
.expect("target preset present");
target.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5.4 is available".to_string(),
});
let tooltip =
prepare_startup_tooltip_override(&mut config, &presets, /*is_first_run*/ false).await;
assert_eq!(tooltip.as_deref(), Some("gpt-5.4 is available"));
assert_eq!(
config.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
let reloaded = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("reloaded config");
assert_eq!(
reloaded.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
}
#[tokio::test]
async fn accepted_model_migration_persists_target_default_reasoning_effort() {
let codex_home = tempdir().expect("temp codex home");
@@ -0,0 +1,89 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn session_summary_skips_when_no_usage_or_resume_hint() {
assert!(
session_summary(
TokenUsage::default(),
/*thread_id*/ None,
/*thread_name*/ None,
/*rollout_path*/ None,
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_skips_resume_hint_until_rollout_exists() {
let usage = TokenUsage::default();
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
assert!(
session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_includes_resume_hint_for_persisted_rollout() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.usage_line,
Some("Token usage: total=12 input=10 output=2".to_string())
);
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
#[tokio::test]
async fn session_summary_uses_id_even_when_thread_has_name() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
Some("my-session".to_string()),
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
+179
View File
@@ -0,0 +1,179 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::StartFresh),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Exit),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
}
)),
false
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
}
)),
false
);
}
#[test]
fn startup_paused_goal_prompt_gate_is_only_for_quiet_resume() {
let resume = SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
});
let fork = SessionSelection::Fork(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
});
let no_images: Vec<PathBuf> = Vec::new();
let initial_images = vec![PathBuf::from("/tmp/image.png")];
assert!(App::should_prompt_for_paused_goal_after_startup_resume(
&resume, &None, &no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&Some("continue from here".to_string()),
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&None,
&initial_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&SessionSelection::StartFresh,
&None,
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&fork, &None, &no_images
));
}
#[test]
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
let mut wait_for_initial_session =
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
assert_eq!(wait_for_initial_session, true);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
false
);
assert_eq!(
App::should_stop_waiting_for_initial_session(
wait_for_initial_session,
/*primary_thread_id*/ None
),
false
);
if App::should_stop_waiting_for_initial_session(wait_for_initial_session, Some(ThreadId::new()))
{
wait_for_initial_session = false;
}
assert_eq!(wait_for_initial_session, false);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
true
);
}
#[test]
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_resume,
/*has_active_thread_receiver*/ true
),
true
);
let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_fork,
/*has_active_thread_receiver*/ true
),
true
);
}
#[tokio::test]
async fn ignore_same_thread_resume_reports_noop_for_current_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(THREAD_EVENT_CHANNEL_CAPACITY, session, Vec::new()),
);
app.activate_thread_channel(thread_id).await;
while app_event_rx.try_recv().is_ok() {}
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(ignored);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected info message after same-thread resume, saw {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert!(rendered.contains(&format!(
"Already viewing {}.",
test_path_display("/tmp/project")
)));
}
#[tokio::test]
async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session);
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(!ignored);
assert!(app.transcript_cells.is_empty());
}
-3
View File
@@ -608,9 +608,6 @@ pub(crate) enum AppEvent {
/// Update the current model slug in the running app and widget.
UpdateModel(String),
/// Update the active collaboration mask in the running app and widget.
UpdateCollaborationMode(CollaborationModeMask),
/// Update the current personality in the running app and widget.
UpdatePersonality(Personality),
+8 -3
View File
@@ -1647,6 +1647,8 @@ mod tests {
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
@@ -1667,7 +1669,7 @@ mod tests {
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.harness_overrides(ConfigOverrides {
default_permissions: Some(":workspace".to_string()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..ConfigOverrides::default()
})
.build()
@@ -1712,7 +1714,8 @@ mod tests {
#[test]
fn embedded_turn_permissions_use_active_profile_selection() {
let cwd = test_path_buf("/workspace/project").abs();
let active_permission_profile = ActivePermissionProfile::new(":workspace");
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let expected_permissions =
permissions_selection_from_active_profile(active_permission_profile.clone());
@@ -1753,7 +1756,9 @@ mod tests {
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::read_only(),
Some(ActivePermissionProfile::new(":read-only")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
cwd.as_path(),
ThreadParamsMode::Remote,
);
@@ -956,7 +956,7 @@ fn special_path_label(value: &FileSystemSpecialPath) -> String {
match value {
FileSystemSpecialPath::Root => ":root".to_string(),
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":project_roots", subpath),
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":workspace_roots", subpath),
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
FileSystemSpecialPath::SlashTmp => "/tmp".to_string(),
FileSystemSpecialPath::Unknown { path, subpath } => path_label(path, subpath),
@@ -1771,6 +1771,31 @@ mod tests {
);
}
#[test]
fn additional_permissions_rule_uses_workspace_roots_label() {
let additional_permissions = AdditionalPermissionProfile {
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: None,
write: None,
entries: Some(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots {
subpath: Some(".git".into()),
},
},
access: FileSystemAccessMode::Read,
}]),
glob_scan_max_depth: None,
}),
};
assert_eq!(
format_additional_permissions_rule(&additional_permissions),
Some("read `:workspace_roots/.git`".to_string())
);
}
#[test]
fn permissions_session_shortcut_submits_session_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
//! Attachment bookkeeping for the chat composer, including local image placeholders,
//! remote-image rows, and keyboard selection over those rows.
use std::collections::HashSet;
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
use ratatui::text::Line;
use super::InputResult;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::textarea::TextArea;
use codex_protocol::models::local_image_label_text;
use codex_protocol::user_input::TextElement;
#[derive(Clone, Debug, PartialEq)]
pub(super) struct AttachedImage {
pub(super) placeholder: String,
pub(super) path: PathBuf,
}
#[derive(Debug, Default)]
pub(super) struct AttachmentState {
pub(super) local_images: Vec<AttachedImage>,
pub(super) remote_image_urls: Vec<String>,
pub(super) selected_remote_image_index: Option<usize>,
}
impl AttachmentState {
pub(super) fn is_empty(&self) -> bool {
self.local_images.is_empty() && self.remote_image_urls.is_empty()
}
pub(super) fn local_image_paths(&self) -> Vec<PathBuf> {
self.local_images
.iter()
.map(|image| image.path.clone())
.collect()
}
pub(super) fn local_images(&self) -> Vec<LocalImageAttachment> {
self.local_images
.iter()
.map(|image| LocalImageAttachment {
placeholder: image.placeholder.clone(),
path: image.path.clone(),
})
.collect()
}
pub(super) fn set_remote_image_urls(&mut self, urls: Vec<String>, textarea: &mut TextArea) {
self.remote_image_urls = urls;
self.selected_remote_image_index = None;
self.relabel_local_images(textarea);
}
pub(super) fn remote_image_urls(&self) -> Vec<String> {
self.remote_image_urls.clone()
}
pub(super) fn take_remote_image_urls(&mut self, textarea: &mut TextArea) -> Vec<String> {
let urls = std::mem::take(&mut self.remote_image_urls);
self.selected_remote_image_index = None;
self.relabel_local_images(textarea);
urls
}
pub(super) fn clear_remote_image_urls(&mut self) {
self.remote_image_urls.clear();
self.selected_remote_image_index = None;
}
pub(super) fn reset_local_images(
&mut self,
local_image_paths: Vec<PathBuf>,
textarea: &mut TextArea,
) {
self.local_images.clear();
self.local_images.extend(
local_image_paths
.into_iter()
.enumerate()
.map(|(index, path)| AttachedImage {
placeholder: local_image_label_text(self.remote_image_urls.len() + index + 1),
path,
}),
);
self.selected_remote_image_index = None;
self.relabel_local_images(textarea);
}
pub(super) fn attach_image(&mut self, textarea: &mut TextArea, path: PathBuf) {
let image_number = self.remote_image_urls.len() + self.local_images.len() + 1;
let placeholder = local_image_label_text(image_number);
textarea.insert_element(&placeholder);
self.local_images.push(AttachedImage { placeholder, path });
}
pub(super) fn prune_local_images_for_submission(
&mut self,
text: &str,
text_elements: &[TextElement],
) {
if self.local_images.is_empty() {
return;
}
let image_placeholders: HashSet<&str> = text_elements
.iter()
.filter_map(|element| element.placeholder(text))
.collect();
self.local_images
.retain(|image| image_placeholders.contains(image.placeholder.as_str()));
}
#[cfg(test)]
pub(super) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
std::mem::take(&mut self.local_images)
.into_iter()
.map(|image| image.path)
.collect()
}
pub(super) fn take_recent_submission_images_with_placeholders(
&mut self,
) -> Vec<LocalImageAttachment> {
std::mem::take(&mut self.local_images)
.into_iter()
.map(|image| LocalImageAttachment {
placeholder: image.placeholder,
path: image.path,
})
.collect()
}
pub(super) fn remote_image_lines(&self) -> Vec<Line<'static>> {
self.remote_image_urls
.iter()
.enumerate()
.map(|(index, _)| {
let label = local_image_label_text(index + 1);
if self.selected_remote_image_index == Some(index) {
label.cyan().reversed().into()
} else {
label.cyan().into()
}
})
.collect()
}
pub(super) fn clear_remote_image_selection(&mut self) {
self.selected_remote_image_index = None;
}
pub(super) fn handle_remote_image_selection_key(
&mut self,
key_event: &KeyEvent,
textarea: &mut TextArea,
) -> Option<(InputResult, bool)> {
if self.remote_image_urls.is_empty()
|| key_event.modifiers != KeyModifiers::NONE
|| key_event.kind != KeyEventKind::Press
{
return None;
}
match key_event.code {
KeyCode::Up => {
if let Some(selected) = self.selected_remote_image_index {
self.selected_remote_image_index = Some(selected.saturating_sub(1));
Some((InputResult::None, true))
} else if textarea.cursor() == 0 {
self.selected_remote_image_index = Some(self.remote_image_urls.len() - 1);
Some((InputResult::None, true))
} else {
None
}
}
KeyCode::Down => {
if let Some(selected) = self.selected_remote_image_index {
if selected + 1 < self.remote_image_urls.len() {
self.selected_remote_image_index = Some(selected + 1);
} else {
self.clear_remote_image_selection();
}
Some((InputResult::None, true))
} else {
None
}
}
KeyCode::Delete | KeyCode::Backspace => {
if let Some(selected) = self.selected_remote_image_index {
self.remove_selected_remote_image(selected, textarea);
Some((InputResult::None, true))
} else {
None
}
}
_ => None,
}
}
pub(super) fn remove_deleted_local_placeholders(
&mut self,
removed_payloads: &[String],
textarea: &mut TextArea,
) -> bool {
let previous_len = self.local_images.len();
self.local_images.retain(|image| {
!removed_payloads
.iter()
.any(|payload| payload == &image.placeholder)
});
let removed_any = self.local_images.len() != previous_len;
if removed_any {
self.relabel_local_images(textarea);
}
removed_any
}
pub(super) fn relabel_local_images(&mut self, textarea: &mut TextArea) {
for (index, image) in self.local_images.iter_mut().enumerate() {
let expected = local_image_label_text(self.remote_image_urls.len() + index + 1);
if image.placeholder == expected {
continue;
}
let current = std::mem::replace(&mut image.placeholder, expected.clone());
let _renamed = textarea.replace_element_payload(&current, &expected);
}
}
fn remove_selected_remote_image(&mut self, selected_index: usize, textarea: &mut TextArea) {
if selected_index >= self.remote_image_urls.len() {
self.clear_remote_image_selection();
return;
}
self.remote_image_urls.remove(selected_index);
self.selected_remote_image_index = if self.remote_image_urls.is_empty() {
None
} else {
Some(selected_index.min(self.remote_image_urls.len() - 1))
};
self.relabel_local_images(textarea);
}
}
@@ -107,13 +107,13 @@ impl ChatComposer {
}
self.paste_burst.clear_window_after_non_char();
if self.current_file_query.is_some() {
if self.popups.current_file_query.is_some() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
self.popups.current_file_query = None;
}
self.active_popup = ActivePopup::None;
self.selected_remote_image_index = None;
self.popups.active = ActivePopup::None;
self.attachments.clear_remote_image_selection();
self.history_search = Some(HistorySearchSession {
original_draft: self.snapshot_draft(),
query: String::new(),
@@ -0,0 +1,32 @@
//! Popup lifecycle state for the chat composer.
//! Tracks the single active popup plus dismissal/query state used to synchronize it.
use crate::bottom_pane::command_popup::CommandPopup;
use crate::bottom_pane::file_search_popup::FileSearchPopup;
use crate::bottom_pane::mentions_v2::MentionV2Popup;
use crate::bottom_pane::skill_popup::SkillPopup;
#[derive(Default)]
pub(super) struct PopupState {
pub(super) active: ActivePopup,
pub(super) dismissed_file_token: Option<String>,
pub(super) current_file_query: Option<String>,
pub(super) dismissed_mention_token: Option<String>,
}
impl PopupState {
pub(super) fn active(&self) -> bool {
!matches!(self.active, ActivePopup::None)
}
}
/// Popup state - at most one can be visible at any time.
#[derive(Default)]
pub(super) enum ActivePopup {
#[default]
None,
Command(CommandPopup),
File(FileSearchPopup),
Skill(SkillPopup),
MentionV2(MentionV2Popup),
}
+1 -33
View File
@@ -419,7 +419,7 @@ mod tests {
}
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
fn plan_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/".to_string());
@@ -431,44 +431,12 @@ mod tests {
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.iter().any(|cmd| cmd == "collab"),
"expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
assert!(
!cmds.iter().any(|cmd| cmd == "plan"),
"expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
}
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected collab command, got service tier {command:?}")
}
other => panic!("expected collab to be selected for exact match, got {other:?}"),
}
}
#[test]
fn plan_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
+35 -1
View File
@@ -1,3 +1,4 @@
use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME;
use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;
use codex_feedback::FeedbackDiagnostics;
use crossterm::event::KeyCode;
@@ -502,6 +503,11 @@ pub(crate) fn feedback_upload_consent_params(
Line::from("").into(),
Line::from("The following files will be sent:".dim()).into(),
Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(),
Line::from(vec![
" • ".into(),
DOCTOR_REPORT_ATTACHMENT_FILENAME.into(),
])
.into(),
];
if let Some(path) = rollout_path.as_deref()
&& let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string())
@@ -538,7 +544,7 @@ pub(crate) fn feedback_upload_consent_params(
super::SelectionItem {
name: "Yes".to_string(),
description: Some(
"Share the current Codex session logs with the team for troubleshooting."
"Share the current Codex session logs and diagnostics with the team for troubleshooting."
.to_string(),
),
actions: vec![yes_action],
@@ -572,7 +578,18 @@ mod tests {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
render_buffer(area, &buf)
}
fn render_renderable(renderable: &dyn Renderable, width: u16) -> String {
let height = renderable.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
renderable.render(area, &mut buf);
render_buffer(area, &buf)
}
fn render_buffer(area: Rect, buf: &Buffer) -> String {
let mut lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
@@ -670,6 +687,23 @@ mod tests {
insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered);
}
#[test]
fn feedback_upload_consent_lists_doctor_report() {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let params = feedback_upload_consent_params(
tx,
FeedbackCategory::Bug,
Some(std::path::PathBuf::from("rollout.jsonl")),
Some("auto-review-rollout.jsonl".to_string()),
&FeedbackDiagnostics::default(),
);
let rendered = render_renderable(params.header.as_ref(), /*width*/ 60);
insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered);
}
#[test]
fn submit_feedback_emits_submit_event_with_trimmed_note() {
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
-2
View File
@@ -711,7 +711,6 @@ impl BottomPane {
if has_pasted_text {
self.record_composer_activity_at(Instant::now());
}
self.composer.sync_popups();
if needs_redraw {
self.request_redraw();
}
@@ -720,7 +719,6 @@ impl BottomPane {
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.composer.sync_popups();
self.request_redraw();
}
@@ -72,10 +72,7 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| {
flags.collaboration_modes_enabled
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Plan)
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/feedback_view.rs
assertion_line: 704
expression: rendered
---
Upload logs?
The following files will be sent:
• codex-logs.log
• codex-doctor-report.json
• rollout.jsonl
• auto-review-rollout.jsonl
@@ -11,7 +11,7 @@ expression: rendered
<LOG_PATH>
› 1. Yes Share the current Codex session logs with the team for
troubleshooting.
› 1. Yes Share the current Codex session logs and diagnostics with the team
for troubleshooting.
2. No
3. Cancel
File diff suppressed because it is too large Load Diff
+414 -1
View File
@@ -1,5 +1,6 @@
//! Connector list cache state for `ChatWidget`.
//! Connector cache, popup, and refresh handling for `ChatWidget`.
use super::*;
use crate::app_event::ConnectorsSnapshot;
#[derive(Debug, Clone, Default)]
@@ -18,3 +19,415 @@ pub(super) struct ConnectorsState {
pub(super) prefetch_in_flight: bool,
pub(super) force_refetch_pending: bool,
}
impl ChatWidget {
pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) {
self.prefetch_connectors_with_options(force_refetch);
}
pub(super) fn prefetch_connectors(&mut self) {
self.prefetch_connectors_with_options(/*force_refetch*/ false);
}
fn prefetch_connectors_with_options(&mut self, force_refetch: bool) {
if !self.connectors_enabled() {
return;
}
if self.connectors.prefetch_in_flight {
if force_refetch {
self.connectors.force_refetch_pending = true;
}
return;
}
self.connectors.prefetch_in_flight = true;
if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) {
self.connectors.cache = ConnectorsCacheState::Loading;
}
let config = self.config.clone();
let environment_manager = Arc::clone(&self.environment_manager);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let accessible_result =
match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
force_refetch,
&environment_manager,
)
.await
{
Ok(connectors) => connectors,
Err(err) => {
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Err(format!("Failed to load apps: {err}")),
is_final: true,
});
return;
}
};
let should_schedule_force_refetch =
!force_refetch && !accessible_result.codex_apps_ready;
let accessible_connectors = accessible_result.connectors;
app_event_tx.send(AppEvent::ConnectorsLoaded {
result: Ok(ConnectorsSnapshot {
connectors: accessible_connectors.clone(),
}),
is_final: false,
});
let result: Result<ConnectorsSnapshot, String> = async {
let all_connectors =
chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch)
.await?;
let connectors = chatgpt_connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
);
Ok(ConnectorsSnapshot { connectors })
}
.await
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
app_event_tx.send(AppEvent::ConnectorsLoaded {
result,
is_final: true,
});
if should_schedule_force_refetch {
app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
}
});
}
pub(super) fn connectors_enabled(&self) -> bool {
self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account
}
pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> {
if !self.connectors_enabled() {
return None;
}
if let Some(snapshot) = &self.connectors.partial_snapshot {
return Some(snapshot.connectors.as_slice());
}
match &self.connectors.cache {
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
_ => None,
}
}
pub(crate) fn add_connectors_output(&mut self) {
if !self.connectors_enabled() {
self.add_info_message(
"Apps are disabled.".to_string(),
Some("Enable the apps feature to use $ or /apps.".to_string()),
);
return;
}
let connectors_cache = self.connectors.cache.clone();
let should_force_refetch = !self.connectors.prefetch_in_flight
|| matches!(connectors_cache, ConnectorsCacheState::Ready(_));
self.prefetch_connectors_with_options(should_force_refetch);
match connectors_cache {
ConnectorsCacheState::Ready(snapshot) => {
if snapshot.connectors.is_empty() {
self.add_info_message("No apps available.".to_string(), /*hint*/ None);
} else {
self.open_connectors_popup(&snapshot.connectors);
}
}
ConnectorsCacheState::Failed(err) => {
self.add_to_history(history_cell::new_error_event(err));
}
ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => {
self.open_connectors_loading_popup();
}
}
self.request_redraw();
}
fn open_connectors_loading_popup(&mut self) {
if !self.bottom_pane.replace_selection_view_if_active(
CONNECTORS_SELECTION_VIEW_ID,
self.connectors_loading_popup_params(),
) {
self.bottom_pane
.show_selection_view(self.connectors_loading_popup_params());
}
}
fn open_connectors_popup(&mut self, connectors: &[AppInfo]) {
self.bottom_pane.show_selection_view(
self.connectors_popup_params(connectors, /*selected_connector_id*/ None),
);
}
fn connectors_loading_popup_params(&self) -> SelectionViewParams {
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from("Loading installed and available apps...".dim()));
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
items: vec![SelectionItem {
name: "Loading apps...".to_string(),
description: Some("This updates when the full list is ready.".to_string()),
is_disabled: true,
..Default::default()
}],
..Default::default()
}
}
fn connectors_popup_params(
&self,
connectors: &[AppInfo],
selected_connector_id: Option<&str>,
) -> SelectionViewParams {
let total = connectors.len();
let installed = connectors
.iter()
.filter(|connector| connector.is_accessible)
.count();
let mut header = ColumnRenderable::new();
header.push(Line::from("Apps".bold()));
header.push(Line::from(
"Use $ to insert an installed app into your prompt.".dim(),
));
header.push(Line::from(
format!("Installed {installed} of {total} available apps.").dim(),
));
let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| {
connectors
.iter()
.position(|connector| connector.id == selected_connector_id)
});
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
for connector in connectors {
let connector_label = codex_connectors::metadata::connector_display_label(connector);
let connector_title = connector_label.clone();
let link_description = Self::connector_description(connector);
let description = Self::connector_brief_description(connector);
let status_label = Self::connector_status_label(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
description: Some(description),
search_value: Some(search_value),
..Default::default()
};
let is_installed = connector.is_accessible;
let selected_label = if is_installed {
format!(
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
)
} else {
format!("{status_label}. Press Enter to open the app page to install this app.")
};
let missing_label = format!("{status_label}. App link unavailable.");
let instructions = if connector.is_accessible {
"Manage this app in your browser."
} else {
"Install this app in your browser, then reload Codex."
};
if let Some(install_url) = connector.install_url.clone() {
let app_id = connector.id.clone();
let is_enabled = connector.is_enabled;
let title = connector_title.clone();
let instructions = instructions.to_string();
let description = link_description.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
app_id: app_id.clone(),
title: title.clone(),
description: description.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
is_installed,
is_enabled,
});
})];
item.dismiss_on_select = true;
item.selected_description = Some(selected_label);
} else {
let missing_label_for_action = missing_label.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
missing_label_for_action.clone(),
/*hint*/ None,
),
)));
})];
item.dismiss_on_select = true;
item.selected_description = Some(missing_label);
}
items.push(item);
}
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
col_width_mode: ColumnWidthMode::AutoAllRows,
initial_selected_idx,
..Default::default()
}
}
fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) {
let selected_connector_id =
if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = (
self.bottom_pane
.selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID),
&self.connectors.cache,
) {
snapshot
.connectors
.get(selected_index)
.map(|connector| connector.id.as_str())
} else {
None
};
let _ = self.bottom_pane.replace_selection_view_if_active(
CONNECTORS_SELECTION_VIEW_ID,
self.connectors_popup_params(connectors, selected_connector_id),
);
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = Self::connector_status_label(connector);
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_status_label(connector: &AppInfo) -> &'static str {
if connector.is_accessible {
if connector.is_enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
"Can be installed"
}
}
fn connector_description(connector: &AppInfo) -> Option<String> {
connector
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
}
pub(crate) fn on_connectors_loaded(
&mut self,
result: Result<ConnectorsSnapshot, String>,
is_final: bool,
) {
let mut trigger_pending_force_refetch = false;
if is_final {
self.connectors.prefetch_in_flight = false;
if self.connectors.force_refetch_pending {
self.connectors.force_refetch_pending = false;
trigger_pending_force_refetch = true;
}
}
match result {
Ok(mut snapshot) => {
if !is_final {
snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible(
Vec::new(),
snapshot.connectors,
/*all_connectors_loaded*/ false,
);
}
snapshot.connectors =
chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config);
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
.connectors
.iter()
.map(|connector| (connector.id.as_str(), connector.is_enabled))
.collect();
for connector in &mut snapshot.connectors {
if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) {
connector.is_enabled = *is_enabled;
}
}
}
if is_final {
self.connectors.partial_snapshot = None;
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
} else {
self.connectors.partial_snapshot = Some(snapshot.clone());
}
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
Err(err) => {
let partial_snapshot = self.connectors.partial_snapshot.take();
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache {
warn!("failed to refresh apps list; retaining current apps snapshot: {err}");
self.bottom_pane
.set_connectors_snapshot(Some(snapshot.clone()));
} else if let Some(snapshot) = partial_snapshot {
warn!(
"failed to load full apps list; falling back to installed apps snapshot: {err}"
);
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
} else {
self.connectors.cache = ConnectorsCacheState::Failed(err);
self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None);
}
}
}
if trigger_pending_force_refetch {
self.prefetch_connectors_with_options(/*force_refetch*/ true);
}
}
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else {
return;
};
let mut changed = false;
for connector in &mut snapshot.connectors {
if connector.id == connector_id {
changed = connector.is_enabled != enabled;
connector.is_enabled = enabled;
break;
}
}
if !changed {
return;
}
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
}
+266
View File
@@ -0,0 +1,266 @@
//! Construction and initial wiring for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self {
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
}
pub(super) fn new_with_op_target(
common: ChatWidgetInit,
codex_op_target: CodexOpTarget,
) -> Self {
let ChatWidgetInit {
config,
environment_manager,
frame_requester,
app_event_tx,
workspace_command_runner,
initial_user_message,
enhanced_keys_supported,
has_chatgpt_account,
model_catalog,
feedback,
is_first_run,
status_account_display,
runtime_model_provider_base_url,
initial_plan_type,
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
let mut config = config;
config.model = model.clone();
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let side_placeholder =
SIDE_PLACEHOLDERS[rng.random_range(0..SIDE_PLACEHOLDERS.len())].to_string();
let model_override = model.as_deref();
let model_for_header = model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let active_collaboration_mask =
Self::initial_collaboration_mask(&config, model_catalog.as_ref(), model_override);
let header_model = active_collaboration_mask
.as_ref()
.and_then(|mask| mask.model.clone())
.unwrap_or_else(|| model_for_header.clone());
let fallback_default = Settings {
model: header_model.clone(),
reasoning_effort: None,
developer_instructions: None,
};
// Collaboration modes start in Default mode.
let current_collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: fallback_default,
};
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier.clone();
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
let copy_last_response_binding = runtime_keymap
.as_ref()
.map(|keymap| keymap.app.copy.clone())
.unwrap_or_else(|| default_keymap.app.copy.clone());
let chat_keymap = runtime_keymap
.as_ref()
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&chat_keymap.edit_queued_message,
current_terminal_info,
);
pets::start_configured_pet_load_if_needed(
&config,
/*ambient_pet_missing*/ true,
frame_requester.clone(),
app_event_tx.clone(),
);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_target,
bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: placeholder.clone(),
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills: None,
}),
transcript: TranscriptState::new(active_cell),
raw_output_mode: config.tui_raw_output_mode,
config,
environment_manager,
effective_service_tier,
skills_all: Vec::new(),
skills_initial_state: None,
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(header_model),
initial_user_message,
status_account_display,
runtime_model_provider_base_url,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
warning_display_state: WarningDisplayState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
add_credits_nudge_email_in_flight: None,
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
mcp_startup_pending_next_round: HashMap::new(),
mcp_startup_pending_next_round_saw_starting: false,
connectors: ConnectorsState::default(),
ide_context: IdeContextState::default(),
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
plugins_active_tab_id: None,
newly_installed_marketplace_tab_id: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
status_state: StatusState::default(),
review: ReviewState::default(),
active_hook_cell: None,
ambient_pet: None,
pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(),
pet_picker_preview_pet: None,
pet_picker_preview_request_id: 0,
pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false),
pet_selection_load_request_id: 0,
#[cfg(test)]
pet_image_support_override: None,
thread_id: None,
dismissed_plan_mode_nudge_scopes: HashSet::new(),
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
input_queue: InputQueueState::default(),
chat_keymap,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
suppress_session_configured_redraw: false,
suppress_initial_user_message_submit: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
turn_runtime_metrics: RuntimeMetricsSummary::default(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
current_cwd,
workspace_command_runner,
instruction_source_paths: Vec::new(),
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
last_terminal_title_requires_action: false,
terminal_title_setup_original_items: None,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
status_line_git_summary: None,
status_line_git_summary_cwd: None,
status_line_git_summary_pending: false,
status_line_git_summary_lookup_complete: false,
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_display: None,
last_non_retry_error: None,
};
widget.prefetch_rate_limits();
if let Some(keymap) = runtime_keymap {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_vim_enabled(widget.config.tui_vim_mode_default);
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
widget
.bottom_pane
.set_collaboration_modes_enabled(/*enabled*/ true);
widget.sync_service_tier_commands();
widget.sync_personality_command_enabled();
widget.sync_plugins_command_enabled();
widget.sync_goal_command_enabled();
widget.sync_mentions_v2_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
#[cfg(target_os = "windows")]
widget.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&widget.config),
WindowsSandboxLevel::RestrictedToken
),
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_status_surfaces();
widget
}
}
+455
View File
@@ -0,0 +1,455 @@
//! Key routing and composer-adjacent UI interaction for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.bottom_pane.has_active_view()
&& !matches!(
key_event,
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
)
&& !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event)
&& !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event)
{
self.bottom_pane.handle_key_event(key_event);
if self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
return;
}
if self.handle_reasoning_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
return;
}
if key_event.kind == KeyEventKind::Press
&& self.copy_last_response_binding.is_pressed(key_event)
{
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {
self.on_ctrl_c();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
if self.on_ctrl_d() {
return;
}
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
_ => {}
}
if key_event.kind == KeyEventKind::Press
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
&& self.has_queued_follow_up_messages()
&& self.bottom_pane.no_modal_or_popup_active()
{
if let Some(user_message) = self.pop_latest_queued_user_message() {
self.restore_user_message_to_composer(user_message);
self.refresh_pending_input_preview();
self.request_redraw();
}
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !self.input_queue.pending_steers.is_empty()
&& self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& !self.should_handle_vim_insert_escape(key_event)
{
self.input_queue.submit_pending_steers_after_interrupt = true;
if !self.submit_op(AppCommand::interrupt()) {
self.input_queue.submit_pending_steers_after_interrupt = false;
}
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& key_event.kind == KeyEventKind::Press
&& self.should_show_plan_mode_nudge()
{
self.dismiss_plan_mode_nudge();
return;
}
if self.handle_plugins_popup_key_event(key_event) {
return;
}
match key_event {
KeyEvent {
code: KeyCode::BackTab,
kind: KeyEventKind::Press,
..
} if self.collaboration_modes_enabled()
&& !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active() =>
{
self.cycle_collaboration_mode();
self.refresh_plan_mode_nudge();
}
_ => {
let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active();
let input_result = self.bottom_pane.handle_key_event(key_event);
self.handle_composer_input_result(input_result, had_modal_or_popup);
}
}
}
/// Attach a local image to the composer when the active model supports image inputs.
///
/// When the model does not advertise image support, we keep the draft unchanged and surface a
/// warning event so users can switch models or remove attachments.
pub(crate) fn attach_image(&mut self, path: PathBuf) {
if !self.current_model_supports_images() {
self.add_to_history(history_cell::new_warning_event(
self.image_inputs_not_supported_message(),
));
self.request_redraw();
return;
}
tracing::info!("attach_image path={path:?}");
self.bottom_pane.attach_image(path);
self.request_redraw();
}
pub(crate) fn composer_text_with_pending(&self) -> String {
self.bottom_pane.composer_text_with_pending()
}
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.bottom_pane.apply_external_edit(text);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn external_editor_state(&self) -> ExternalEditorState {
self.external_editor_state
}
pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) {
self.external_editor_state = state;
}
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
self.bottom_pane.set_footer_hint_override(items);
}
pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) {
self.bottom_pane.show_selection_view(params);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.bottom_pane.no_modal_or_popup_active()
}
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.bottom_pane.can_launch_external_editor()
}
pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool {
// Ctrl+L is not a slash command, but it follows /clear's current rule:
// block while a task is running.
if !self.bottom_pane.is_task_running() {
return true;
}
let message = "Ctrl+L is disabled while a task is in progress.".to_string();
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
false
}
/// Copy the last agent response (raw markdown) to the system clipboard.
pub(crate) fn copy_last_agent_markdown(&mut self) {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
}
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
&mut self,
user_turn_count: usize,
) {
self.transcript
.truncate_copy_history_to_user_turn_count(user_turn_count);
}
/// Inner implementation with an injectable clipboard backend for testing.
pub(super) fn copy_last_agent_markdown_with(
&mut self,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
match self.transcript.last_agent_markdown.clone() {
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_to_history(history_cell::new_info_event(
"Copied last message to clipboard".into(),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
},
_ if self.transcript.copy_history_evicted_by_rollback => {
self.add_to_history(history_cell::new_error_event(format!(
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
)));
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
}
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
self.transcript.last_agent_markdown.as_deref()
}
pub(super) fn show_rename_prompt(&mut self) {
if !self.ensure_thread_rename_allowed() {
return;
}
let tx = self.app_event_tx.clone();
let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
let title = if existing_name.is_some() {
"Rename thread"
} else {
"Name thread"
};
let view = CustomPromptView::new(
title.to_string(),
"Type a name and press Enter".to_string(),
/*initial_text*/ existing_name.unwrap_or_default().to_string(),
/*context_label*/ None,
Box::new(move |name: String| {
let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
)));
return;
};
tx.set_thread_name(name);
}),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool {
match self.thread_rename_block_message.clone() {
Some(message) => {
self.add_error_message(message);
false
}
None => true,
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
self.refresh_plan_mode_nudge();
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
if self.bottom_pane.flush_paste_burst_if_due() {
self.refresh_plan_mode_nudge();
// A paste just flushed; request an immediate redraw and skip this frame.
self.request_redraw();
true
} else if self.bottom_pane.is_in_paste_burst() {
// While capturing a burst, schedule a follow-up tick and skip this frame
// to avoid redundant renders between ticks.
frame_requester.schedule_frame_in(
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
);
true
} else {
false
}
}
/// Handles a Ctrl+C press at the chat-widget layer.
///
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
///
/// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the
/// first press always stops live voice, even when the composer contains the recording meter.
///
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
/// expiry requests a shutdown-first quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
if self.realtime_conversation.is_live() {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.stop_realtime_conversation_from_ui();
return;
}
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
} else {
self.arm_quit_shortcut(key);
}
}
return;
}
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
self.submit_op(AppCommand::interrupt());
} else {
self.request_quit_without_confirmation();
}
return;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return;
}
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active() {
self.submit_op(AppCommand::interrupt());
}
}
/// Handles a Ctrl+D press at the chat-widget layer.
///
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
/// Otherwise it should be routed to the active view and not attempt to quit.
fn on_ctrl_d(&mut self) -> bool {
let key = key_hint::ctrl(KeyCode::Char('d'));
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
{
return false;
}
self.request_quit_without_confirmation();
return true;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return true;
}
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
return false;
}
self.arm_quit_shortcut(key);
true
}
/// True if `key` matches the armed quit shortcut and the window has not expired.
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
self.quit_shortcut_key == Some(key)
&& self
.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
/// Arm the double-press quit shortcut and show the footer hint.
///
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
pub(super) fn arm_quit_shortcut(&mut self, key: KeyBinding) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = Some(key);
self.bottom_pane.show_quit_shortcut_hint(key);
}
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
fn is_cancellable_work_active(&self) -> bool {
self.bottom_pane.is_task_running() || self.review.is_review_mode
}
}
+565
View File
@@ -0,0 +1,565 @@
//! 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 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::CodexOp(AppCommand::override_model_and_effort(
model_for_action.clone(),
effort_for_action,
)));
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();
let mut mask = collaboration_modes::plan_mask_or_default(self.model_catalog.as_ref());
mask.mode = Some(ModeKind::Plan);
mask.model = Some(model.clone());
mask.reasoning_effort = Some(effort);
let override_command = self.collaboration_mode_override_command(&mut mask);
move |tx| {
tx.send(AppEvent::CodexOp(override_command.clone()));
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({
let mut mask = collaboration_modes::plan_mask_or_default(self.model_catalog.as_ref());
mask.mode = Some(ModeKind::Plan);
mask.model = Some(model.clone());
mask.reasoning_effort = Some(effort);
let override_command = self.collaboration_mode_override_command(&mut mask);
move |tx| {
tx.send(AppEvent::CodexOp(override_command.clone()));
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::CodexOp(AppCommand::override_model_and_effort(
model_for_action.clone(),
choice_effort,
)));
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::CodexOp(AppCommand::override_model_and_effort(
model.clone(),
effort,
)));
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,128 @@
//! Desktop notification coalescing for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(super) fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.config.tui_notifications.notifications) {
return;
}
if let Some(existing) = self.pending_notification.as_ref()
&& existing.priority() > notification.priority()
{
return;
}
self.pending_notification = Some(notification);
self.request_redraw();
}
pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) {
if let Some(notif) = self.pending_notification.take() {
tui.notify(notif.display());
}
}
}
#[derive(Debug)]
pub(super) enum Notification {
AgentTurnComplete { response: String },
ExecApprovalRequested { command: String },
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
ElicitationRequested { server_name: String },
PlanModePrompt { title: String },
}
impl Notification {
pub(super) fn display(&self) -> String {
match self {
Notification::AgentTurnComplete { response } => {
Notification::agent_turn_preview(response)
.unwrap_or_else(|| "Agent turn complete".to_string())
}
Notification::ExecApprovalRequested { command } => {
format!(
"Approval requested: {}",
truncate_text(command, /*max_graphemes*/ 30)
)
}
Notification::EditApprovalRequested { cwd, changes } => {
format!(
"Codex wants to edit {}",
if changes.len() == 1 {
#[allow(clippy::unwrap_used)]
display_path_for(changes.first().unwrap(), cwd)
} else {
format!("{} files", changes.len())
}
)
}
Notification::ElicitationRequested { server_name } => {
format!("Approval requested by {server_name}")
}
Notification::PlanModePrompt { title } => {
format!("Plan mode prompt: {title}")
}
}
}
fn type_name(&self) -> &str {
match self {
Notification::AgentTurnComplete { .. } => "agent-turn-complete",
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. } => "approval-requested",
Notification::PlanModePrompt { .. } => "plan-mode-prompt",
}
}
fn priority(&self) -> u8 {
match self {
Notification::AgentTurnComplete { .. } => 0,
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. }
| Notification::PlanModePrompt { .. } => 1,
}
}
pub(super) fn allowed_for(&self, settings: &Notifications) -> bool {
match settings {
Notifications::Enabled(enabled) => *enabled,
Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()),
}
}
pub(super) fn agent_turn_preview(response: &str) -> Option<String> {
let mut normalized = String::new();
for part in response.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(part);
}
let trimmed = normalized.trim();
if trimmed.is_empty() {
None
} else {
Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES))
}
}
pub(super) fn user_input_request_summary(
questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion],
) -> Option<String> {
let first_question = questions.first()?;
let summary = if first_question.header.trim().is_empty() {
first_question.question.trim()
} else {
first_question.header.trim()
};
if summary.is_empty() {
None
} else {
Some(truncate_text(summary, /*max_graphemes*/ 30))
}
}
}
const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200;
@@ -0,0 +1,463 @@
//! Permission and approval popup flows for `ChatWidget`.
//!
//! This module owns the generic permission pickers and confirmation surfaces;
//! Windows-specific sandbox prompting lives beside it in
//! `windows_sandbox_prompts`.
use super::*;
impl ChatWidget {
/// Open the permissions popup.
pub(crate) fn open_approvals_popup(&mut self) {
self.open_permissions_popup();
}
/// Open a popup to choose the permissions mode.
pub(crate) fn open_permissions_popup(&mut self) {
let include_read_only = cfg!(target_os = "windows");
let current_approval =
AskForApproval::from(self.config.permissions.approval_policy.value());
let current_permission_profile = self.config.permissions.permission_profile();
let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval);
let current_review_policy = self.config.approvals_reviewer;
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
#[cfg(target_os = "windows")]
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
#[cfg(target_os = "windows")]
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
#[cfg(not(target_os = "windows"))]
let windows_degraded_sandbox_enabled = false;
let show_elevate_sandbox_hint =
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& windows_degraded_sandbox_enabled
&& presets.iter().any(|preset| preset.id == "auto");
let guardian_disabled_reason = |enabled: bool| {
let mut next_features = self.config.features.get().clone();
next_features.set_enabled(Feature::GuardianApproval, enabled);
self.config
.features
.can_set(&next_features)
.err()
.map(|err| err.to_string())
};
for preset in presets.into_iter() {
if !include_read_only && preset.id == "read-only" {
continue;
}
let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
"Default (non-admin sandbox)".to_string()
} else {
preset.label.to_string()
};
let preset_approval = AskForApproval::from(preset.approval);
let base_description =
Some(preset.description.replace(" (Identical to Agent mode)", ""));
let approval_disabled_reason = match self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
Ok(()) => None,
Err(err) => Some(err.to_string()),
};
let default_disabled_reason = approval_disabled_reason
.clone()
.or_else(|| guardian_disabled_reason(false));
let requires_confirmation = preset.id == "full-access"
&& !self
.config
.notices
.hide_full_access_warning
.unwrap_or(false);
let default_actions: Vec<SelectionAction> = if requires_confirmation {
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenFullAccessConfirmation {
preset: preset_clone.clone(),
return_to_permissions: !include_read_only,
});
})]
} else if preset.id == "auto" {
#[cfg(target_os = "windows")]
{
if WindowsSandboxLevel::from_config(&self.config)
== WindowsSandboxLevel::Disabled
{
let preset_clone = preset.clone();
if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& crate::legacy_core::windows_sandbox::sandbox_setup_is_complete(
self.config.codex_home.as_path(),
)
{
vec![Box::new(move |tx| {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset: preset_clone.clone(),
mode: WindowsSandboxEnableMode::Elevated,
});
})]
} else {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWindowsSandboxEnablePrompt {
preset: preset_clone.clone(),
});
})]
}
} else if let Some((sample_paths, extra_count, failed_scan)) =
self.world_writable_warning_details()
{
let preset_clone = preset.clone();
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})]
} else {
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
}
} else {
Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
};
if preset.id == "auto" {
items.push(SelectionItem {
name: base_name.clone(),
description: base_description.clone(),
is_current: current_review_policy == ApprovalsReviewer::User
&& Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: default_actions,
dismiss_on_select: true,
disabled_reason: default_disabled_reason,
..Default::default()
});
if guardian_approval_enabled {
items.push(SelectionItem {
name: "Auto-review".to_string(),
description: Some(
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."
.to_string(),
),
is_current: current_review_policy == ApprovalsReviewer::AutoReview
&& Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: Self::approval_preset_actions(
preset_approval,
preset.permission_profile.clone(),
"Auto-review".to_string(),
ApprovalsReviewer::AutoReview,
),
dismiss_on_select: true,
disabled_reason: approval_disabled_reason
.or_else(|| guardian_disabled_reason(true)),
..Default::default()
});
}
} else {
items.push(SelectionItem {
name: base_name,
description: base_description,
is_current: Self::preset_matches_current(
current_approval,
&current_permission_profile,
self.config.cwd.as_path(),
&preset,
),
actions: default_actions,
dismiss_on_select: true,
disabled_reason: default_disabled_reason,
..Default::default()
});
}
}
let footer_note = show_elevate_sandbox_hint.then(|| {
vec![
"The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(),
"/setup-default-sandbox".cyan(),
".".dim(),
]
.into()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Update Model Permissions".to_string()),
footer_note,
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(()),
..Default::default()
});
}
pub(crate) fn open_auto_review_denials_popup(&mut self) {
if self.review.recent_auto_review_denials.is_empty() {
self.add_info_message(
"No recent auto-review denials in this thread.".to_string(),
Some("Denials are recorded after auto-review rejects an action.".to_string()),
);
return;
}
let Some(thread_id) = self.thread_id() else {
self.add_error_message("That thread is no longer available.".to_string());
return;
};
let mut items = vec![SelectionItem {
name: "Command".to_string(),
description: Some("Rationale".to_string()),
is_disabled: true,
search_value: Some(String::new()),
..Default::default()
}];
items.extend(
self.review
.recent_auto_review_denials
.entries()
.map(|event| {
let id = event.id.clone();
let summary = auto_review_denials::action_summary(&event.action);
let rationale = event
.rationale
.as_deref()
.unwrap_or("Auto-review did not include a rationale.");
SelectionItem {
name: summary.clone(),
description: Some(rationale.to_string()),
selected_description: Some(rationale.to_string()),
search_value: Some(format!("{summary} {rationale}")),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::ApproveRecentAutoReviewDenial {
thread_id,
id: id.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
}
}),
);
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Auto-review Denials".to_string()),
subtitle: Some("Select a denied action to approve.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
col_width_mode: ColumnWidthMode::AutoAllRows,
..Default::default()
});
self.request_redraw();
}
pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) {
let Some(event) = self.review.recent_auto_review_denials.take(&id) else {
self.add_error_message("That auto-review denial is no longer available.".to_string());
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::approve_guardian_denied_action(event),
});
self.add_info_message(
"Approval recorded for one retry of the selected auto-review denial.".to_string(),
Some(
"The model will see the approval context; the retry still goes through auto-review."
.to_string(),
),
);
}
pub(super) fn approval_preset_actions(
approval: AskForApproval,
permission_profile: PermissionProfile,
label: String,
approvals_reviewer: ApprovalsReviewer,
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
let permission_profile_clone = permission_profile.clone();
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
Some(approval),
Some(approvals_reviewer),
Some(permission_profile_clone.clone()),
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone));
tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
format!("Permissions updated to {label}"),
/*hint*/ None,
),
)));
})]
}
pub(super) fn preset_matches_current(
current_approval: AskForApproval,
current_permission_profile: &PermissionProfile,
cwd: &std::path::Path,
preset: &ApprovalPreset,
) -> bool {
let preset_approval = AskForApproval::from(preset.approval);
if current_approval != preset_approval {
return false;
}
match preset.id {
"full-access" => matches!(current_permission_profile, PermissionProfile::Disabled),
"read-only" => {
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
matches!(
current_permission_profile,
PermissionProfile::Managed { .. }
) && !file_system_policy.has_full_disk_write_access()
&& file_system_policy
.get_writable_roots_with_cwd(cwd)
.is_empty()
&& current_permission_profile.network_sandbox_policy()
== preset.permission_profile.network_sandbox_policy()
}
"auto" => {
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
matches!(
current_permission_profile,
PermissionProfile::Managed { .. }
) && file_system_policy.can_write_path_with_cwd(cwd, cwd)
&& !file_system_policy.has_full_disk_write_access()
&& current_permission_profile.network_sandbox_policy()
== preset.permission_profile.network_sandbox_policy()
}
_ => current_permission_profile == &preset.permission_profile,
}
}
pub(crate) fn open_full_access_confirmation(
&mut self,
preset: ApprovalPreset,
return_to_permissions: bool,
) {
let selected_name = preset.label.to_string();
let approval = AskForApproval::from(preset.approval);
let permission_profile = preset.permission_profile;
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let title_line = Line::from("Enable full access?").bold();
let info_line = Line::from(vec![
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
.into(),
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
.fg(Color::Red),
]);
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions = Self::approval_preset_actions(
approval,
permission_profile.clone(),
selected_name.clone(),
ApprovalsReviewer::User,
);
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions = Self::approval_preset_actions(
approval,
permission_profile,
selected_name,
ApprovalsReviewer::User,
);
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
}));
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
if return_to_permissions {
tx.send(AppEvent::OpenPermissionsPopup);
} else {
tx.send(AppEvent::OpenApprovalsPopup);
}
})];
let items = vec![
SelectionItem {
name: "Yes, continue anyway".to_string(),
description: Some("Apply full access for this session".to_string()),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Yes, and don't ask again".to_string(),
description: Some("Enable full access and remember this choice".to_string()),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Go back without enabling full access".to_string()),
actions: deny_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
}
+6 -5
View File
@@ -2023,10 +2023,11 @@ fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
}
fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.get_user_layer()
.and_then(|user_layer| user_layer.config.get("marketplaces"))
let Some(user_config) = config.config_layer_stack.effective_user_config() else {
return false;
};
user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
.is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name))
}
@@ -2034,7 +2035,7 @@ fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bo
fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.get_user_layer()
.get_active_user_layer()
.and_then(|user_layer| user_layer.config.get("marketplaces"))
.and_then(toml::Value::as_table)
.and_then(|marketplaces| marketplaces.get(marketplace_name))
+326 -1
View File
@@ -1,5 +1,6 @@
//! Rate-limit warning and prompt state for `ChatWidget`.
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
use super::*;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
@@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind(
pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
}
impl ChatWidget {
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
if let Some(mut snapshot) = snapshot {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
let limit_label = snapshot
.limit_name
.clone()
.unwrap_or_else(|| limit_id.clone());
if snapshot.credits.is_none() {
snapshot.credits = self
.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.credits.as_ref())
.map(|credits| CreditsSnapshot {
has_credits: credits.has_credits,
unlimited: credits.unlimited,
balance: credits.balance.clone(),
});
}
self.plan_type = snapshot.plan_type.or(self.plan_type);
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
if is_codex_limit
&& let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type
{
self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);
}
let warnings = if is_codex_limit {
self.rate_limit_warnings.take_warnings(
snapshot
.secondary
.as_ref()
.map(|window| f64::from(window.used_percent)),
snapshot
.secondary
.as_ref()
.and_then(|window| window.window_duration_mins),
snapshot
.primary
.as_ref()
.map(|window| f64::from(window.used_percent)),
snapshot
.primary
.as_ref()
.and_then(|window| window.window_duration_mins),
)
} else {
vec![]
};
let high_usage = is_codex_limit
&& (snapshot
.secondary
.as_ref()
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
.unwrap_or(false)
|| snapshot
.primary
.as_ref()
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
.unwrap_or(false));
let has_workspace_credits = snapshot
.credits
.as_ref()
.map(|credits| credits.has_credits)
.unwrap_or(false);
if high_usage
&& !has_workspace_credits
&& !self.rate_limit_switch_prompt_hidden()
&& self.current_model() != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
)
{
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
}
let display =
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
self.rate_limit_snapshots_by_limit_id
.insert(limit_id, display);
if !warnings.is_empty() {
for warning in warnings {
self.add_to_history(history_cell::new_warning_event(warning));
}
self.request_redraw();
}
} else {
self.rate_limit_snapshots_by_limit_id.clear();
self.codex_rate_limit_reached_type = None;
}
self.refresh_status_line();
}
pub(super) fn stop_rate_limit_poller(&mut self) {}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn prefetch_rate_limits(&mut self) {
self.stop_rate_limit_poller();
}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn should_prefetch_rate_limits(&self) -> bool {
self.config.model_provider.requires_openai_auth && self.has_chatgpt_account
}
fn lower_cost_preset(&self) -> Option<ModelPreset> {
let models = self.model_catalog.try_list_models().ok()?;
models
.iter()
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
.cloned()
}
fn rate_limit_switch_prompt_hidden(&self) -> bool {
self.config
.notices
.hide_rate_limit_model_nudge
.unwrap_or(false)
}
pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {
if self.rate_limit_switch_prompt_hidden() {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
return;
}
if !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
) {
return;
}
if let Some(preset) = self.lower_cost_preset() {
self.open_rate_limit_switch_prompt(preset);
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;
} else {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {
let switch_model = preset.model;
let switch_model_for_events = switch_model.clone();
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*windows_sandbox_level*/ None,
Some(switch_model_for_events.clone()),
Some(Some(default_effort)),
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
)));
tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
})];
let keep_actions: Vec<SelectionAction> = Vec::new();
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
})];
let description = if preset.description.is_empty() {
Some("Uses fewer credits for upcoming turns.".to_string())
} else {
Some(preset.description)
};
let items = vec![
SelectionItem {
name: format!("Switch to {switch_model}"),
description,
selected_description: None,
is_current: false,
actions: switch_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model".to_string(),
description: None,
selected_description: None,
is_current: false,
actions: keep_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model (never show again)".to_string(),
description: Some(
"Hide future rate limit reminders about switching models.".to_string(),
),
selected_description: None,
is_current: false,
actions: never_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Approaching rate limits".to_string()),
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(super) fn open_workspace_owner_nudge_prompt(
&mut self,
credit_type: AddCreditsNudgeCreditType,
) {
if self.add_credits_nudge_email_in_flight.is_some() {
return;
}
let (title, prompt) = match credit_type {
AddCreditsNudgeCreditType::Credits => (
"You've reached your workspace credit limit",
"Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?",
),
AddCreditsNudgeCreditType::UsageLimit => (
"Usage limit reached",
"Request a limit increase from your owner to continue using codex. Request increase?",
),
};
let send_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type });
})];
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
actions: send_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
is_default: true,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(title.to_string()),
subtitle: Some(prompt.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx: Some(1),
..Default::default()
});
}
pub(crate) fn start_add_credits_nudge_email_request(
&mut self,
credit_type: AddCreditsNudgeCreditType,
) -> bool {
self.add_credits_nudge_email_in_flight = Some(credit_type);
true
}
pub(crate) fn finish_add_credits_nudge_email_request(
&mut self,
result: Result<AddCreditsNudgeEmailStatus, String>,
) {
let credit_type = self
.add_credits_nudge_email_in_flight
.take()
.unwrap_or(AddCreditsNudgeCreditType::Credits);
let message = match (credit_type, result) {
(AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
"Workspace owner notified."
}
(
AddCreditsNudgeCreditType::Credits,
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
) => "Workspace owner was already notified recently.",
(AddCreditsNudgeCreditType::Credits, Err(_)) => {
"Could not notify your workspace owner. Please try again."
}
(AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
"Limit increase requested."
}
(
AddCreditsNudgeCreditType::UsageLimit,
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
) => "A limit increase was already requested recently.",
(AddCreditsNudgeCreditType::UsageLimit, Err(_)) => {
"Could not request a limit increase. Please try again."
}
};
self.add_to_history(history_cell::new_info_event(
message.to_string(),
/*hint*/ None,
));
self.request_redraw();
}
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
if hidden {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Render composition for the main chat widget surface.
use super::*;
impl ChatWidget {
pub(super) fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell.as_ref(),
top: 1,
right: active_cell_right_reserve,
})),
None => RenderableItem::Owned(Box::new(())),
};
let active_hook_cell_renderable = match &self.active_hook_cell {
Some(cell) if cell.should_render() => {
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
}))
}
_ => RenderableItem::Owned(Box::new(())),
};
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: active_cell_right_reserve,
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
}
struct BottomPaneComposerReserveRenderable<'a> {
bottom_pane: &'a BottomPane,
right_reserve: u16,
}
impl Renderable for BottomPaneComposerReserveRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.bottom_pane
.render_with_composer_right_reserve(area, buf, self.right_reserve);
}
fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane
.desired_height_with_composer_right_reserve(width, self.right_reserve)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane
.cursor_pos_with_composer_right_reserve(area, self.right_reserve)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.bottom_pane
.cursor_style_with_composer_right_reserve(area, self.right_reserve)
}
}
struct TranscriptAreaRenderable<'a> {
child: &'a dyn HistoryCell,
top: u16,
right: u16,
}
impl Renderable for TranscriptAreaRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let area = self.child_area(area);
let lines = self.child.display_lines(area.width);
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let y = if area.height == 0 {
0
} else {
let overflow = paragraph
.line_count(area.width)
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
Clear.render(area, buf);
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let child_width = width.saturating_sub(self.right).max(1);
HistoryCell::desired_height(self.child, child_width) + self.top
}
}
impl TranscriptAreaRenderable<'_> {
fn child_area(&self, area: Rect) -> Rect {
let y = area.y.saturating_add(self.top);
let height = area.height.saturating_sub(self.top);
Rect::new(
area.x,
y,
area.width.saturating_sub(self.right).max(1),
height,
)
}
}
impl Renderable for ChatWidget {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
self.last_rendered_width.set(Some(area.width as usize));
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable().desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.as_renderable().cursor_style(area)
}
}
@@ -0,0 +1,182 @@
//! Review preset selection and custom review prompt surfaces.
use super::*;
impl ChatWidget {
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("(PR Style)".into()),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.review(ReviewTarget::UncommittedChanges);
})],
dismiss_on_select: true,
..Default::default()
});
items.push(SelectionItem {
name: "Review a commit".to_string(),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewCustomPrompt);
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review preset".into()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
let branches = local_git_branches(cwd).await;
let current_branch = current_branch_name(cwd)
.await
.unwrap_or_else(|| "(detached HEAD)".to_string());
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
for option in branches {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::BaseBranch {
branch: branch.clone(),
});
})],
dismiss_on_select: true,
search_value: Some(option),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
..Default::default()
});
}
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
let commits = recent_commits(cwd, /*limit*/ 100).await;
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
pub(crate) fn show_review_custom_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"Custom review instructions".to_string(),
"Type instructions and press Enter".to_string(),
/*initial_text*/ String::new(),
/*context_label*/ None,
Box::new(move |prompt: String| {
let trimmed = prompt.trim().to_string();
if trimmed.is_empty() {
return;
}
tx.review(ReviewTarget::Custom {
instructions: trimmed,
});
}),
);
self.bottom_pane.show_view(Box::new(view));
}
}
#[cfg(test)]
pub(crate) fn show_review_commit_picker_with_entries(
chat: &mut ChatWidget,
entries: Vec<CommitLogEntry>,
) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
for entry in entries {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
+207
View File
@@ -0,0 +1,207 @@
//! Session configuration and thread-header orchestration for `ChatWidget`.
use super::*;
impl ChatWidget {
fn on_session_configured_with_display_and_fork_parent_title(
&mut self,
session: ThreadSessionState,
display: SessionConfiguredDisplay,
fork_parent_title: Option<String>,
) {
self.transcript.reset_copy_history();
let history_metadata = session.message_history.unwrap_or_default();
self.bottom_pane.set_history_metadata(
session.thread_id,
history_metadata.log_id,
history_metadata.entry_count,
);
self.set_skills(/*skills*/ None);
self.session_network_proxy = session.network_proxy.clone();
let previous_thread_id = self.thread_id;
self.thread_id = Some(session.thread_id);
if previous_thread_id != self.thread_id {
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
self.thread_name = session.thread_name.clone();
self.current_goal_status_indicator = None;
self.current_goal_status = None;
self.update_collaboration_mode_indicator();
self.forked_from = session.forked_from_id;
self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf());
self.config.cwd = session.cwd.clone();
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
.permissions
.approval_policy
.set(session.approval_policy.to_core())
{
tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured");
self.config.permissions.approval_policy =
Constrained::allow_only(session.approval_policy.to_core());
}
let permission_sync = self
.config
.permissions
.set_permission_profile_with_active_profile(
session.permission_profile.clone(),
session.active_permission_profile.clone(),
);
if let Err(err) = permission_sync {
tracing::warn!(%err, "failed to sync permissions from SessionConfigured");
self.config.permissions.permission_profile =
Constrained::allow_only(session.permission_profile.clone());
self.config.permissions.active_permission_profile =
session.active_permission_profile.clone();
}
self.config.approvals_reviewer = session.approvals_reviewer;
self.status_line_project_root_name_cache = None;
let forked_from_id = session.forked_from_id;
let model_for_header = session.model.clone();
self.session_header.set_model(&model_for_header);
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model_for_header.clone()),
Some(session.reasoning_effort),
/*developer_instructions*/ None,
);
if let Some(mask) = self.active_collaboration_mask.as_mut() {
mask.model = Some(model_for_header.clone());
mask.reasoning_effort = Some(session.reasoning_effort);
}
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
&session,
self.show_welcome_banner,
startup_tooltip_override,
self.plan_type,
show_fast_status,
);
self.apply_session_info_cell(session_info_cell);
} else if self
.transcript
.active_cell
.as_ref()
.is_some_and(|cell| cell.as_any().is::<history_cell::SessionHeaderHistoryCell>())
{
self.transcript.active_cell = None;
self.bump_active_cell_revision();
}
self.transcript.saw_copy_source_this_turn = false;
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
if self.connectors_enabled() {
self.prefetch_connectors();
}
if let Some(user_message) = self.initial_user_message.take() {
if self.suppress_initial_user_message_submit {
self.initial_user_message = Some(user_message);
} else {
self.submit_user_message(user_message);
}
}
if display == SessionConfiguredDisplay::Normal
&& let Some(forked_from_id) = forked_from_id
{
self.emit_forked_thread_event(forked_from_id, fork_parent_title);
}
if !self.suppress_session_configured_redraw {
self.request_redraw();
}
}
pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::Normal,
fork_parent_title,
);
}
pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::Quiet,
/*fork_parent_title*/ None,
);
}
pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::SideConversation,
fork_parent_title,
);
}
pub(super) fn emit_forked_thread_event(
&mut self,
forked_from_id: ThreadId,
fork_parent_title: Option<String>,
) {
let forked_from_id_text = forked_from_id.to_string();
let line: Line<'static> = if let Some(name) = fork_parent_title
&& !name.trim().is_empty()
{
vec![
"• ".dim(),
"Thread forked from ".into(),
name.cyan(),
" (".into(),
forked_from_id_text.cyan(),
")".into(),
]
.into()
} else {
vec![
"• ".dim(),
"Thread forked from ".into(),
forked_from_id_text.cyan(),
]
.into()
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![line]),
)));
}
pub(super) fn on_thread_name_updated(
&mut self,
thread_id: ThreadId,
thread_name: Option<String>,
) {
if self.thread_id == Some(thread_id) {
if let Some(name) = thread_name.as_deref() {
let cell = Self::rename_confirmation_cell(name, self.thread_id);
self.add_boxed_history(Box::new(cell));
}
self.thread_name = thread_name;
self.refresh_status_surfaces();
self.request_redraw();
self.maybe_send_next_queued_input();
}
}
pub(super) fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.bottom_pane.set_skills(skills);
}
}
+625
View File
@@ -0,0 +1,625 @@
//! 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(mut next_mask) = collaboration_modes::next_mask(
self.model_catalog.as_ref(),
self.active_collaboration_mask.as_ref(),
) {
let override_command = self.collaboration_mode_override_command(&mut next_mask);
self.app_event_tx.send(AppEvent::CodexOp(override_command));
self.set_collaboration_mask(next_mask);
}
}
pub(super) fn collaboration_mode_override_command(
&self,
mask: &mut CollaborationModeMask,
) -> AppCommand {
self.apply_plan_reasoning_override_to_mask(mask);
AppCommand::override_collaboration_mode(self.current_collaboration_mode.apply_mask(mask))
}
fn apply_plan_reasoning_override_to_mask(&self, mask: &mut CollaborationModeMask) {
if mask.mode == Some(ModeKind::Plan)
&& let Some(effort) = self.config.plan_mode_reasoning_effort
{
mask.reasoning_effort = Some(Some(effort));
}
}
/// 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();
self.apply_plan_reasoning_override_to_mask(&mut mask);
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.",
}
}
}
@@ -241,16 +241,6 @@ impl ChatWidget {
);
}
}
SlashCommand::Collab => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /collab.".to_string()),
);
return;
}
self.open_collaboration_modes_popup();
}
SlashCommand::Side => {
self.request_empty_side_conversation();
}
@@ -968,7 +958,6 @@ impl ChatWidget {
| SlashCommand::Personality
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Collab
| SlashCommand::Side
| SlashCommand::Keymap
| SlashCommand::Agent
@@ -6,11 +6,12 @@ expression: popup
The following files will be sent:
• codex-logs.log
• codex-doctor-report.json
• auto-review-rollout-thread-1.jsonl
• codex-connectivity-diagnostics.txt
› 1. Yes Share the current Codex session logs with the team for
troubleshooting.
› 1. Yes Share the current Codex session logs and diagnostics with the team
for troubleshooting.
2. No
Press enter to confirm or esc to go back
@@ -6,6 +6,7 @@ expression: popup
The following files will be sent:
• codex-logs.log
• codex-doctor-report.json
• auto-review-rollout-thread-1.jsonl
• codex-connectivity-diagnostics.txt
@@ -13,8 +14,8 @@ expression: popup
- Proxy environment variables are set and may affect connectivity.
- HTTPS_PROXY = hello
› 1. Yes Share the current Codex session logs with the team for
troubleshooting.
› 1. Yes Share the current Codex session logs and diagnostics with the team
for troubleshooting.
2. No
Press enter to confirm or esc to go back
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests/goal_validation.rs
expression: rendered
---
■ Goal objective is too long: 4,001 characters. Limit: 4,000 characters. Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md.
@@ -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",
}
}
}
@@ -120,7 +120,12 @@ async fn goal_slash_command_rejects_oversized_objective() {
"oversized goal should not emit a SetThreadGoalObjective event: {events:?}"
);
let rendered = rendered_insert_history(&events);
assert_chatwidget_snapshot!("goal_slash_command_oversized_objective_error", rendered);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
assert!(
!rendered.contains("Message exceeds the maximum length"),
"expected goal-specific length error, got {rendered:?}"
);
assert_no_submit_op(&mut op_rx);
}
@@ -1389,74 +1389,6 @@ async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_sam
);
}
#[tokio::test]
async fn collab_slash_command_opens_picker_and_updates_mode() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Collab);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Select Collaboration Mode"),
"expected collaboration picker: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::CodexOp(Op::OverrideTurnContext {
collaboration_mode:
Some(CollaborationMode {
mode: ModeKind::Default,
..
}),
..
})) => {}
other => panic!("expected collaboration OverrideTurnContext event, got {other:?}"),
}
let selected_mask = match rx.try_recv() {
Ok(AppEvent::UpdateCollaborationMode(mask)) => mask,
other => panic!("expected UpdateCollaborationMode event, got {other:?}"),
};
chat.set_collaboration_mask(selected_mask);
chat.bottom_pane
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match next_submit_op(&mut op_rx) {
Op::UserTurn {
collaboration_mode:
Some(CollaborationMode {
mode: ModeKind::Default,
..
}),
personality: Some(Personality::Pragmatic),
..
} => {}
other => {
panic!("expected Op::UserTurn with code collab mode, got {other:?}")
}
}
chat.bottom_pane
.set_composer_text("follow up".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match next_submit_op(&mut op_rx) {
Op::UserTurn {
collaboration_mode:
Some(CollaborationMode {
mode: ModeKind::Default,
..
}),
personality: Some(Personality::Pragmatic),
..
} => {}
other => {
panic!("expected Op::UserTurn with code collab mode, got {other:?}")
}
}
}
#[tokio::test]
async fn plan_slash_command_switches_to_plan_mode() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -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) {}
}
+4
View File
@@ -12,6 +12,10 @@ pub struct Cli {
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
pub prompt: Option<String>,
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
pub strict_config: bool,
// Internal controls set by the top-level `codex resume` subcommand.
// These are not exposed as user flags on the base `codex` command.
#[clap(skip)]
-4
View File
@@ -11,10 +11,6 @@ fn filtered_presets(_model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask>
.collect()
}
pub(crate) fn presets_for_tui(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
filtered_presets(model_catalog)
}
pub(crate) fn default_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
let presets = filtered_presets(model_catalog);
presets
+5 -2
View File
@@ -385,7 +385,7 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String {
ConfigLayerSource::System { file } => {
format!("system ({})", file.as_path().display())
}
ConfigLayerSource::User { file } => {
ConfigLayerSource::User { file, .. } => {
format!("user ({})", file.as_path().display())
}
ConfigLayerSource::Project { dot_codex_folder } => {
@@ -728,7 +728,10 @@ mod tests {
};
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
ConfigLayerSource::User {
file: user_file,
profile: None,
},
empty_toml_table(),
)],
requirements,
@@ -148,7 +148,7 @@ async fn persist_external_agent_config_migration_prompt_shown(
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
@@ -221,7 +221,7 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
+145 -90
View File
@@ -8,8 +8,9 @@ use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::find_codex_home;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_loader_overrides;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use crate::legacy_core::format_exec_policy_error_with_source;
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use crate::session_resume::ResolveCwdOutcome;
@@ -282,6 +283,7 @@ async fn start_embedded_app_server(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -293,6 +295,7 @@ async fn start_embedded_app_server(
config,
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -453,6 +456,7 @@ async fn start_app_server(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -465,6 +469,7 @@ async fn start_app_server(
config,
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -489,6 +494,7 @@ pub(crate) async fn start_app_server_for_picker(
config.clone(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
@@ -519,6 +525,7 @@ async fn start_embedded_app_server_with<F, Fut>(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -545,6 +552,7 @@ where
config: Arc::new(config),
cli_overrides: cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -759,6 +767,7 @@ pub async fn run_main(
loader_overrides: LoaderOverrides,
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
let strict_config = cli.strict_config;
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
@@ -834,13 +843,22 @@ pub async fn run_main(
let cwd = cli.cwd.clone();
let config_cwd =
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
let mut loader_overrides = loader_overrides;
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
loader_overrides.user_config_path = Some(user_config_path);
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
#[allow(clippy::print_stderr)]
let config_toml = match load_config_as_toml_with_cli_and_loader_overrides(
let config_toml = match load_config_as_toml_with_cli_and_load_options(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
)
.await
{
@@ -935,7 +953,9 @@ pub async fn run_main(
let mut config = load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await;
@@ -990,7 +1010,9 @@ pub async fn run_main(
config = load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await;
}
@@ -1133,6 +1155,7 @@ pub async fn run_main(
cli,
arg0_paths,
loader_overrides,
strict_config,
app_server_target,
remote_cwd_override,
config,
@@ -1154,6 +1177,7 @@ async fn run_ratatui_app(
cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
strict_config: bool,
app_server_target: AppServerTarget,
remote_cwd_override: Option<PathBuf>,
initial_config: Config,
@@ -1217,6 +1241,7 @@ async fn run_ratatui_app(
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
@@ -1303,7 +1328,9 @@ async fn run_ratatui_app(
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await
} else {
@@ -1505,7 +1532,9 @@ async fn run_ratatui_app(
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
fallback_cwd,
)
.await
@@ -1514,7 +1543,9 @@ async fn run_ratatui_app(
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await
}
@@ -1555,7 +1586,8 @@ async fn run_ratatui_app(
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides,
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
@@ -1586,6 +1618,7 @@ async fn run_ratatui_app(
config,
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
active_profile,
prompt,
images,
@@ -1696,12 +1729,16 @@ async fn get_login_status(
async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
strict_config: bool,
) -> Config {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides,
overrides,
loader_overrides,
cloud_requirements,
strict_config,
/*fallback_cwd*/ None,
)
.await
@@ -1710,13 +1747,17 @@ async fn load_config_or_exit(
async fn load_config_or_exit_with_fallback_cwd(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
strict_config: bool,
fallback_cwd: Option<PathBuf>,
) -> Config {
#[allow(clippy::print_stderr)]
match ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.loader_overrides(loader_overrides)
.strict_config(strict_config)
.cloud_requirements(cloud_requirements)
.fallback_cwd(fallback_cwd)
.build()
@@ -1788,6 +1829,7 @@ mod tests {
config,
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
@@ -1797,6 +1839,15 @@ mod tests {
.await
}
fn large_stack_test_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_stack_size(16 * 1024 * 1024)
.enable_all()
.build()
.expect("test runtime")
}
#[test]
fn alternate_screen_auto_uses_alt_screen() {
assert!(determine_alt_screen_mode(
@@ -2015,8 +2066,8 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> {
#[test]
fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> {
fn write_session_rollout(
codex_home: &Path,
filename_ts: &str,
@@ -2096,71 +2147,73 @@ mod tests {
Ok(thread_id)
}
let temp_dir = TempDir::new()?;
let project_cwd = temp_dir.path().join("project");
let other_cwd = temp_dir.path().join("other-project");
std::fs::create_dir_all(&project_cwd)?;
std::fs::create_dir_all(&other_cwd)?;
large_stack_test_runtime().block_on(async {
let temp_dir = TempDir::new()?;
let project_cwd = temp_dir.path().join("project");
let other_cwd = temp_dir.path().join("other-project");
std::fs::create_dir_all(&project_cwd)?;
std::fs::create_dir_all(&other_cwd)?;
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(project_cwd.clone()),
..Default::default()
})
.build()
.await?;
let model_provider = config.model_provider_id.as_str();
let project_thread_id = write_session_rollout(
temp_dir.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"older project session",
model_provider,
&project_cwd,
)?;
let other_thread_id = write_session_rollout(
temp_dir.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"newer other project session",
model_provider,
&other_cwd,
)?;
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(project_cwd.clone()),
..Default::default()
})
.build()
.await?;
let model_provider = config.model_provider_id.as_str();
let project_thread_id = write_session_rollout(
temp_dir.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"older project session",
model_provider,
&project_cwd,
)?;
let other_thread_id = write_session_rollout(
temp_dir.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"newer other project session",
model_provider,
&other_cwd,
)?;
let mut app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config.clone()).await?,
));
let filter_cwd = latest_session_cwd_filter(
/*remote_mode*/ false, /*remote_cwd_override*/ None, &config,
/*show_all*/ false,
);
let scoped_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
filter_cwd,
/*include_non_interactive*/ false,
)
.await?
.expect("expected project-scoped fork --last target");
let show_all_filter_cwd = latest_session_cwd_filter(
/*remote_mode*/ false, /*remote_cwd_override*/ None, &config,
/*show_all*/ true,
);
let show_all_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
show_all_filter_cwd,
/*include_non_interactive*/ false,
)
.await?
.expect("expected global fork --last target");
app_server.shutdown().await?;
let mut app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(config.clone()).await?,
));
let filter_cwd = latest_session_cwd_filter(
/*remote_mode*/ false, /*remote_cwd_override*/ None, &config,
/*show_all*/ false,
);
let scoped_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
filter_cwd,
/*include_non_interactive*/ false,
)
.await?
.expect("expected project-scoped fork --last target");
let show_all_filter_cwd = latest_session_cwd_filter(
/*remote_mode*/ false, /*remote_cwd_override*/ None, &config,
/*show_all*/ true,
);
let show_all_target = lookup_latest_session_target_with_app_server(
&mut app_server,
&config,
show_all_filter_cwd,
/*include_non_interactive*/ false,
)
.await?
.expect("expected global fork --last target");
app_server.shutdown().await?;
assert_eq!(scoped_target.thread_id, project_thread_id);
assert_eq!(show_all_target.thread_id, other_thread_id);
Ok(())
assert_eq!(scoped_target.thread_id, project_thread_id);
assert_eq!(show_all_target.thread_id, other_thread_id);
Ok(())
})
}
#[tokio::test]
@@ -2260,30 +2313,32 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let app_server = start_test_embedded_app_server(config).await?;
let response: ThreadStartResponse = app_server
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams {
ephemeral: Some(true),
..ThreadStartParams::default()
},
})
.await
.expect("thread/start should succeed");
assert!(!response.thread.id.is_empty());
#[test]
fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> {
large_stack_test_runtime().block_on(async {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let app_server = start_test_embedded_app_server(config).await?;
let response: ThreadStartResponse = app_server
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams {
ephemeral: Some(true),
..ThreadStartParams::default()
},
})
.await
.expect("thread/start should succeed");
assert!(!response.thread.id.is_empty());
app_server.shutdown().await?;
Ok(())
app_server.shutdown().await?;
Ok(())
})
}
#[tokio::test]
async fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
Box::pin(async {
#[test]
fn lookup_session_target_by_name_uses_backend_title_search() -> color_eyre::Result<()> {
large_stack_test_runtime().block_on(async {
let temp_dir = TempDir::new()?;
let config = build_config(&temp_dir).await?;
let thread_id = ThreadId::new();
@@ -2341,7 +2396,6 @@ mod tests {
app_server.shutdown().await?;
Ok(())
})
.await
}
#[tokio::test]
@@ -2353,6 +2407,7 @@ mod tests {
config,
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
+1
View File
@@ -1056,6 +1056,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: Default::default(),
strict_config: false,
cloud_requirements: cloud_requirements_loader_for_storage(
codex_home_path.clone(),
/*enable_codex_api_key_env*/ false,
+2 -2
View File
@@ -132,7 +132,7 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport {
Some(Multiplexer::Tmux { .. }) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux);
}
Some(Multiplexer::Zellij {}) => {
Some(Multiplexer::Zellij { .. }) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij);
}
None => {}
@@ -389,7 +389,7 @@ mod tests {
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Kitty,
Some(Multiplexer::Zellij {}),
Some(Multiplexer::Zellij { version: None }),
Some("kitty"),
/*term*/ None,
)),
-3
View File
@@ -36,7 +36,6 @@ pub enum SlashCommand {
Compact,
Plan,
Goal,
Collab,
Agent,
Side,
Copy,
@@ -114,7 +113,6 @@ impl SlashCommand {
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Goal => "set or view the goal for a long-running task",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Side => "start a side conversation in an ephemeral fork",
SlashCommand::Permissions => "choose what Codex is allowed to do",
@@ -224,7 +222,6 @@ impl SlashCommand {
SlashCommand::TestApproval => true,
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Theme | SlashCommand::Pets => false,
}
+8 -3
View File
@@ -16,6 +16,9 @@ use codex_protocol::account::PlanType;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_sandbox_summary::summarize_permission_profile;
@@ -587,7 +590,7 @@ fn status_permissions_label(
count => format!(" + {count} writable roots"),
};
match active_id {
Some(":read-only") => {
Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => {
let label = if sandbox == "read-only with network access" {
"Read Only with network access"
} else {
@@ -595,14 +598,16 @@ fn status_permissions_label(
};
return format!("{label}{modification_suffix} ({approval})");
}
Some(":workspace") => match sandbox {
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox {
"workspace" => return format!("Workspace{modification_suffix} ({approval})"),
"workspace with network access" => {
return format!("Workspace with network access{modification_suffix} ({approval})");
}
_ => {}
},
Some(":danger-no-sandbox") if permission_profile == &PermissionProfile::Disabled => {
Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS)
if permission_profile == &PermissionProfile::Disabled =>
{
return if approval_policy == AskForApproval::Never {
"Full Access".to_string()
} else {
+31 -15
View File
@@ -24,6 +24,7 @@ use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_config::LoaderOverrides;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::ThreadId;
@@ -31,6 +32,8 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -80,6 +83,7 @@ fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfil
async fn test_config(temp_home: &TempDir) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(temp_home.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await
.expect("load config");
@@ -296,7 +300,9 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() {
.permissions
.set_permission_profile_with_active_profile(
PermissionProfile::read_only(),
Some(ActivePermissionProfile::new(":read-only")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
)
.expect("set permission profile");
@@ -327,11 +333,12 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots()
NetworkSandboxPolicy::Restricted,
),
Some(
ActivePermissionProfile::new(":read-only").with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
.with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
),
)
.expect("set permission profile");
@@ -355,7 +362,9 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() {
.permissions
.set_permission_profile_with_active_profile(
PermissionProfile::workspace_write(),
Some(ActivePermissionProfile::new(":workspace")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
)
.expect("set permission profile");
@@ -379,7 +388,9 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() {
.permissions
.set_permission_profile_with_active_profile(
PermissionProfile::workspace_write(),
Some(ActivePermissionProfile::new(":workspace")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
)
.expect("set permission profile");
@@ -409,11 +420,12 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() {
/*exclude_slash_tmp*/ false,
),
Some(
ActivePermissionProfile::new(":workspace").with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
.with_modifications(vec![
ActivePermissionProfileModification::AdditionalWritableRoot {
path: extra_root,
},
]),
),
)
.expect("set permission profile");
@@ -442,7 +454,9 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() {
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
),
Some(ActivePermissionProfile::new(":workspace")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
)
.expect("set permission profile");
@@ -578,7 +592,9 @@ async fn status_snapshot_shows_auto_review_permissions() {
.permissions
.set_permission_profile_with_active_profile(
PermissionProfile::workspace_write(),
Some(ActivePermissionProfile::new(":workspace")),
Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
)
.expect("set permission profile");
+44 -6
View File
@@ -49,8 +49,16 @@ where
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
match line {
std::borrow::Cow::Borrowed(slice) => {
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
let end = start + slice.len();
let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
});
let start = range.start;
let end = range.end;
let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
lines.push(start..end + trailing_spaces + 1);
cursor = end + trailing_spaces;
@@ -84,10 +92,16 @@ where
for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
match line {
std::borrow::Cow::Borrowed(slice) => {
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
let end = start + slice.len();
lines.push(start..end);
cursor = end;
let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
let synthetic_prefix = if line_index == 0 {
opts.initial_indent
} else {
opts.subsequent_indent
};
map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
});
cursor = range.end;
lines.push(range);
}
std::borrow::Cow::Owned(slice) => {
let synthetic_prefix = if line_index == 0 {
@@ -104,6 +118,19 @@ where
lines
}
fn borrowed_slice_range(text: &str, slice: &str) -> Option<Range<usize>> {
let text_start = text.as_ptr() as usize;
let text_end = text_start.checked_add(text.len())?;
let slice_start = slice.as_ptr() as usize;
let slice_end = slice_start.checked_add(slice.len())?;
if slice_start < text_start || slice_end > text_end {
return None;
}
Some((slice_start - text_start)..(slice_end - text_start))
}
/// Maps an owned (materialized) wrapped line back to a byte range in `text`.
///
/// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty
@@ -1485,6 +1512,17 @@ them."#
assert_eq!(range, 0..5);
}
#[test]
fn borrowed_slice_range_rejects_slices_outside_source_text() {
let text = "test message";
let external = String::from("test");
assert_eq!(borrowed_slice_range(text, &external), None);
let fallback = map_owned_wrapped_line_to_range(text, /*cursor*/ 0, &external, "");
assert_eq!(fallback, 0..4);
}
#[test]
fn map_owned_wrapped_line_to_range_indent_coincides_with_source() {
// When the synthetic indent prefix starts with a character that also
-2
View File
@@ -1,6 +1,4 @@
// Aggregates all former standalone integration tests as modules.
mod model_availability_nux;
mod no_panic_on_startup;
mod resize_reflow;
mod status_indicator;
mod vt100_history;
@@ -1,226 +0,0 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_models_manager::bundled_models_response;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use serde_json::Value as JsonValue;
use tempfile::tempdir;
use tokio::select;
use tokio::time::sleep;
use tokio::time::timeout;
use wiremock::MockServer;
#[tokio::test]
async fn resume_startup_does_not_consume_model_availability_nux_count() -> Result<()> {
// run_codex_cli() does not work on Windows due to PTY limitations.
if cfg!(windows) {
return Ok(());
}
skip_if_no_network!(Ok(()));
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex_home = tempdir()?;
let mut source_catalog: JsonValue = serde_json::to_value(bundled_models_response()?)?;
let models = source_catalog
.get_mut("models")
.and_then(JsonValue::as_array_mut)
.context("models array missing")?;
for model in models.iter_mut() {
if let Some(object) = model.as_object_mut() {
object.remove("availability_nux");
}
}
let first_model = models.first_mut().context("models array is empty")?;
let first_model_object = first_model
.as_object_mut()
.context("first model was not a JSON object")?;
let model_slug = first_model_object
.get("slug")
.and_then(JsonValue::as_str)
.context("first model missing slug")?
.to_string();
first_model_object.insert(
"availability_nux".to_string(),
serde_json::json!({
"message": "Model now available",
}),
);
let custom_catalog_path = codex_home.path().join("catalog.json");
std::fs::write(
&custom_catalog_path,
serde_json::to_string(&source_catalog)?,
)?;
let repo_root_display = repo_root.display();
let catalog_display = custom_catalog_path.display();
let config_contents = format!(
r#"model = "{model_slug}"
model_provider = "openai"
model_catalog_json = "{catalog_display}"
[projects."{repo_root_display}"]
trust_level = "trusted"
[tui.model_availability_nux]
"{model_slug}" = 1
"#
);
std::fs::write(codex_home.path().join("config.toml"), config_contents)?;
let server = MockServer::start().await;
let sse = responses::sse(vec![
responses::ev_response_created("resp-seed-session"),
responses::ev_assistant_message("msg-seed-session", "seed session response"),
responses::ev_completed("resp-seed-session"),
]);
let _response_mock = responses::mount_sse_once(&server, sse).await;
let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri());
let codex = if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
path
} else {
let fallback = repo_root.join("codex-rs/target/debug/codex");
if fallback.is_file() {
fallback
} else {
eprintln!("skipping integration test because codex binary is unavailable");
return Ok(());
}
};
let exec_output = std::process::Command::new(&codex)
.arg("exec")
.arg("--skip-git-repo-check")
.arg("-c")
.arg(&openai_base_url_config)
.arg("-C")
.arg(&repo_root)
.arg("seed session for resume")
.env("CODEX_HOME", codex_home.path())
.env("OPENAI_API_KEY", "dummy")
.output()
.context("failed to execute codex exec")?;
anyhow::ensure!(
exec_output.status.success(),
"codex exec failed: {}",
String::from_utf8_lossy(&exec_output.stderr)
);
let mut env = HashMap::new();
env.insert(
"CODEX_HOME".to_string(),
codex_home.path().display().to_string(),
);
env.insert("OPENAI_API_KEY".to_string(), "dummy".to_string());
let args = vec![
"resume".to_string(),
"--last".to_string(),
"--no-alt-screen".to_string(),
"-C".to_string(),
repo_root.display().to_string(),
"-c".to_string(),
"analytics.enabled=false".to_string(),
"-c".to_string(),
openai_base_url_config,
];
let spawned = codex_utils_pty::spawn_pty_process(
codex.to_string_lossy().as_ref(),
&args,
&repo_root,
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let interrupt_writer = writer_tx.clone();
let mut startup_ready = false;
let mut answered_cursor_query = false;
let exit_code_result = timeout(Duration::from_secs(30), async {
loop {
select! {
result = output_rx.recv() => match result {
Ok(chunk) => {
let has_cursor_query = chunk.windows(4).any(|window| window == b"\x1b[6n");
if has_cursor_query {
let _ = writer_tx.send(b"\x1b[1;1R".to_vec()).await;
answered_cursor_query = true;
}
output.extend_from_slice(&chunk);
if !startup_ready && answered_cursor_query && !has_cursor_query {
startup_ready = true;
for _ in 0..4 {
let _ = interrupt_writer.send(vec![3]).await;
sleep(Duration::from_millis(500)).await;
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break exit_rx.await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
},
result = &mut exit_rx => break result,
}
}
})
.await;
let exit_code = match exit_code_result {
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
session.terminate();
anyhow::bail!("timed out waiting for codex resume to exit");
}
};
let output_text = String::from_utf8_lossy(&output);
let rendered_output = {
let mut parser = vt100::Parser::new(
/*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0,
);
parser.process(&output);
parser.screen().contents()
};
let interrupted_during_terminal_startup = {
let trimmed_output = rendered_output.trim();
trimmed_output.is_empty()
|| trimmed_output
.chars()
.all(|character| character == '^' || character == 'C' || character.is_whitespace())
};
anyhow::ensure!(
exit_code == 0
|| exit_code == 130
|| (exit_code == 1 && interrupted_during_terminal_startup),
"unexpected exit code from codex resume: {exit_code}; output: {output_text}",
);
let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let config: toml::Value = toml::from_str(&config_contents)?;
let shown_count = config
.get("tui")
.and_then(|tui| tui.get("model_availability_nux"))
.and_then(|nux| nux.get(&model_slug))
.and_then(toml::Value::as_integer)
.context("missing tui.model_availability_nux count")?;
assert_eq!(shown_count, 1);
Ok(())
}
@@ -1,127 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tokio::select;
use tokio::time::timeout;
/// Regression test for https://github.com/openai/codex/issues/8803.
#[tokio::test]
#[ignore = "TODO(mbolin): flaky"]
async fn malformed_rules_should_not_panic() -> anyhow::Result<()> {
// run_codex_cli() does not work on Windows due to PTY limitations.
if cfg!(windows) {
return Ok(());
}
let tmp = tempfile::tempdir()?;
let codex_home = tmp.path();
std::fs::write(
codex_home.join("rules"),
"rules should be a directory not a file",
)?;
// TODO(mbolin): Figure out why using a temp dir as the cwd causes this test
// to hang.
let cwd = std::env::current_dir()?;
let config_contents = format!(
r#"
# Pick a local provider so the CLI doesn't prompt for OpenAI auth in this test.
model_provider = "ollama"
[projects]
"{cwd}" = {{ trust_level = "trusted" }}
"#,
cwd = cwd.display()
);
std::fs::write(codex_home.join("config.toml"), config_contents)?;
let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?;
assert_ne!(0, exit_code, "Codex CLI should exit nonzero.");
assert!(
output.contains("ERROR: Failed to initialize codex:"),
"expected startup error in output, got: {output}"
);
assert!(
output.contains("failed to read rules files"),
"expected rules read error in output, got: {output}"
);
Ok(())
}
struct CodexCliOutput {
exit_code: i32,
output: String,
}
async fn run_codex_cli(
codex_home: impl AsRef<Path>,
cwd: impl AsRef<Path>,
) -> anyhow::Result<CodexCliOutput> {
let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?;
let mut env = HashMap::new();
env.insert(
"CODEX_HOME".to_string(),
codex_home.as_ref().display().to_string(),
);
let args = vec!["-c".to_string(), "analytics.enabled=false".to_string()];
let spawned = codex_utils_pty::spawn_pty_process(
codex_cli.to_string_lossy().as_ref(),
&args,
cwd.as_ref(),
&env,
&None,
codex_utils_pty::TerminalSize::default(),
)
.await?;
let mut output = Vec::new();
let codex_utils_pty::SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = spawned;
let mut output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut exit_rx = exit_rx;
let writer_tx = session.writer_sender();
let exit_code_result = timeout(Duration::from_secs(10), async {
// Read PTY output until the process exits while replying to cursor
// position queries so the TUI can initialize without a real terminal.
loop {
select! {
result = output_rx.recv() => match result {
Ok(chunk) => {
// The TUI asks for the cursor position via ESC[6n.
// Respond with a valid position to unblock startup.
if chunk.windows(4).any(|window| window == b"\x1b[6n") {
let _ = writer_tx.send(b"\x1b[1;1R".to_vec()).await;
}
output.extend_from_slice(&chunk);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break exit_rx.await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
},
result = &mut exit_rx => break result,
}
}
})
.await;
let exit_code = match exit_code_result {
Ok(Ok(code)) => code,
Ok(Err(err)) => return Err(err.into()),
Err(_) => {
session.terminate();
anyhow::bail!("timed out waiting for codex CLI to exit");
}
};
// Drain any output that raced with the exit notification.
while let Ok(chunk) = output_rx.try_recv() {
output.extend_from_slice(&chunk);
}
let output = String::from_utf8_lossy(&output);
Ok(CodexCliOutput {
exit_code,
output: output.to_string(),
})
}