mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+329
-559
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())
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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(¤t, &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),
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
+15
-4821
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
//! Connector list cache state for `ChatWidget`.
|
||||
//! Connector cache, popup, and refresh handling for `ChatWidget`.
|
||||
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -18,3 +19,415 @@ pub(super) struct ConnectorsState {
|
||||
pub(super) prefetch_in_flight: bool,
|
||||
pub(super) force_refetch_pending: bool,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) {
|
||||
self.prefetch_connectors_with_options(force_refetch);
|
||||
}
|
||||
|
||||
pub(super) fn prefetch_connectors(&mut self) {
|
||||
self.prefetch_connectors_with_options(/*force_refetch*/ false);
|
||||
}
|
||||
|
||||
fn prefetch_connectors_with_options(&mut self, force_refetch: bool) {
|
||||
if !self.connectors_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.connectors.prefetch_in_flight {
|
||||
if force_refetch {
|
||||
self.connectors.force_refetch_pending = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.connectors.prefetch_in_flight = true;
|
||||
if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) {
|
||||
self.connectors.cache = ConnectorsCacheState::Loading;
|
||||
}
|
||||
|
||||
let config = self.config.clone();
|
||||
let environment_manager = Arc::clone(&self.environment_manager);
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let accessible_result =
|
||||
match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
&config,
|
||||
force_refetch,
|
||||
&environment_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connectors) => connectors,
|
||||
Err(err) => {
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result: Err(format!("Failed to load apps: {err}")),
|
||||
is_final: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let should_schedule_force_refetch =
|
||||
!force_refetch && !accessible_result.codex_apps_ready;
|
||||
let accessible_connectors = accessible_result.connectors;
|
||||
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result: Ok(ConnectorsSnapshot {
|
||||
connectors: accessible_connectors.clone(),
|
||||
}),
|
||||
is_final: false,
|
||||
});
|
||||
|
||||
let result: Result<ConnectorsSnapshot, String> = async {
|
||||
let all_connectors =
|
||||
chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch)
|
||||
.await?;
|
||||
let connectors = chatgpt_connectors::merge_connectors_with_accessible(
|
||||
all_connectors,
|
||||
accessible_connectors,
|
||||
/*all_connectors_loaded*/ true,
|
||||
);
|
||||
Ok(ConnectorsSnapshot { connectors })
|
||||
}
|
||||
.await
|
||||
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
|
||||
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result,
|
||||
is_final: true,
|
||||
});
|
||||
|
||||
if should_schedule_force_refetch {
|
||||
app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn connectors_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account
|
||||
}
|
||||
|
||||
pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> {
|
||||
if !self.connectors_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(snapshot) = &self.connectors.partial_snapshot {
|
||||
return Some(snapshot.connectors.as_slice());
|
||||
}
|
||||
|
||||
match &self.connectors.cache {
|
||||
ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_connectors_output(&mut self) {
|
||||
if !self.connectors_enabled() {
|
||||
self.add_info_message(
|
||||
"Apps are disabled.".to_string(),
|
||||
Some("Enable the apps feature to use $ or /apps.".to_string()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let connectors_cache = self.connectors.cache.clone();
|
||||
let should_force_refetch = !self.connectors.prefetch_in_flight
|
||||
|| matches!(connectors_cache, ConnectorsCacheState::Ready(_));
|
||||
self.prefetch_connectors_with_options(should_force_refetch);
|
||||
|
||||
match connectors_cache {
|
||||
ConnectorsCacheState::Ready(snapshot) => {
|
||||
if snapshot.connectors.is_empty() {
|
||||
self.add_info_message("No apps available.".to_string(), /*hint*/ None);
|
||||
} else {
|
||||
self.open_connectors_popup(&snapshot.connectors);
|
||||
}
|
||||
}
|
||||
ConnectorsCacheState::Failed(err) => {
|
||||
self.add_to_history(history_cell::new_error_event(err));
|
||||
}
|
||||
ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => {
|
||||
self.open_connectors_loading_popup();
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn open_connectors_loading_popup(&mut self) {
|
||||
if !self.bottom_pane.replace_selection_view_if_active(
|
||||
CONNECTORS_SELECTION_VIEW_ID,
|
||||
self.connectors_loading_popup_params(),
|
||||
) {
|
||||
self.bottom_pane
|
||||
.show_selection_view(self.connectors_loading_popup_params());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_connectors_popup(&mut self, connectors: &[AppInfo]) {
|
||||
self.bottom_pane.show_selection_view(
|
||||
self.connectors_popup_params(connectors, /*selected_connector_id*/ None),
|
||||
);
|
||||
}
|
||||
|
||||
fn connectors_loading_popup_params(&self) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Apps".bold()));
|
||||
header.push(Line::from("Loading installed and available apps...".dim()));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Loading apps...".to_string(),
|
||||
description: Some("This updates when the full list is ready.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn connectors_popup_params(
|
||||
&self,
|
||||
connectors: &[AppInfo],
|
||||
selected_connector_id: Option<&str>,
|
||||
) -> SelectionViewParams {
|
||||
let total = connectors.len();
|
||||
let installed = connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_accessible)
|
||||
.count();
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Apps".bold()));
|
||||
header.push(Line::from(
|
||||
"Use $ to insert an installed app into your prompt.".dim(),
|
||||
));
|
||||
header.push(Line::from(
|
||||
format!("Installed {installed} of {total} available apps.").dim(),
|
||||
));
|
||||
let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| {
|
||||
connectors
|
||||
.iter()
|
||||
.position(|connector| connector.id == selected_connector_id)
|
||||
});
|
||||
let mut items: Vec<SelectionItem> = Vec::with_capacity(connectors.len());
|
||||
for connector in connectors {
|
||||
let connector_label = codex_connectors::metadata::connector_display_label(connector);
|
||||
let connector_title = connector_label.clone();
|
||||
let link_description = Self::connector_description(connector);
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.id);
|
||||
let mut item = SelectionItem {
|
||||
name: connector_label,
|
||||
description: Some(description),
|
||||
search_value: Some(search_value),
|
||||
..Default::default()
|
||||
};
|
||||
let is_installed = connector.is_accessible;
|
||||
let selected_label = if is_installed {
|
||||
format!(
|
||||
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
|
||||
)
|
||||
} else {
|
||||
format!("{status_label}. Press Enter to open the app page to install this app.")
|
||||
};
|
||||
let missing_label = format!("{status_label}. App link unavailable.");
|
||||
let instructions = if connector.is_accessible {
|
||||
"Manage this app in your browser."
|
||||
} else {
|
||||
"Install this app in your browser, then reload Codex."
|
||||
};
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
let app_id = connector.id.clone();
|
||||
let is_enabled = connector.is_enabled;
|
||||
let title = connector_title.clone();
|
||||
let instructions = instructions.to_string();
|
||||
let description = link_description.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenAppLink {
|
||||
app_id: app_id.clone(),
|
||||
title: title.clone(),
|
||||
description: description.clone(),
|
||||
instructions: instructions.clone(),
|
||||
url: install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled,
|
||||
});
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(selected_label);
|
||||
} else {
|
||||
let missing_label_for_action = missing_label.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(
|
||||
missing_label_for_action.clone(),
|
||||
/*hint*/ None,
|
||||
),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(missing_label);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
search_placeholder: Some("Type to search apps".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
initial_selected_idx,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) {
|
||||
let selected_connector_id =
|
||||
if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = (
|
||||
self.bottom_pane
|
||||
.selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID),
|
||||
&self.connectors.cache,
|
||||
) {
|
||||
snapshot
|
||||
.connectors
|
||||
.get(selected_index)
|
||||
.map(|connector| connector.id.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = self.bottom_pane.replace_selection_view_if_active(
|
||||
CONNECTORS_SELECTION_VIEW_ID,
|
||||
self.connectors_popup_params(connectors, selected_connector_id),
|
||||
);
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &AppInfo) -> String {
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
match Self::connector_description(connector) {
|
||||
Some(description) => format!("{status_label} · {description}"),
|
||||
None => status_label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_status_label(connector: &AppInfo) -> &'static str {
|
||||
if connector.is_accessible {
|
||||
if connector.is_enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
}
|
||||
} else {
|
||||
"Can be installed"
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_description(connector: &AppInfo) -> Option<String> {
|
||||
connector
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|description| !description.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn on_connectors_loaded(
|
||||
&mut self,
|
||||
result: Result<ConnectorsSnapshot, String>,
|
||||
is_final: bool,
|
||||
) {
|
||||
let mut trigger_pending_force_refetch = false;
|
||||
if is_final {
|
||||
self.connectors.prefetch_in_flight = false;
|
||||
if self.connectors.force_refetch_pending {
|
||||
self.connectors.force_refetch_pending = false;
|
||||
trigger_pending_force_refetch = true;
|
||||
}
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(mut snapshot) => {
|
||||
if !is_final {
|
||||
snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible(
|
||||
Vec::new(),
|
||||
snapshot.connectors,
|
||||
/*all_connectors_loaded*/ false,
|
||||
);
|
||||
}
|
||||
snapshot.connectors =
|
||||
chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config);
|
||||
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
|
||||
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.is_enabled))
|
||||
.collect();
|
||||
for connector in &mut snapshot.connectors {
|
||||
if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) {
|
||||
connector.is_enabled = *is_enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_final {
|
||||
self.connectors.partial_snapshot = None;
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
} else {
|
||||
self.connectors.partial_snapshot = Some(snapshot.clone());
|
||||
}
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
Err(err) => {
|
||||
let partial_snapshot = self.connectors.partial_snapshot.take();
|
||||
if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache {
|
||||
warn!("failed to refresh apps list; retaining current apps snapshot: {err}");
|
||||
self.bottom_pane
|
||||
.set_connectors_snapshot(Some(snapshot.clone()));
|
||||
} else if let Some(snapshot) = partial_snapshot {
|
||||
warn!(
|
||||
"failed to load full apps list; falling back to installed apps snapshot: {err}"
|
||||
);
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
} else {
|
||||
self.connectors.cache = ConnectorsCacheState::Failed(err);
|
||||
self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trigger_pending_force_refetch {
|
||||
self.prefetch_connectors_with_options(/*force_refetch*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
|
||||
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for connector in &mut snapshot.connectors {
|
||||
if connector.id == connector_id {
|
||||
changed = connector.is_enabled != enabled;
|
||||
connector.is_enabled = enabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¤t_permission_profile,
|
||||
self.config.cwd.as_path(),
|
||||
&preset,
|
||||
),
|
||||
actions: default_actions,
|
||||
dismiss_on_select: true,
|
||||
disabled_reason: default_disabled_reason,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if guardian_approval_enabled {
|
||||
items.push(SelectionItem {
|
||||
name: "Auto-review".to_string(),
|
||||
description: Some(
|
||||
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."
|
||||
.to_string(),
|
||||
),
|
||||
is_current: current_review_policy == ApprovalsReviewer::AutoReview
|
||||
&& Self::preset_matches_current(
|
||||
current_approval,
|
||||
¤t_permission_profile,
|
||||
self.config.cwd.as_path(),
|
||||
&preset,
|
||||
),
|
||||
actions: Self::approval_preset_actions(
|
||||
preset_approval,
|
||||
preset.permission_profile.clone(),
|
||||
"Auto-review".to_string(),
|
||||
ApprovalsReviewer::AutoReview,
|
||||
),
|
||||
dismiss_on_select: true,
|
||||
disabled_reason: approval_disabled_reason
|
||||
.or_else(|| guardian_disabled_reason(true)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: base_name,
|
||||
description: base_description,
|
||||
is_current: Self::preset_matches_current(
|
||||
current_approval,
|
||||
¤t_permission_profile,
|
||||
self.config.cwd.as_path(),
|
||||
&preset,
|
||||
),
|
||||
actions: default_actions,
|
||||
dismiss_on_select: true,
|
||||
disabled_reason: default_disabled_reason,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let footer_note = show_elevate_sandbox_hint.then(|| {
|
||||
vec![
|
||||
"The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(),
|
||||
"/setup-default-sandbox".cyan(),
|
||||
".".dim(),
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Update Model Permissions".to_string()),
|
||||
footer_note,
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_auto_review_denials_popup(&mut self) {
|
||||
if self.review.recent_auto_review_denials.is_empty() {
|
||||
self.add_info_message(
|
||||
"No recent auto-review denials in this thread.".to_string(),
|
||||
Some("Denials are recorded after auto-review rejects an action.".to_string()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(thread_id) = self.thread_id() else {
|
||||
self.add_error_message("That thread is no longer available.".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let mut items = vec![SelectionItem {
|
||||
name: "Command".to_string(),
|
||||
description: Some("Rationale".to_string()),
|
||||
is_disabled: true,
|
||||
search_value: Some(String::new()),
|
||||
..Default::default()
|
||||
}];
|
||||
items.extend(
|
||||
self.review
|
||||
.recent_auto_review_denials
|
||||
.entries()
|
||||
.map(|event| {
|
||||
let id = event.id.clone();
|
||||
let summary = auto_review_denials::action_summary(&event.action);
|
||||
let rationale = event
|
||||
.rationale
|
||||
.as_deref()
|
||||
.unwrap_or("Auto-review did not include a rationale.");
|
||||
SelectionItem {
|
||||
name: summary.clone(),
|
||||
description: Some(rationale.to_string()),
|
||||
selected_description: Some(rationale.to_string()),
|
||||
search_value: Some(format!("{summary} {rationale}")),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::ApproveRecentAutoReviewDenial {
|
||||
thread_id,
|
||||
id: id.clone(),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Auto-review Denials".to_string()),
|
||||
subtitle: Some("Select a denied action to approve.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
..Default::default()
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) {
|
||||
let Some(event) = self.review.recent_auto_review_denials.take(&id) else {
|
||||
self.add_error_message("That auto-review denial is no longer available.".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
self.app_event_tx.send(AppEvent::SubmitThreadOp {
|
||||
thread_id,
|
||||
op: AppCommand::approve_guardian_denied_action(event),
|
||||
});
|
||||
self.add_info_message(
|
||||
"Approval recorded for one retry of the selected auto-review denial.".to_string(),
|
||||
Some(
|
||||
"The model will see the approval context; the retry still goes through auto-review."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn approval_preset_actions(
|
||||
approval: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
label: String,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
) -> Vec<SelectionAction> {
|
||||
vec![Box::new(move |tx| {
|
||||
let permission_profile_clone = permission_profile.clone();
|
||||
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
|
||||
/*cwd*/ None,
|
||||
Some(approval),
|
||||
Some(approvals_reviewer),
|
||||
Some(permission_profile_clone.clone()),
|
||||
/*windows_sandbox_level*/ None,
|
||||
/*model*/ None,
|
||||
/*effort*/ None,
|
||||
/*summary*/ None,
|
||||
/*service_tier*/ None,
|
||||
/*collaboration_mode*/ None,
|
||||
/*personality*/ None,
|
||||
)));
|
||||
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
|
||||
tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone));
|
||||
tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(
|
||||
format!("Permissions updated to {label}"),
|
||||
/*hint*/ None,
|
||||
),
|
||||
)));
|
||||
})]
|
||||
}
|
||||
|
||||
pub(super) fn preset_matches_current(
|
||||
current_approval: AskForApproval,
|
||||
current_permission_profile: &PermissionProfile,
|
||||
cwd: &std::path::Path,
|
||||
preset: &ApprovalPreset,
|
||||
) -> bool {
|
||||
let preset_approval = AskForApproval::from(preset.approval);
|
||||
if current_approval != preset_approval {
|
||||
return false;
|
||||
}
|
||||
|
||||
match preset.id {
|
||||
"full-access" => matches!(current_permission_profile, PermissionProfile::Disabled),
|
||||
"read-only" => {
|
||||
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
|
||||
matches!(
|
||||
current_permission_profile,
|
||||
PermissionProfile::Managed { .. }
|
||||
) && !file_system_policy.has_full_disk_write_access()
|
||||
&& file_system_policy
|
||||
.get_writable_roots_with_cwd(cwd)
|
||||
.is_empty()
|
||||
&& current_permission_profile.network_sandbox_policy()
|
||||
== preset.permission_profile.network_sandbox_policy()
|
||||
}
|
||||
"auto" => {
|
||||
let file_system_policy = current_permission_profile.file_system_sandbox_policy();
|
||||
matches!(
|
||||
current_permission_profile,
|
||||
PermissionProfile::Managed { .. }
|
||||
) && file_system_policy.can_write_path_with_cwd(cwd, cwd)
|
||||
&& !file_system_policy.has_full_disk_write_access()
|
||||
&& current_permission_profile.network_sandbox_policy()
|
||||
== preset.permission_profile.network_sandbox_policy()
|
||||
}
|
||||
_ => current_permission_profile == &preset.permission_profile,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_full_access_confirmation(
|
||||
&mut self,
|
||||
preset: ApprovalPreset,
|
||||
return_to_permissions: bool,
|
||||
) {
|
||||
let selected_name = preset.label.to_string();
|
||||
let approval = AskForApproval::from(preset.approval);
|
||||
let permission_profile = preset.permission_profile;
|
||||
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let title_line = Line::from("Enable full access?").bold();
|
||||
let info_line = Line::from(vec![
|
||||
"When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. "
|
||||
.into(),
|
||||
"Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior."
|
||||
.fg(Color::Red),
|
||||
]);
|
||||
header_children.push(Box::new(title_line));
|
||||
header_children.push(Box::new(
|
||||
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
|
||||
));
|
||||
let header = ColumnRenderable::with(header_children);
|
||||
|
||||
let mut accept_actions = Self::approval_preset_actions(
|
||||
approval,
|
||||
permission_profile.clone(),
|
||||
selected_name.clone(),
|
||||
ApprovalsReviewer::User,
|
||||
);
|
||||
accept_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||||
}));
|
||||
|
||||
let mut accept_and_remember_actions = Self::approval_preset_actions(
|
||||
approval,
|
||||
permission_profile,
|
||||
selected_name,
|
||||
ApprovalsReviewer::User,
|
||||
);
|
||||
accept_and_remember_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
|
||||
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
|
||||
}));
|
||||
|
||||
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
if return_to_permissions {
|
||||
tx.send(AppEvent::OpenPermissionsPopup);
|
||||
} else {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
}
|
||||
})];
|
||||
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Yes, continue anyway".to_string(),
|
||||
description: Some("Apply full access for this session".to_string()),
|
||||
actions: accept_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Yes, and don't ask again".to_string(),
|
||||
description: Some("Enable full access and remember this choice".to_string()),
|
||||
actions: accept_and_remember_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Go back without enabling full access".to_string()),
|
||||
actions: deny_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header: Box::new(header),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Rate-limit warning and prompt state for `ChatWidget`.
|
||||
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
|
||||
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
||||
@@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind(
|
||||
pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
let limit_label = snapshot
|
||||
.limit_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| limit_id.clone());
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
.and_then(|display| display.credits.as_ref())
|
||||
.map(|credits| CreditsSnapshot {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
if is_codex_limit
|
||||
&& let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type
|
||||
{
|
||||
self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);
|
||||
}
|
||||
let warnings = if is_codex_limit {
|
||||
self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| f64::from(window.used_percent)),
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_duration_mins),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| f64::from(window.used_percent)),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_duration_mins),
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let high_usage = is_codex_limit
|
||||
&& (snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false));
|
||||
|
||||
let has_workspace_credits = snapshot
|
||||
.credits
|
||||
.as_ref()
|
||||
.map(|credits| credits.has_credits)
|
||||
.unwrap_or(false);
|
||||
|
||||
if high_usage
|
||||
&& !has_workspace_credits
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
&& self.current_model() != NUDGE_MODEL_SLUG
|
||||
&& !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
)
|
||||
{
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
|
||||
let display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
|
||||
if !warnings.is_empty() {
|
||||
for warning in warnings {
|
||||
self.add_to_history(history_cell::new_warning_event(warning));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
self.codex_rate_limit_reached_type = None;
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
|
||||
pub(super) fn stop_rate_limit_poller(&mut self) {}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn prefetch_rate_limits(&mut self) {
|
||||
self.stop_rate_limit_poller();
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn should_prefetch_rate_limits(&self) -> bool {
|
||||
self.config.model_provider.requires_openai_auth && self.has_chatgpt_account
|
||||
}
|
||||
|
||||
fn lower_cost_preset(&self) -> Option<ModelPreset> {
|
||||
let models = self.model_catalog.try_list_models().ok()?;
|
||||
models
|
||||
.iter()
|
||||
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn rate_limit_switch_prompt_hidden(&self) -> bool {
|
||||
self.config
|
||||
.notices
|
||||
.hide_rate_limit_model_nudge
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {
|
||||
if self.rate_limit_switch_prompt_hidden() {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Some(preset) = self.lower_cost_preset() {
|
||||
self.open_rate_limit_switch_prompt(preset);
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;
|
||||
} else {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {
|
||||
let switch_model = preset.model;
|
||||
let switch_model_for_events = switch_model.clone();
|
||||
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
|
||||
|
||||
let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
|
||||
/*cwd*/ None,
|
||||
/*approval_policy*/ None,
|
||||
/*approvals_reviewer*/ None,
|
||||
/*permission_profile*/ None,
|
||||
/*windows_sandbox_level*/ None,
|
||||
Some(switch_model_for_events.clone()),
|
||||
Some(Some(default_effort)),
|
||||
/*summary*/ None,
|
||||
/*service_tier*/ None,
|
||||
/*collaboration_mode*/ None,
|
||||
/*personality*/ None,
|
||||
)));
|
||||
tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));
|
||||
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
|
||||
})];
|
||||
|
||||
let keep_actions: Vec<SelectionAction> = Vec::new();
|
||||
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
|
||||
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
|
||||
})];
|
||||
let description = if preset.description.is_empty() {
|
||||
Some("Uses fewer credits for upcoming turns.".to_string())
|
||||
} else {
|
||||
Some(preset.description)
|
||||
};
|
||||
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: format!("Switch to {switch_model}"),
|
||||
description,
|
||||
selected_description: None,
|
||||
is_current: false,
|
||||
actions: switch_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Keep current model".to_string(),
|
||||
description: None,
|
||||
selected_description: None,
|
||||
is_current: false,
|
||||
actions: keep_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Keep current model (never show again)".to_string(),
|
||||
description: Some(
|
||||
"Hide future rate limit reminders about switching models.".to_string(),
|
||||
),
|
||||
selected_description: None,
|
||||
is_current: false,
|
||||
actions: never_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Approaching rate limits".to_string()),
|
||||
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn open_workspace_owner_nudge_prompt(
|
||||
&mut self,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
) {
|
||||
if self.add_credits_nudge_email_in_flight.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (title, prompt) = match credit_type {
|
||||
AddCreditsNudgeCreditType::Credits => (
|
||||
"You've reached your workspace credit limit",
|
||||
"Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?",
|
||||
),
|
||||
AddCreditsNudgeCreditType::UsageLimit => (
|
||||
"Usage limit reached",
|
||||
"Request a limit increase from your owner to continue using codex. Request increase?",
|
||||
),
|
||||
};
|
||||
let send_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type });
|
||||
})];
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Yes".to_string(),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
|
||||
actions: send_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "No".to_string(),
|
||||
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
|
||||
is_default: true,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some(title.to_string()),
|
||||
subtitle: Some(prompt.to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
initial_selected_idx: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn start_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
) -> bool {
|
||||
self.add_credits_nudge_email_in_flight = Some(credit_type);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn finish_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
result: Result<AddCreditsNudgeEmailStatus, String>,
|
||||
) {
|
||||
let credit_type = self
|
||||
.add_credits_nudge_email_in_flight
|
||||
.take()
|
||||
.unwrap_or(AddCreditsNudgeCreditType::Credits);
|
||||
let message = match (credit_type, result) {
|
||||
(AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
|
||||
"Workspace owner notified."
|
||||
}
|
||||
(
|
||||
AddCreditsNudgeCreditType::Credits,
|
||||
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
||||
) => "Workspace owner was already notified recently.",
|
||||
(AddCreditsNudgeCreditType::Credits, Err(_)) => {
|
||||
"Could not notify your workspace owner. Please try again."
|
||||
}
|
||||
(AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
|
||||
"Limit increase requested."
|
||||
}
|
||||
(
|
||||
AddCreditsNudgeCreditType::UsageLimit,
|
||||
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
||||
) => "A limit increase was already requested recently.",
|
||||
(AddCreditsNudgeCreditType::UsageLimit, Err(_)) => {
|
||||
"Could not request a limit increase. Please try again."
|
||||
}
|
||||
};
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
message.to_string(),
|
||||
/*hint*/ None,
|
||||
));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
|
||||
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
|
||||
if hidden {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
-5
@@ -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) {}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user